啟動(dòng)速度
用戶從點(diǎn)擊APP圖標(biāo)到完全看到APP內(nèi)容的過(guò)程稱為啟動(dòng),如果啟動(dòng)耗時(shí)較長(zhǎng)可能會(huì)影響用戶的體驗(yàn),所以啟動(dòng)速度優(yōu)化就顯得很有必要。
最佳速度:400ms,這是剛好是啟動(dòng)動(dòng)畫(huà)的時(shí)間,這是app啟動(dòng)時(shí)間的最佳時(shí)間。業(yè)界建議啟動(dòng)時(shí)間保持在1.5s內(nèi)比較合適。
最慢速度:超過(guò)20s,則會(huì)被系統(tǒng)殺掉。
啟動(dòng)的分類
1、冷啟動(dòng):系統(tǒng)里沒(méi)有APP的進(jìn)程緩存信息,例如重啟手機(jī)或者更新APP后的首次啟動(dòng)APP,APP長(zhǎng)時(shí)間不用系統(tǒng)清掉已有的進(jìn)程緩存
2、熱啟動(dòng):系統(tǒng)里有APP的進(jìn)程緩存信息,例如殺死APP后短時(shí)間內(nèi)重啟APP
3、回前臺(tái):APP退入后臺(tái)再進(jìn)入前臺(tái),APP進(jìn)程從掛起到激活狀態(tài)
一般只討論1、2兩種情況的啟動(dòng)優(yōu)化。如何從代碼層面計(jì)算啟動(dòng)速度?根據(jù)蘋果官方文檔的計(jì)算方式:進(jìn)程創(chuàng)建時(shí)間到第一個(gè)CA::Transaction::commit()
啟動(dòng)流程
如下引用自:抖音品質(zhì)建設(shè) - iOS啟動(dòng)優(yōu)化《原理篇》

