歡迎訪問個人博客原文html
當 App 中的業務模塊愈來愈多、愈來愈複雜,集成了更多的三方庫,App 啓動也會愈來愈慢,所以咱們但願能在業務擴張的同時,保持較優的啓動速度,給用戶帶來良好的使用體驗。ios
當用戶按下 home 鍵,iOS App 不會馬上被 kill,而是存活一段時間,這段時間裏用戶再打開 App,App 基本上不須要作什麼,就能還原到退到後臺前的狀態。咱們把 App 進程還在系統中,無需開啓新進程的啓動過程稱爲熱啓動。git
而冷啓動則是指 App 不在系統進程中,好比設備重啓後,或是手動殺死 App 進程,又或是 App 長時間未打開過,用戶再點擊啓動 App 的過程,這時須要建立一個新進程分配給 App。咱們能夠將冷啓動看做一次完整的 App 啓動過程,本文討論的就是冷啓動的優化。github
WWDC 2016 中首次出現了 App 啓動優化的話題,其中提到:web
冷啓動的整個過程是指從用戶喚起 App 開始到 AppDelegate 中的 didFinishLaunchingWithOptions
方法執行完畢爲止,並以執行 main()
函數的時機爲分界點,分爲 pre-main
和 main()
兩個階段。緩存
也有一種說法是將整個冷啓動階段以主 UI 框架的 viewDidAppear
函數執行完畢纔算結束。這兩種說法均可以,前者的界定範圍是 App 啓動和初始化完畢,後者的界定範圍是用戶視角的啓動完畢,也就是首屏已經被加載出來。安全
注意:這裏不少文章都會把第二個階段描述爲 main 函數以後,我的認爲這種說法不是很好,容易讓人誤解。要知道 main 函數在 App 運行過程當中是不會退出的,不管是 AppDelegate 中的
didFinishLaunchingWithOptions
方法仍是 ViewController 中的viewDidAppear
方法,都仍是在 main 函數內部執行的。bash
pre-main
階段指的是從用戶喚起 App 到 main()
函數執行以前的過程。網絡
咱們能夠在 Xcode 中配置環境變量 DYLD_PRINT_STATISTICS
爲 1(Edit Scheme → Run → Arguments → Environment Variables → +
)。多線程
這時在 iOS 10 以上系統中運行一個 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%)
複製代碼
若是要更詳細的信息,就設置 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
複製代碼
這裏統計到的啓動耗時出現必定波動是正常的,無須過度在乎。
爲了更準確地瞭解 App 啓動的流程,咱們先熟悉一下幾個概念。
Mach-O(Mach Object File Format)是一種用於記錄可執行文件、對象代碼、共享庫、動態加載代碼和內存轉儲的文件格式。App 編譯生成的二進制可執行文件就是 Mach-O 格式的,iOS 工程全部的類編譯後會生成對應的目標文件 .o
文件,而這個可執行文件就是這些 .o
文件的集合。
在 Xcode 的控制檯輸入如下命令,能夠打印出運行時全部加載進應用程序的 Mach-O 文件。
image list -o -f
複製代碼
Mach-O 文件主要由三部分組成:
__TEXT
包含 Mach header,被執行的代碼和只讀常量(如C 字符串)。只讀可執行(r-x)。__DATA
包含全局變量,靜態變量等。可讀寫(rw-)。__LINKEDIT
包含了加載程序的元數據,好比函數的名稱和地址。只讀(r–-)。dylib 也是一種 Mach-O 格式的文件,後綴名爲 .dylib
的文件就是動態庫(也叫動態連接庫)。動態庫是運行時加載的,能夠被多個 App 的進程共用。
若是想知道 TestDemo 中依賴的全部動態庫,能夠經過下面的指令實現:
otool -L /TestDemo.app/TestDemo
複製代碼
動態連接庫分爲系統 dylib 和內嵌 dylib(embed dylib,即開發者手動引入的動態庫)。系統 dylib 有:
dyld(Dynamic Link Editor):動態連接器,其本質也是 Mach-O 文件,一個專門用來加載 dylib 文件的庫。 dyld 位於 /usr/lib/dyld
,能夠在 mac 和越獄機中找到。dyld 會將 App 依賴的動態庫和 App 文件加載到內存後執行。
dyld shared cache 就是動態庫共享緩存。當須要加載的動態庫很是多時,相互依賴的符號也更多了,爲了節省解析處理符號的時間,OS X 和 iOS 上的動態連接器使用了共享緩存。OS X 的共享緩存位於 /private/var/db/dyld/
,iOS 的則在 /System/Library/Caches/com.apple.dyld/
。
當加載一個 Mach-O 文件時,dyld 首先會檢查是否存在於共享緩存,存在就直接取出使用。每個進程都會把這個共享緩存映射到了本身的地址空間中。這種方法大大優化了 OS X 和 iOS 上程序的啓動時間。
images 在這裏不是指圖片,而是鏡像。每一個 App 都是以 image 爲單位進行加載的。images 類型包括:
dlopen()
加載。framework 能夠是動態庫,也是靜態庫,是一個包含 dylib、bundle 和頭文件的文件夾。
啓動一個應用時,系統會經過 fork()
方法來新建立一個進程,而後執行鏡像經過 exec()
來替換爲另外一個可執行程序,而後執行以下操做:
結合上面 pre-main
打印的結果,咱們能夠大體瞭解整個啓動過程以下圖所示:
這一步,指的是動態庫加載。在此階段,dyld 會:
mmap()
。通常狀況下,iOS App 須要加載 100-400 個 dylibs。這些動態庫包括系統的,也包括開發者手動引入的。其中大部分 dylib 都是系統庫,系統已經作了優化,所以開發者更應關心本身手動集成的內嵌 dylib,加載它們時性能開銷較大。
App 中依賴的 dylib 越少越好,Apple 官方建議儘可能將內嵌 dylib 的個數維持在6個之內。
優化方案:
optional
和 required
設置,若是 framework 在當前的 App 支持的 iOS 系統版本中都存在,就設爲 required
,由於設爲 optional
會有額外的檢查;dlopen()
對性能會產生影響,由於 App 啓動時是本來是單線程運行,系統會取消加鎖,但 dlopen()
開啓了多線程,系統不得不加鎖,這樣不只會使性能下降,可能還會形成死鎖及未知的後果,不是很推薦這種作法。)這一步,作的是指針重定位。
在 dylib 的加載過程當中,系統爲了安全考慮,引入了 ASLR(Address Space Layout Randomization)技術和代碼簽名。因爲 ASLR 的存在,鏡像會在新的隨機地址(actual_address)上加載,和以前指針指向的地址(preferred_address)會有一個誤差(slide,slide=actual_address-preferred_address),所以 dyld 須要修正這個誤差,指向正確的地址。具體經過這兩步實現:
第一步:Rebase,在 image 內部調整指針的指向。將 image 讀入內存,並以 page 爲單位進行加密驗證,保證不會被篡改,性能消耗主要在 IO。
第二步:Binding,符號綁定。將指針指向 image 外部的內容。查詢符號表,設置指向鏡像外部的指針,性能消耗主要在 CPU 計算。
經過如下命令能夠查看 rebase 和 bind 等信息:
xcrun dyldinfo -rebase -bind -lazy_bind TestDemo.app/TestDemo
複製代碼
經過 LC_DYLD_INFO_ONLY 能夠查看各類信息的偏移量和大小。若是想要更方便直觀地查看,推薦使用 MachOView 工具。
指針數量越少,指針修復的耗時也就越少。因此,優化該階段的關鍵就是減小 __DATA
段中的指針數量。
優化方案:
__DATA
段中建立結構。)完成 Rebase 和 Bind 以後,通知 runtime 去作一些代碼運行時須要作的事情:
優化方案:
Rebase/Binding 階段優化好了,這一步的耗時也會相應減小。
Rebase 和 Binding 屬於靜態調整(fix-up),修改的是 __DATA
段中的內容,而這裏則開始動態調整,往堆和棧中寫入內容。具體工做有:
+load
方法;attribute((constructor))
修飾的函數);優化方案:
+load
方法中初始化,能夠推遲到 +initiailize
中進行;(由於在一個 +load
方法中進行運行時方法替換操做會帶來 4ms 的消耗)__atribute__((constructor))
將方法顯式標記爲初始化器,而是讓初始化方法調用時再執行。好比用 dispatch_once()
、pthread_once()
或 std::once()
,至關於在第一次使用時才初始化,推遲了一部分工做耗時。:總結一下 pre-main
階段可行的優化方案:
+load
方法中執行的任務延遲到 +initialize
中對於 main()
階段,主要測量的就是從 main()
函數開始執行到 didFinishLaunchingWithOptions
方法執行結束的耗時。
這裏介紹兩種查看 main()
階段耗時的方法。
方法一:手動插入代碼,進行耗時計算。
// 第一步:在 main() 函數裏用變量 MainStartTime 記錄當前時間
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 方法結束前,再獲取一下當前時間,與 MainStartTime 的差值就是 main() 函數階段的耗時
double mainLaunchTime = (CFAbsoluteTimeGetCurrent() - MainStartTime);
NSLog(@"main() 階段耗時:%.2fms", mainLaunchTime * 1000);
複製代碼
方法二:藉助 Instruments 的 Time Profiler 工具查看耗時。
打開方式爲:Xcode → Open Developer Tool → Instruments → Time Profiler
。
操做步驟:
配置 Scheme。點擊 Edit Scheme
找到 Profile
下的 Build Configuration
,設置爲 Debug
。
配置 PROJECT。點擊 PROJECT,在 Build Settings
中找到 Build Options
選項裏的 Debug Information Format
,把 Debug
對應的值改成 DWARF with dSYM File
。
啓動 Time Profiler,點擊左上角紅色圓形按鈕開始檢測,而後就能夠看到執行代碼的完整路徑和對應的耗時。
爲了方面查看應用程序中實際代碼的執行耗時和代碼路徑實際所在的位置,能夠勾選上 Call Tree
中的 Separate Thread
和 Hide System Libraries
。
main()
被調用以後,didFinishLaunchingWithOptions
階段,App 會進行必要的初始化操做,而 viewDidAppear
執行結束以前則是作了首頁內容的加載和顯示。
關於 App 的初始化,除了統計、日誌這種需要在 App 一啓動就配置的事件,有一些配置也能夠考慮延遲加載。若是你在 didFinishLaunchingWithOptions
中同時也涉及到了首屏的加載,那麼能夠考慮從這些角度優化:
若是首屏爲 H5 頁面,針對它的優化,參考 VasSonic 的原理,能夠從這幾個角度入手:
終端耗時
頁面耗時(靜態頁面)
頁面耗時(常常須要動態更新的頁面)
隨着業務的增加,App 中的模塊愈來愈多,冷啓動的時間也必不可少地增長。冷啓動本就是一個比較複雜的流程,它的優化沒有固定的公式,咱們須要結合業務,配合一些性能分析工具和線上監控日誌,有耐心、多維度地進行分析和解決。
參考連接: