iOS之性能优化·优化App的启动速度
拋磚引玉
- 啟動是 App 給用戶的第一印象,啟動越慢用戶流失的概率就越高,良好的啟動速度是用戶體驗不可缺少的一環(huán)。
- 蘋果是一家特別注重用戶體驗的公司,過去幾年一直在優(yōu)化 App 的啟動時間,特別是去年的 WWDC 2019 keynote [1] 上提到,在過去一年蘋果開發(fā)團隊對啟動時間提升了 200%;
- 雖然說是提升了 200%,但是有些問題還是沒有說清楚,比如:
- 為什么優(yōu)化了這么多時間?
- 作為開發(fā)者的我們,我們還可以做哪些針對啟動速度的優(yōu)化?
- 所以我們今天結(jié)合 WWDC2019 - 423 - Optimizing App Launch [2] 聊一下和啟動相關(guān)的東西。
概念引入
一、Mach-O
- Mach-O 是 iOS 系統(tǒng)不同運行時期可執(zhí)行文件的文件類型統(tǒng)稱。主要分以下三類:
- Executable :可執(zhí)行文件,是 App 中的主要二進制文件;
- Dylib :動態(tài)庫,在其他平臺也叫 DSO 或者 DLL;
- Bundle :蘋果平臺特有的類型,是無法被連接的 Dylib。只能在運行時通過 dlopen() 加載。
- Mach-O 的基本結(jié)構(gòu)如下圖所示,分為三個部分:
- 結(jié)構(gòu)分析:
- Header: 包含了 Mach-O 文件的基本信息,如 CPU 架構(gòu),文件類型,加載指令數(shù)量等;
- Load Commands: 是跟在 Header 后面的加載命令區(qū),包含文件的組織架構(gòu)和在虛擬內(nèi)存中的布局方式,在調(diào)用的時候知道如何設(shè)置和加載二進制數(shù)據(jù);
- Data:包含 Load Commands 中需要的各個 Segment 的數(shù)據(jù);
- 絕大多數(shù) Mach-O 文件包括以下三種 Segment:
- __TEXT :代碼段,包括頭文件、代碼和常量,只讀不可修改。
- __DATA :數(shù)據(jù)段,包括全局變量, 靜態(tài)變量等,可讀可寫。
- __LINKEDIT :如何加載程序, 包含了方法和變量的元數(shù)據(jù)(位置,偏移量),以及代碼簽名等信息,只讀不可修改。
二、Image
- 指的是 Executable,Dylib 或者 Bundle 的一種。
三、Framework
- 有很多東西都叫做 Framework,但在本文中,Framework 指的是一個 dylib,它周圍有一個特殊的目錄結(jié)構(gòu)來保存該 dylib 所需的文件。
- 一般會用 Root Controller 的 viewDidApper 作為渲染的終點,但其實這時候首幀已經(jīng)渲染完成一小段時間了,Apple 在 MetricsKit 里對啟動終點定義是第一個CA::Transaction::commit()。
- 什么是 CATransaction 呢?我們先來看一下渲染的大致流程:
- iOS 的渲染是在一個單獨的進程 RenderServer 做的,App 會把 Render Tree 編碼打包給 RenderServer,RenderServer 再調(diào)用渲染框架(Metal/OpenGL ES)來生成 bitmap,放到幀緩沖區(qū)里,硬件根據(jù)時鐘信號讀取幀緩沖區(qū)內(nèi)容,完成屏幕刷新。CATransaction 就是把一組 UI 上的修改,合并成一個事務,通過 commit 提交。
- 渲染可以分為四個步驟:
- Layout(布局),源頭是 Root Layer 調(diào)用[CALayer layoutSubLayers],這時候 UIViewController 的 viewDidLoad 和 LayoutSubViews 會調(diào)用,autolayout 也是在這一步生效;
- Display(繪制),源頭是 Root Layer 調(diào)用[CALayer display],如果 View 實現(xiàn)了 drawRect 方法,會在這個階段調(diào)用;
- Prepare(準備),這個過程中會完成圖片的解碼;
- Commit(提交),打包 Render Tree 通過 XPC 的方式發(fā)給 Render Server。
四、虛擬內(nèi)存(Virtual Memory)
- 虛擬內(nèi)存是建立在物理內(nèi)存和進程之間的中間層。是一個連續(xù)的邏輯地址空間,而且邏輯地址可以沒有對應的實際物理內(nèi)存地址,也可以讓多個邏輯地址對應到一個物理內(nèi)存地址上。
- 內(nèi)存可以分為虛擬內(nèi)存和物理內(nèi)存,其中物理內(nèi)存是實際占用的內(nèi)存,虛擬內(nèi)存是在物理內(nèi)存之上建立的一層邏輯地址,保證內(nèi)存訪問安全的同時為應用提供了連續(xù)的地址空間。
- 物理內(nèi)存和虛擬內(nèi)存以頁為單位映射,但這個映射關(guān)系不是一一對應的:一頁物理內(nèi)存可能對應多頁虛擬內(nèi)存;一頁虛擬內(nèi)存也可能不占用物理內(nèi)存。
- iPhone 6s 開始,物理內(nèi)存的 Page 大小是 16K,6 和之前的設(shè)備都是 4K,這是 iPhone 6 相比 6s 啟動速度斷崖式下降的原因之一。
五、Page Fault
- 當進程訪問一個沒有對應物理地址的邏輯地址時,會發(fā)生 Page Fault。
六、Lazy Reading
- 某個想要讀取的頁沒有在內(nèi)存中就會觸發(fā) Page Fault,系統(tǒng)通過調(diào)用 mmap() 函數(shù)讀取指定頁,這個過程叫做 Lazy Reading。
七、COW(Copy-On-Write)
- 當進程需要對某一頁內(nèi)容進行修改時,內(nèi)核會把需要修改的部分先復制一份,然后再修改,并把邏輯地址重新映射到新的物理內(nèi)存去,這個過程叫做 Copy-On-Write。
八、Dirty Page & Clean Page
- Image 加載后,被修改過內(nèi)容的 Page 叫做 Dirty Page,會包含著進程特定的信息。
- 與之相對的叫 Clean Page,可以從磁盤重新生成。
九、共享內(nèi)存(Share RAM)
- 當多個 Mach-O 都依賴同一個 Dylib(eg. UIKit)時,系統(tǒng)會讓這幾個 Mach-O 的調(diào)用 Dylib 的邏輯地址都指向同一塊物理內(nèi)存區(qū)域,從而實現(xiàn)內(nèi)存共享。
- Dirty Page 為進程獨有,不能被共享。
十、地址空間布局隨機化(ASLR)
- 當 Image 加載到邏輯地址空間的時候,系統(tǒng)會利用 ASLR 技術(shù),使得 Image 的起始地址總是隨機的,以避免黑客通過起始地址+偏移量找到函數(shù)的地址。
- 當系統(tǒng)利用 ASLR 分配了隨機地址后,從 0 到該地址的整個區(qū)間會被標記為不可訪問,意味著不可讀,不可寫,不可被執(zhí)行。這個區(qū)域就是 __PAGEZERO 段,它的大小在 32 位系統(tǒng)是 4KB+,而在 64 位系統(tǒng)是 4GB+
十一、代碼簽名(Code Sign)
- 代碼簽名可以讓 iOS 系統(tǒng)確保要被加載的 Image 的安全性,用 Code Sign 設(shè)置簽名時,每頁內(nèi)容都會生成一個單獨的加密散列值,并存儲到 __LINKEDIT 中去,系統(tǒng)在加載時會校驗每頁內(nèi)容確保沒有被篡改。
十二、dyld(dynamic loader)
- dyld 是 iOS 上的二進制加載器,用于加載 Image。有不少人認為 dyld 只負責加載應用依賴的所有動態(tài)鏈接庫,這個理解是錯誤的。dyld 工作的具體流程如下:
- dyld 啟動請參考:dyld啟動流程 [3];
- dyld 啟動也可以參考我之前的博客:iOS之深入解析App啟動dyld加載流程的底層原理。
十三、Load dylibs
- dyld 在加載 Mach-O 之前會先解析 Header 和 Load Commands, 然后就知道了這個 Mach-O 所依賴的 dylibs,以此類推,通過遞歸的方式把全部需要的 dylib 都加載進來。
- 一般來說,一個 App 所依賴的 dylib 在 100 - 400 左右,其中大多數(shù)都是系統(tǒng)的 dylib,因為有緩存和共享的緣故,讀取速度比較高。
十四、Fix-ups
- 因為 ASLR 和 Code Sign 的原因,剛被加載進來的 dylib 都處于相對獨立的狀態(tài),為了把它們綁定起來,需要經(jīng)過一個 Fix-ups 過程。
- Fix-ups 主要有兩種類型:Rebase 和 Bind。
十五、PIC(Position Independent Code)
- 因為代碼簽名的原因,dyld 無法直接修改指令,但是為了實現(xiàn)在運行時可以 Fix-ups,在 code gen 時,通過動態(tài) PIC(Position Independent Code)技術(shù),使本來因為代碼簽名限制不能再修改的代碼,可以被加載到間接地址上。
- 當要調(diào)用一個方法時,會先在 __DATA 段中建立一個指針指向這個方法,再通過這個指針實現(xiàn)間接調(diào)用。
十六、Rebase
- Rebase:修復內(nèi)部指針。這是因為 Mach-O 在 mmap 到虛擬內(nèi)存的時候,起始地址會有一個隨機的偏移量 slide,需要把內(nèi)部的指針指向加上這個 slide。
- Rebase 是針對“因為 ASLR 導致 Mach-O 在加載到內(nèi)存中是一個隨機的首地址”這一個問題做一個數(shù)據(jù)修正的過程。會將內(nèi)部指針地址都加上一個偏移量,偏移量的計算方法如下:
- 所有需要 Rebase 的指針信息已經(jīng)被編碼到 __LINKEDIT 里。然后就是不斷重復地對 __DATA 中需要 Rebase 的指針加上這個偏移量。這個過程中可能會不斷發(fā)生 Page Fault 和 COW,從而導致 I/0 的性能損耗問題,不過因為 Rebase 處理的是連續(xù)地址,所以內(nèi)核會預先讀取數(shù)據(jù),減少 I/O 的消耗。
十七、Binding
- Binding:修復外部指針。這個比較好理解,因為像 printf 等外部函數(shù),只有運行時才知道它的地址是什么,Binding 就是把指針指向這個地址。
- Binding 就是對調(diào)用的外部符號進行綁定的過程。比如我們要使用到 UITableView,即符號 OBJC_CLASS$_UITableView,但這個符號又不在 Mach-O 中,需要從 UIKit.framework 中獲取,因此需要通過 Binding 把這個對應關(guān)系綁定到一起。
- 在運行時,dyld 需要找到符號名對應的實現(xiàn)。而這需要很多計算,包括去符號表里找。找到后就會將對應的值記錄到 __DATA 的那個指針里。Binding 的計算量雖然比 Rebasing 更多,但實際需要的 I/O 操作很少,因為之前 Rebasing 已經(jīng)做過了。
- 舉個例子:一個 Objective C 字符串@“1234”,編譯到最后的二進制的時候是會存儲在兩個 section 里的:
- __TEXT,__cstring,存儲實際的字符串"1234"
- __DATA,__cfstring,存儲 Objective C 字符串的元數(shù)據(jù),每個元數(shù)據(jù)占用 32Byte,里面有兩個指針:內(nèi)部指針,指向__TEXT,__cstring中字符串的位置;外部指針 isa,指向類對象的,這就是為什么可以對 Objective C 的字符串字面量發(fā)消息的原因。
- 如下圖,編譯的時候,字符串 1234 在__cstring的 0x10 處,所以 DATA 段的指針指向 0x10。但是 mmap 之后有一個偏移量 slide=0x1000,這時候字符串在運行時的地址就是 0x1010,那么 DATA 段的指針指向就不對了。Rebase 的過程就是把指針從 0x10,加上 slide 變成 0x1010。運行時類對象的地址已經(jīng)知道了,bind 就是把 isa 指向?qū)嶋H的內(nèi)存地址。
十八、dyld2 & dyld3
- 在 iOS 13 之前,所有的第三方 App 都是通過 dyld 2 來啟動 App 的,主要過程如下:
- 解析 Mach-O 的 Header 和 Load Commands,找到其依賴的庫,并遞歸找到所有依賴的庫
- 加載 Mach-O 文件
- 進行符號查找
- 綁定和變基
- 運行初始化程序
- 上面的所有過程都發(fā)生在 App 啟動時,包含了大量的計算和I/O,所以蘋果開發(fā)團隊為了加快啟動速度,在 WWDC2017 - 413 - App Startup Time: Past, Present, and Future [4] 上正式提出了 dyld3。
- dyld2 & dyld3 比較如下:
- dyld3 被分為了三個組件:
-
一個進程外的 MachO 解析器
- 預先處理了所有可能影響啟動速度的 search path、@rpaths 和環(huán)境變量
- 然后分析 Mach-O 的 Header 和依賴,并完成了所有符號查找的工作
- 最后將這些結(jié)果創(chuàng)建成了一個啟動閉包
- 這是一個普通的 daemon 進程,可以使用通常的測試架構(gòu)
-
一個進程內(nèi)的引擎,用來運行啟動閉包
- 這部分在進程中處理
- 驗證啟動閉包的安全性,然后映射到 dylib 之中,再跳轉(zhuǎn)到 main 函數(shù)
- 不需要解析 Mach-O 的 Header 和依賴,也不需要符號查找。
-
一個啟動閉包緩存服務
- 系統(tǒng) App 的啟動閉包被構(gòu)建在一個 Shared Cache 中, 我們甚至不需要打開一個單獨的文件
- 對于第三方的 App,我們會在 App 安裝或者升級的時候構(gòu)建這個啟動閉包。
- 在 iOS、tvOS、watchOS中,這這一切都是 App 啟動之前完成的。在 macOS 上,由于有 Side Load App,進程內(nèi)引擎會在首次啟動的時候啟動一個 daemon 進程,之后就可以使用啟動閉包啟動了。
-
- dyld 3 把很多耗時的查找、計算和 I/O 的事前都預先處理好了,這使得啟動速度有了很大的提升。
十九、mmap
- mmap 的全稱是 memory map,是一種內(nèi)存映射技術(shù),可以把文件映射到虛擬內(nèi)存的地址空間里,這樣就可以像直接操作內(nèi)存那樣來讀寫文件。當讀取虛擬內(nèi)存,其對應的文件內(nèi)容在物理內(nèi)存中不存在的時候,會觸發(fā)一個事件:File Backed Page In,把對應的文件內(nèi)容讀入物理內(nèi)存。
- 啟動的時候,Mach-O 就是通過 mmap 映射到虛擬內(nèi)存里的(如下圖)。下圖中部分頁被標記為 zero fill,是因為全局變量的初始值往往都是 0,那么這些 0 就沒必要存儲在二進制里,增加文件大小。操作系統(tǒng)會識別出這些頁,在 Page In 之后對其置為 0,這個行為叫做 zero fill。
二十、Page In
- 啟動的路徑上會觸發(fā)很多次 Page In,其實也比較容易理解,因為啟動的會讀寫二進制中的很多內(nèi)容。Page In 會占去啟動耗時的很大一部分,我們來看看單個 Page In 的過程:
- 分析如下:
- MMU 找到空閑的物理內(nèi)存頁面;
- 觸發(fā)磁盤 IO,把數(shù)據(jù)讀入物理內(nèi)存;
- 如果是 TEXT 段的頁,要進行解密;
- 對解密后的頁,進行簽名驗證;
- 其中解密是大頭,IO 其次。為什么要解密呢?因為 iTunes Connect 會對上傳 Mach-O 的 TEXT 段進行加密,防止 IPA 下載下來就直接可以看到代碼。這也就是為什么逆向里會有個概念叫做“砸殼”,砸的就是這一層 TEXT 段加密。iOS 13 對這個過程進行了優(yōu)化,Page In 的時候不需要解密了。
二十一、二進制重排
- 既然 Page In 耗時,有沒有什么辦法優(yōu)化呢?
- 啟動具有局部性特征,即只有少部分函數(shù)在啟動的時候用到,這些函數(shù)在二進制中的分布是零散的,所以 Page In 讀入的數(shù)據(jù)利用率并不高。如果我們可以把啟動用到的函數(shù)排列到二進制的連續(xù)區(qū)間,那么就可以減少 Page In 的次數(shù),從而優(yōu)化啟動時間:
- 以下圖為例,方法 1 和方法 3 是啟動的時候用到的,為了執(zhí)行對應的代碼,就需要兩次 Page In。假如我們把方法 1 和 3 排列到一起,那么只需要一次 Page In,從而提升啟動速度。
- 鏈接器 ld 有個參數(shù)-order_file 支持按照符號的方式排列二進制。獲取啟動時候用到的符號的有很多種方式,這里不做說明。
IPA 構(gòu)建
- 既然要構(gòu)建,那么必然會有一些地方去定義如何構(gòu)建,對應 Xcode 中的兩個配置項:
- Build Phase:以 Target 為維度定義了構(gòu)建的流程。可以在 Build Phase 中插入腳本,來做一些定制化的構(gòu)建,比如 CocoaPod 的拷貝資源就是通過腳本的方式完成的。
- Build Settings:配置編譯和鏈接相關(guān)的參數(shù)。特別要提到的是 other link flags 和 other c flags,因為編譯和鏈接的參數(shù)非常多,有些需要手動在這里配置。很多項目用的 CocoaPod 做的組件化,這時候編譯選項在對應的.xcconfig 文件里。
- 以單 Target 為例,來看下構(gòu)建流程:
- 流程說明:
- 源文件(.m/.c/.swift 等)是單獨編譯的,輸出對應的目標文件(.o)
- 目標文件和靜態(tài)庫/動態(tài)庫一起,鏈接出最后的 Mach-O
- Mach-O 會被裁剪,去掉一些不必要的信息
- 資源文件如 storyboard,asset 也會編譯,編譯后加載速度會變快
- Mach-O 和資源文件一起,打包出最后的.app
- 對.app 簽名,防篡改
編譯
- 編譯器可以分為兩大部分:前端和后端,二者以 IR(中間代碼)作為媒介。這樣前后端分離,使得前后端可以獨立的變化,互不影響。C 語言家族的前端是 clang,swift 的前端是 swiftc,二者的后端都是 llvm。
- 前端負責預處理,詞法語法分析,生成 IR;
- 后端基于 IR 做優(yōu)化,生成機器碼;
- 那么如何利用編譯優(yōu)化啟動速度呢?
代碼數(shù)量會影響啟動速度,為了提升啟動速度,我們可以把一些無用代碼下掉。那怎么統(tǒng)計哪些代碼沒有用到呢?可以利用 LLVM 插樁來實現(xiàn)。LLVM 的代碼優(yōu)化流程是一個一個 Pass,由于 LLVM 是開源的,我們可以添加一個自定義的 Pass,在函數(shù)的頭部插入一些代碼,這些代碼會記錄這個函數(shù)被調(diào)用了,然后把統(tǒng)計到的數(shù)據(jù)上傳分析,就可以知道哪些代碼是用不到的了 。 - Facebook 給 LLVM 提的 order_file[2]的 feature 就是實現(xiàn)了類似的插樁。
鏈接
- 經(jīng)過編譯后,我們有很多個目標文件,接著這些目標文件會和靜態(tài)庫,動態(tài)庫一起,鏈接出一個 Mach-O。鏈接的過程并不產(chǎn)生新的代碼,只會做一些移動和補丁。
- tbd 的全稱是 text-based stub library,是因為鏈接的過程中只需要符號就可以了,所以 Xcode 6 開始,像 UIKit 等系統(tǒng)庫就不提供完整的 Mach-O,而是提供一個只包含符號等信息的 tbd 文件。
- 最開始講解 Page In 的時候,我們提到 TEXT 段的頁解密很耗時,有沒有辦法優(yōu)化呢?可以通過 ld 的-rename_section,把 TEXT 段中的內(nèi)容,比如字符串移動到其他的段(啟動路徑上難免會讀很多字符串),從而規(guī)避這個解密的耗時。
App 啟動
一、啟動定義
- 啟動有兩種定義:
- 廣義:點擊圖標到首頁數(shù)據(jù)加載完畢;
- 狹義:點擊圖標到 Launch Image 完全消失第一幀;
- 不同產(chǎn)品的業(yè)務形態(tài)不一樣,對于抖音來說,首頁的數(shù)據(jù)加載完成就是視頻的第一幀播放;對其他首頁是靜態(tài)的 App 來說,Launch Image 消失就是首頁數(shù)據(jù)加載完成。由于標準很難對齊,所以我們一般使用狹義的啟動定義:即啟動終點為啟動圖完全消失的第一幀。
- 啟動最佳時間是 400ms 以內(nèi),因為啟動動畫時長是 400ms。
- 這是從用戶感知維度定義啟動,那么代碼上如何定義啟動呢?Apple 在 MetricKit 中給出了官方計算方式:
- 起點:進程創(chuàng)建的時間;
- 終點:第一個CA::Transaction::commit();
- CATransaction 是 Core Animation 提供的一種事務機制,把一組 UI 上的修改打包,一起發(fā)給 Render Server 渲染。
二、App 啟動為什么這么重要?
- App 啟動是和用戶的第一個交互過程,所以要盡量縮短這個過程的時間,給用戶一個良好的第一印象;
- 啟動代表了你的代碼的整體性能,如果啟動的性能不好,其他部分的性能可能也不會太好
啟動會占用 CPU 和內(nèi)存,從而影響系統(tǒng)性能和電池; - 所以我們要好好優(yōu)化啟動時間。
三、啟動類型
App 的啟動類型分為三類
- Cold Launch 也就是冷啟動,即為系統(tǒng)里沒有任何進程的緩存信息,典型的是重啟手機后直接啟動 App。冷啟動需要滿足以下幾個條件:
- 重啟之后
- App 不在內(nèi)存中
- 沒有相關(guān)的進程存在
- Warm Launch 也就是熱啟動,即為如果把 App 進程殺了,然后立刻重新啟動,這次啟動就是熱啟動,因為進程緩存還在。熱啟動需要滿足以下幾個條件:
- App 剛被終止
- App 還沒完全從內(nèi)存中移除
- 沒有相關(guān)的進程存在
- Resume Launch 指的是被掛起的 App 繼續(xù)的過程,大多數(shù)時候不會被定義為啟動,因為此時 App 仍然活著,只不過處于 suspended 狀態(tài)。需要滿足以下幾個條件:
- App 被掛起
- App 還全部都在內(nèi)存中
- 還存在相關(guān)的進程
四、App 啟動階段
- App 啟動分為三個階段:
- 初始化 App 的準備工作;
- 繪制第一幀 App 的準備工作及繪制(這里的第一幀并不是獲取到數(shù)據(jù)之后的第一幀,可以是一張占位視圖),這時候用戶與App已經(jīng)可以交互了,比如 tabbar 切換;
- 獲取到頁面的所有數(shù)據(jù)之后的完整的繪制第一幀頁面。
- 在這個地方,蘋果再次強調(diào)了一下,建議「用戶從點擊 App 圖標到可以再次交互,也就是第二階段結(jié)束」的時間最好在 400ms 以內(nèi)。目前來看,大部分 App 都沒有達到這個目標。
- 下面,我們把上面三個階段分成下面這 6 個部分,講一下這幾個階段做了什么以及有什么可以優(yōu)化的地方。
五、啟動優(yōu)化
① System Interface
- 初始化 App 的準備工作,系統(tǒng)主要做了兩個事情:Load dylibs 和 libSystem init;
- 在 2017 年蘋果介紹過 dyld3 給系統(tǒng) App 帶來了多少優(yōu)化,今年 dyld3 正式開發(fā)給開發(fā)者使用,這意味著 iOS 系統(tǒng)會將熱啟動的運行時依賴給緩存起來,以達到減少啟動時間的目的,這也就是提升 200% 的原因之一。
- 除此之外,在 Load dylibs 階段,開發(fā)者還可以做以下優(yōu)化:
- 避免鏈接無用的 frameworks,在 Xcode 中檢查一下項目中的「Linked Frameworks and Librares」部分是否有無用的鏈接。
- 避免在啟動時加載動態(tài)庫,將項目的 Pods 以靜態(tài)編譯的方式打包,尤其是 Swift 項目,這地方的時間損耗是很大的。
- 硬鏈接你的依賴項,這里做了緩存優(yōu)化。
- 也許有人會困惑是不是使用了 dyld3 了,我們就不需要做 Static Link 了,其實還是需要的,感興趣的可以看一下 Static linking vs dyld3 [5] 這篇文章,里面有一個詳細的數(shù)據(jù)對比。
- libSystem init 部分,主要是加載一些優(yōu)先級比較低的系統(tǒng)組件,這部分時間是一個固定的成本,所以我們開發(fā)人員不需要關(guān)心。
② Static Runtime Initializaiton
- 這個階段主要是 Objective-C 和 Swift Runtime 的初始化,會調(diào)用所有的 +load 方法,將類的信息注冊到 runtime 中。
- 在這個階段,原則上不建議開發(fā)者做任何事情,所以為了避免一些啟動時間的損耗,你可以做以下幾個事情:
- 在 framework 開發(fā)時,公開專有的初始化 API;
- 減少在 +load 中做的事情;
- 使用 initialize 進行懶加載初始化工作;
③ UIKit Initializaiton
- 這個階段主要做了兩個事情:
- 實例化 UIApplication 和 UIApplicationDelegate;
- 開始事件處理和系統(tǒng)集成。
- 所以這個階段的優(yōu)化也比較簡單,需要做兩個事情:
- 最大限度的減少 UIApplication 子類初始化時候的工作,更甚至與不子類化 UIApplication;
- 減少 UIApplicationDelegate 的初始化工作。
④ Application Initializaiton
- 這個階段主要是生命周期方法的回調(diào),也正是我們開發(fā)者最熟悉的部分。
- 調(diào)用 UIApplicationDelegate 的 App 生命周期方法:
- UIApplicationDelegate 的 UI 生命周期方法:
- 同時,iOS 13 針對 UISceneDelegate 增加了新的回調(diào):
- 也會在這個階段調(diào)用。感興趣的可以關(guān)注一下 Getting the Most out of Multitasking 這個 Session,暫時沒有視頻資源,懷疑是現(xiàn)場演示翻車了,所以沒有把視頻資源放出來。
- 在這個階段,我們可以做的優(yōu)化:
- 推遲和啟動時無關(guān)的工作
- Senens 之間共享資源
⑤ Fisrt Frame Render
- 這個階段主要做了創(chuàng)建、布局和繪制視圖的工作,并把準備好的第一幀提交給渲染層渲染。會頻繁調(diào)用以下幾個函數(shù):
- 在這個階段,開發(fā)者可以做的優(yōu)化:
- 減少視圖層級,懶加載一些不需要的視圖;
- 優(yōu)化布局,減少約束。
- 更多細節(jié)可以從 WWDC2018 - 220 - High Performance Auto Layout [6] 中了解。
⑥ Extend
- 大部分 App 都會通過異步的方式獲取數(shù)據(jù),并最終呈現(xiàn)給用戶。我們把這一部分稱為 Extend。
- 因為這一部分每個 App 的表現(xiàn)都不一樣,所以蘋果建議開發(fā)者使用 os_signpost 進行測量然后慢慢分析慢慢優(yōu)化。
⑦ load 舉例
- 如果+load 方法里的內(nèi)容很簡單,會影響啟動時間么?比如這樣的一個+load 方法?
- 編譯完了之后,這個函數(shù)會在二進制中的 TEXT 兩個段存在:__text存函數(shù)二進制,cstring存儲字符串 1234。為了執(zhí)行函數(shù),首先要訪問__text觸發(fā)一次 Page In 讀入物理內(nèi)存,為了打印字符串,要訪問__cstring,還會觸發(fā)一次 Page In。
- 為了執(zhí)行這個簡單的函數(shù),系統(tǒng)要額外付出兩次 Page In 的代價,所以 load 函數(shù)多了,page in 會成為啟動性能的瓶頸。
- static initializer 產(chǎn)生的條件:靜態(tài)初始化是從哪來的呢?以下幾種代碼會導致靜態(tài)初始化
- attribute((constructor))
- static class object
- static object in global namespace
- 注意,并不是所有的 static 變量都會產(chǎn)生靜態(tài)初始化,編譯器很智能,對于在編譯期間就能確定的變量是會直接 inline。
- std::string 會合成 static initializer 是因為初始化的時候必須執(zhí)行構(gòu)造函數(shù),這時候編譯器就不知道怎么做了,只能延遲到運行時。
- +load 和 static initializer 執(zhí)行完畢之后,dyld 會把啟動流程交給 App,開始執(zhí)行 main 函數(shù)。main 函數(shù)里要做的最重要的事情就是初始化 UIKit。UIKit 主要會做兩個大的初始化:
- 初始化 UIApplication;
- 啟動主線程的 Runloop;
- 由于主線程的 dispatch_async 是基于 runloop 的,所以在+load 里如果調(diào)用了 dispatch_async 會在這個階段執(zhí)行。
- 線程在執(zhí)行完代碼就會退出,很明顯主線程是不能退出的,那么就需要一種機制:事件來的時候執(zhí)行任務,否則讓線程休眠,Runloop 就是實現(xiàn)這個功能的。
- Runloop 本質(zhì)上是一個While 循環(huán),在圖中橙色部分的 mach_msg_trap 就是觸發(fā)一個系統(tǒng)調(diào)用,讓線程休眠,等待事件到來,喚醒 Runloop,繼續(xù)執(zhí)行這個 while循環(huán)。
- Runloop 主要處理幾種任務:Source0,Source1,Timer,GCD MainQueue,Block。在循環(huán)的合適時機,會以 Observer 的方式通知外部執(zhí)行到了哪里。
- 那么,Runloop 與啟動又有什么關(guān)系呢?
- App 的 LifeCycle 方法是基于 Runloop 的 Source0 的;
- 首幀渲染是基于 Runloop Block 的。
- Runloop 在啟動上主要有幾點應用:
- 精準統(tǒng)計啟動時間;
- 找到一個時機,在啟動結(jié)束去執(zhí)行一些預熱任務;
- 利用 Runloop 打散耗時的啟動預熱任務。
測量 App 啟動時間
- 要找到啟動過程中的問題,就要進行多次測量并前后比較。但是如果變量沒有控制好,就會導致誤差。
- 所以為了保證測量的數(shù)據(jù)能夠真實的反應問題,我們要減少不穩(wěn)定性因素,保證在可控的相近的環(huán)境下進行測量,最后使用一致的結(jié)果來分析。
- ① 條件一致性
- 為了保證環(huán)境一致,我們可以做下面這幾個事情:
- 重啟手機,并等待 2-3 分鐘
- 啟用飛行模式或者使用模擬網(wǎng)絡
- 不使用或者不變更 iCloud 的賬戶
- 使用 release 模式進行 build
- 測量熱啟動時間
- iColud 賬戶切換會影響性能,所以不要切換賬號或者不開啟 iCloud。
- 為了保證環(huán)境一致,我們可以做下面這幾個事情:
- ② 測量注意點
- 盡可能的使用具有代表性的數(shù)據(jù)進行測試;
- 如果不使用具有代表性的數(shù)據(jù)進行測試,就會出現(xiàn)偏差;
- 使用不同的新舊設(shè)備進行測試;
- 最后你還可以使用 XCTest 來測試,多運行幾次,取平均結(jié)果。
- ③ 關(guān)于使用 XCTest 測試啟動時間的信息,可以看一下 WWDC2019 - 417 - Improving Battery Life and Performance [7],但是我測試了一下,目前好像還有一部分 API 還沒有開放出來,暫時還不能使用。
使用 Instruments 分析和優(yōu)化 App 啟動過程
一、Minimize Work
- 推遲與第一幀無關(guān)的工作
- 從主線程移開阻塞工作
- 減少內(nèi)存使用量
二、Prioritize Work
- 定義好任務的優(yōu)先級
- 利用好 GCD 來優(yōu)化你的啟動速度
- 讓重要的事情保持優(yōu)先
三、Optimize Work
- 簡化現(xiàn)有工作,比如只請求必要的數(shù)據(jù)
- 優(yōu)化算法和數(shù)據(jù)結(jié)構(gòu)
- 緩存資源和計算
四、使用 Instruments 分析 App 啟動過程
- 當知道如何優(yōu)化之后,我們需要針對我們的啟動過程進行分析。Xcode 11 的 Instruments 為此新增了一個 App launch 模板,讓開發(fā)者可以更好的分析自己 App 的啟動速度。
- 運行后可以看到各個階段的具體時間,根據(jù)數(shù)據(jù)進行優(yōu)化,還能看到耗時的函數(shù)調(diào)用。
系統(tǒng)優(yōu)化
- 蘋果做了很多優(yōu)化,下面這幾個高亮的是和啟動速度有關(guān)的優(yōu)化:
- 但是不知道是不是時間原因,在 session 中對于這部分的解釋特別少,很難理解 200% 到底做了什么。
- 但是 Craig Federighi 在 The Talk Show Live From WWDC 2019, With Craig Federighi and Greg Joswiak[9] 中針對為什么優(yōu)化了 200% 說了這樣一段話:
Isn’t that crazy that was quite a discovery for us. No it turns out that over times as in terms of the way the apps were encrypted and the way fair play worked and so forth. The encryption became part of the critical path actually of launching the apps. I mean the processors are capable or up and through the thing that actually it was a problem. And then there are other optimizations that based on what was visible to system at certain things. And so it actually cut out optimization opportunities and so when we really identified that opportunity we said okay. We can actually come up with better format that’s gonna eliminate that being on the critical path, It’s going to enable all these pre-binding things. And then we did a whole bunch of other work to optimize the objective-c runtime to optimize the linker the dynamic linker a bunch of other things and you put it all together. And yeah that I mean a cold launch this is we’ve never had a win like this to launch time in a single release.
- 從這段話中,除了 dyld3 的功勞之外,減少對代碼簽名加密也是優(yōu)化之一。
監(jiān)控線上用戶 App 的啟動
- Xcode 11 在 Xcode Organizer 新增了一個監(jiān)控面板,在這個面板里面可以查看多個維度的用戶數(shù)據(jù),其中還包括平均啟動時間。
- 當你通過 Instruments 分析完你的啟動過程,并做了大量優(yōu)化之后,你就可以通過 Xcode Organizer 來分析你這次優(yōu)化效果到底怎么樣。
- 當然你可以通過去年新出的 MetricKit [10] 獲取一些自定義的數(shù)據(jù),具體參照 WWDC2019 - 417 -Improving Battery Life and Performance [11]。
參考資料
- [1] WWDC 2019 keynote
- [2] WWDC2019 - 423 - Optimizing App Launch
- [3] dyld啟動流程
- [4] WWDC2017 - 413 - App Startup Time: Past, Present, and Future
- [5] Static linking vs dyld3
- [6] WWDC2018 - 220 - High Performance Auto Layout
- [7] WWDC2019 - 417 - Improving Battery Life and Performance
- [8] WWDC2017 - 706 - Modernizing Grand Central Dispatch Usage
- [9] The Talk Show Live From WWDC 2019, With Craig Federighi and Greg Joswiak
- [10] MetricKit
- [11] WWDC2019 - 417 -Improving Battery Life and Performance
總結(jié)
以上是生活随笔為你收集整理的iOS之性能优化·优化App的启动速度的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: iOS之深入解析dyld与ObjC关联的
- 下一篇: iOS之LLVM编译流程和Clang插件