1、點(diǎn)擊APP圖標(biāo)后,內(nèi)核創(chuàng)建APP進(jìn)程
2、將APP的Mach-O可執(zhí)行文件mmap進(jìn)虛擬內(nèi)存,加載dyld程序,接下來(lái)調(diào)用_dyld_start函數(shù)開(kāi)始程序的初始化
3、重啟手機(jī)/更新APP會(huì)先創(chuàng)建啟動(dòng)閉包,然后根據(jù)啟動(dòng)閉包進(jìn)行相關(guān)的初始化
4、將動(dòng)態(tài)庫(kù)mmap進(jìn)虛擬內(nèi)存,動(dòng)態(tài)庫(kù)數(shù)量太多則這里耗時(shí)會(huì)增加
5、對(duì)動(dòng)態(tài)庫(kù)和APP的Mach-O可執(zhí)行文件做bind&rebase,主要耗時(shí)在 Page In,影響 Page In 數(shù)量的是 objc 的元數(shù)據(jù)
6、初始化 objc 的 runtime,如果有了閉包,由于閉包已經(jīng)初始化了大部分,這里只會(huì)注冊(cè) sel 和裝載 category
7、+load 和靜態(tài)初始化被調(diào)用,除了方法本身耗時(shí),這里還會(huì)引起大量 Page In
8、初始化 UIApplication,啟動(dòng) Main Runloop
9、執(zhí)行 will/didFinishLaunch,這里主要是業(yè)務(wù)代碼耗時(shí)
10、Layout,viewDidLoad 和 Layoutsubviews 會(huì)在這里調(diào)用,Autolayout 太多會(huì)影響這部分時(shí)間
11、Display,drawRect 會(huì)調(diào)用
12、Prepare,圖片解碼發(fā)生在這一步
13、Commit,首幀渲染數(shù)據(jù)打包發(fā)給 RenderServer,啟動(dòng)結(jié)束
啟動(dòng)速度優(yōu)化思路:
1、控制APP的可執(zhí)行文件大小
2、控制動(dòng)態(tài)庫(kù)數(shù)量
3、控制Page In 次數(shù)
4、控制首幀渲染前業(yè)務(wù)邏輯相關(guān)耗時(shí)
5、控制首幀視圖渲染耗時(shí),即上面流程中的步驟10-12
Tips:iOS13之后系統(tǒng)采用dyld3加載器,才有啟動(dòng)閉包的機(jī)制。之前使用的是dyld2,無(wú)此機(jī)制
優(yōu)化啟動(dòng)速度
1、動(dòng)態(tài)庫(kù)數(shù)量
解決方案:將項(xiàng)目中的動(dòng)態(tài)庫(kù)全部替換成靜態(tài)庫(kù)。
目前項(xiàng)目中有32個(gè)三方庫(kù)采用動(dòng)態(tài)庫(kù)方式引入,蘋果推薦的是最多不要超過(guò)6個(gè)動(dòng)態(tài)庫(kù)。先看如下一組試驗(yàn)數(shù)據(jù):
APP進(jìn)程創(chuàng)建到首次+load調(diào)用時(shí)間
測(cè)試設(shè)備ipad pro 2,iOS13.4,冷啟動(dòng)5次;
動(dòng)態(tài)庫(kù)方式耗時(shí):721.7ms、619.6ms、782.8ms、761.0ms、724.8ms
靜態(tài)庫(kù)方式耗時(shí):143.9ms、133.1ms、139.7ms、145.8ms、151.4ms
測(cè)試設(shè)備ipad pro 2,iOS13.4,熱啟動(dòng)5次;
動(dòng)態(tài)庫(kù)方式耗時(shí):122.6ms、117.4ms、121.3ms、120.7ms、120.5ms
靜態(tài)庫(kù)方式耗時(shí):36.1ms、37.0ms、36.6ms、36.1ms、36.2ms
測(cè)試設(shè)備ipad air,iOS12.1.1,熱啟動(dòng)5次;
動(dòng)態(tài)庫(kù)方式耗時(shí):524.3ms、3435.9ms、3861.4ms、1927.8ms、474.9ms
靜態(tài)庫(kù)方式耗時(shí):3145.2ms、4393.3ms、3095.3ms、3126.5ms、2567.0ms
結(jié)論:如果項(xiàng)目中有很多動(dòng)態(tài)庫(kù),以本項(xiàng)目32個(gè)動(dòng)態(tài)庫(kù)為例,iPad Pro2 測(cè)試結(jié)果,冷啟動(dòng)耗時(shí)減少600ms+,熱啟動(dòng)耗時(shí)減少80ms+
疑問(wèn):
1、靜態(tài)庫(kù)增加了APP可執(zhí)行文件的體積,必然導(dǎo)致dyld加載可執(zhí)行文件耗時(shí)增加,TEXT解密耗時(shí)增加。是否靜態(tài)庫(kù)被鏈接的文件數(shù)量或者代碼量大于某個(gè)值后采用動(dòng)態(tài)庫(kù)更合適?
答案:暫未研究
2、采用靜態(tài)庫(kù)的方案是否會(huì)導(dǎo)致APP的體積大幅增加?
答案:本項(xiàng)目中并未增加APP體積,反而減小,如下為對(duì)比

