本文首發個人博客:iOS App 啓動性能優化
應用啓動時間,直接影響用戶對一款應用的判斷和使用體驗。ZAKER新聞
本身就包含非常多並且複雜度高的業務模塊(如新聞、視頻等),也接入了很多第三方的插件,這勢必會拖慢應用的啓動時間,本着精益求精的態度和對用戶體驗的追求,我們希望在業務擴張的同時最大程度的優化啓動時間。
啓動時間
總時間 = T1 + T2
T1
加載系統dylib
和可執行文件
的時間。
T2
從main
到applicationWillFinishLaunching
結束的時間。
App啓動過程
1)解析Info.plist
- 加載相關信息,例如如閃屏
- 沙箱建立、權限檢查
2)Mach-O
加載
- 如果是胖二進制文件,尋找合適當前CPU類別的部分
- 加載所有依賴的
Mach-O
文件(遞歸調用Mach-O
加載的方法) - 定位內部、外部指針引用,例如字符串、函數等
- 執行聲明爲
__attribute__((constructor))
的C函數 - 加載類擴展(Category)中的方法
- C++靜態對象加載、調用ObjC的
+load
函數
3)程序執行
- 調用
main()
- 調用
UIApplicationMain()
- 調用
applicationWillFinishLaunching
Mach-O
Mach-O 是針對不同運行時可執行文件的文件類型。
文件類型:
Executable: 應用的主要二進制
Dylib: 動態鏈接庫(又稱 DSO 或 DLL)
Bundle: 不能被鏈接的 Dylib,只能在運行時使用 dlopen() 加載,可當做 macOS 的插件。
Image: executable,dylib 或 bundle
Framework: 包含 Dylib 以及資源文件和頭文件的文件夾
Mach-O 鏡像文件
Mach-O 被劃分成一些 segement,每個 segement 又被劃分成一些 section。
segment 的名字都是大寫的,且空間大小爲頁的整數。頁的大小跟硬件有關,在 arm64 架構一頁是 16KB,其餘爲 4KB。
section 雖然沒有整數倍頁大小的限制,但是 section 之間不會有重疊。
幾乎所有 Mach-O 都包含這三個段(segment): __TEXT
,__DATA
和 __LINKEDIT
:
__TEXT
包含 Mach header,被執行的代碼和只讀常量(如C 字符串)。只讀可執行(r-x)。__DATA
包含全局變量,靜態變量等。可讀寫(rw-)。__LINKEDIT
包含了加載程序的『元數據』,比如函數的名稱和地址。只讀(r–)。
Mach-O Universal 文件
FAT 二進制文件,將多種架構的 Mach-O 文件合併而成。它通過 Fat Header 來記錄不同架構在文件中的偏移量,Fat Header 佔一頁的空間。
按分頁來存儲這些 segement 和 header 會浪費空間,但這有利於虛擬內存的實現。
什麼是image
1.executable可執行文件 比如.o文件。
2.dylib 動態鏈接庫 framework就是動態鏈接庫和相應資源包含在一起的一個文件夾結構。
3.bundle 資源文件 只能用dlopen加載,不推薦使用這種方式加載。
除了我們App本身的可行性文件,系統中所有的framework比如UIKit、Foundation等都是以動態鏈接庫的方式集成進App中的。
什麼是ImageLoader
image 表示一個二進制文件(可執行文件或 so 文件),裏面是被編譯過的符號、代碼等,所以 ImageLoader 作用是將這些文件加載進內存,且每一個文件對應一個ImageLoader實例來負責加載。
兩步走:在程序運行時它先將動態鏈接的 image 遞歸加載 (也就是上面測試棧中一串的遞歸調用的時刻)。 再從可執行文件 image 遞歸加載所有符號。
冷啓動和熱啓動
冷啓動
應用首次啓動。即後臺線程中未有當前打開的應用,所有的資源都需要加載並初始化。
熱啓動
應用非首次啓動。即後臺線程中保留有當前應用,應用的資源在內存中有保存。
啓動時間分析
1)開啓時間分析功能
在Xcode的菜單中選擇Project
→Scheme
→Edit Scheme...
,然後找到Run
→ Environment Variables
→+
,添加name
爲DYLD_PRINT_STATISTICSvalue
爲1
的環境變量。
load dylibs image
在每個動態庫的加載過程中, dyld需要:
1.分析所依賴的動態庫
2.找到動態庫的mach-o文件
3.打開文件
4.驗證文件
5.在系統核心註冊文件簽名
6.對動態庫的每一個segment調用mmap()
通常的,一個App需要加載100到400個dylibs, 但是其中的系統庫被優化,可以很快的加載。 針對這一步驟的優化有:
1.減少非系統庫的依賴
2.合併非系統庫
3.使用靜態資源,比如把代碼加入主程序
rebase/bind
由於ASLR(address space layout randomization)的存在,可執行文件和動態鏈接庫在虛擬內存中的加載地址每次啓動都不固定,所以需要這2步來修復鏡像中的資源指針,來指向正確的地址。 rebase修復的是指向當前鏡像內部的資源指針; 而bind指向的是鏡像外部的資源指針。
rebase步驟先進行,需要把鏡像讀入內存,並以page爲單位進行加密驗證,保證不會被篡改,所以這一步的瓶頸在IO。bind在其後進行,由於要查詢符號表,來指向跨鏡像的資源,加上在rebase階段,鏡像已被讀入和加密驗證,所以這一步的瓶頸在於CPU計算。
優化該階段的關鍵在於減少__DATA segment中的指針數量。我們可以優化的點有:
1.減少Objc類數量, 減少selector數量
2.減少C++虛函數數量
3.轉而使用swift stuct(其實本質上就是爲了減少符號的數量)
解讀
-
main()
函數之前總共使用了506.48ms - 在506.48ms中,加載動態庫用了46.35ms,指針重定位使用了137.72ms,ObjC類初始化使用了95.39ms,各種初始化使用了226.92ms。
- 在初始化耗費的226.92ms中,用時最多的幾個初始化是
libSystem.B.dylib
、libBacktraceRecording.dylib
、libglInterpose.dylib
以及libMTLInterpose.dylib
。
2)使用instruments工作分析具體時間消耗點
耗時的影響因素
1) main()
函數之前耗時的影響因素
- 動態庫加載越多,啓動越慢。
- ObjC類越多,啓動越慢
- C的
constructor
函數越多,啓動越慢 - C++靜態對象越多,啓動越慢
- ObjC的
+load
越多,啓動越慢
實驗證明,在ObjC類的數目一樣多的情況下,需要加載的動態庫越多,App啓動就越慢。同樣的,在動態庫一樣多的情況下,ObjC的類越多,App的啓動也越慢。需要加載的動態庫從1個上升到10個的時候,用戶幾乎感知不到任何分別,但從10個上升到100個的時候就會變得十分明顯。同理,100個類和1000個類,可能也很難查察覺得出,但1000個類和10000個類的分別就開始明顯起來。
同樣的,儘量不要寫__attribute__((constructor))
的C函數,也儘量不要用到C++的靜態對象;至於ObjC的+load
方法,似乎大家已經習慣不用它了。任何情況下,能用dispatch_once()
來完成的,就儘量不要用到以上的方法。
2) main()
函數之後耗時的影響因素
從main()
函數開始至applicationWillFinishLaunching
結束,我們統一稱爲main()
函數之後的部分。
- 執行
main()
函數的耗時 - 執行
applicationWillFinishLaunching
的耗時 -
rootViewController
及其childViewController
的加載、view
及其subviews
的加載
實踐
移除不需要用到的類
爲了解決這個歷史問題,我使用了一個叫做fui(Find Unused Imports)的開源項目,它能很好的分析出不再使用的類,準確率非常高,唯一的問題是它處理不了動態庫和靜態庫裏提供的類,也處理不了C++的類模板。
使用方法是在Terminal
中cd
到項目所在的目錄,然後執行fui find
,然後等上那麼幾分鐘(是的你沒有看錯,真的需要好幾分鐘甚至需要更長的時間),就可以得到一個列表了。由於這個工具還不是100%靠譜,可根據這個列表,在Xcode中手動檢查並刪除不再用到的類。
合併功能類似的類和擴展(Category)
優化application:didFinishLaunchingWithOptions:
方法
優化rootViewController
加載
問題
1)NSUserDefaults
是否是瓶頸
2)還有其他哪些點可以做優化
參考文檔:《優化 App 的啓動時間》