應用啟動時間,直接影響用戶對一款應用的判斷和使用體驗。頭條主app本身就包含非常多並且復雜度高的業務模塊(如新聞、視頻等),也接入了很多第三方的插件,這勢必會拖慢應用的啟動時間,本著精益求精的態度和對用戶體驗的追求,我們希望在業務擴張的同時最大程度的優化啟動時間。
技術調研
先說結論,t(App總啟動時間) = t1(main()之前的加載時間) + t2(main()之後的加載時間)。
t1 = 系統dylib(動態鏈接庫)和自身App可執行文件的加載;
t2 = main方法執行之後到AppDelegate類中的- (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法執行結束前這段時間,主要是構建第一個界面,並完成渲染展示。
main()調用之前的加載過程
App開始啟動後, 系統首先加載可執行文件(自身App的所有.o文件的集合),然後加載動態鏈接庫dyld,dyld是一個專門用來加載動態鏈接庫的庫。 執行從dyld開始,dyld從可執行文件的依賴開始, 遞歸加載所有的依賴動態鏈接庫。
動態鏈接庫包括:iOS 中用到的所有系統 framework,加載OC runtime方法的libobjc,系統級別的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)。
其實無論對於系統的動態鏈接庫還是對於App本身的可執行文件而言,他們都算是image(鏡像),而每個App都是以image(鏡像)為單位進行加載的,那麼image究竟包括哪些呢?
什麼是image
1.executable可執行文件 比如.o文件。
2.dylib 動態鏈接庫 framework就是動態鏈接庫和相應資源包含在一起的一個文件夾結構。
3.bundle 資源文件 只能用dlopen加載,不推薦使用這種方式加載。
除了我們App本身的可行性文件,系統中所有的framework比如UIKit、Foundation等都是以動態鏈接庫的方式集成進App中的。
系統使用動態鏈接有幾點好處:
代碼共用:很多程序都動態鏈接了這些 lib,但它們在內存和磁盤中中只有一份。
易於維護:由於被依賴的 lib 是程序執行時才鏈接的,所以這些 lib 很容易做更新,比如libSystem.dylib 是 libSystem.B.dylib 的替身,哪天想升級直接換成libSystem.C.dylib 然後再替換替身就行了。
減少可執行文件體積:相比靜態鏈接,動態鏈接在編譯時不需要打進去,所以可執行文件的體積要小很多。
如上圖所示,不同進程之間共用系統dylib的_TEXT區,但是各自維護對應的_DATA區。
所有動態鏈接庫和我們App中的靜態庫.a和所有類文件編譯後的.o文件最終都是由dyld(the dynamic link editor),Apple的動態鏈接器來加載到內存中。每個image都是由一個叫做ImageLoader的類來負責加載(一一對應),那麼ImageLoader又是什麼呢?
什麼是ImageLoader
image 表示一個二進制文件(可執行文件或 so 文件),裡面是被編譯過的符號、代碼等,所以 ImageLoader 作用是將這些文件加載進內存,且每一個文件對應一個ImageLoader實例來負責加載。
兩步走:
在程序運行時它先將動態鏈接的 image 遞歸加載 (也就是上面測試棧中一串的遞歸調用的時刻)。
再從可執行文件 image 遞歸加載所有符號。
當然所有這些都發生在我們真正的main函數執行前。
動態鏈接庫加載的具體流程
動態鏈接庫的加載步驟具體分為5步:
1.load dylibs image 讀取庫鏡像文件
2.Rebase image
3.Bind image
4.Objc setup
5.initializers
load dylibs image
在每個動態庫的加載過程中, dyld需要:
1.分析所依賴的動態庫
2.找到動態庫的mach-o文件
3.打開文件
4.驗證文件
5.在系統核心注冊文件簽名
6.對動態庫的每一個segment調用mmap()
通常的,一個App需要加載100到400個dylibs, 但是其中的系統庫被優化,可以很快的加載。
針對這一步驟的優化有:
減少非系統庫的依賴
合並非系統庫
使用靜態資源,比如把代碼加入主程序
rebase/bind
由於ASLR(address space layout randomization)的存在,可執行文件和動態鏈接庫在虛擬內存中的加載地址每次啟動都不固定,所以需要這2步來修復鏡像中的資源指針,來指向正確的地址。
rebase修復的是指向當前鏡像內部的資源指針; 而bind指向的是鏡像外部的資源指針。
rebase步驟先進行,需要把鏡像讀入內存,並以page為單位進行加密驗證,保證不會被篡改,所以這一步的瓶頸在IO。bind在其後進行,由於要查詢符號表,來指向跨鏡像的資源,加上在rebase階段,鏡像已被讀入和加密驗證,所以這一步的瓶頸在於CPU計算。
通過命令行可以查看相關的資源指針:
xcrun dyldinfo -rebase -bind -lazy_bind myApp.App/myApp
優化該階段的關鍵在於減少__DATA segment中的指針數量。我們可以優化的點有:
減少Objc類數量, 減少selector數量
減少C++虛函數數量
轉而使用swift stuct(其實本質上就是為了減少符號的數量)
Objc setup
這一步主要工作是:
注冊Objc類 (class registration)
把category的定義插入方法列表 (category registration)
保證每一個selector唯一 (selctor uniquing)
由於之前2步驟的優化,這一步實際上沒有什麼可做的。
initializers
以上三步屬於靜態調整(fix-up),都是在修改__DATA segment中的內容,而這裡則開始動態調整,開始在堆和堆棧中寫入內容。
在這裡的工作有:
Objc的+load()函數
C++的構造函數屬性函數 形如attribute((constructor)) void DoSomeInitializationWork()
非基本類型的C++靜態全局變量的創建(通常是類或結構體)(non-trivial initializer) 比如一個全局靜態結構體的構建,如果在構造函數中有繁重的工作,那麼會拖慢啟動速度
Objc的load函數和C++的靜態構造函數采用由底向上的方式執行,來保證每個執行的方法,都可以找到所依賴的動態庫。
上圖是在自定義的類XXViewController的+load方法斷點的調用堆棧,清楚的看到整個調用棧和順序:
1.dyld 開始將程序二進制文件初始化
2.交由 ImageLoader 讀取 image,其中包含了我們的類、方法等各種符號
3.由於 runtime 向 dyld 綁定了回調,當 image 加載到內存後,dyld 會通知 runtime 進行處理
4.runtime 接手後調用 map_images 做解析和處理,接下來 load_images 中調用 call_load_methods 方法,遍歷所有加載進來的 Class,按繼承層級依次調用 Class 的 +load 方法和其 Category 的 +load 方法
至此,可執行文件中和動態庫所有的符號(Class,Protocol,Selector,IMP,…)都已經按格式成功加載到內存中,被 runtime 所管理,再這之後,runtime 的那些方法(動態添加 Class、swizzle 等等才能生效)。
整個事件由 dyld 主導,完成運行環境的初始化後,配合 ImageLoader 將二進制文件按格式加載到內存,
動態鏈接依賴庫,並由 runtime 負責加載成 objc 定義的結構,所有初始化工作結束後,dyld 調用真正的 main 函數。
如果程序剛剛被運行過,那麼程序的代碼會被dyld緩存,因此即使殺掉進程再次重啟加載時間也會相對快一點,如果長時間沒有啟動或者當前dyld的緩存已經被其他應用占據,那麼這次啟動所花費的時間就要長一點,這就分別是熱啟動和冷啟動的概念,如下圖所示:
main()之前的加載時間如何衡量
那麼問題就來了,那怎麼衡量main()之前也就是time1的耗時呢,蘋果官方提供了一種方法,那就是在真機調試的時候勾選dyld_PRINT_STATISTICS選項。
會得到如下形式的輸出:
由此可見對於系統級別的動態鏈接庫,因為蘋果做了優化,所以耗時並不多,在這個awesome的例子中,自身App中的代碼占用了整體時間的94.2%
我們應用中一次典型的Log如下:
由此可見,最多的用時還是在image加載和OC類的初始化,共占用總時長的79.3%,精簡framework的引入和OC類有優化的空間。
總結一下:對於main()調用之前的耗時我們可以優化的點有:
減少不必要的framework,因為動態鏈接比較耗時
check framework應當設為optional和required,如果該framework在當前App支持的所有iOS系統版本都存在,那麼就設為required,否則就設為optional,因為optional會有些額外的檢查
合並或者刪減一些OC類,關於清理項目中沒用到的類,使用工具AppCode代碼檢查功能,查到當前項目中沒有用到的類如下:
1.刪減一些無用的靜態變量
2.刪減沒有被調用到或者已經廢棄的方法
方法見:
http://stackoverflow.com/questions/35233564/how-to-find-unused-code-in-xcode-7
https://developer.Apple.com/library/ios/documentation/ToolsLanguages/Conceptual/Xcode_Overview/CheckingCodeCoverage.html
3.將不必須在+load方法中做的事情延遲到+initialize中
4.盡量不要用C++虛函數(創建虛函數表有開銷)
main()調用之後的加載時間
在main()被調用之後,App的主要工作就是初始化必要的服務,顯示首頁內容等。而我們的優化也是圍繞如何能夠快速展現首頁來開展。
App通常在AppDelegate類中的- (BOOL)Application:(UIApplication )Application didFinishLaunchingWithOptions:(NSDictionary)launchOptions方法中創建首頁需要展示的view,然後在當前runloop的末尾,主動調用CA::Transaction::commit完成視圖的渲染。
而視圖的渲染主要涉及三個階段:
1.准備階段 這裡主要是圖片的解碼
2.布局階段 首頁所有UIView的- (void)layoutSubViews()運行
3.繪制階段 首頁所有UIView的- (void)drawRect:(CGRect)rect運行
再加上啟動之後必要服務的啟動、必要數據的創建和讀取,這些就是我們可以嘗試優化的地方
因此,對於main()函數調用之前我們可以優化的點有:
1.不使用xib,直接視用代碼加載首頁視圖
2.NSUserDefaults實際上是在Library文件夾下會生產一個plist文件,如果文件太大的話一次能讀取到內存中可能很耗時,這個影響需要評估,如果耗時很大的話需要拆分(需考慮老版本覆蓋安裝兼容問題)
3.每次用NSLog方式打印會隱式的創建一個Calendar,因此需要刪減啟動時各業務方打的log,或者僅僅針對內測版輸出log
4.梳理應用啟動時發送的所有網絡請求,是否可以統一在異步線程請求
實測數據
建立了一個空的HelloWorld工程,只加入了pods中的代碼,不包含主端的業務邏輯代碼,一次典型的冷啟動基本接近2s iPhone6 iOS9.3.5系統測試主要時間在加載動態庫,類/方法的初始化還有符號地址綁定階段。
一次典型的熱啟動數據如下:可以看到因為系統做了緩存方面的優化,比冷啟動快了500ms加上頭條主端業務邏輯代碼之後一次典型的熱啟動耗時2.1s。
以上用時均為main()之前的加載耗時。
main函數之後加載時間優化記錄
NSUserDefaults是否是瓶頸
蘋果官方文檔提到NSUserDefaults加載的時候是整個plist配置文件全部load到內存中,目前頭條主端當中NSUserDefaults存儲了200多項緩存數據,因此懷疑可能拖慢啟動速度,但是測試結果顯示並不會。
通過符號斷點+[NSUserDefaults standardUserDefaults]確定最早一次的+load()從執行到結束耗時1.8ms,可見NSUserDefaults的初始化僅耗時1.8ms,並不是啟動耗時的瓶頸。
如何找到拖慢啟動應用時長的瓶頸
為了找到瓶頸,我們在啟動之後的didFinishLauhcning方法開始執行到首頁列表頁的NewsListViewController的viewDidAppear方法,幾乎每個可能比較耗時的流程進行拆分和統計,得到統計數據之後發現:
主要耗時在首頁UI構造和渲染(storyboard加載,tabBar/topBar渲染,開屏廣告加載/cell注冊/日志模塊初始化這幾個步驟)。
具體優化點
因此,針對於今日頭條這個App我們可以優化的點如下:
1.純代碼方式而不是storyboard加載首頁UI。
2.對didFinishLaunching裡的函數考慮能否挖掘可以延遲加載或者懶加載,需要與各個業務方pm和rd共同check 對於一些已經下線的業務,刪減冗余代碼。
對於一些與UI展示無關的業務,如微博認證過期檢查、圖片最大緩存空間設置等做延遲加載
3.對實現了+load()方法的類進行分析,盡量將load裡的代碼延後調用。
4.上面統計數據顯示展示feed的導航控制器頁面(NewsListViewController)比較耗時,對於viewDidLoad以及viewWillAppear方法中盡量去嘗試少做,晚做,不做。
優化結果
之前曾經有一位同事已經做了一定的優化,比如啟動之後展示閃屏廣告圖的同時初始化首頁的列表頁,當廣告展示完成之後列表頁也就渲染完成了。經過這一次優化之後的main()之後的啟動總時長通過上線之後收集數據的驗證達到了預期的效果。