3、采用靜態(tài)庫(kù)方式可能導(dǎo)致的問(wèn)題?(之所以有這樣的思考,看到文章中提到了如下的觀點(diǎn):)
- 三方庫(kù)中使用了
[NSBundle bundleForClass:[self class]]的行為會(huì)和[NSBundle mainBundle]一致。 - 由于上一個(gè)問(wèn)題可能導(dǎo)致
Bundle找不到的問(wèn)題(目前正在嘗試能否處理)。
本文采用的靜態(tài)方式use_modular_headers!和上面文章中方式不一樣(很明顯更加簡(jiǎn)單,自動(dòng)實(shí)現(xiàn)了上文中資源拷貝的功能)
利用cocopods管理三方庫(kù)的資源還是比較智能的,以下兩個(gè)選項(xiàng)用于指定需要拷貝到主工程的資源文件和文件夾語(yǔ)法,默認(rèn)將這些資源拷貝到主工程的MainBundle中(但如果三方庫(kù)資源和主工程資源文件重名則會(huì)報(bào)錯(cuò)),不指定則不拷貝
spec.resource = "ic_loading_004.png"
spec.resources = "Resources/*.png"
如下則將資源拷貝到指定的Bundle中,盡量避免和主工程沖突
spec.resource_bundles = {
'resources' => ['Resources']
}
2、無(wú)用代碼
思考:無(wú)用代碼會(huì)增加APP可執(zhí)行文件的大小嗎?
無(wú)用代碼包括第三方庫(kù)中的無(wú)用代碼和自身工程中未使用到的無(wú)用代碼。現(xiàn)在分別做試驗(yàn)(設(shè)備iPad Pro2 iOS13.4),隨便創(chuàng)建一個(gè)工程,打release包,可執(zhí)行文件最終大小109k,APP最終大小為133k
1、自身工程中未使用到的無(wú)用代碼
隨便拖入一堆無(wú)用代碼后(工程中不使用)打出來(lái)的APP包大小201k,可執(zhí)行文件大小變?yōu)?76k。
2、未使用到的無(wú)用第三方靜態(tài)庫(kù)
拖入ffmpeg靜態(tài)庫(kù),打出來(lái)的APP包仍然為133k,可執(zhí)行文件大小仍然為109k
3、未使用到的無(wú)用第三方動(dòng)態(tài)庫(kù)
拖入Alamofire.framework動(dòng)態(tài)庫(kù),打出來(lái)的APP包大小2.1M,可執(zhí)行文件大小仍然為109k。APP包增加的大小為Alamofire.framework庫(kù)本身的大小
答案:項(xiàng)目工程中未使用到的無(wú)用代碼最終會(huì)編譯到APP的可執(zhí)行文件中區(qū),所以會(huì)導(dǎo)致APP可執(zhí)行文件體積增加。當(dāng)APP可執(zhí)行文件變大時(shí),會(huì)導(dǎo)致dyld加載可執(zhí)行文件的耗時(shí)增加,即增加了啟動(dòng)時(shí)間
Tips:統(tǒng)計(jì)項(xiàng)目中代碼行數(shù)(包括注釋和空格)
cd 項(xiàng)目目錄
find . ! -path "./*.framework/*" ! -path "./tttt/*" "(" -name "*.m" -or -name "*.mm" -or -name "*.cpp" -or -name "*.h" -or -name "*.swift" ")" -print | xargs wc -l
解決方案:
目前業(yè)界給出了兩種方案:1、自定義Pass、在函數(shù)頭部插樁,在編譯期間確定代碼是否被使用,優(yōu)點(diǎn)是準(zhǔn)確,缺點(diǎn)需要查看llvm源碼,實(shí)現(xiàn)難度大,目前也只是停留在理論階段,并無(wú)具體的解決方案;2、基于Mach-O可執(zhí)行文件格式中的字段來(lái)確定類或者函數(shù)是否被使用,優(yōu)點(diǎn)是實(shí)現(xiàn)簡(jiǎn)單,缺點(diǎn)是不夠準(zhǔn)確,只能做靜態(tài)分析,動(dòng)態(tài)調(diào)用代碼無(wú)法查出來(lái),所以刪代碼前需要二次確認(rèn),目前也有很多不錯(cuò)的實(shí)現(xiàn)方案。
采用業(yè)界開(kāi)源方案:WBBlades 分析項(xiàng)目中的無(wú)用類,該工具支持OC和Swift的檢測(cè),且使用簡(jiǎn)單。安裝該工具后執(zhí)行如下命令:
blades -unused /Users/ws/Library/Developer/Xcode/DerivedData/FilmoraGo-blntnxezkthnqjdamaarjzspcgmz/Build/Products/Debug-iphoneos/FilmoraGo.app
最終輸出項(xiàng)目中的無(wú)用類到桌面UnusedClass.plist文件中

