歡迎訪問我的博客原文
當(dāng) App 中的業(yè)務(wù)模塊越來越多、越來越復(fù)雜汰现,集成了更多的三方庫,App 啟動也會越來越慢叔壤,因此我們希望能在業(yè)務(wù)擴(kuò)張的同時瞎饲,保持較優(yōu)的啟動速度,給用戶帶來良好的使用體驗(yàn)炼绘。
熱啟動與冷啟動
當(dāng)用戶按下 home 鍵嗅战,iOS App 不會立刻被 kill,而是存活一段時間俺亮,這段時間里用戶再打開 App仗哨,App 基本上不需要做什么,就能還原到退到后臺前的狀態(tài)铅辞。我們把 App 進(jìn)程還在系統(tǒng)中厌漂,無需開啟新進(jìn)程的啟動過程稱為熱啟動。
而冷啟動則是指 App 不在系統(tǒng)進(jìn)程中斟珊,比如設(shè)備重啟后苇倡,或是手動殺死 App 進(jìn)程富纸,又或是 App 長時間未打開過,用戶再點(diǎn)擊啟動 App 的過程旨椒,這時需要創(chuàng)建一個新進(jìn)程分配給 App晓褪。我們可以將冷啟動看作一次完整的 App 啟動過程,本文討論的就是冷啟動的優(yōu)化综慎。
冷啟動概要
WWDC 2016 中首次出現(xiàn)了 App 啟動優(yōu)化的話題涣仿,其中提到:
- App 啟動最佳速度是400ms以內(nèi),因?yàn)閺狞c(diǎn)擊 App 圖標(biāo)啟動示惊,然后 Launch Screen 出現(xiàn)再消失的時間就是400ms好港;
- App 啟動最慢不得大于20s,否則進(jìn)程會被系統(tǒng)殺死米罚;(啟動時間最好以 App 所支持的最低配置設(shè)備為準(zhǔn)钧汹。)
冷啟動的整個過程是指從用戶喚起 App 開始到 AppDelegate 中的 didFinishLaunchingWithOptions
方法執(zhí)行完畢為止,并以執(zhí)行 main()
函數(shù)的時機(jī)為分界點(diǎn)录择,分為 pre-main
和 main()
兩個階段拔莱。
也有一種說法是將整個冷啟動階段以主 UI 框架的 viewDidAppear
函數(shù)執(zhí)行完畢才算結(jié)束。這兩種說法都可以隘竭,前者的界定范圍是 App 啟動和初始化完畢塘秦,后者的界定范圍是用戶視角的啟動完畢,也就是首屏已經(jīng)被加載出來动看。
注意:這里很多文章都會把第二個階段描述為 main 函數(shù)之后尊剔,個人認(rèn)為這種說法不是很好,容易讓人誤解弧圆。要知道 main 函數(shù)在 App 運(yùn)行過程中是不會退出的赋兵,無論是 AppDelegate 中的
didFinishLaunchingWithOptions
方法還是 ViewController 中的viewDidAppear
方法笔咽,都還是在 main 函數(shù)內(nèi)部執(zhí)行的搔预。
pre-main 階段
pre-main
階段指的是從用戶喚起 App 到 main()
函數(shù)執(zhí)行之前的過程。
查看階段耗時
我們可以在 Xcode 中配置環(huán)境變量 DYLD_PRINT_STATISTICS
為 1(Edit Scheme → Run → Arguments → Environment Variables → +
)叶组。
這時在 iOS 10 以上系統(tǒng)中運(yùn)行一個 TestDemo拯田,pre-main
階段的啟動時間會在控制臺中打印出來。
Total pre-main time: 354.21 milliseconds (100.0%)
dylib loading time: 25.52 milliseconds (7.2%)
rebase/binding time: 12.70 milliseconds (3.5%)
ObjC setup time: 152.74 milliseconds (43.1%)
initializer time: 163.24 milliseconds (46.0%)
slowest intializers :
libSystem.B.dylib : 7.98 milliseconds (2.2%)
libBacktraceRecording.dylib : 13.53 milliseconds (3.8%)
libMainThreadChecker.dylib : 41.11 milliseconds (11.6%)
TestDemo : 88.76 milliseconds (25.0%)
如果要更詳細(xì)的信息甩十,就設(shè)置 DYLD_PRINT_STATISTICS_DETAILS
為 1船庇。
total time: 1.6 seconds (100.0%)
total images loaded: 388 (381 from dyld shared cache)
total segments mapped: 23, into 413 pages
total images loading time: 805.78 milliseconds (48.6%)
total load time in ObjC: 152.74 milliseconds (9.2%)
total debugger pause time: 780.26 milliseconds (47.1%)
total dtrace DOF registration time: 0.00 milliseconds (0.0%)
total rebase fixups: 54,265
total rebase fixups time: 20.77 milliseconds (1.2%)
total binding fixups: 527,211
total binding fixups time: 513.54 milliseconds (31.0%)
total weak binding fixups time: 0.31 milliseconds (0.0%)
total redo shared cached bindings time: 521.93 milliseconds (31.5%)
total bindings lazily fixed up: 0 of 0
total time in initializers and ObjC +load: 163.24 milliseconds (9.8%)
libSystem.B.dylib : 7.98 milliseconds (0.4%)
libBacktraceRecording.dylib : 13.53 milliseconds (0.8%)
libMainThreadChecker.dylib : 41.11 milliseconds (2.4%)
libViewDebuggerSupport.dylib : 6.68 milliseconds (0.4%)
TestDemo : 88.76 milliseconds (5.3%)
total symbol trie searches: 1306942
total symbol table binary searches: 0
total images defining weak symbols: 41
total images using weak symbols: 105
這里統(tǒng)計(jì)到的啟動耗時出現(xiàn)一定波動是正常的,無須過分在意侣监。
理論知識
為了更準(zhǔn)確地了解 App 啟動的流程鸭轮,我們先熟悉一下幾個概念。
Mach-O
Mach-O(Mach Object File Format)是一種用于記錄可執(zhí)行文件橄霉、對象代碼窃爷、共享庫、動態(tài)加載代碼和內(nèi)存轉(zhuǎn)儲的文件格式。App 編譯生成的二進(jìn)制可執(zhí)行文件就是 Mach-O 格式的按厘,iOS 工程所有的類編譯后會生成對應(yīng)的目標(biāo)文件 .o
文件医吊,而這個可執(zhí)行文件就是這些 .o
文件的集合。
在 Xcode 的控制臺輸入以下命令逮京,可以打印出運(yùn)行時所有加載進(jìn)應(yīng)用程序的 Mach-O 文件约巷。
image list -o -f
Mach-O 文件主要由三部分組成:
- Mach header:描述 Mach-O 的 CPU 架構(gòu)弟塞、文件類型以及加載命令等;
- Load commands:描述了文件中數(shù)據(jù)的具體組織結(jié)構(gòu),不同的數(shù)據(jù)類型使用不同的加載命令反粥;
- Data:Data 中的每個段(segment)的數(shù)據(jù)都保存在這里,每個段都有一個或多個 Section茸习,它們存放了具體的數(shù)據(jù)與代碼柔逼,主要包含這三種類型:
-
__TEXT
包含 Mach header,被執(zhí)行的代碼和只讀常量(如C 字符串)享钞。只讀可執(zhí)行(r-x)揍诽。 -
__DATA
包含全局變量,靜態(tài)變量等栗竖∈畲啵可讀寫(rw-)。 -
__LINKEDIT
包含了加載程序的元數(shù)據(jù)狐肢,比如函數(shù)的名稱和地址添吗。只讀(r–-)。
-
dylib
dylib 也是一種 Mach-O 格式的文件份名,后綴名為 .dylib
的文件就是動態(tài)庫(也叫動態(tài)鏈接庫)碟联。動態(tài)庫是運(yùn)行時加載的,可以被多個 App 的進(jìn)程共用僵腺。
如果想知道 TestDemo 中依賴的所有動態(tài)庫鲤孵,可以通過下面的指令實(shí)現(xiàn):
otool -L /TestDemo.app/TestDemo
動態(tài)鏈接庫分為系統(tǒng) dylib 和內(nèi)嵌 dylib(embed dylib,即開發(fā)者手動引入的動態(tài)庫)辰如。系統(tǒng) dylib 有:
- iOS 中用到的所有系統(tǒng) framework普监,比如 UIKit、Foundation琉兜;
- 系統(tǒng)級別的 libSystem(如 libdispatch(GCD) 和 libsystem_blocks(Block))凯正;
- 加載 OC runtime 方法的 libobjc;
- ……
dyld
dyld(Dynamic Link Editor):動態(tài)鏈接器豌蟋,其本質(zhì)也是 Mach-O 文件廊散,一個專門用來加載 dylib 文件的庫。 dyld 位于 /usr/lib/dyld
梧疲,可以在 mac 和越獄機(jī)中找到允睹。dyld 會將 App 依賴的動態(tài)庫和 App 文件加載到內(nèi)存后執(zhí)行施符。
dyld shared cache
dyld shared cache 就是動態(tài)庫共享緩存。當(dāng)需要加載的動態(tài)庫非常多時擂找,相互依賴的符號也更多了戳吝,為了節(jié)省解析處理符號的時間,OS X 和 iOS 上的動態(tài)鏈接器使用了共享緩存贯涎。OS X 的共享緩存位于 /private/var/db/dyld/
听哭,iOS 的則在 /System/Library/Caches/com.apple.dyld/
。
當(dāng)加載一個 Mach-O 文件時塘雳,dyld 首先會檢查是否存在于共享緩存陆盘,存在就直接取出使用。每一個進(jìn)程都會把這個共享緩存映射到了自己的地址空間中败明。這種方法大大優(yōu)化了 OS X 和 iOS 上程序的啟動時間隘马。
images
images 在這里不是指圖片,而是鏡像妻顶。每個 App 都是以 images 為單位進(jìn)行加載的酸员。images 類型包括:
- executable:應(yīng)用的二進(jìn)制可執(zhí)行文件;
- dylib:動態(tài)鏈接庫讳嘱;
- bundle:資源文件幔嗦,屬于不能被鏈接的 dylib,只能在運(yùn)行時通過
dlopen()
加載沥潭。
framework
framework 可以是動態(tài)庫邀泉,也是靜態(tài)庫,是一個包含 dylib钝鸽、bundle 和頭文件的文件夾汇恤。
啟動過程分析與優(yōu)化
啟動一個應(yīng)用時,系統(tǒng)會通過 fork()
方法來新創(chuàng)建一個進(jìn)程拔恰,然后執(zhí)行鏡像通過 exec()
來替換為另一個可執(zhí)行程序因谎,然后執(zhí)行如下操作:
- 把可執(zhí)行文件加載到內(nèi)存空間,從可執(zhí)行文件中能夠分析出 dyld 的路徑仁连;
- 把 dyld 加載到內(nèi)存蓝角;
- dyld 從可執(zhí)行文件的依賴開始阱穗,遞歸加載所有的依賴動態(tài)鏈接庫 dylib 并進(jìn)行相應(yīng)的初始化操作饭冬。
結(jié)合上面 pre-main
打印的結(jié)果,我們可以大致了解整個啟動過程如下圖所示:
Load Dylibs
這一步揪阶,指的是動態(tài)庫加載昌抠。在此階段,dyld 會:
- 分析 App 依賴的所有 dylib鲁僚;
- 找到 dylib 對應(yīng)的 Mach-O 文件炊苫;
- 打開裁厅、讀取這些 Mach-O 文件,并驗(yàn)證其有效性侨艾;
- 在系統(tǒng)內(nèi)核中注冊代碼簽名执虹;
- 對 dylib 的每一個 segment 調(diào)用
mmap()
。
一般情況下唠梨,iOS App 需要加載 100-400 個 dylibs袋励。這些動態(tài)庫包括系統(tǒng)的,也包括開發(fā)者手動引入的当叭。其中大部分 dylib 都是系統(tǒng)庫茬故,系統(tǒng)已經(jīng)做了優(yōu)化,因此開發(fā)者更應(yīng)關(guān)心自己手動集成的內(nèi)嵌 dylib蚁鳖,加載它們時性能開銷較大磺芭。
App 中依賴的 dylib 越少越好,Apple 官方建議盡量將內(nèi)嵌 dylib 的個數(shù)維持在6個以內(nèi)醉箕。
優(yōu)化方案:
- 盡量不使用內(nèi)嵌 dylib钾腺;
- 合并已有內(nèi)嵌 dylib;
- 檢查 framework 的
optional
和required
設(shè)置讥裤,如果 framework 在當(dāng)前的 App 支持的 iOS 系統(tǒng)版本中都存在垮庐,就設(shè)為required
,因?yàn)樵O(shè)為optional
會有額外的檢查坞琴; - 使用靜態(tài)庫作為代替哨查;(不過靜態(tài)庫會在編譯期被打進(jìn)可執(zhí)行文件,造成可執(zhí)行文件體積增大剧辐,兩者各有利弊寒亥,開發(fā)者自行權(quán)衡。)
- 懶加載 dylib荧关。(但使用
dlopen()
對性能會產(chǎn)生影響溉奕,因?yàn)?App 啟動時是原本是單線程運(yùn)行,系統(tǒng)會取消加鎖忍啤,但dlopen()
開啟了多線程加勤,系統(tǒng)不得不加鎖,這樣不僅會使性能降低同波,可能還會造成死鎖及未知的后果鳄梅,不是很推薦這種做法。)
Rebase/Binding
這一步未檩,做的是指針重定位戴尸。
在 dylib 的加載過程中,系統(tǒng)為了安全考慮冤狡,引入了 ASLR(Address Space Layout Randomization)技術(shù)和代碼簽名孙蒙。由于 ASLR 的存在项棠,鏡像會在新的隨機(jī)地址(actual_address)上加載,和之前指針指向的地址(preferred_address)會有一個偏差(slide挎峦,slide=actual_address-preferred_address)香追,因此 dyld 需要修正這個偏差,指向正確的地址坦胶。具體通過這兩步實(shí)現(xiàn):
第一步:Rebase翅阵,在 image 內(nèi)部調(diào)整指針的指向。將 image 讀入內(nèi)存迁央,并以 page 為單位進(jìn)行加密驗(yàn)證掷匠,保證不會被篡改,性能消耗主要在 IO岖圈。
第二步:Binding讹语,符號綁定。將指針指向 image 外部的內(nèi)容蜂科。查詢符號表顽决,設(shè)置指向鏡像外部的指針,性能消耗主要在 CPU 計(jì)算导匣。
通過以下命令可以查看 rebase 和 bind 等信息:
xcrun dyldinfo -rebase -bind -lazy_bind TestDemo.app/TestDemo
通過 LC_DYLD_INFO_ONLY 可以查看各種信息的偏移量和大小才菠。如果想要更方便直觀地查看,推薦使用 MachOView 工具贡定。
指針數(shù)量越少赋访,指針修復(fù)的耗時也就越少。所以缓待,優(yōu)化該階段的關(guān)鍵就是減少 __DATA
段中的指針數(shù)量蚓耽。
優(yōu)化方案:
- 減少 ObjC 類(class)、方法(selector)旋炒、分類(category)的數(shù)量步悠,比如合并一些功能,刪除無效的類瘫镇、方法和分類等(可以借助 AppCode 的 Inspect Code 功能進(jìn)行代碼瘦身)鼎兽;
- 減少 C++ 虛函數(shù);(虛函數(shù)會創(chuàng)建 vtable铣除,這也會在
__DATA
段中創(chuàng)建結(jié)構(gòu)谚咬。) - 多用 Swift Structs。(因?yàn)?Swift Structs 是靜態(tài)分發(fā)的通孽,它的結(jié)構(gòu)內(nèi)部做了優(yōu)化序宦,符號數(shù)量更少睁壁。)
ObjC Setup
完成 Rebase 和 Bind 之后背苦,通知 runtime 去做一些代碼運(yùn)行時需要做的事情:
- dyld 會注冊所有聲明過的 ObjC 類互捌;
- 將分類插入到類的方法列表中;
- 檢查每個 selector 的唯一性行剂。
優(yōu)化方案:
Rebase/Binding 階段優(yōu)化好了秕噪,這一步的耗時也會相應(yīng)減少。
Initializers
Rebase 和 Binding 屬于靜態(tài)調(diào)整(fix-up)厚宰,修改的是 __DATA
段中的內(nèi)容腌巾,而這里則開始動態(tài)調(diào)整,往堆和棧中寫入內(nèi)容铲觉。具體工作有:
- 調(diào)用每個 Objc 類和分類中的
+load
方法澈蝙; - 調(diào)用 C/C++ 中的構(gòu)造器函數(shù)(用
attribute((constructor))
修飾的函數(shù)); - 創(chuàng)建非基本類型的 C++ 靜態(tài)全局變量撵幽。
優(yōu)化方案:
- 盡量避免在類的
+load
方法中初始化灯荧,可以推遲到+initiailize
中進(jìn)行;(因?yàn)樵谝粋€+load
方法中進(jìn)行運(yùn)行時方法替換操作會帶來 4ms 的消耗) - 避免使用
__atribute__((constructor))
將方法顯式標(biāo)記為初始化器盐杂,而是讓初始化方法調(diào)用時再執(zhí)行逗载。比如用dispatch_once()
、pthread_once()
或std::once()
链烈,相當(dāng)于在第一次使用時才初始化厉斟,推遲了一部分工作耗時。: - 減少非基本類型的 C++ 靜態(tài)全局變量的個數(shù)强衡。(因?yàn)檫@類全局變量通常是類或者結(jié)構(gòu)體擦秽,如果在構(gòu)造函數(shù)中有繁重的工作,就會拖慢啟動速度)
總結(jié)一下 pre-main
階段可行的優(yōu)化方案:
- 重新梳理架構(gòu)漩勤,減少不必要的內(nèi)置動態(tài)庫數(shù)量
- 進(jìn)行代碼瘦身号涯,合并或刪除無效的ObjC類、Category锯七、方法链快、C++ 靜態(tài)全局變量等
- 將不必須在
+load
方法中執(zhí)行的任務(wù)延遲到+initialize
中 - 減少 C++ 虛函數(shù)
main() 階段
對于 main()
階段,主要測量的就是從 main()
函數(shù)開始執(zhí)行到 didFinishLaunchingWithOptions
方法執(zhí)行結(jié)束的耗時眉尸。
查看階段耗時
這里介紹兩種查看 main()
階段耗時的方法域蜗。
方法一:手動插入代碼,進(jìn)行耗時計(jì)算噪猾。
// 第一步:在 main() 函數(shù)里用變量 MainStartTime 記錄當(dāng)前時間
CFAbsoluteTime MainStartTime;
int main(int argc, char * argv[]) {
MainStartTime = CFAbsoluteTimeGetCurrent();
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
// 第二步:在 AppDelegate.m 文件中用 extern 聲明全局變量 MainStartTime
extern CFAbsoluteTime MainStartTime;
// 第三步:在 didFinishLaunchingWithOptions 方法結(jié)束前霉祸,再獲取一下當(dāng)前時間,與 MainStartTime 的差值就是 main() 函數(shù)階段的耗時
double mainLaunchTime = (CFAbsoluteTimeGetCurrent() - MainStartTime);
NSLog(@"main() 階段耗時:%.2fms", mainLaunchTime * 1000);
方法二:借助 Instruments 的 Time Profiler 工具查看耗時袱蜡。
打開方式為:Xcode → Open Developer Tool → Instruments → Time Profiler
丝蹭。
操作步驟:
配置 Scheme。點(diǎn)擊
Edit Scheme
找到Profile
下的Build Configuration
坪蚁,設(shè)置為Debug
奔穿。配置 PROJECT镜沽。點(diǎn)擊 PROJECT,在
Build Settings
中找到Build Options
選項(xiàng)里的Debug Information Format
贱田,把Debug
對應(yīng)的值改為DWARF with dSYM File
缅茉。啟動 Time Profiler,點(diǎn)擊左上角紅色圓形按鈕開始檢測男摧,然后就可以看到執(zhí)行代碼的完整路徑和對應(yīng)的耗時蔬墩。
為了方面查看應(yīng)用程序中實(shí)際代碼的執(zhí)行耗時和代碼路徑實(shí)際所在的位置,可以勾選上 Call Tree
中的 Separate Thread
和 Hide System Libraries
耗拓。
啟動優(yōu)化
main()
被調(diào)用之后拇颅,didFinishLaunchingWithOptions
階段,App 會進(jìn)行必要的初始化操作乔询,而 viewDidAppear
執(zhí)行結(jié)束之前則是做了首頁內(nèi)容的加載和顯示蔬蕊。
關(guān)于 App 的初始化,除了統(tǒng)計(jì)哥谷、日志這種須要在 App 一啟動就配置的事件岸夯,有一些配置也可以考慮延遲加載。如果你在 didFinishLaunchingWithOptions
中同時也涉及到了首屏的加載们妥,那么可以考慮從這些角度優(yōu)化:
- 用純代碼的方式猜扮,而不是 xib/Storyboard,來加載首頁視圖
- 延遲暫時不需要的二方/三方庫加載监婶;
- 延遲執(zhí)行部分業(yè)務(wù)邏輯和 UI 配置旅赢;
- 延遲加載/懶加載部分視圖;
- 避免首屏加載時大量的本地/網(wǎng)絡(luò)數(shù)據(jù)讀然蠡獭煮盼;
- 在 release 包中移除 NSLog 打印带污;
- 在視覺可接受的范圍內(nèi)僵控,壓縮頁面中的圖片大小鱼冀;
- ……
如果首屏為 H5 頁面报破,針對它的優(yōu)化,參考 VasSonic 的原理千绪,可以從這幾個角度入手:
-
終端耗時
- webView 預(yù)加載:在 App 啟動時期預(yù)先加載了一次 webView充易,通過創(chuàng)建空的 webView,預(yù)先啟動 Web 線程荸型,完成一些全局性的初始化工作盹靴,對二次創(chuàng)建 webView 能有數(shù)百毫秒的提升。
-
頁面耗時(靜態(tài)頁面)
- 靜態(tài)直出:服務(wù)端拉取數(shù)據(jù)后通過 Node.js 進(jìn)行渲染,生成包含首屏數(shù)據(jù)的 HTML 文件稿静,發(fā)布到 CDN 上梭冠,webView 直接從 CDN 上獲取自赔;
- 離線預(yù)推:使用離線包妈嘹。
-
頁面耗時(經(jīng)常需要動態(tài)更新的頁面)
- 并行加載:WebView 的打開和資源的請求并行柳琢;
- 動態(tài)緩存:動態(tài)頁面緩存在客戶端绍妨,用戶下次打開的時候先打開緩存頁面,然后再刷新柬脸;
- 動靜分離:將頁面分為靜態(tài)模板和動態(tài)數(shù)據(jù)他去,根據(jù)不同的啟動場景進(jìn)行不同的刷新方案;
- 預(yù)加載:提前拉取需要的增量更新數(shù)據(jù)倒堕。
小結(jié)
隨著業(yè)務(wù)的增長灾测,App 中的模塊越來越多,冷啟動的時間也必不可少地增加垦巴。冷啟動本就是一個比較復(fù)雜的流程媳搪,它的優(yōu)化沒有固定的公式,我們需要結(jié)合業(yè)務(wù)骤宣,配合一些性能分析工具和線上監(jiān)控日志秦爆,有耐心、多維度地進(jìn)行分析和解決憔披。
參考鏈接:
WWDC2016: Optimizing App Startup Time
WWDC2017: App Startup Time: Past, Present, and Future
優(yōu)化 App 的啟動時間
今日頭條 iOS 客戶端啟動速度優(yōu)化
VasSonic 源碼