背景
一個項(xiàng)目做的時間長了阎姥,啟動流程往往容易雜亂,庫也用的越來越多鸽捻,APP 的啟動時間也會慢慢變長呼巴。本次將針對 iOS APP 的啟動時間優(yōu)化一波氨淌。
通常針對一個技術(shù)點(diǎn)做優(yōu)化的時候,都要先了解清楚這個技術(shù)點(diǎn)有哪些流程伊磺,優(yōu)化的方向往往是減少流程的數(shù)量盛正,以及減少每個流程的消耗。
本次優(yōu)化從結(jié)果上來看屑埋,main 階段的優(yōu)化效果最顯著豪筝,尤其是啟動時的一些 IO 操作處理,對啟動時間的減少有很大作用摘能。多線程啟動的設(shè)計和驗(yàn)證最有意思续崖,但是在實(shí)踐上由于我們業(yè)務(wù)本身的原因,只開了額外一個子線程來并行啟動团搞,且僅在子線程做了少量的獨(dú)立操作严望,主要還是我們的業(yè)務(wù)之間耦合性太強(qiáng)了,不太適合拆分到子線程逻恐。
一般說來像吻,pre-main階段的定義為 APP 開始啟動到系統(tǒng)調(diào)用 main 函數(shù)這一段時間;main 階段則代表從main函數(shù)入口到主 UI 框架的 viewDidAppear 函數(shù)調(diào)用的這一段時間复隆。(本文后續(xù) main 階段的時間統(tǒng)計都用 viewDidAppear 作為基準(zhǔn)而非的applicationWillFinishLaunching)
本文前半部分講原理(內(nèi)容基本是從網(wǎng)上借鑒/摘錄)拨匆,后半部分講實(shí)踐,pre-main階段的原理比較難理解挽拂,不過實(shí)踐倒是根據(jù)結(jié)論直接做就好了惭每。
App啟動過程
解析Info.plist;加載相關(guān)信息亏栈,例如閃屏台腥;沙箱建立、權(quán)限檢查
Mach-O加載绒北;如果是胖二進(jìn)制文件黎侈,尋找合適當(dāng)前CPU架構(gòu)的部分;加載所有依賴的Mach-O文件(遞歸調(diào)用Mach-O加載的方法)
定位內(nèi)部镇饮、外部指針引用蜓竹,例如字符串、函數(shù)等储藐;執(zhí)行聲明為attribute((constructor))的C函數(shù)俱济;加載類擴(kuò)展(Category)中的方法;C++靜態(tài)對象加載钙勃、調(diào)用ObjC的 +load 函數(shù)
程序執(zhí)行:調(diào)用main()蛛碌;調(diào)用UIApplicationMain();調(diào)用applicationWillFinishLaunching
換成另一個說法就是:
App 開始啟動后辖源,系統(tǒng)首先加載可執(zhí)行文件(自身 App 的所有.o文件的集合)蔚携,然后加載動態(tài)鏈接器dyld希太,dyld 是一個專門用來加載動態(tài)鏈接庫的庫。 執(zhí)行從 dyld 開始酝蜒,dyld 從可執(zhí)行文件的依賴開始誊辉,遞歸加載所有的依賴動態(tài)鏈接庫。
動態(tài)鏈接庫包括:iOS 中用到的所有系統(tǒng) framework亡脑,加載 OC runtime 方法的libobjc堕澄,系統(tǒng)級別的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)霉咨。
可執(zhí)行文件的內(nèi)核流程
如圖蛙紫,當(dāng)啟動一個應(yīng)用程序時,系統(tǒng)最后會根據(jù)你的行為調(diào)用兩個函數(shù)途戒,fork和execve坑傅。fork 功能創(chuàng)建一個進(jìn)程;execve 功能加載和運(yùn)行程序喷斋。這里有多個不同的功能唁毒,比如execl、execv和exect继准,每個功能提供了不同傳參和環(huán)境變量的方法到程序中枉证。在 OSX 中,每個這些其他的exec路徑最終調(diào)用了內(nèi)核路徑execve移必。
執(zhí)行exec系統(tǒng)調(diào)用,一般都是這樣毡鉴,用fork()函數(shù)新建立一個進(jìn)程崔泵,然后讓進(jìn)程去執(zhí)行exec調(diào)用。我們知道猪瞬,在fork()建立新進(jìn)程之后憎瘸,父進(jìn)程與子進(jìn)程共享代碼段(TEXT),但數(shù)據(jù)空間(DATA)是分開的陈瘦,但父進(jìn)程會把自己數(shù)據(jù)空間的內(nèi)容copy到子進(jìn)程中去幌甘,還有上下文也會copy到子進(jìn)程中去。
為了提高效率痊项,采用一種寫時copy的策略锅风,即創(chuàng)建子進(jìn)程的時候,并不copy父進(jìn)程的地址空間鞍泉,父子進(jìn)程擁有共同的地址空間皱埠,只有當(dāng)子進(jìn)程需要寫入數(shù)據(jù)時(如向緩沖區(qū)寫入數(shù)據(jù)),這時候會復(fù)制地址空間咖驮,復(fù)制緩沖區(qū)到子進(jìn)程中去边器。從而父子進(jìn)程擁有獨(dú)立的地址空間训枢。而對于fork()之后執(zhí)行exec之前,這種策略能夠很好的提高效率忘巧,如果一開始就copy恒界,那么exec之后,子進(jìn)程(此處可以說是父進(jìn)程也可以說是子進(jìn)程砚嘴,因?yàn)閭z進(jìn)程的數(shù)據(jù)此時是一樣的)的數(shù)據(jù)會被放棄仗处,被新的進(jìn)程所代替。見下圖:
啟動時間的分布枣宫,pre-main和main階段原理淺析
rebase修復(fù)的是指向當(dāng)前鏡像內(nèi)部的資源指針婆誓; 而bind指向的是鏡像外部的資源指針。
rebase步驟先進(jìn)行也颤,需要把鏡像讀入內(nèi)存洋幻,并以page為單位進(jìn)行加密驗(yàn)證,保證不會被篡改翅娶,所以這一步的瓶頸在IO文留。bind在其后進(jìn)行,由于要查詢符號表竭沫,來指向跨鏡像的資源燥翅,加上在rebase階段,鏡像已被讀入和加密驗(yàn)證蜕提,所以這一步的瓶頸在于CPU計算森书。這兩個步驟在下面會詳細(xì)闡述。
pre-main過程
main過程
一些概念
什么是dyld?
動態(tài)鏈接庫的加載過程主要由dyld來完成谎势,dyld是蘋果的動態(tài)鏈接器凛膏。
系統(tǒng)先讀取App的可執(zhí)行文件(Mach-O文件),從里面獲得dyld的路徑脏榆,然后加載dyld猖毫,dyld去初始化運(yùn)行環(huán)境,開啟緩存策略须喂,加載程序相關(guān)依賴庫(其中也包含我們的可執(zhí)行文件)吁断,并對這些庫進(jìn)行鏈接,最后調(diào)用每個依賴庫的初始化方法坞生,在這一步仔役,runtime被初始化。當(dāng)所有依賴庫的初始化后恨胚,輪到最后一位(程序可執(zhí)行文件)進(jìn)行初始化骂因,在這時runtime會對項(xiàng)目中所有類進(jìn)行類結(jié)構(gòu)初始化,然后調(diào)用所有的load方法赃泡。最后dyld返回main函數(shù)地址寒波,main函數(shù)被調(diào)用乘盼,我們便來到了熟悉的程序入口。
當(dāng)加載一個 Mach-O 文件 (一個可執(zhí)行文件或者一個庫) 時俄烁,動態(tài)鏈接器首先會檢查共享緩存看看是否存在其中绸栅,如果存在,那么就直接從共享緩存中拿出來使用页屠。每一個進(jìn)程都把這個共享緩存映射到了自己的地址空間中粹胯。這個方法大大優(yōu)化了 OS X 和 iOS 上程序的啟動時間。
問題:測試發(fā)現(xiàn)辰企,由于手機(jī)從開機(jī)后风纠,連續(xù)兩次啟動同一個APP的pre-main實(shí)際時間的差值比較大,這一步可以在真機(jī)上復(fù)現(xiàn)牢贸,那么這兩次啟動pre-main的時間差值竹观,是跟系統(tǒng)的framework關(guān)系比較大,還是跟APP自身依賴的第三方framework關(guān)系比較大呢潜索?
Mach-O 鏡像文件
Mach-O被劃分成一些segement臭增,每個 segement 又被劃分成一些section。segment 的名字都是大寫的竹习,且空間大小為頁的整數(shù)誊抛。頁的大小跟硬件有關(guān),在arm64架構(gòu)一頁是16KB整陌,其余為4KB拗窃。
section 雖然沒有整數(shù)倍頁大小的限制,但是 section 之間不會有重疊蔓榄。幾乎所有 Mach-O 都包含這三個段(segment):__TEXT并炮,__DATA和__LINKEDIT。
__TEXT包含Mach header甥郑,被執(zhí)行的代碼和只讀常量(如C 字符串)。只讀可執(zhí)行(r-x)荤西。
__DATA包含全局變量澜搅,靜態(tài)變量等⌒靶浚可讀寫(rw-)勉躺。
__LINKEDIT包含了加載程序的『元數(shù)據(jù)』,比如函數(shù)的名稱和地址觅丰。只讀(r–)饵溅。
ASLR(Address Space Layout Randomization):地址空間布局隨機(jī)化,鏡像會在隨機(jī)的地址上加載妇萄。
傳統(tǒng)方式下蜕企,進(jìn)程每次啟動采用的都是固定可預(yù)見的方式咬荷,這意味著一個給定的程序在給定的架構(gòu)上的進(jìn)程初始虛擬內(nèi)存都是基本一致的,而且在進(jìn)程正常運(yùn)行的生命周期中轻掩,內(nèi)存中的地址分布具有非常強(qiáng)的可預(yù)測性幸乒,這給了黑客很大的施展空間(代碼注入,重寫內(nèi)存)唇牧;
如果采用ASLR罕扎,進(jìn)程每次啟動,地址空間都會被簡單地隨機(jī)化丐重,但是只是偏移腔召,不是攪亂。大體布局——程序文本扮惦、數(shù)據(jù)和庫是一樣的臀蛛,但是具體的地址都不同了,可以阻擋黑客對地址的猜測 径缅。
代碼簽名:可能我們認(rèn)為 Xcode 會把整個文件都做加密 hash 并用做數(shù)字簽名掺栅。其實(shí)為了在運(yùn)行時驗(yàn)證 Mach-O 文件的簽名,并不是每次重復(fù)讀入整個文件纳猪,而是把每頁內(nèi)容都生成一個單獨(dú)的加密散列值氧卧,并存儲在 __LINKEDIT 中。這使得文件每頁的內(nèi)容都能及時被校驗(yàn)確并保不被篡改氏堤。
關(guān)于虛擬內(nèi)存
我們開發(fā)者開發(fā)過程中所接觸到的內(nèi)存均為虛擬內(nèi)存沙绝,虛擬內(nèi)存使App認(rèn)為它擁有連續(xù)的可用的內(nèi)存(一個連續(xù)完整的地址空間),這是系統(tǒng)給我們的饋贈鼠锈,而實(shí)際上闪檬,它通常是分布在多個物理內(nèi)存碎片,系統(tǒng)的虛擬內(nèi)存空間映射vm_map負(fù)責(zé)虛擬內(nèi)存和物理內(nèi)存的映射關(guān)系购笆。
ARM處理器64bit的架構(gòu)情況下械哟,也就是0x000000000-0xFFFFFFFFF菠赚,每個16進(jìn)制數(shù)是4位,即2的36次冪,就是64GB拯爽,即App最大的虛擬內(nèi)存空間為64GB纠修。
共享動態(tài)庫其實(shí)就是共享的物理內(nèi)存中的那份動態(tài)庫淘讥,App虛擬內(nèi)存中的共享動態(tài)庫并未真實(shí)分配物理內(nèi)存士袄,使用時虛擬內(nèi)存會訪問同一份物理內(nèi)存達(dá)到共享動態(tài)庫的目的,iPhone7 PLUS(之前的產(chǎn)品最大為2GB)的物理內(nèi)存RAM也只有3GB襟锐,那么超過3GB的物理內(nèi)存如何處理呢撤逢,系統(tǒng)會使用一部分硬盤空間ROM來充當(dāng)內(nèi)存使用,在需要時進(jìn)行數(shù)據(jù)交換,當(dāng)然磁盤的數(shù)據(jù)交換是遠(yuǎn)遠(yuǎn)慢于物理內(nèi)存的蚊荣,這也是我們內(nèi)存過載時初狰,App卡頓的原因之一。
系統(tǒng)使用動態(tài)鏈接有幾點(diǎn)好處:
代碼共用:很多程序都動態(tài)鏈接了這些 lib妇押,但它們在內(nèi)存和磁盤中中只有一份跷究。
易于維護(hù):由于被依賴的 lib 是程序執(zhí)行時才鏈接的,所以這些 lib 很容易做更新敲霍,比如libSystem.dylib是libSystem.B.dylib的替身俊马,哪天想升級直接換成libSystem.C.dylib然后再替換替身就行了。
減少可執(zhí)行文件體積:相比靜態(tài)鏈接肩杈,動態(tài)鏈接在編譯時不需要打進(jìn)去柴我,所以可執(zhí)行文件的體積要小很多。
上圖中扩然,TEXT段兩個進(jìn)程共用艘儒,DATA段每個進(jìn)程各一份。
下面開始詳細(xì)分析pre-main的各個階段
加載 Dylib
從主執(zhí)行文件的 header 獲取到需要加載的所依賴動態(tài)庫列表夫偶,而 header 早就被內(nèi)核映射過界睁。然后它需要找到每個dylib,然后打開文件讀取文件起始位置兵拢,確保它是Mach-O文件翻斟。接著會找到代碼簽名并將其注冊到內(nèi)核。然后在 dylib 文件的每個 segment 上調(diào)用 mmap()说铃。應(yīng)用所依賴的 dylib 文件可能會再依賴其他 dylib访惜,所以 dyld 所需要加載的是動態(tài)庫列表一個遞歸依賴的集合。一般應(yīng)用會加載100到400個 dylib 文件腻扇,但大部分都是系統(tǒng) dylib债热,它們會被預(yù)先計算和緩存起來,加載速度很快幼苛。
加載系統(tǒng)的 dylib 很快窒篱,因?yàn)橛袃?yōu)化(因?yàn)椴僮飨到y(tǒng)自己要用部分framework所以在操作系統(tǒng)開機(jī)后就已經(jīng)緩存了?)舶沿。但加載內(nèi)嵌(embedded)的 dylib 文件很占時間舌剂,所以盡可能把多個內(nèi)嵌 dylib 合并成一個來加載,或者使用static archive暑椰。使用dlopen()來在運(yùn)行時懶加載是不建議的,這么做可能會帶來一些問題荐绝,并且總的開銷更大一汽。
在每個動態(tài)庫的加載過程中, dyld需要:
分析所依賴的動態(tài)庫
找到動態(tài)庫的mach-o文件
打開文件
驗(yàn)證文件
在系統(tǒng)核心注冊文件簽名
對動態(tài)庫的每一個segment調(diào)用mmap()
針對這一步驟的優(yōu)化有:
減少非系統(tǒng)庫的依賴;
使用靜態(tài)庫而不是動態(tài)庫召夹;
合并非系統(tǒng)動態(tài)庫為一個動態(tài)庫岩喷;
Rebase && Binding
Fix-ups
在加載所有的動態(tài)鏈接庫之后,它們只是處在相互獨(dú)立的狀態(tài)监憎,需要將它們綁定起來纱意,這就是Fix-ups。代碼簽名使得我們不能修改指令鲸阔,那樣就不能讓一個 dylib 的調(diào)用另一個 dylib偷霉。這時需要加很多間接層。
現(xiàn)代code-gen被叫做動態(tài)PIC(Position Independent Code)褐筛,意味著代碼可以被加載到間接的地址上类少。當(dāng)調(diào)用發(fā)生時,code-gen 實(shí)際上會在 __DATA 段中創(chuàng)建一個指向被調(diào)用者的指針渔扎,然后加載指針并跳轉(zhuǎn)過去硫狞。
所以 dyld 做的事情就是修正(fix-up)指針和數(shù)據(jù)。Fix-up 有兩種類型晃痴,rebasing和binding残吩。
Rebase
Rebasing:在鏡像內(nèi)部調(diào)整指針的指向,針對mach-o在加載到內(nèi)存中不是固定的首地址(ASLR)這一現(xiàn)象做數(shù)據(jù)修正的過程倘核;
由于ASLR(address space layout randomization)的存在泣侮,可執(zhí)行文件和動態(tài)鏈接庫在虛擬內(nèi)存中的加載地址每次啟動都不固定,所以需要這2步來修復(fù)鏡像中的資源指針笤虫,來指向正確的地址旁瘫。 rebase修復(fù)的是指向當(dāng)前鏡像內(nèi)部的資源指針; 而bind指向的是鏡像外部的資源指針琼蚯。
在iOS4.3前會把dylib加載到指定地址酬凳,所有指針和數(shù)據(jù)對于代碼來說都是固定的,dyld 就無需做rebase/binding了遭庶。
iOS4.3后引入了 ASLR 宁仔,dylib會被加載到隨機(jī)地址,這個隨機(jī)的地址跟代碼和數(shù)據(jù)指向的舊地址會有偏差峦睡,dyld 需要修正這個偏差翎苫,做法就是將 dylib 內(nèi)部的指針地址都加上這個偏移量,偏移量的計算方法如下:
Slide=?actual_address?-?preferred_address
然后就是重復(fù)不斷地對 __DATA 段中需要 rebase 的指針加上這個偏移量榨了。這就又涉及到page fault和COW煎谍。這可能會產(chǎn)生 I/O 瓶頸,但因?yàn)?rebase 的順序是按地址排列的龙屉,所以從內(nèi)核的角度來看這是個有次序的任務(wù)呐粘,它會預(yù)先讀入數(shù)據(jù)满俗,減少 I/O 消耗。
在 Rebasing 和 Binding 前會判斷是否已經(jīng) Prebinding作岖。如果已經(jīng)進(jìn)行過預(yù)綁定(Prebinding)唆垃,那就不需要 Rebasing 和 Binding 這些 Fix-up 流程了,因?yàn)橐呀?jīng)在預(yù)先綁定的地址加載好了痘儡。
rebase步驟先進(jìn)行辕万,需要把鏡像讀入內(nèi)存,并以page為單位進(jìn)行加密驗(yàn)證沉删,保證不會被篡改渐尿,所以這一步的瓶頸在IO。bind在其后進(jìn)行丑念,由于要查詢符號表涡戳,來指向跨鏡像的資源,加上在rebase階段脯倚,鏡像已被讀入和加密驗(yàn)證渔彰,所以這一步的瓶頸在于CPU計算。
Binding
Binding:將指針指向鏡像外部的內(nèi)容推正,binding就是將這個二進(jìn)制調(diào)用的外部符號進(jìn)行綁定的過程恍涂。比如我們objc代碼中需要使用到NSObject, 即符號_OBJC_CLASS_$_NSObject,但是這個符號又不在我們的二進(jìn)制中植榕,在系統(tǒng)庫Foundation.framework中再沧,因此就需要binding這個操作將對應(yīng)關(guān)系綁定到一起;
lazyBinding就是在加載動態(tài)庫的時候不會立即binding, 當(dāng)時當(dāng)?shù)谝淮握{(diào)用這個方法的時候再實(shí)施binding尊残。 做到的方法也很簡單: 通過dyld_stub_binder這個符號來做炒瘸。lazyBinding的方法第一次會調(diào)用到dyld_stub_binder, 然后dyld_stub_binder負(fù)責(zé)找到真實(shí)的方法,并且將地址bind到樁上寝衫,下一次就不用再bind了顷扩。
Binding 是處理那些指向 dylib 外部的指針,它們實(shí)際上被符號(symbol)名稱綁定慰毅,也就是個字符串隘截。__LINKEDIT段中也存儲了需要 bind 的指針,以及指針需要指向的符號汹胃。dyld 需要找到 symbol 對應(yīng)的實(shí)現(xiàn)婶芭,這需要很多計算,去符號表里查找着饥。找到后會將內(nèi)容存儲到 __DATA 段中的那個指針中犀农。Binding 看起來計算量比 Rebasing 更大,但其實(shí)需要的 I/O 操作很少宰掉,Binding的時間主要是耗費(fèi)在計算上井赌,因?yàn)镮O操作之前 Rebasing 已經(jīng)替 Binding 做過了谤逼,所以這兩個步驟的耗時是混在一起的。
可以從查看 __DATA 段中需要修正(fix-up)的指針仇穗,所以減少指針數(shù)量才會減少這部分工作的耗時。對于 ObjC 來說就是減少 Class,selector 和 category 這些元數(shù)據(jù)的數(shù)量戚绕。從編碼原則和設(shè)計模式之類的理論都會鼓勵大家多寫精致短小的類和方法纹坐,并將每部分方法獨(dú)立出一個類別,其實(shí)這會增加啟動時間舞丛。對于 C++ 來說需要減少虛方法耘子,因?yàn)樘摲椒〞?chuàng)建 vtable,這也會在 __DATA 段中創(chuàng)建結(jié)構(gòu)球切。雖然 C++ 虛方法對啟動耗時的增加要比 ObjC 元數(shù)據(jù)要少谷誓,但依然不可忽視。最后推薦使用 Swift 結(jié)構(gòu)體吨凑,它需要 fix-up 的內(nèi)容較少捍歪。
Objective-C 中有很多數(shù)據(jù)結(jié)構(gòu)都是靠 Rebasing 和 Binding 來修正(fix-up)的,比如 Class 中指向父類的指針和指向方法的指針鸵钝。
Rebase&&Binding該階段的優(yōu)化關(guān)鍵在于減少__DATA segment中的指針數(shù)量糙臼。我們可以優(yōu)化的點(diǎn)有:
減少Objc類數(shù)量, 減少selector數(shù)量恩商,把未使用的類和函數(shù)都可以刪掉
減少C++虛函數(shù)數(shù)量
轉(zhuǎn)而使用swift stuct(其實(shí)本質(zhì)上就是為了減少符號的數(shù)量变逃,使用swift語言來開發(fā)?)
未使用類的掃描,可以利用linkmap文件和otool工機(jī)具反編譯APP的可進(jìn)行二進(jìn)制文件得出一個大概的結(jié)果怠堪,但是不算非常精確揽乱,掃描出來后需要手動一個個確認(rèn)。掃描原理大致是classlist和classref兩者的差值粟矿,所有的類和使用了的類的差值就是未使用的類啦凰棉。因?yàn)槲词褂玫念愔饕獌?yōu)化的是pre-main的時間,根據(jù)測試我們的工程pre-main時間并不長嚷炉,所以本次并沒有針對這一塊做優(yōu)化渊啰。(TODO:寫腳本來驗(yàn)證這一點(diǎn))。
ObjC SetUp
主要做以下幾件事來完成Objc Setup:
讀取二進(jìn)制文件的 DATA 段內(nèi)容申屹,找到與 objc 相關(guān)的信息
注冊 Objc 類绘证,ObjC Runtime 需要維護(hù)一張映射類名與類的全局表。當(dāng)加載一個 dylib 時哗讥,其定義的所有的類都需要被注冊到這個全局表中嚷那;
讀取 protocol 以及 category 的信息,把category的定義插入方法列表 (category registration)杆煞,
確保 selector 的唯一性
ObjC 是個動態(tài)語言魏宽,可以用類的名字來實(shí)例化一個類的對象腐泻。這意味著 ObjC Runtime 需要維護(hù)一張映射類名與類的全局表。當(dāng)加載一個 dylib 時队询,其定義的所有的類都需要被注冊到這個全局表中派桩。
C++ 中有個問題叫做易碎的基類(fragile base class)。ObjC 就沒有這個問題蚌斩,因?yàn)闀诩虞d時通過 fix-up 動態(tài)類中改變實(shí)例變量的偏移量铆惑。
在 ObjC 中可以通過定義類別(Category)的方式改變一個類的方法。有時你想要添加方法的類在另一個 dylib 中送膳,而不在你的鏡像中(也就是對系統(tǒng)或別人的類動刀)员魏,這時也需要做些 fix-up。
ObjC 中的 selector 必須是唯一的撕阎。
由于之前2步驟的優(yōu)化,這一步實(shí)際上沒有什么可做的碌补。幾乎都靠 Rebasing 和 Binding 步驟中減少所需 fix-up 內(nèi)容魄眉。因?yàn)榍懊娴墓ぷ饕矔沟眠@步耗時減少囊骤。
Initializers
以上三步屬于靜態(tài)調(diào)整也物,都是在修改__DATA segment中的內(nèi)容宫屠,而這里則開始動態(tài)調(diào)整,開始在堆和棧中寫入內(nèi)容滑蚯。 工作主要有:
Objc的+load()函數(shù)
C++的構(gòu)造函數(shù)屬性函數(shù) 形如attribute((constructor)) void DoSomeInitializationWork()
非基本類型的C++靜態(tài)全局變量的創(chuàng)建(通常是類或結(jié)構(gòu)體)(non-trivial initializer) 比如一個全局靜態(tài)結(jié)構(gòu)體的構(gòu)建浪蹂,如果在構(gòu)造函數(shù)中有繁重的工作,那么會拖慢啟動速度
Objc的load函數(shù)和C++的靜態(tài)構(gòu)造函數(shù)采用由底向上的方式執(zhí)行告材,來保證每個執(zhí)行的方法坤次,都可以找到所依賴的動態(tài)庫
dyld開始將程序二進(jìn)制文件初始化
交由ImageLoader讀取image,其中包含了我們的類斥赋、方法等各種符號
由于runtime向dyld綁定了回調(diào)缰猴,當(dāng)image加載到內(nèi)存后,dyld會通知runtime進(jìn)行處理
runtime接手后調(diào)用mapimages做解析和處理疤剑,接下來loadimages中調(diào)用 callloadmethods方法滑绒,遍歷所有加載進(jìn)來的Class闷堡,按繼承層級依次調(diào)用Class的+load方法和其 Category的+load方法
整個事件由dyld主導(dǎo),完成運(yùn)行環(huán)境的初始化后疑故,配合ImageLoader 將二進(jìn)制文件按格式加載到內(nèi)存杠览,動態(tài)鏈接依賴庫,并由runtime負(fù)責(zé)加載成objc 定義的結(jié)構(gòu)焰扳,所有初始化工作結(jié)束后倦零,dyld調(diào)用真正的main函數(shù)
C++ 會為靜態(tài)創(chuàng)建的對象生成初始化器。而在 ObjC 中有個叫 +load 的方法吨悍,然而它被廢棄了,現(xiàn)在建議使用 +initialize蹋嵌。對比詳見StackOverflow的一個連接;
這一步可以做的優(yōu)化有:
使用 +initialize 來替代 +load
不要使用 atribute((constructor)) 將方法顯式標(biāo)記為初始化器育瓜,而是讓初始化方法調(diào)用時才執(zhí)行。
比如使用 `dispatch_once()`,`pthread_once()` 或 `std::once()`栽烂。也就是在第一次使用時才初始化躏仇,推遲了一部分工作耗時。
也盡量不要用到C++的靜態(tài)對象腺办。
從效率上來說焰手,在 +load 和+initialize里執(zhí)行同樣的代碼,效率是一樣的怀喉,即使有差距书妻,也不會差距太大。 但所有的+load 方法都在啟動的時候調(diào)用躬拢,方法多了就會嚴(yán)重影響啟動速度了躲履。 就說我們項(xiàng)目中,有200個左右+load方法聊闯,一共耗時大概1s 左右工猜,這塊就會嚴(yán)重影響到用戶感知了。 而+initialize方法是在對應(yīng) Class 第一次使用的時候調(diào)用菱蔬,這是一個懶加載的方法篷帅,理想情況下,這200個+load方法都使用+initialize來代替拴泌,將耗時分?jǐn)偟接脩羰褂眠^程中魏身,每個方法平均耗時只有5ms,用戶完全可以無感知弛针。 因?yàn)閘oad是在啟動的時候調(diào)用叠骑,而initialize是在類首次被使用的時候調(diào)用,不過當(dāng)你把load中的邏輯移到initialize中時候削茁,一定要注意initialize的重復(fù)調(diào)用問題宙枷,能用dispatch_once()來完成的掉房,就盡量不要用到load方法。
如果程序剛剛被運(yùn)行過慰丛,那么程序的代碼會被dyld緩存卓囚,因此即使殺掉進(jìn)程再次重啟加載時間也會相對快一點(diǎn),如果長時間沒有啟動或者當(dāng)前dyld的緩存已經(jīng)被其他應(yīng)用占據(jù)诅病,那么這次啟動所花費(fèi)的時間就要長一點(diǎn)哪亿,這就分別是熱啟動和冷啟動的概念。下文中的啟動時間統(tǒng)計贤笆,均統(tǒng)計的是第二次啟動后的數(shù)據(jù)蝇棉。(具體dyld緩存的是動態(tài)庫還是APP的可執(zhí)行代碼,緩存多長時間芥永,需要再研究篡殷,有懂的大神可以告知一下?)
見下圖埋涧,出處是這里 (http://lingyuncxb.com/2018/01/30/iOS%E5%90%AF%E5%8A%A8%E4%BC%98%E5%8C%96/):
其實(shí)在我們APP的實(shí)踐過程中也會遇到類似的事情板辽,只不過我只統(tǒng)計了第二次啟動后的時間,也就是定義中的熱啟動時間棘催。
注:通過在工程的scheme中添加環(huán)境變量DYLD_PRINT_STATISTICS劲弦,設(shè)置值為1,App啟動加載時Xcode的控制臺就會有pre-main各個階段的詳細(xì)耗時輸出醇坝。但是DYLD_PRINT_STATISTICS 變量打印時間是iOS10以后才支持的功能邑跪,所以需要用iOS10系統(tǒng)及以上的機(jī)器來做測試。
pre-main階段耗時的影響因素:
動態(tài)庫加載越多纲仍,啟動越慢呀袱。
ObjC類越多,函數(shù)越多郑叠,啟動越慢夜赵。
可執(zhí)行文件越大啟動越慢。
C的constructor函數(shù)越多乡革,啟動越慢寇僧。
C++靜態(tài)對象越多,啟動越慢沸版。
ObjC的+load越多嘁傀,啟動越慢。
整體上pre-main階段的優(yōu)化有:
減少依賴不必要的庫视粮,不管是動態(tài)庫還是靜態(tài)庫细办;如果可以的話,把動態(tài)庫改造成靜態(tài)庫;如果必須依賴動態(tài)庫笑撞,則把多個非系統(tǒng)的動態(tài)庫合并成一個動態(tài)庫岛啸;
檢查下 framework應(yīng)當(dāng)設(shè)為optional和required,如果該framework在當(dāng)前App支持的所有iOS系統(tǒng)版本都存在茴肥,那么就設(shè)為required坚踩,否則就設(shè)為optional,因?yàn)閛ptional會有些額外的檢查瓤狐;
合并或者刪減一些OC類和函數(shù)瞬铸;關(guān)于清理項(xiàng)目中沒用到的類,使用工具AppCode代碼檢查功能础锐,查到當(dāng)前項(xiàng)目中沒有用到的類(也可以用根據(jù)linkmap文件來分析嗓节,但是準(zhǔn)確度不算很高);有一個叫做FUI的開源項(xiàng)目能很好的分析出不再使用的類皆警,準(zhǔn)確率非常高赦政,唯一的問題是它處理不了動態(tài)庫和靜態(tài)庫里提供的類,也處理不了C++的類模板耀怜。
刪減一些無用的靜態(tài)變量,
刪減沒有被調(diào)用到或者已經(jīng)廢棄的方法桐愉,方法見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财破。
將不必須在+load方法中做的事情延遲到+initialize中,盡量不要用C++虛函數(shù)(創(chuàng)建虛函數(shù)表有開銷)
類和方法名不要太長:iOS每個類和方法名都在__cstring段里都存了相應(yīng)的字符串值从诲,所以類和方法名的長短也是對可執(zhí)行文件大小是有影響的左痢;因還是object-c的動態(tài)特性,因?yàn)樾枰ㄟ^類/方法名反射找到這個類/方法進(jìn)行調(diào)用系洛,object-c對象模型會把類/方法名字符串都保存下來俊性;
用dispatch_once()代替所有的 attribute((constructor)) 函數(shù)、C++靜態(tài)對象初始化描扯、ObjC的+load函數(shù)定页;
在設(shè)計師可接受的范圍內(nèi)壓縮圖片的大小,會有意外收獲绽诚。壓縮圖片為什么能加快啟動速度呢典徊?因?yàn)閱拥臅r候大大小小的圖片加載個十來二十個是很正常的,圖片小了恩够,IO操作量就小了卒落,啟動當(dāng)然就會快了,比較靠譜的壓縮算法是TinyPNG蜂桶。
我們的實(shí)踐
統(tǒng)計了各個庫所占的size(使用之前做安裝包size優(yōu)化的一個腳本)儡毕,基本上一個公共庫越大,類越多扑媚,啟動時在pre-main階段所需要的時間也越多腰湾。
所以去掉了Realm雷恃,DiffMatchPatch源碼庫,以及AlicloudHttpDNS檐盟,BCConnectorBundl褂萧,F(xiàn)eedBack,SGMain和SecurityGuardSDK幾個庫葵萎;
結(jié)果如下:
靜態(tài)庫导犹,少了7M左右:
第三方framework(其實(shí)也是靜態(tài)庫,只是腳本會分開統(tǒng)計而已)羡忘,少了1M左右:
我們使用cocoapodbs并沒有設(shè)置user_frameworks谎痢,所以pod管理的有源碼的第三方庫都是靜態(tài)庫的形式,而framework形式的靜態(tài)庫基本都是第三方公司提供的服務(wù)卷雕,上圖可以看到节猿,size占比最大的還是阿里和騰訊兩家的SDK,比如阿里的推送和騰訊的直播和IM漫雕。
上圖在統(tǒng)計中滨嘱,AliCloudHttpDNS的可執(zhí)行文件在Mac的Finder下的大小大概是10M,AlicloudUtils是3.4M浸间,UTMini是16M太雨,而UTDID只有1.6M。依賴關(guān)系上魁蒜,AliCloudHttpDNS依賴AlicloudUtils囊扳,而AlicloudUtils依賴UTMini和UTDID,UTMini依賴UTDID兜看。
上圖中在統(tǒng)計上锥咸,應(yīng)該是有所AlicloudUtils在左右兩個圖中size差值過大,應(yīng)該是依賴關(guān)系中UTMini導(dǎo)致的統(tǒng)計偏差细移。兩邊幾個庫加起來的差值大概是200kb搏予,這也應(yīng)該是AlicloudHttpDNS這個庫所占的size大小。
main階段
總體原則無非就是減少啟動的時候的步驟葫哗,以及每一步驟的時間消耗缔刹。
main階段的優(yōu)化大致有如下幾個點(diǎn):
減少啟動初始化的流程,能懶加載的就懶加載劣针,能放后臺初始化的就放后臺校镐,能夠延時初始化的就延時,不要卡主線程的啟動時間捺典,已經(jīng)下線的業(yè)務(wù)直接刪掉鸟廓;
優(yōu)化代碼邏輯,去除一些非必要的邏輯和代碼,減少每個流程所消耗的時間引谜;
啟動階段使用多線程來進(jìn)行初始化牍陌,把CPU的性能盡量發(fā)揮出來;
使用純代碼而不是xib或者storyboard來進(jìn)行UI框架的搭建员咽,尤其是主UI框架比如TabBarController這種毒涧,盡量避免使用xib和storyboard,因?yàn)閤ib和storyboard也還是要解析成代碼來渲染頁面贝室,多了一些步驟契讲;
這里重點(diǎn)講一下多線程啟動設(shè)計的原理
首先,iPhone有雙核滑频,除了維持操作系統(tǒng)運(yùn)轉(zhuǎn)和后臺進(jìn)程(包括電話短信等守護(hù)進(jìn)程)捡偏,在打開APP時,猜想雙核中的另一個核應(yīng)該有余力來幫忙分擔(dān)啟動的任務(wù)【待驗(yàn)證】峡迷;
其次银伟,iPhone7開始使用A10,iPhone8開始使用A11處理器绘搞,根據(jù)維基百科的定義彤避,A10 CPU包括兩枚高性能核心及兩枚低功耗核心,A11則包括兩個高性能核心和四個高能效核心夯辖,而且相比A10忠藤,A11兩個性能核心的速度提升了25%,四個能效核心的速度提升了70%楼雹。而且蘋果還準(zhǔn)備了第二代性能控制器,因此可以同時發(fā)揮六個核心的全部威力尖阔,性能提升最高可達(dá)70%贮缅,多線程能力可見一斑。
多線程測試結(jié)果如下圖:
結(jié)論如下:
純算法的CPU運(yùn)算介却,指定計算的總數(shù)量谴供,對于iPhone6和iPhone X來說,把計算量平均分配到多線程比全部放在主線程執(zhí)行要快;
iPhone6三個線程跟兩個線程的總體耗時總體一致齿坷,甚至要多一點(diǎn)點(diǎn)桂肌,所以對于iPhone6來說用兩個線程來做啟動設(shè)計足夠了;
iPhone X三個線程的耗時要比兩個線程短一些永淌,但是差值已經(jīng)不算太大了崎场;四個線程跟三個線程的總體一致,偶爾能比三個線程快一點(diǎn)點(diǎn)遂蛀;
綜上谭跨,利用多個線程來加速啟動時間的設(shè)計,是合理的。
但是多線程啟動的設(shè)計有幾個需要注意的點(diǎn):
黑屏問題螃宙;
用狀態(tài)機(jī)來設(shè)計蛮瞄,每個狀態(tài)機(jī)有2或3個線程在跑不同的任務(wù),所有線程任務(wù)都完成后谆扎,進(jìn)入到下一個狀態(tài)挂捅,方便擴(kuò)展;
線程碧煤活問題闲先,以及用完后要銷毀;
資源競爭或線程同步造成卡死的問題苗缩。
針對第一點(diǎn)饵蒂,多線程跑初始化任務(wù)的時候,可能主線程會有空閑等待子線程的階段酱讶,而主線程一旦空閑退盯,iOS系統(tǒng)的啟動畫面就會消失,如果此時APP尚未初始化完全泻肯,則可能會黑屏渊迁。為了避免黑屏,我們需要一個假的啟動畫面灶挟,在剛開始跑初始化任務(wù)時琉朽,就生成這個啟動畫面,啟動過程完全結(jié)束后再去掉稚铣∠淙或者當(dāng)一個狀態(tài)機(jī)里的主線程跑完時檢查下是否所有線程都執(zhí)行完任務(wù)了,如果沒有則去生成這個假的初始化頁面惕医,避免黑屏(我們采用后一種方式)耕漱。
第二點(diǎn)用狀態(tài)機(jī)來設(shè)計啟動,每個狀態(tài)跑兩個或者多個線程抬伺,單個狀態(tài)里每個線程的耗時是不一樣的螟够,跑完一個狀態(tài)再繼續(xù)下一個狀態(tài),可以多次測試去動態(tài)調(diào)整分派到各個線程里的任務(wù)峡钓。
第三點(diǎn)線程奔梭希活則跟runloop有關(guān),每個線程啟動后能岩,跑完一個狀態(tài)寞宫,不能立馬就回收,不然下一個狀態(tài)的子線程就永遠(yuǎn)不會執(zhí)行了拉鹃;另外就是淆九,APP初始化完成后统锤,線程要注意回收。
第四點(diǎn)跟具體的業(yè)務(wù)有關(guān)炭庙,只要不是一個線程去做初始化饲窿,就有可能遇到線程間死鎖的問題,比如下面采坑記錄里就有提到一個例子焕蹄。
我們在實(shí)踐中大概做了以下的幾點(diǎn):
把啟動時RN包的刪除和拷貝操作逾雄,僅在APP安裝后第一次啟動時才做,之后的啟動不再做這操作腻脏,而是等到網(wǎng)絡(luò)請求RN數(shù)據(jù)回來鸦泳,根據(jù)是否需要更新RN包的判斷,再去做這些IO操作從而避免啟動的耗時永品。iPhone5C能節(jié)省1.4s做鹰;
OSS token的獲取不是一個需要在啟動的時候必須要做的操作,放到子線程去處理鼎姐,大部分時候是節(jié)省10-15ms钾麸,偶爾能去到50ms;
去掉啟動狀態(tài)機(jī)里的原有定位服務(wù)炕桨,原來SSZLocationManager的定位服務(wù)因?yàn)閮?nèi)部依賴高德的SDK饭尝,需要初始化SDK,iPhone5C大概耗時100ms献宫。同時SSZLocationManager這個類代碼保留钥平,但是APP的工程去除對其的依賴;
打點(diǎn)統(tǒng)計模塊里的定位服務(wù)權(quán)限請求改成異步姊途,大概有50ms涉瘾;
阿里百川的Feedback,在網(wǎng)校并沒有使用捷兰,直接去掉其初始化流程睡汹,大概5ms左右;
友盟的分享服務(wù)寂殉,沒有必要在啟動的時候去初始化,初始化任務(wù)丟到后臺線程解決原在,大概600-800ms;
狀態(tài)機(jī)跑完后的啟動內(nèi)存統(tǒng)計,放到后臺去做蜀撑,大概50ms法瑟;
UserAgentManager里對于webview是否為UIWebview的判斷,以前是新創(chuàng)建一個對象使用對象方法來判斷浮庐,修改為直接使用類方法甚负,避免創(chuàng)建對象的消耗柬焕,節(jié)省約200ms;
阿里云的HTTPDNS已經(jīng)沒有再使用了梭域,所以這里也可以直接去掉斑举。大概20-40ms;
SSZAppConfig里把網(wǎng)絡(luò)請求放到后臺線程病涨,這樣子就可以配合啟動狀態(tài)機(jī)把該任務(wù)放到子線程進(jìn)行初始化了富玷,否則子線程消耗的時間太長了;
采用兩個線程來進(jìn)行啟動流程的初始化既穆,用狀態(tài)機(jī)來控制狀態(tài)的變化赎懦。但是要針對業(yè)務(wù)區(qū)分開,并不是把一部分業(yè)務(wù)拆分到子線程幻工,就可以讓整體的啟動速度更快励两;因?yàn)槿绻泳€程有一些操作是要在主線程做的,有可能會出現(xiàn)等待主線程空閑再繼續(xù)的情況囊颅;或者當(dāng)兩個線程的耗時操作都是IO時当悔,拆開到兩個線程,并不一定比單個線程去做IO操作要快迁酸。
主UI框架tabBarController的viewDidLoad函數(shù)里先鱼,去掉一些不必要的函數(shù)調(diào)用。
NSUserDefaults的synchronize函數(shù)盡量不要在啟動流程中去調(diào)用奸鬓,統(tǒng)一在APP進(jìn)入后臺焙畔,willTerminate和完全進(jìn)入前臺后把數(shù)據(jù)落地;
因?yàn)槲覀兊捻?xiàng)目用到了React Native技術(shù)(簡稱RN)串远,所以會有RN包的拷貝和更新這一操作宏多,之前的邏輯是每次啟動都從bundle里拷貝一次到Document的指定目錄,本次優(yōu)化修正為除了安裝后第一次啟動拷貝澡罚,其他時候不在做這個拷貝操作伸但,不過RN包熱更新的覆蓋操作,還是每次都要做檢查的留搔,如果有需要則執(zhí)行更新操作
其中遇到幾個坑:
并不是什么任務(wù)都適合放子線程更胖,有些任務(wù)在主線程大概10ms,放到子線程需要幾百ms隔显,因?yàn)槟承┤蝿?wù)內(nèi)部可能會用到UIKit的api却妨,又或者某些操作是需要提交到主線程去執(zhí)行的,關(guān)鍵是要搞清楚這個任務(wù)里邊究竟做了啥括眠,有些SDK并不見得適合放到子線程去初始化彪标,需要具體情況具體去測試和分析。
AccountManager初始化導(dǎo)致的主線程卡死掷豺,子線程的任務(wù)依賴AccountManager捞烟,主線程也依賴薄声,當(dāng)子線程比主線程先調(diào)用時,造成了主線程卡死题画,其原因是子線程會提交一個同步阻塞的操作到主線程默辨,而此時主線程被dipatch_one的鎖鎖住了,所以造成子線程不能返回婴程,主線程也無法繼續(xù)執(zhí)行廓奕。調(diào)試的時候還會用到符號斷點(diǎn)和LLDB去打印函數(shù)入?yún)ⅲㄒ话闶莚0-r3之間的寄存器)的值。
RN包的拷貝檢查除了是否第一次打開APP之外档叔,還要注意RN版本如果升級時桌粉,需要用新的包強(qiáng)制覆蓋掉舊的包,否則js代碼會一直得不到更新衙四。
實(shí)際優(yōu)化效果對比
由于只是去掉了幾個靜態(tài)庫铃肯,而且本來pre-main階段的耗時就不長,基本在200ms-500ms左右传蹈,所以pre-main階段優(yōu)化前后效果并不明顯押逼,有時候還沒有前后測試的誤差大。惦界。挑格。
main的階段的優(yōu)化效果還是很明顯的:
iPhone5C iOS10.3.3系統(tǒng)優(yōu)化前main階段的時間消耗為4秒左右,優(yōu)化后基本在1.8秒內(nèi)沾歪;
iPhone7 iOS10.3.3系統(tǒng)優(yōu)化前main階段的時間消耗為1.1秒左右漂彤,優(yōu)化后基本在600ms內(nèi);
iPhoneX iOS11.3.1系統(tǒng)優(yōu)化前main階段的時間消耗基本在1.5秒以上灾搏,優(yōu)化后在1秒內(nèi)挫望;
可以看到,同樣arm64架構(gòu)的機(jī)器狂窑,main階段是iPhone7比iPhoneX更快媳板,說明就操作系統(tǒng)來說,iOS11.3要比iOS10.3慢不少泉哈;
詳細(xì)測試數(shù)據(jù)見下圖
上圖中iPhone5C為啥前后測試pre-main時間差值那么大蛉幸?而且是優(yōu)化后的值比優(yōu)化前還要大?我也不知道丛晦,大概機(jī)器自己才知道吧奕纫。。采呐。
注意:
關(guān)于冷啟動和熱啟動,業(yè)界對冷啟動的定義沒有問題搁骑,普遍認(rèn)為是手機(jī)開機(jī)后第一次啟動某個APP斧吐,但是對熱啟動有不同的看法又固,有些人認(rèn)為是按下home鍵把APP掛到后臺,之后點(diǎn)擊APP的icon再拉回來到前臺算是熱啟動煤率,也有些人認(rèn)為是手機(jī)開機(jī)后在短時間內(nèi)第二次啟動APP(殺掉進(jìn)程重啟)算是熱啟動(此時dyld會對部分APP的數(shù)據(jù)和庫進(jìn)行緩存仰冠,所以比第一次啟動要快)。筆者認(rèn)為APP從后臺拉起到前臺的時間沒啥研究的意義蝶糯,而即使是短時間內(nèi)第二次啟動APP洋只,啟動時間也是很重要的,所以在統(tǒng)計啟動時間時昼捍,筆者會傾向于后一種說法识虚,不過具體怎么定義還是看個人吧,知道其中的區(qū)別就好妒茬。
關(guān)于如何區(qū)分framework是靜態(tài)庫還是動態(tài)庫見這里担锤。原理就是在終端使用指令file,輸出如果是ar archive就是靜態(tài)庫乍钻,如果是動態(tài)庫則會輸出dynamically linked相關(guān)信息肛循。
特別鳴謝
在做啟動優(yōu)化的過程中,得到了很多朋友們的幫助和支持银择。借鑒了淮哥狀態(tài)機(jī)的設(shè)計思路多糠,同時也感謝singro大神的指點(diǎn),感謝劉金哥叫我玩LLDB浩考,感謝長元兄對于動態(tài)庫和靜態(tài)庫的指教夹孔,感謝森哥的鞭策和精神鼓舞,以及展少爺在整個過程中的技術(shù)支持怀挠,引導(dǎo)和不耐其煩的解釋析蝴,再次謝謝大家,愛你們喲??绿淋!
參考鏈接:
優(yōu)化 App 的啟動時間 http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/
iOS啟動優(yōu)化 http://lingyuncxb.com/2018/01/30/iOS%E5%90%AF%E5%8A%A8%E4%BC%98%E5%8C%96/
iOSApp啟動性能優(yōu)化 https://mp.weixin.qq.com/s/Kf3EbDIUuf0aWVT-UCEmbA
今日頭條iOS客戶端啟動速度優(yōu)化 https://techblog.toutiao.com/2017/01/17/iosspeed/