結(jié)論:
經(jīng)過(guò)對(duì)比,最終減少安裝包0.3M左右,對(duì)啟動(dòng)時(shí)間基本沒(méi)有幫助,此外再刪除無(wú)用類的過(guò)程中發(fā)現(xiàn)一下現(xiàn)象,予以記錄
如下情況類仍然被該工具標(biāo)記為未使用:
1、Swift中一個(gè)類聲明為Private
2、collectionView.register()
疑問(wèn):
1、目前項(xiàng)目中無(wú)用類比較少,刪除無(wú)用類文件后,對(duì)安裝包和啟動(dòng)時(shí)間影響不大,故減少無(wú)用類對(duì)啟動(dòng)時(shí)間的幫助量化目前還未得知
2、通過(guò)Pod方式引入的庫(kù)也檢查出來(lái)了很多無(wú)用類,有沒(méi)有辦法過(guò)濾掉Pods庫(kù)中的無(wú)用類?
參考文章:
從Mach-O角度談?wù)凷wift和OC的存儲(chǔ)差異
58同城iOS混編項(xiàng)目無(wú)用代碼檢測(cè)方案介紹
3、二進(jìn)制重排
思考:
二進(jìn)制重排為什么會(huì)加快啟動(dòng)速度?
當(dāng)APP進(jìn)程訪問(wèn)一頁(yè)虛擬內(nèi)存page,而對(duì)應(yīng)的物理內(nèi)存不存在時(shí),先觸發(fā)缺頁(yè)中斷(Page Fault)阻塞當(dāng)前進(jìn)程,然后加載數(shù)據(jù)到對(duì)應(yīng)物理內(nèi)存(Release版本還要對(duì)加載的數(shù)據(jù)進(jìn)行簽名),所以缺頁(yè)中斷還是比較耗時(shí)的。假設(shè)APP啟動(dòng)時(shí)調(diào)用100個(gè)函數(shù),這100個(gè)函數(shù)如果分布在100個(gè)不同的內(nèi)存頁(yè),那會(huì)產(chǎn)生100次缺頁(yè)中斷。如通過(guò)二進(jìn)制重排將這100函數(shù)分布到50個(gè)或者更少的內(nèi)存頁(yè)中,缺頁(yè)中斷的次數(shù)減半,啟動(dòng)速度就提升了 。
獲取啟動(dòng)階段Page Fault的次數(shù)
打開(kāi)Instruments,選擇System Trace工具

重啟手機(jī)(熱啟動(dòng)情況下系統(tǒng)已經(jīng)做了加載緩存,產(chǎn)生缺頁(yè)中斷大幅減少,所以最好重啟手機(jī)),然后點(diǎn)擊啟動(dòng),待首屏出現(xiàn)后停止,如下圖:

以iPad Pro2代為測(cè)試設(shè)備,優(yōu)化前啟動(dòng)APP時(shí)產(chǎn)生缺頁(yè)中斷3969次,耗時(shí)303.82ms
解決方案
獲取啟動(dòng)階段調(diào)用的函數(shù)符號(hào)然后編寫order_file編譯順序文件然后在Build Settings -> Order File中配置一個(gè)后綴為order的文件路徑是實(shí)現(xiàn)二進(jìn)制重排的核心思路。目前業(yè)界獲取啟動(dòng)階段調(diào)用的函數(shù)符號(hào)主要有三種:
1、抖音通過(guò)靜態(tài)掃描和運(yùn)行時(shí) Trace 等方法確定 order_file。該方案實(shí)現(xiàn)難度大(需要匯編、反匯編等知識(shí)),且只能覆蓋部分符號(hào)(無(wú)法覆蓋純Swift、initialize、部分block 和 C++ 通過(guò)寄存器的間接函數(shù)調(diào)用)
2、手淘通過(guò)修改 .o 目標(biāo)文件實(shí)現(xiàn)靜態(tài)插樁,需要對(duì)目標(biāo)代碼較為熟悉,通用性不高
3、clang編譯器靜態(tài)插樁,目前業(yè)界已有成熟的庫(kù)和方案
靜態(tài)插樁:在 build settings->"Other C Flags"中添加"-fsanitize-coverage=func,trace-pc-guard"。如過(guò)項(xiàng)目中有 Swift 代碼,還需要在 "Other Swift Flags" 中加入"-sanitize-coverage=fun"和"-sanitize=undefined",如下:


