在 WWDC 2016 上首次提到了關於 App 應用啟動速度優化的話題:Session 406 Optimizing App Startup Time。
一、冷啟動與熱啟動
熱啟動是,APP會恢復之前的狀態繼續運行,這種就是熱啟動,我們平時所說的APP在后台的存活時間,其實就是APP能執行熱啟動的最大時間間隔。而冷啟動則是APP從被加載到內存到運行的狀態,下面我們要講的主要是冷啟動。
- 熱啟動:由於某種原因,APP 的狀態由 running 切換為 suspend,但此時 APP 並沒有被系統 kill掉,當我們再次把 APP 切換到前台的時候,此時啟動 app 所需要的數據仍然在緩存中,再次啟動的時候稱為熱啟動。通常情況下熱啟動能幫助提升啟動速度,但有時也可能會出現 app 卡死手動退出進程后重新打開仍然是卡死狀態。
- 冷啟動:如果是比較長時間沒有啟動過 app 或者設備剛剛重啟,這種情況下啟動 app,就被稱為冷啟動。
二、查看啟動時間
- 最佳速度:400ms。因為不添加任何同步任務從圖標被點擊到顯示 Launch Screen,然后 Launch Screen 消失這段時間就是 400ms。如果 app 啟動時間接近這個數值,那證明 app 的啟動任務已經優化到最佳。
- 最慢速度:不可以大於 20s,否則會被系統殺掉。
配置 Xcode 環境變量在日志中打印啟動時間:
- 打開工程 -> Edit Scheme -> Run -> Environment Variables
根據需要添加 DYLD_PRINT_STATISTICS
和 DYLD_PRINT_STATISTICS_DETAILS
環境變量。1 表示 YES,開啟這個功能。
Total pre-main time: 617.58 milliseconds (100.0%)
dylib loading time: 472.75 milliseconds (76.5%)
rebase/binding time: 27.01 milliseconds (4.3%)
ObjC setup time: 28.90 milliseconds (4.6%)
initializer time: 88.76 milliseconds (14.3%)
slowest intializers :
libSystem.B.dylib : 8.81 milliseconds (1.4%)
libMainThreadChecker.dylib : 14.42 milliseconds (2.3%)
AFNetworking : 18.43 milliseconds (2.9%)
Realm : 20.98 milliseconds (3.3%)
CYKJBasic : 12.96 milliseconds (2.0%)
包括執行以下步驟的所有時間
- 解析鏡像
- 映射鏡像
- Rebase 鏡像
- Bind 鏡像
- 鏡像初始化
- 調用 main()方法
- 調用 UIApplicationMain() 方法
- 調用 applicationWillFinishLaunching 回調
DYLD_PRINT_STATISTICS 環境變量只能幫助測量 Dyld 的啟動時間,無法幫助我們完整地測量 main() 方法執行之前的所有時間。
為了能夠解析 dylib 的符號表,debugger 需要在加載每個 dylib 的時候暫停一下,同時通過 USB 線來傳輸這些數據,這是非常耗時的,但是 DYLD_PRINT_STATISTICS 在測量的時候已經減去了這些耗時,因此不用擔心在 debug 模式下數據的准確性,而且測量出的耗時通常會比肉眼計算出來的耗時要小。
三、優化啟動
啟動時間其實包括了兩部分:main 函數之前和 main 函數到第一個界面的 viewDidAppear:。
所以,優化也是從兩個方面進行的,優化效果主要來自於后者,因為絕大多數 App 的瓶頸在自己的代碼里。而對於 pre-main 的優化能做的無非是減少不必要的動態庫引用、多個庫合並成一個,從上面的打印數據也可以看出,主要耗時是在 dylib loading
,消耗 78.8%
的時間。
四、Main 函數之后
從 main 函數開始執行,到第一個界面顯示,期間一般做以下任務:
- 執行 AppDelegate 的代理方法,主要是 didFinishLaunchingWithOptions
- 初始化 Window,初始化基礎的 ViewController 結構
- 獲取數據(Local DB/Network),展示給用戶。
優化:
使用代碼繪制 UI,減少或者不用 xib 和 storyboard
延遲初始化和加載不必要的 UIViewController 和 View。
比如 UITabBarController 有四個 Item,在啟動的時候盡量只初始化首頁的頁面,其它 Item 頁面先用空 VC 占位。而且首頁的內容中不必要的內容也可以先不初始化,做成懶加載形式,在用戶確實需要查看和使用時再初始化。
對於確實需要啟動時使用但又比較耗時的事物放倒后台處理,如果涉及到 UI 則在處理完成后把刷新任務放回主線程。
- 日志功能,日志往往涉及到 DB 操作;
- 文件讀取,比如讀取本地存儲的省份城市區縣文件和圖片處理;
- 大量的計算,比如圖片處理,比較大的 json 數據轉 Model;
能延遲初始化的盡量延遲初始化
三方 SDK 初始化,比如 Crash 統計,分享之類的,可以等到第一次調用再去初始化。
五、Main函數之前
Main 函數之前是 iOS 系統的工作,所以這部分的優化往往更具有通用性。
Pre-Main 包含以下工作:
- dylib loading time: 341.79 milliseconds (78.8%)
- rebase/binding time: 14.18 milliseconds (3.2%)
- ObjC setup time: 35.27 milliseconds (8.1%)
- initializer time: 41.79 milliseconds (9.6%)
- slowest intializers :
- libSystem.B.dylib : 3.40 milliseconds (0.7%)
- libMainThreadChecker.dylib : 19.68 milliseconds (4.5%)
- libViewDebuggerSupport.dylib : 8.75 milliseconds (2.0%)
Session 所要傳達的內容:
- 使用 DYLD_PRINT_STATISTICS 測試啟動加載時間
- 減少自定義的動態庫集成
- 精簡原有的 Objective-C 類和代碼
- 移除靜態的初始化操作
- 使用更多的 Swift 代碼
優化:
loading dylib
啟動的第一步是加載動態庫,加載系統的動態庫使很快的,因為可以緩存,而加載內嵌的動態庫速度較慢。所以,提高這一步的效率的關鍵是:減少動態庫的數量。
加載 App 內嵌的動態庫比較耗時,因為每加載一個動態庫,系統都需要文件驗證、注冊簽名、針對 segment 進行 mmap。embedded framework 一般用於 Extension 跟主 APP 共享代碼邏輯。
合並動態庫,比如公司內部由私有 Pod 建立了如下動態庫:XXTableView、XXHUD、XXLabel,強烈建議合並成一個 XXUIKit 來提高加載速度。盡量保證將 App 現有的非系統級的動態庫個數保證在 6 個以內.
也可以使用靜態庫,雖然會增加些許 rebase/binding 的時間。
rebase/binding
Rebase 和 Binding 都是為了解決指針引用的問題。所以為了加快 rebase/binding,則需要更少的做指針修復。當你的 app 當中有太多的 Objective-C 的類、方法選擇器和類別,會增加這一部分的啟動時間。有一個數據:當大於 20000 個時候,會增加 800ms 的時間。
對於 Objective-C 開發來說,主要的時間消耗在 Class/Method 的符號加載上,所以常見的優化方案是:
- 減少 __DATA 段中的指針數量。
- 合並 Category 和功能類似的類,減少唯一 Selector 的個數。主要是為了加快程序的整個動態鏈接,在進行動態庫的重定位和綁定(Rebase/binding)過程中減少指針修正的使用,加快程序機器碼的生成。
- 刪除無用的方法和類。
- 多用 Swift Structs,因為 Swfit Structs 是靜態分發的。可以參考Swift進階之內存模型和方法調度
- 減少 c++ 虛函數
ObjC setup time
在 Objective-C 的運行時(runtime),需要對類(class),類別(category)進行注冊,以及選擇器的分配,所以參照 rebase/binding time,盡量減少類的數量,可以達到減少這一部分的時間。
Initializers
- 用 initialize 替代 load。load 在程序啟動的時候就會調用,而且必須阻塞等着所有類的 load 方法都執行完;initialize 在類首次使用的時候調用。
- 減少使用 c/c++ 的 __atribute__((constructor))。推薦使用 dispatch_once()、pthread_once()、std:once()等方法;
- 推薦使用 swift
- 不要在初始化中調用 dlopen() 方法,因為加載過程是單線程,無鎖,如果調用 dlopen 則會變成多線程,會開啟鎖的消耗,同時有可能死鎖
- 不要在初始化中創建線程