最近讀了幾篇文章拾徙,關(guān)于iOS啟動(dòng)優(yōu)化误续,目前看,只有這一篇全面透徹一點(diǎn)定拟。
今日頭條iOS客戶端啟動(dòng)速度優(yōu)化
應(yīng)用啟動(dòng)時(shí)間于微,直接影響用戶對(duì)一款應(yīng)用的判斷和使用體驗(yàn)。頭條主app本身就包含非常多并且復(fù)雜度高的業(yè)務(wù)模塊(如新聞青自、視頻等)株依,也接入了很多第三方的插件,這勢(shì)必會(huì)拖慢應(yīng)用的啟動(dòng)時(shí)間延窜,本著精益求精的態(tài)度和對(duì)用戶體驗(yàn)的追求恋腕,我們希望在業(yè)務(wù)擴(kuò)張的同時(shí)最大程度的優(yōu)化啟動(dòng)時(shí)間。
技術(shù)調(diào)研
先說結(jié)論逆瑞,t(App總啟動(dòng)時(shí)間) = t1(main()之前的加載時(shí)間) + t2(main()之后的加載時(shí)間)荠藤。
t1 = 系統(tǒng)dylib(動(dòng)態(tài)鏈接庫)和自身App可執(zhí)行文件的加載伙单;
t2 = main方法執(zhí)行之后到AppDelegate類中的- (BOOL)Application:(UIApplication *)Application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions方法執(zhí)行結(jié)束前這段時(shí)間,主要是構(gòu)建第一個(gè)界面哈肖,并完成渲染展示吻育。
main()調(diào)用之前的加載過程
App開始啟動(dòng)后, 系統(tǒng)首先加載可執(zhí)行文件(自身App的所有.o文件的集合)牡彻,然后加載動(dòng)態(tài)鏈接庫dyld扫沼,dyld是一個(gè)專門用來加載動(dòng)態(tài)鏈接庫的庫。 執(zhí)行從dyld開始庄吼,dyld從可執(zhí)行文件的依賴開始, 遞歸加載所有的依賴動(dòng)態(tài)鏈接庫缎除。
動(dòng)態(tài)鏈接庫包括:iOS 中用到的所有系統(tǒng) framework,加載OC runtime方法的libobjc总寻,系統(tǒng)級(jí)別的libSystem器罐,例如libdispatch(GCD)和libsystem_blocks (Block)。
其實(shí)無論對(duì)于系統(tǒng)的動(dòng)態(tài)鏈接庫還是對(duì)于App本身的可執(zhí)行文件而言渐行,他們都算是image(鏡像)轰坊,而每個(gè)App都是以image(鏡像)為單位進(jìn)行加載的,那么image究竟包括哪些呢祟印?
什么是image
1.executable可執(zhí)行文件 比如.o文件肴沫。
2.dylib 動(dòng)態(tài)鏈接庫 framework就是動(dòng)態(tài)鏈接庫和相應(yīng)資源包含在一起的一個(gè)文件夾結(jié)構(gòu)。
3.bundle 資源文件 只能用dlopen加載蕴忆,不推薦使用這種方式加載颤芬。
除了我們App本身的可行性文件,系統(tǒng)中所有的framework比如UIKit套鹅、Foundation等都是以動(dòng)態(tài)鏈接庫的方式集成進(jìn)App中的站蝠。
系統(tǒng)使用動(dòng)態(tài)鏈接有幾點(diǎn)好處:
代碼共用:很多程序都動(dòng)態(tài)鏈接了這些 lib,但它們?cè)趦?nèi)存和磁盤中中只有一份卓鹿。
易于維護(hù):由于被依賴的 lib 是程序執(zhí)行時(shí)才鏈接的菱魔,所以這些 lib 很容易做更新,比如libSystem.dylib 是 libSystem.B.dylib 的替身吟孙,哪天想升級(jí)直接換成libSystem.C.dylib 然后再替換替身就行了澜倦。
減少可執(zhí)行文件體積:相比靜態(tài)鏈接,動(dòng)態(tài)鏈接在編譯時(shí)不需要打進(jìn)去杰妓,所以可執(zhí)行文件的體積要小很多肥隆。
如上圖所示,不同進(jìn)程之間共用系統(tǒng)dylib的_TEXT區(qū)稚失,但是各自維護(hù)對(duì)應(yīng)的_DATA區(qū)栋艳。
所有動(dòng)態(tài)鏈接庫和我們App中的靜態(tài)庫.a和所有類文件編譯后的.o文件最終都是由dyld(the dynamic link editor),Apple的動(dòng)態(tài)鏈接器來加載到內(nèi)存中句各。每個(gè)image都是由一個(gè)叫做ImageLoader的類來負(fù)責(zé)加載(一一對(duì)應(yīng))吸占,那么ImageLoader又是什么呢晴叨?
什么是ImageLoader
image 表示一個(gè)二進(jìn)制文件(可執(zhí)行文件或 so 文件),里面是被編譯過的符號(hào)矾屯、代碼等兼蕊,所以 ImageLoader 作用是將這些文件加載進(jìn)內(nèi)存,且每一個(gè)文件對(duì)應(yīng)一個(gè)ImageLoader實(shí)例來負(fù)責(zé)加載件蚕。
兩步走:
在程序運(yùn)行時(shí)它先將動(dòng)態(tài)鏈接的 image 遞歸加載 (也就是上面測(cè)試棧中一串的遞歸調(diào)用的時(shí)刻)孙技。
再從可執(zhí)行文件 image 遞歸加載所有符號(hào)。
當(dāng)然所有這些都發(fā)生在我們真正的main函數(shù)執(zhí)行前排作。
動(dòng)態(tài)鏈接庫加載的具體流程
動(dòng)態(tài)鏈接庫的加載步驟具體分為5步:
1. load dylibs image 讀取庫鏡像文件
2. Rebase image
3. Bind image
4. Objc setup
5. initializers
load dylibs image
在每個(gè)動(dòng)態(tài)庫的加載過程中牵啦, dyld需要:
1. 分析所依賴的動(dòng)態(tài)庫
2. 找到動(dòng)態(tài)庫的mach-o文件
3. 打開文件
4. 驗(yàn)證文件
5. 在系統(tǒng)核心注冊(cè)文件簽名
6. 對(duì)動(dòng)態(tài)庫的每一個(gè)segment調(diào)用mmap()
通常的,一個(gè)App需要加載100到400個(gè)dylibs妄痪, 但是其中的系統(tǒng)庫被優(yōu)化哈雏,可以很快的加載。
針對(duì)這一步驟的優(yōu)化有:
1. 減少非系統(tǒng)庫的依賴
2. 合并非系統(tǒng)庫
3. 使用靜態(tài)資源衫生,比如把代碼加入主程序
rebase/bind
由于ASLR(address space layout randomization)的存在裳瘪,可執(zhí)行文件和動(dòng)態(tài)鏈接庫在虛擬內(nèi)存中的加載地址每次啟動(dòng)都不固定,所以需要這2步來修復(fù)鏡像中的資源指針罪针,來指向正確的地址彭羹。
rebase修復(fù)的是指向當(dāng)前鏡像內(nèi)部的資源指針; 而bind指向的是鏡像外部的資源指針泪酱。
rebase步驟先進(jìn)行派殷,需要把鏡像讀入內(nèi)存,并以page為單位進(jìn)行加密驗(yàn)證西篓,保證不會(huì)被篡改愈腾,所以這一步的瓶頸在IO憋活。bind在其后進(jìn)行岂津,由于要查詢符號(hào)表,來指向跨鏡像的資源悦即,加上在rebase階段吮成,鏡像已被讀入和加密驗(yàn)證,所以這一步的瓶頸在于CPU計(jì)算辜梳。
通過命令行可以查看相關(guān)的資源指針:
xcrun dyldinfo -rebase -bind -lazy_bind myApp.App/myApp
優(yōu)化該階段的關(guān)鍵在于減少__DATA segment中的指針數(shù)量粱甫。我們可以優(yōu)化的點(diǎn)有:
1. 減少Objc類數(shù)量, 減少selector數(shù)量
2. 減少C++虛函數(shù)數(shù)量
3. 轉(zhuǎn)而使用swift stuct(其實(shí)本質(zhì)上就是為了減少符號(hào)的數(shù)量)
Objc setup
這一步主要工作是:
1. 注冊(cè)O(shè)bjc類 (class registration)
2. 把category的定義插入方法列表 (category registration)
3. 保證每一個(gè)selector唯一 (selctor uniquing)
由于之前2步驟的優(yōu)化作瞄,這一步實(shí)際上沒有什么可做的茶宵。
initializers
以上三步屬于靜態(tài)調(diào)整(fix-up),都是在修改__DATA segment中的內(nèi)容宗挥,而這里則開始動(dòng)態(tài)調(diào)整乌庶,開始在堆和堆棧中寫入內(nèi)容种蝶。
在這里的工作有:
1. Objc的+load()函數(shù)
2. C++的構(gòu)造函數(shù)屬性函數(shù) 形如attribute((constructor)) void DoSomeInitializationWork()
3. 非基本類型的C++靜態(tài)全局變量的創(chuàng)建(通常是類或結(jié)構(gòu)體)(non-trivial initializer) 比如一個(gè)全局靜態(tài)結(jié)構(gòu)體的構(gòu)建,如果在構(gòu)造函數(shù)中有繁重的工作瞒大,那么會(huì)拖慢啟動(dòng)速度
Objc的load函數(shù)和C++的靜態(tài)構(gòu)造函數(shù)采用由底向上的方式執(zhí)行螃征,來保證每個(gè)執(zhí)行的方法,都可以找到所依賴的動(dòng)態(tài)庫透敌。
上圖是在自定義的類XXViewController的+load方法斷點(diǎn)的調(diào)用堆棧盯滚,清楚的看到整個(gè)調(diào)用棧和順序:
1. dyld 開始將程序二進(jìn)制文件初始化
2. 交由 ImageLoader 讀取 image,其中包含了我們的類酗电、方法等各種符號(hào)
3. 由于 runtime 向 dyld 綁定了回調(diào)魄藕,當(dāng) image 加載到內(nèi)存后,dyld 會(huì)通知 runtime 進(jìn)行處理
4. runtime 接手后調(diào)用 map_images 做解析和處理顾瞻,接下來 load_images 中調(diào)用 call_load_methods 方法泼疑,遍歷所有加載進(jìn)來的 Class,按繼承層級(jí)依次調(diào)用 Class 的 +load 方法和其 Category 的 +load 方法
至此荷荤,可執(zhí)行文件中和動(dòng)態(tài)庫所有的符號(hào)(Class退渗,Protocol,Selector蕴纳,IMP会油,…)都已經(jīng)按格式成功加載到內(nèi)存中,被 runtime 所管理古毛,再這之后翻翩,runtime 的那些方法(動(dòng)態(tài)添加 Class、swizzle 等等才能生效)稻薇。
整個(gè)事件由 dyld 主導(dǎo)嫂冻,完成運(yùn)行環(huán)境的初始化后,配合 ImageLoader 將二進(jìn)制文件按格式加載到內(nèi)存塞椎,
動(dòng)態(tài)鏈接依賴庫桨仿,并由 runtime 負(fù)責(zé)加載成 objc 定義的結(jié)構(gòu),所有初始化工作結(jié)束后案狠,dyld 調(diào)用真正的 main 函數(shù)服傍。
如果程序剛剛被運(yùn)行過,那么程序的代碼會(huì)被dyld緩存骂铁,因此即使殺掉進(jìn)程再次重啟加載時(shí)間也會(huì)相對(duì)快一點(diǎn)吹零,如果長時(shí)間沒有啟動(dòng)或者當(dāng)前dyld的緩存已經(jīng)被其他應(yīng)用占據(jù),那么這次啟動(dòng)所花費(fèi)的時(shí)間就要長一點(diǎn)拉庵,這就分別是熱啟動(dòng)和冷啟動(dòng)的概念灿椅,如下圖所示:
main()之前的加載時(shí)間如何衡量
那么問題就來了,那怎么衡量main()之前也就是time1的耗時(shí)呢,蘋果官方提供了一種方法茫蛹,那就是在真機(jī)調(diào)試的時(shí)候勾選dyld_PRINT_STATISTICS選項(xiàng)泣懊。
會(huì)得到如下形式的輸出:
由此可見對(duì)于系統(tǒng)級(jí)別的動(dòng)態(tài)鏈接庫,因?yàn)樘O果做了優(yōu)化麻惶,所以耗時(shí)并不多馍刮,在這個(gè)awesome的例子中,自身App中的代碼占用了整體時(shí)間的94.2%
我們應(yīng)用中一次典型的Log如下:
由此可見窃蹋,最多的用時(shí)還是在image加載和OC類的初始化卡啰,共占用總時(shí)長的79.3%,精簡(jiǎn)framework的引入和OC類有優(yōu)化的空間警没。
總結(jié)一下:對(duì)于main()調(diào)用之前的耗時(shí)我們可以優(yōu)化的點(diǎn)有:
1. 減少不必要的framework匈辱,因?yàn)閯?dòng)態(tài)鏈接比較耗時(shí)
2. check framework應(yīng)當(dāng)設(shè)為optional和required慷丽,如果該framework在當(dāng)前App支持的所有iOS系統(tǒng)版本都存在霎奢,那么就設(shè)為required刃泌,否則就設(shè)為optional纯陨,因?yàn)閛ptional會(huì)有些額外的檢查
3. 合并或者刪減一些OC類,關(guān)于清理項(xiàng)目中沒用到的類赘风,使用工具AppCode代碼檢查功能挡逼,查到當(dāng)前項(xiàng)目中沒有用到的類如下:
1. 刪減一些無用的靜態(tài)變量
2. 刪減沒有被調(diào)用到或者已經(jīng)廢棄的方法方法見:
3.
4. http://stackoverflow.com/questions/35233564/how-to-find-unused-code-in-xcode-7
5. https://developer.Apple.com/library/ios/documentation/ToolsLanguages/Conceptual/Xcode_Overview/CheckingCodeCoverage.html
6. 將不必須在+load方法中做的事情延遲到+initialize中
7. 盡量不要用C++虛函數(shù)(創(chuàng)建虛函數(shù)表有開銷)
main()調(diào)用之后的加載時(shí)間
在main()被調(diào)用之后从祝,App的主要工作就是初始化必要的服務(wù)续语,顯示首頁內(nèi)容等垂谢。而我們的優(yōu)化也是圍繞如何能夠快速展現(xiàn)首頁來開展。
App通常在AppDelegate類中的- (BOOL)Application:(UIApplication )Application didFinishLaunchingWithOptions:(NSDictionary )launchOptions方法中創(chuàng)建首頁需要展示的view疮茄,然后在當(dāng)前runloop的末尾滥朱,主動(dòng)調(diào)用CA::Transaction::commit完成視圖的渲染。
而視圖的渲染主要涉及三個(gè)階段:
1. 準(zhǔn)備階段 這里主要是圖片的解碼
2. 布局階段 首頁所有UIView的- (void)layoutSubViews()運(yùn)行
3. 繪制階段 首頁所有UIView的- (void)drawRect:(CGRect)rect運(yùn)行再加上啟動(dòng)之后必要服務(wù)的啟動(dòng)力试、必要數(shù)據(jù)的創(chuàng)建和讀取徙邻,這些就是我們可以嘗試優(yōu)化的地方
因此,對(duì)于main()函數(shù)調(diào)用之前我們可以優(yōu)化的點(diǎn)有:
1. 不使用xib畸裳,直接視用代碼加載首頁視圖
2. NSUserDefaults實(shí)際上是在Library文件夾下會(huì)生產(chǎn)一個(gè)plist文件缰犁,如果文件太大的話一次能讀取到內(nèi)存中可能很耗時(shí),這個(gè)影響需要評(píng)估躯畴,如果耗時(shí)很大的話需要拆分(需考慮老版本覆蓋安裝兼容問題)
3. 每次用NSLog方式打印會(huì)隱式的創(chuàng)建一個(gè)Calendar民鼓,因此需要?jiǎng)h減啟動(dòng)時(shí)各業(yè)務(wù)方打的log薇芝,或者僅僅針對(duì)內(nèi)測(cè)版輸出log
4. 梳理應(yīng)用啟動(dòng)時(shí)發(fā)送的所有網(wǎng)絡(luò)請(qǐng)求蓬抄,是否可以統(tǒng)一在異步線程請(qǐng)求
實(shí)測(cè)數(shù)據(jù)
建立了一個(gè)空的HelloWorld工程,只加入了pods中的代碼夯到,不包含主端的業(yè)務(wù)邏輯代碼嚷缭,一次典型的冷啟動(dòng)基本接近2s iPhone6 iOS9.3.5系統(tǒng)測(cè)試主要時(shí)間在加載動(dòng)態(tài)庫,類/方法的初始化還有符號(hào)地址綁定階段。
一次典型的熱啟動(dòng)數(shù)據(jù)如下:可以看到因?yàn)橄到y(tǒng)做了緩存方面的優(yōu)化阅爽,比冷啟動(dòng)快了500ms加上頭條主端業(yè)務(wù)邏輯代碼之后一次典型的熱啟動(dòng)耗時(shí)2.1s路幸。
以上用時(shí)均為main()之前的加載耗時(shí)。
main函數(shù)之后加載時(shí)間優(yōu)化記錄
NSUserDefaults是否是瓶頸
蘋果官方文檔提到NSUserDefaults加載的時(shí)候是整個(gè)plist配置文件全部load到內(nèi)存中付翁,目前頭條主端當(dāng)中NSUserDefaults存儲(chǔ)了200多項(xiàng)緩存數(shù)據(jù)简肴,因此懷疑可能拖慢啟動(dòng)速度,但是測(cè)試結(jié)果顯示并不會(huì)百侧。
通過符號(hào)斷點(diǎn)+[NSUserDefaults standardUserDefaults]確定最早一次的+load()從執(zhí)行到結(jié)束耗時(shí)1.8ms砰识,可見NSUserDefaults的初始化僅耗時(shí)1.8ms,并不是啟動(dòng)耗時(shí)的瓶頸佣渴。
如何找到拖慢啟動(dòng)應(yīng)用時(shí)長的瓶頸
為了找到瓶頸辫狼,我們?cè)趩?dòng)之后的didFinishLauhcning方法開始執(zhí)行到首頁列表頁的NewsListViewController的viewDidAppear方法,幾乎每個(gè)可能比較耗時(shí)的流程進(jìn)行拆分和統(tǒng)計(jì)辛润,得到統(tǒng)計(jì)數(shù)據(jù)之后發(fā)現(xiàn):
主要耗時(shí)在首頁UI構(gòu)造和渲染(storyboard加載膨处,tabBar/topBar渲染,開屏廣告加載/cell注冊(cè)/日志模塊初始化這幾個(gè)步驟)砂竖。
具體優(yōu)化點(diǎn)
因此真椿,針對(duì)于今日頭條這個(gè)App我們可以優(yōu)化的點(diǎn)如下:
1. 純代碼方式而不是storyboard加載首頁UI。
2. 對(duì)didFinishLaunching里的函數(shù)考慮能否挖掘可以延遲加載或者懶加載乎澄,需要與各個(gè)業(yè)務(wù)方pm和rd共同check 對(duì)于一些已經(jīng)下線的業(yè)務(wù)瀑粥,刪減冗余代碼。對(duì)于一些與UI展示無關(guān)的業(yè)務(wù)三圆,如微博認(rèn)證過期檢查狞换、圖片最大緩存空間設(shè)置等做延遲加載
3. 對(duì)實(shí)現(xiàn)了+load()方法的類進(jìn)行分析,盡量將load里的代碼延后調(diào)用舟肉。
4. 上面統(tǒng)計(jì)數(shù)據(jù)顯示展示feed的導(dǎo)航控制器頁面(NewsListViewController)比較耗時(shí)修噪,對(duì)于viewDidLoad以及viewWillAppear方法中盡量去嘗試少做,晚做路媚,不做黄琼。
優(yōu)化結(jié)果
之前曾經(jīng)有一位同事已經(jīng)做了一定的優(yōu)化,比如啟動(dòng)之后展示閃屏廣告圖的同時(shí)初始化首頁的列表頁整慎,當(dāng)廣告展示完成之后列表頁也就渲染完成了脏款。經(jīng)過這一次優(yōu)化之后的main()之后的啟動(dòng)總時(shí)長通過上線之后收集數(shù)據(jù)的驗(yàn)證達(dá)到了預(yù)期的效果。
北上廣 -北