靜態(tài)插樁腳本 獲取啟動(dòng)階段符號(hào)表的使用步驟:
1、在Podfile中添加如下代碼:
pod 'YCSymbolTracker'
post_install do |installer|
require './Pods/YCSymbolTracker/YCSymbolTracker/symbol_tracker.rb'
symbol_tracker(installer)
end
然后執(zhí)行pod install
2、再首幀完成渲染前調(diào)用如下代碼:
// 首幀渲染完成后調(diào)用此方法,一般在跟控制器的viewDidAppear方法中調(diào)用即可
static func runAfterFirstFrameRendered(){
....省略的業(yè)務(wù)代碼
// 首幀渲染前調(diào)用監(jiān)控代碼
let filePath = NSTemporaryDirectory().appending("/demo.order")
YCSymbolTracker.exportSymbols(filePath: filePath)
}
3、關(guān)機(jī)然后打開(kāi)APP,獲取啟動(dòng)階段的符號(hào)表

結(jié)論:
最后獲得150個(gè)page in fault的提升,大概50ms左右的速度提升
參考文章
[抖音研發(fā)實(shí)踐:基于二進(jìn)制文件重排的解決方案,APP 啟動(dòng)速度提升超 15%](抖音研發(fā)實(shí)踐:基于二進(jìn)制文件重排的解決方案 APP啟動(dòng)速度提升超15%)
手淘架構(gòu)組最新實(shí)踐 | iOS基于靜態(tài)庫(kù)插樁的?進(jìn)制重排啟動(dòng)優(yōu)化
深入探索 iOS 啟動(dòng)速度優(yōu)化(二進(jìn)制重排)
4、首幀渲染前的業(yè)務(wù)邏輯優(yōu)化
這部分代表了main()函數(shù)之后的時(shí)間,即從didFinishLaunchingWithOptions()開(kāi)始到根控制器viewDidAppear函數(shù)結(jié)束主線程耗時(shí)的優(yōu)化
解決方案:
利用Xcode自帶的Instruments工具APP Launch分析啟動(dòng)耗時(shí),找出耗時(shí)嚴(yán)重的函數(shù)調(diào)用然后進(jìn)行優(yōu)化。該工具會(huì)追蹤應(yīng)用啟動(dòng)后5秒內(nèi)的所有線程的耗時(shí),自帶Time Profiler和應(yīng)用進(jìn)程的System Trace兩個(gè)看板,如下:

可以看到APP的啟動(dòng)時(shí)間為1.7秒(備注:通過(guò)這個(gè)工具是APP的冷啟動(dòng)時(shí)間),和之前通過(guò)檢測(cè)工具測(cè)出的APP冷啟動(dòng)時(shí)間還是比較吻合的。
接下來(lái)利用System Trace功能對(duì)APP啟動(dòng)后的業(yè)務(wù)邏輯進(jìn)行耗時(shí)分析,點(diǎn)擊System Trace看板(也就是上面FilmoraGo)左邊的右箭頭,選擇Main Thread,接下來(lái)就可以看到啟動(dòng)階段各個(gè)函數(shù)的耗時(shí)了

