app啟動速度通常關(guān)乎用戶對app的總體評價兰绣,在這方面也有很多優(yōu)秀關(guān)于優(yōu)化方面的文章雕欺,不過這類文章更多地著墨于具體的優(yōu)化方案,對原理的介紹往往并不詳實收捣,所以對于想了解個中原理進(jìn)而深入學(xué)習(xí)系統(tǒng)機制的研發(fā)會有些美中不足的感覺届案。
本文根據(jù)wwdc 2012 iOS App Performance: Responsiveness,wwdc 2016 Optimizing App Startup Time及wwdc 2017 app start time:Past,Present and Future深入探討啟動原理與優(yōu)化策略
應(yīng)用啟動概論
伴隨app啟動的過程會出現(xiàn)app應(yīng)用界面放大出現(xiàn)的效果罢艾,iPhone及iPad上這個放大動畫分別為400ms與500ms楣颠,如果動畫結(jié)束時app已經(jīng)啟動完成,那么用戶看起來就像app在點擊了圖標(biāo)之后馬上啟動了一樣咐蚯,這樣的啟動速度自然是最佳的童漩。
watchdog
watchdog機制會在app發(fā)生超時的場景下強行中止其運行,由下表可知它對啟動場景的最大容忍時間為20s(xcode在debug期間會禁用watchdog)
場景 | watchdog 超時 |
---|---|
啟動 | 20秒 |
恢復(fù)運行 | 10秒 |
暫停(退后臺) | 10秒 |
退出 | 6秒 |
后臺執(zhí)行 | 10分鐘 |
啟動時間的衡量
watchdog判定啟動結(jié)束的時間點是第一個CATransaction的結(jié)束春锋,這個點意味著UI在CPU中第一次布局和繪制的結(jié)束矫膨,其標(biāo)志性的api調(diào)用為[UIApplication _reportAppLaunchFinished](iOS6及之前版本,是此內(nèi)部api調(diào)用為啟動結(jié)束標(biāo)志,但iOS8以后已經(jīng)無法斷點到這個api侧馅,現(xiàn)在做啟動優(yōu)化判定啟動結(jié)束的一般的做法是rootViewController的viewDidAppear調(diào)用時間點)
而對于特定功能的app比如相機應(yīng)用來說直奋,用戶所感知到的啟動終結(jié)應(yīng)當(dāng)是快門達(dá)到可點按狀態(tài)所需要的時間,也就是說如果在watchdog超時間內(nèi)完成界面啟動而功能卻并未完成初始化施禾,仍然算做啟動未完成脚线,只是已經(jīng)沒有在啟動過程被系統(tǒng)強行終止的危險而已。
啟動過程
階段 | 主要工作 |
---|---|
鏈接和加載 | 1.庫映射到app進(jìn)程空間 2.綁定符號(比如app引用了framework中的某常量符號)3.運行靜態(tài)初始化 |
UIKit初始化 | 創(chuàng)建Fonts, status bar, 讀取user defaults, 反序列化main nib |
Application回調(diào) | 啟動行將結(jié)束時將控制權(quán)交回給app |
首次CoreAnimation transaction | new首個CATransaction,用于在didFinishLaunching后批處理layout和繪制views弥搞,發(fā)生在CA::Transaction::commit, iOS6以前這次提交最遲會在[UIApplication _reportAppLaunchFinished]中發(fā)生邮绿,盡管這個內(nèi)部api已經(jīng)難尋蹤跡,但這次commit在后續(xù)的iOS系統(tǒng)也一直發(fā)生在didFinishLaunching之后 |
鏈接與加載階段優(yōu)化策略
1.精簡依賴的framework(每個OC庫在加載階段都會有些額外的工作要做攀例,比如類的hash表需要在加載階段將各類添加進(jìn)去)
2.不要將require的framework標(biāo)記為optional船逮,因為optional需要更多的檢查消耗
3.避免靜態(tài)的初始化過程:
全局C++對象的創(chuàng)建:
static std::map<int, int> GlobalMap = {{1,2},{3,4}};
在main之前的load階段執(zhí)行的代碼:
+ (void)load{ //do any stuff }
__attribute__((constructor)) void doInitializationStuff() {}
將盡可能多的工作放到運行期去做,比如+(void)initialize{}
UIKit 初始化優(yōu)化策略
涉及的api
UIApplicationInitialize(iOS7以后應(yīng)該已經(jīng)沒有了)
UIApplicationInstantiateSingleton
-[UIApplication _createStatusBarWithRequestedStyle: ...]
-[UIApplication _loadMainNibFileName:bundle:]
1 精簡main nib的大小粤铭,更好的優(yōu)化自然是用代碼創(chuàng)建UI
2 不要在userdefault中存太多數(shù)據(jù)挖胃,因為userdefaults是作為property文件存儲的,整個property list會一次性反序列化梆惯,所以不要存大塊的數(shù)據(jù)酱鸭,比如
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSData* data = UIImagePNGRepresentation(image);
[ud setObject: data forKey: @"image"];
Application 回調(diào) 優(yōu)化策略
此階段過程如下:
回調(diào)application: willFinishLaunchingWithOptions:
恢復(fù)application 狀態(tài)
回調(diào)application: didFinishLaunchingWithOptions:
首次Core Animation Transaction
提交的重要階段:
- 準(zhǔn)備階段:解碼圖片
- layout: 計算所有l(wèi)ayer的大小(-layoutSubviews)
- 繪制: -drawRect:
提交階段的優(yōu)化策略很明確,即盡可能精簡root viewController首次出現(xiàn)時的view層次復(fù)雜度及圖片素材的總量垛吗,同時不要做太復(fù)雜的drawRect操作
app啟動原理及優(yōu)化實踐
這部分根據(jù)wwdc2016 session406, 主要內(nèi)容為啟動原理及優(yōu)化實踐
Mach-O 和虛存 掠影
Mach-O文件類型:
Executable - 應(yīng)用主二進(jìn)制文件
Dylib - 動態(tài)鏈接庫(類似于其它平臺的DSO和DLL)
Bundle - 無法鏈接的Dylib凹髓,只能通過dlopen(), 比如插件
Image - Executable, Dylib 或者 Bundle
Framework - 包含所屬資源與頭文件目錄的Dylib
Mach-O文件又劃分為段segment, 比如__TEXT, __DATA, __LINKEDIT, 每個段大小均為pagesize的整數(shù)倍,在arm64 pagesize為16KB怯屉,其它架構(gòu)為4KB蔚舀,段又劃分為sections,section之間不重疊
常見段名 | 內(nèi)容 |
---|---|
__TEXT | Mach-O頭锨络,代碼和只讀常量 |
__DATA | 所有可讀可寫的內(nèi)容赌躺,比如全局變量,靜態(tài)變量等 |
__LINKEDIT | 不包含函數(shù)與變量但包含函數(shù)與變量的信息羡儿,比如名字和地址礼患,即加載程序的“元數(shù)據(jù)” |
Mach-O Universal Files
構(gòu)建同時支持32位與64位架構(gòu)時會將兩個架構(gòu)的Mach-O文件合并為UniversalFile
支持的架構(gòu)會列舉在fat header中,這個header也是一個page size
虛存
虛存是一種間接管理內(nèi)存的方式失受,是為了方便多進(jìn)程使用物理內(nèi)存讶泰,常見特性比如:當(dāng)訪問的虛存對應(yīng)的頁不在內(nèi)存中時發(fā)生的頁錯誤引發(fā)加載,同一內(nèi)存頁映射到多進(jìn)程中的內(nèi)存共享模式拂到,文件映射頁mmap()與lazy reading特性(讀到特定地址時才引發(fā)頁錯誤引發(fā)加載)痪署,copy on write(COW,多進(jìn)程共享數(shù)據(jù)頁,直到對其進(jìn)行修改時才引發(fā)內(nèi)存將頁復(fù)制到新內(nèi)存頁并將進(jìn)程映射的頁指向新內(nèi)存頁)
虛存的這些特性應(yīng)用在__TEXT段極為合適兄旬,COW對于__DATA段是很好的優(yōu)化狼犯,這也引出了另一個概念余寥,即臟數(shù)據(jù)頁和干凈數(shù)據(jù)頁:臟數(shù)據(jù)頁是包含特定進(jìn)程信息的頁,而干凈頁是內(nèi)核可以重新從disk中讀取的數(shù)據(jù)頁悯森,所以臟數(shù)據(jù)頁比干凈數(shù)據(jù)頁會帶來更多消耗宋舷。
頁權(quán)限屬性:rwx(代碼段設(shè)置為只讀 r,數(shù)據(jù)段 rw)
加載dylib的時候瓢姻,會將其文件映射到內(nèi)存中祝蝠,由于大部分全局變量都初始化為0,所以靜態(tài)優(yōu)化把它們都放在后面幻碱,以不占用空間绎狭,而且通過VM特性在首次讀取時將其填充為0,dyld的第一件事情是讀取Mach-O header褥傍,即第一頁儡嘶,會引發(fā)頁錯誤進(jìn)而進(jìn)行頁加載,它會發(fā)現(xiàn)此頁進(jìn)行了文件映射并加載文件首頁到物理內(nèi)存恍风。然后dyld開始讀mach header蹦狂,接著Mach header說在__LINKEDIT段中有些信息需要讀下,于是dyld開始讀最下面那段朋贬,同樣引發(fā)頁錯誤及加載凯楔,然后LINKEDIT告訴dyld需要對DATA段做一些修正才能讓dylib真正可運行。dyld于是開始入數(shù)據(jù)段寫一些數(shù)據(jù)兄世,這時候COW發(fā)生啼辣,數(shù)據(jù)頁變臟。而如果此時另一個進(jìn)程需要這個dylib御滩,那么TEXT段和LINKEDIT段只需要復(fù)用已經(jīng)在物理內(nèi)存頁中的這兩個段即可,操作系統(tǒng)只需要將對應(yīng)的內(nèi)存頁映射到新進(jìn)程的虛存中即可党远。而數(shù)據(jù)段如果仍在內(nèi)存中削解,也可以類似地映射,否則需要再次讀取disk沟娱,這算是動態(tài)庫加載的一項優(yōu)化氛驮。而LINKEDIT段只有在dyld修正DATA段的時候才有用,此后其內(nèi)存頁即可回收做它用济似。
有兩件想提及的事情是安全是如何影響dyld的矫废,而正是這兩件事關(guān)安全的事情影響了dyld。
一個是ASLR(address space layout randomization)砰蠢,用于隨機化加載地址的常用技術(shù)蓖扑。
另一個是code sign,構(gòu)建時每頁Mach-O文件都會生成各自的加密hash台舱,均存儲在LINKEDIT中律杠,于是每頁均可以驗證其是否被更動過手腳。
EXEC
exec是一個系統(tǒng)調(diào)用,會使用指定的新程序替換當(dāng)前進(jìn)程柜去,內(nèi)核會將整個進(jìn)程空間抹掉并將你指定的可執(zhí)行文件映射進(jìn)來灰嫉,由于ASLR的存在,映射到的是一個隨機的地址嗓奢,并且從這個地址到0的整個區(qū)域都會標(biāo)記為不可讀不可寫不可執(zhí)行讼撒。在32位進(jìn)程中,這個區(qū)域大小為4KB股耽,在64位進(jìn)程最少為4GB根盒。它會用來捕獲空指針引用及指針截斷錯誤,因為塊虛存未映射任何實際物理地址豺谈,所以訪問會引發(fā)異常郑象。
多年以前EXEC很輕松,因為只需要將程序映射到進(jìn)程茬末,再設(shè)置PC就可以開始執(zhí)行了厂榛,但隨著動態(tài)鏈接庫的發(fā)明,出現(xiàn)了helper程序來幫忙加載動態(tài)鏈接庫丽惭,apple平臺上這個程序叫做dyld击奶。內(nèi)核在映射可執(zhí)行文件到進(jìn)程之后,會映射dyld到進(jìn)程另一個隨機地址责掏,然后將PC設(shè)置進(jìn)dyld中柜砾,并讓其完成進(jìn)程的啟動。
dyld執(zhí)行階段 | 職責(zé) |
---|---|
加載所有dylib | 讀取主可執(zhí)行文件的頭以獲取依賴庫的列表换衬,然后開始找每個dylib痰驱,一旦找到即開始打開并讀取dylib的頭部,因為需要確保它是Mach-O文件瞳浦,驗證這個文件并找到其code signature担映,將code signature注冊到內(nèi)核,對dylib的每個段調(diào)用mmap叫潦,最終遞歸加載完所有依賴的dylib(由于大多都是OS dylib蝇完,OS本身在構(gòu)建時預(yù)先做了很多dyld在加載這些dylib時需要做的計算等工作) |
fix-up(包含下列2個階段) | 將這些獨立的dylib綁定在一起,鑒于code signing不允許我們修改指令矗蕊,而為了在不修改指令的情況下讓dylib調(diào)用另一個dylib短蜕,需要借助現(xiàn)代編譯代碼生成的動態(tài)PIC(Position Independent Code)機制,意味著代碼可以動態(tài)加載進(jìn)虛存傻咖,也就是說調(diào)用是間接尋址的朋魔。如果被調(diào)用方會以指針的形式存在DATA數(shù)據(jù)段中,這個指針才是最后真正被調(diào)用的 |
rebase | 調(diào)整指向image內(nèi)部的指針没龙,早期可以為dylib指定偏好的加載地址铺厨,如果進(jìn)程可以滿足這些要求缎玫,則dyld不需要做任何fix-up,但現(xiàn)在ASLR的存在解滓,dylib會加載到隨機的地址赃磨,這種情況下需要計算一個slide=actual_address-preferred_address,并為每個內(nèi)部指針加上這個slide,而這些內(nèi)部指針本身的地址是存在LINKEDIT段中洼裤。由于我們只是將數(shù)據(jù)映射到進(jìn)程邻辉,修改地址的時候一般會發(fā)生COW,所以rebase通常因為這些IO會很耗時腮鞍。所幸修改是順序進(jìn)行的值骇,所以在內(nèi)核看來寫數(shù)據(jù)是順序地進(jìn)行的,內(nèi)核會為我們預(yù)加載移国,提升性能 |
binding | 調(diào)整指向image外的指針吱瘩,通常是用字符串形式的符號名來表示的,所以"malloc"代表這個指針指向malloc迹缀,而dyld就需要找到其實現(xiàn)并將地址寫入指針使碾,也意味著要查找符號表并做很多計算。雖然需要很多的計算祝懂,但由于rebasing已經(jīng)做了大部分的IO票摇,所以binding需要的IO比較少 |
objc | 到了objc階段,大部分類數(shù)據(jù)結(jié)構(gòu)已經(jīng)就緒砚蓬,指向其方法矢门,父類的指針皆已就緒,但還有一些objc運行時需要的數(shù)據(jù)未完成灰蛙。第一個是OC動態(tài)性需要可以通過類名創(chuàng)建對象祟剔,所以O(shè)C運行時需要維護(hù)一個類名與類的映射表。所以加載類定義時摩梧,類名需要注冊到一個全局表中峡扩。另一個問題是C++中的fragile base class問題,OC由于在加載階段fix-up時會修正所有ivar的偏移而不存在這個問題障本。另一個問題是在另外的dylib中定義的category,需要在這個階段將方法添加到類實現(xiàn)中响鹃。此外OC依賴于selector的唯一性驾霜,所以還需要將selector唯一化 |
initializer | 執(zhí)行c++編譯生成的等號右邊的任意初始化表達(dá)式,執(zhí)行oc中的+load |
查看image中需fix-up的指針信息的命令:
xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp
上述所有工作完成之后买置,dyld會調(diào)用可執(zhí)行文件中的main()
提升啟動速度實用策略
首先最好的啟動時間是比啟動動畫更快粪糙,前述啟動概論有提及,400ms最好
在scheme中添加環(huán)境變量 DYLD_PRINT_STATISTICS 1 后忿项,在device log中會輸出main之前所有執(zhí)行過程的耗時統(tǒng)計
優(yōu)化項 | 優(yōu)化策略 | 備注 |
---|---|---|
dylib加載 | 少用動態(tài)鏈接庫蓉冈,使用靜態(tài)庫,懶加載(dlopen) | 合并動態(tài)鏈接庫(基本不太具有可操作性城舞,因為要先拿到所有的代碼),dlopen實際會引發(fā)很多細(xì)微的性能和正確性問題寞酿,而且會在后續(xù)引發(fā)相較下更多的消耗家夺,雖然加載被延遲的,這個方案可行伐弹,但是需要謹(jǐn)慎使用拉馋。 |
fix-up | 使用前述工具查看fix-up指針?biāo)诘膕egment及section,減少fixup的指針數(shù) 減少C++虛函數(shù)的使用惨好,因為它會創(chuàng)建虛函數(shù)表煌茴,會和OC的metaclass一樣在DATA段中添加需要fix-up的數(shù)據(jù) 可以多使用swift struct,因為它們生成的需要fix-up的指針更少日川,而且swift更加內(nèi)聯(lián)化的特性可以更多地減少fix-up指針 |
如果在objc域中見到oc類的符號蔓腐,則可以考慮精減OC類數(shù)量及實例變量數(shù)量,尤其是對于一些鼓勵實現(xiàn)很多簡單的類的編程模式龄句,這些簡單的類通常只有一到兩個方法回论,這種編程模式會導(dǎo)致啟動速度越來越慢 |
initializer | 將+load更多地用+initialize代替,C/C++中的____attribute____((constructor))顯式初始化函數(shù)更多地由call site initializer即dispatch_once或者 pthread_once, std::once代替 隱式初始化C++全局變量使用call site initializer撒璧,或者使用非全局?jǐn)?shù)據(jù)去代替透葛,又或者不使用重量的初始化,比如C++中的POD,plain old data 當(dāng)然也可以用swift來改寫卿樱,因為swift全局變量首先肯定會在被使用之前初始化僚害,但并不是在initializer中初始化,而是使用dispatch_once |
對于POD類型數(shù)據(jù)繁调,靜態(tài)鏈接器會為DATA段預(yù)計算所有數(shù)據(jù)萨蚕,所以這些數(shù)據(jù)不需要運行,也不需要fix-up蹄胰,這種隱式初始化可以通過Apple LLVM 8.1 - Custom Compiler Flags=>Other Warning Flags 添加編譯器警告Flag -Wglobal-constructors 來提示此類initializer dyld運行時可以不使用鎖因為它是單線程運行的岳遥,但如果使用了dlopen,initializers運行會相應(yīng)發(fā)生改變裕寨,同時需要打開鎖以應(yīng)對多線程運行的情況浩蓉,這也意味著處理不好可能會有死鎖的危險;另外也不要在initializer中啟動線程 |