可以看到這里面didFinishiLanchingWithOptions耗時(shí)197ms,加載解析首屏Main文件及創(chuàng)建相關(guān)視圖耗時(shí)20ms,首次Runloop接手了GCD主線程的代碼塊執(zhí)行耗時(shí)121ms。接著依次點(diǎn)開(kāi)這些耗時(shí)點(diǎn)逐個(gè)分析進(jìn)行優(yōu)化
1、在didFinishiLanchingWithOptions中將字體文件加載到內(nèi)存中(方法configure.fontPath = .. 的耗時(shí)),該方法耗時(shí)144ms,是大頭。經(jīng)過(guò)和業(yè)務(wù)同學(xué)溝通,可以將此動(dòng)作延后加載。可以看到這個(gè)方法是通過(guò)source0消息觸發(fā)的,在__CFRunLoopDoSources0代碼塊中調(diào)用
2、加載解析首屏Main文件及創(chuàng)建相關(guān)視圖耗時(shí)20ms,通過(guò)分析解析XML耗時(shí)3ms左右,基本可以忽略,主要時(shí)間消耗在一個(gè)視圖的創(chuàng)建上(大概10ms)。這段代碼也是在__CFRunLoopDoSources0中執(zhí)行
3、
結(jié)論:
具體改進(jìn)思路為:設(shè)計(jì)啟動(dòng)器,改啟動(dòng)器統(tǒng)一管理啟動(dòng)任務(wù),包括首屏選日前必須要加載的任務(wù)和可以在渲染完成后延后加載的任務(wù);目的是優(yōu)化啟動(dòng)速度。啟動(dòng)速度減少大概200ms
疑問(wèn):
這種方式是否可以全量覆蓋APP啟動(dòng)階段的耗時(shí)?如果不能,解決方案?
答案:不能全量檢測(cè),例如兩個(gè)方法A和B,滿足某個(gè)條件調(diào)用了A,滿足另外一個(gè)條件調(diào)用了B,而B(niǎo)非常耗時(shí),通過(guò)這種方式只是測(cè)試到了A的耗時(shí)。一種是線上監(jiān)控,不過(guò)任務(wù)過(guò)于艱巨。二是仔細(xì)排查代碼將這種AB邏輯相關(guān)代碼揪出來(lái)單獨(dú)進(jìn)行測(cè)試可能更加容易實(shí)現(xiàn)一些。
5、首幀視圖的渲染優(yōu)化
思考:
首幀視圖耗時(shí)即首次執(zhí)行CA::Transaction::commit()到將最終的渲染樹(shù)提交給渲染進(jìn)程結(jié)束所消耗的時(shí)間,同樣利用APP Lauch查看它的耗時(shí)如下:

可以看到視圖的渲染是在代碼塊__CFRunLoopDoBlocks中調(diào)用的,整個(gè)耗時(shí)20ms,基本沒(méi)有優(yōu)化的空間。展開(kāi)這個(gè)調(diào)用棧,可以非常清楚的看到視圖渲染的整個(gè)流程。
優(yōu)化效果
以設(shè)備iPad Pro 2代為例
優(yōu)化前:冷啟動(dòng)5次,時(shí)間為:1449.2ms、1426.4ms、1300.1ms、1441.1ms、1351.1ms;熱啟動(dòng)5次,時(shí)間為:314.2ms、315.2ms、342.1ms、321.4ms、310.3ms
優(yōu)化后:冷啟動(dòng)5次,時(shí)間為:709.3ms、610.3ms、679.3ms、646.7ms、667.4.0ms;熱啟動(dòng)5次,時(shí)間為:
293.8ms、299.1ms、299.5ms、299.9ms、293.6ms
可以看到冷啟動(dòng)的優(yōu)化效果還是很明顯的,基本上在50%左右。熱啟動(dòng)也有一定的提升
思考
此為線下優(yōu)化啟動(dòng)速度的一次實(shí)踐,可以看到經(jīng)過(guò)這次優(yōu)化啟動(dòng)速度提升不少,那如何做到在業(yè)務(wù)快速迭代中啟動(dòng)速度不會(huì)持續(xù)增加呢?或者說(shuō)增加了能立馬知道,研發(fā)階段就快速優(yōu)化,不要等到累積到一定程度再來(lái)優(yōu)化。
1、首先研發(fā)階段針對(duì)啟動(dòng)相關(guān)代碼做code review,主要包括:
新增動(dòng)態(tài)庫(kù)
新增啟動(dòng)相關(guān)業(yè)務(wù)邏輯
2、將啟動(dòng)速度監(jiān)控自動(dòng)化
設(shè)計(jì)一套啟動(dòng)速度監(jiān)控自動(dòng)化系統(tǒng),當(dāng)發(fā)現(xiàn)啟動(dòng)速度增加了某個(gè)閾值給予開(kāi)發(fā)人員警告,并將耗時(shí)增加的方法打印出來(lái),相關(guān)業(yè)務(wù)人員針對(duì)性的優(yōu)化。
參考文章
抖音品質(zhì)建設(shè) - iOS啟動(dòng)優(yōu)化《原理篇》
抖音品質(zhì)建設(shè) - iOS啟動(dòng)優(yōu)化《實(shí)戰(zhàn)篇》
iOS靜態(tài)庫(kù)&動(dòng)態(tài)庫(kù)依賴探索
