app啟動速度通常關乎用戶對app的總體評價,在這方面也有很多優秀關于優化方面的文章,不過這類文章更多地著墨于具體的優化方案,對原理的介紹往往并不詳實,所以對于想了解個中原理進而深入學習系統機制的研發會有些美中不足的感覺。
本文根據wwdc 2012 iOS App Performance: Responsiveness,wwdc 2016 Optimizing App Startup Time及wwdc 2017 app start time:Past,Present and Future深入探討啟動原理與優化策略
應用啟動概論
伴隨app啟動的過程會出現app應用界面放大出現的效果,iPhone及iPad上這個放大動畫分別為400ms與500ms,如果動畫結束時app已經啟動完成,那么用戶看起來就像app在點擊了圖標之后馬上啟動了一樣,這樣的啟動速度自然是最佳的。
watchdog
watchdog機制會在app發生超時的場景下強行中止其運行,由下表可知它對啟動場景的最大容忍時間為20s(xcode在debug期間會禁用watchdog)
場景 | watchdog 超時 |
---|---|
啟動 | 20秒 |
恢復運行 | 10秒 |
暫停(退后臺) | 10秒 |
退出 | 6秒 |
后臺執行 | 10分鐘 |
啟動時間的衡量
watchdog判定啟動結束的時間點是第一個CATransaction的結束,這個點意味著UI在CPU中第一次布局和繪制的結束,其標志性的api調用為[UIApplication _reportAppLaunchFinished](iOS6及之前版本,是此內部api調用為啟動結束標志,但iOS8以后已經無法斷點到這個api,現在做啟動優化判定啟動結束的一般的做法是rootViewController的viewDidAppear調用時間點)
而對于特定功能的app比如相機應用來說,用戶所感知到的啟動終結應當是快門達到可點按狀態所需要的時間,也就是說如果在watchdog超時間內完成界面啟動而功能卻并未完成初始化,仍然算做啟動未完成,只是已經沒有在啟動過程被系統強行終止的危險而已。
啟動過程
階段 | 主要工作 |
---|---|
鏈接和加載 | 1.庫映射到app進程空間 2.綁定符號(比如app引用了framework中的某常量符號)3.運行靜態初始化 |
UIKit初始化 | 創建Fonts, status bar, 讀取user defaults, 反序列化main nib |
Application回調 | 啟動行將結束時將控制權交回給app |
首次CoreAnimation transaction | new首個CATransaction,用于在didFinishLaunching后批處理layout和繪制views,發生在CA::Transaction::commit, iOS6以前這次提交最遲會在[UIApplication _reportAppLaunchFinished]中發生,盡管這個內部api已經難尋蹤跡,但這次commit在后續的iOS系統也一直發生在didFinishLaunching之后 |
鏈接與加載階段優化策略
1.精簡依賴的framework(每個OC庫在加載階段都會有些額外的工作要做,比如類的hash表需要在加載階段將各類添加進去)
2.不要將require的framework標記為optional,因為optional需要更多的檢查消耗
3.避免靜態的初始化過程:
全局C++對象的創建:
static std::map<int, int> GlobalMap = {{1,2},{3,4}};
在main之前的load階段執行的代碼:
+ (void)load{ //do any stuff }
__attribute__((constructor)) void doInitializationStuff() {}
將盡可能多的工作放到運行期去做,比如+(void)initialize{}
UIKit 初始化優化策略
涉及的api
UIApplicationInitialize(iOS7以后應該已經沒有了)
UIApplicationInstantiateSingleton
-[UIApplication _createStatusBarWithRequestedStyle: ...]
-[UIApplication _loadMainNibFileName:bundle:]
1 精簡main nib的大小,更好的優化自然是用代碼創建UI
2 不要在userdefault中存太多數據,因為userdefaults是作為property文件存儲的,整個property list會一次性反序列化,所以不要存大塊的數據,比如
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSData* data = UIImagePNGRepresentation(image);
[ud setObject: data forKey: @"image"];
Application 回調 優化策略
此階段過程如下:
回調application: willFinishLaunchingWithOptions:
恢復application 狀態
回調application: didFinishLaunchingWithOptions:
首次Core Animation Transaction
提交的重要階段:
- 準備階段:解碼圖片
- layout: 計算所有layer的大小(-layoutSubviews)
- 繪制: -drawRect:
提交階段的優化策略很明確,即盡可能精簡root viewController首次出現時的view層次復雜度及圖片素材的總量,同時不要做太復雜的drawRect操作
app啟動原理及優化實踐
這部分根據wwdc2016 session406, 主要內容為啟動原理及優化實踐
Mach-O 和虛存 掠影
Mach-O文件類型:
Executable - 應用主二進制文件
Dylib - 動態鏈接庫(類似于其它平臺的DSO和DLL)
Bundle - 無法鏈接的Dylib,只能通過dlopen(), 比如插件
Image - Executable, Dylib 或者 Bundle
Framework - 包含所屬資源與頭文件目錄的Dylib
Mach-O文件又劃分為段segment, 比如__TEXT, __DATA, __LINKEDIT, 每個段大小均為pagesize的整數倍,在arm64 pagesize為16KB,其它架構為4KB,段又劃分為sections,section之間不重疊
常見段名 | 內容 |
---|---|
__TEXT | Mach-O頭,代碼和只讀常量 |
__DATA | 所有可讀可寫的內容,比如全局變量,靜態變量等 |
__LINKEDIT | 不包含函數與變量但包含函數與變量的信息,比如名字和地址,即加載程序的“元數據” |
Mach-O Universal Files
構建同時支持32位與64位架構時會將兩個架構的Mach-O文件合并為UniversalFile
支持的架構會列舉在fat header中,這個header也是一個page size
虛存
虛存是一種間接管理內存的方式,是為了方便多進程使用物理內存,常見特性比如:當訪問的虛存對應的頁不在內存中時發生的頁錯誤引發加載,同一內存頁映射到多進程中的內存共享模式,文件映射頁mmap()與lazy reading特性(讀到特定地址時才引發頁錯誤引發加載),copy on write(COW,多進程共享數據頁,直到對其進行修改時才引發內存將頁復制到新內存頁并將進程映射的頁指向新內存頁)
虛存的這些特性應用在__TEXT段極為合適,COW對于__DATA段是很好的優化,這也引出了另一個概念,即臟數據頁和干凈數據頁:臟數據頁是包含特定進程信息的頁,而干凈頁是內核可以重新從disk中讀取的數據頁,所以臟數據頁比干凈數據頁會帶來更多消耗。
頁權限屬性:rwx(代碼段設置為只讀 r,數據段 rw)
加載dylib的時候,會將其文件映射到內存中,由于大部分全局變量都初始化為0,所以靜態優化把它們都放在后面,以不占用空間,而且通過VM特性在首次讀取時將其填充為0,dyld的第一件事情是讀取Mach-O header,即第一頁,會引發頁錯誤進而進行頁加載,它會發現此頁進行了文件映射并加載文件首頁到物理內存。然后dyld開始讀mach header,接著Mach header說在__LINKEDIT段中有些信息需要讀下,于是dyld開始讀最下面那段,同樣引發頁錯誤及加載,然后LINKEDIT告訴dyld需要對DATA段做一些修正才能讓dylib真正可運行。dyld于是開始入數據段寫一些數據,這時候COW發生,數據頁變臟。而如果此時另一個進程需要這個dylib,那么TEXT段和LINKEDIT段只需要復用已經在物理內存頁中的這兩個段即可,操作系統只需要將對應的內存頁映射到新進程的虛存中即可。而數據段如果仍在內存中,也可以類似地映射,否則需要再次讀取disk,這算是動態庫加載的一項優化。而LINKEDIT段只有在dyld修正DATA段的時候才有用,此后其內存頁即可回收做它用。
有兩件想提及的事情是安全是如何影響dyld的,而正是這兩件事關安全的事情影響了dyld。
一個是ASLR(address space layout randomization),用于隨機化加載地址的常用技術。
另一個是code sign,構建時每頁Mach-O文件都會生成各自的加密hash,均存儲在LINKEDIT中,于是每頁均可以驗證其是否被更動過手腳。
EXEC
exec是一個系統調用,會使用指定的新程序替換當前進程,內核會將整個進程空間抹掉并將你指定的可執行文件映射進來,由于ASLR的存在,映射到的是一個隨機的地址,并且從這個地址到0的整個區域都會標記為不可讀不可寫不可執行。在32位進程中,這個區域大小為4KB,在64位進程最少為4GB。它會用來捕獲空指針引用及指針截斷錯誤,因為塊虛存未映射任何實際物理地址,所以訪問會引發異常。
多年以前EXEC很輕松,因為只需要將程序映射到進程,再設置PC就可以開始執行了,但隨著動態鏈接庫的發明,出現了helper程序來幫忙加載動態鏈接庫,apple平臺上這個程序叫做dyld。內核在映射可執行文件到進程之后,會映射dyld到進程另一個隨機地址,然后將PC設置進dyld中,并讓其完成進程的啟動。
dyld執行階段 | 職責 |
---|---|
加載所有dylib | 讀取主可執行文件的頭以獲取依賴庫的列表,然后開始找每個dylib,一旦找到即開始打開并讀取dylib的頭部,因為需要確保它是Mach-O文件,驗證這個文件并找到其code signature,將code signature注冊到內核,對dylib的每個段調用mmap,最終遞歸加載完所有依賴的dylib(由于大多都是OS dylib,OS本身在構建時預先做了很多dyld在加載這些dylib時需要做的計算等工作) |
fix-up(包含下列2個階段) | 將這些獨立的dylib綁定在一起,鑒于code signing不允許我們修改指令,而為了在不修改指令的情況下讓dylib調用另一個dylib,需要借助現代編譯代碼生成的動態PIC(Position Independent Code)機制,意味著代碼可以動態加載進虛存,也就是說調用是間接尋址的。如果被調用方會以指針的形式存在DATA數據段中,這個指針才是最后真正被調用的 |
rebase | 調整指向image內部的指針,早期可以為dylib指定偏好的加載地址,如果進程可以滿足這些要求,則dyld不需要做任何fix-up,但現在ASLR的存在,dylib會加載到隨機的地址,這種情況下需要計算一個slide=actual_address-preferred_address,并為每個內部指針加上這個slide,而這些內部指針本身的地址是存在LINKEDIT段中。由于我們只是將數據映射到進程,修改地址的時候一般會發生COW,所以rebase通常因為這些IO會很耗時。所幸修改是順序進行的,所以在內核看來寫數據是順序地進行的,內核會為我們預加載,提升性能 |
binding | 調整指向image外的指針,通常是用字符串形式的符號名來表示的,所以"malloc"代表這個指針指向malloc,而dyld就需要找到其實現并將地址寫入指針,也意味著要查找符號表并做很多計算。雖然需要很多的計算,但由于rebasing已經做了大部分的IO,所以binding需要的IO比較少 |
objc | 到了objc階段,大部分類數據結構已經就緒,指向其方法,父類的指針皆已就緒,但還有一些objc運行時需要的數據未完成。第一個是OC動態性需要可以通過類名創建對象,所以OC運行時需要維護一個類名與類的映射表。所以加載類定義時,類名需要注冊到一個全局表中。另一個問題是C++中的fragile base class問題,OC由于在加載階段fix-up時會修正所有ivar的偏移而不存在這個問題。另一個問題是在另外的dylib中定義的category,需要在這個階段將方法添加到類實現中。此外OC依賴于selector的唯一性,所以還需要將selector唯一化 |
initializer | 執行c++編譯生成的等號右邊的任意初始化表達式,執行oc中的+load |
查看image中需fix-up的指針信息的命令:
xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp
上述所有工作完成之后,dyld會調用可執行文件中的main()
提升啟動速度實用策略
首先最好的啟動時間是比啟動動畫更快,前述啟動概論有提及,400ms最好
在scheme中添加環境變量 DYLD_PRINT_STATISTICS 1 后,在device log中會輸出main之前所有執行過程的耗時統計
優化項 | 優化策略 | 備注 |
---|---|---|
dylib加載 | 少用動態鏈接庫,使用靜態庫,懶加載(dlopen) | 合并動態鏈接庫(基本不太具有可操作性,因為要先拿到所有的代碼),dlopen實際會引發很多細微的性能和正確性問題,而且會在后續引發相較下更多的消耗,雖然加載被延遲的,這個方案可行,但是需要謹慎使用。 |
fix-up | 使用前述工具查看fix-up指針所在的segment及section,減少fixup的指針數 減少C++虛函數的使用,因為它會創建虛函數表,會和OC的metaclass一樣在DATA段中添加需要fix-up的數據 可以多使用swift struct,因為它們生成的需要fix-up的指針更少,而且swift更加內聯化的特性可以更多地減少fix-up指針 |
如果在objc域中見到oc類的符號,則可以考慮精減OC類數量及實例變量數量,尤其是對于一些鼓勵實現很多簡單的類的編程模式,這些簡單的類通常只有一到兩個方法,這種編程模式會導致啟動速度越來越慢 |
initializer | 將+load更多地用+initialize代替,C/C++中的____attribute____((constructor))顯式初始化函數更多地由call site initializer即dispatch_once或者 pthread_once, std::once代替 隱式初始化C++全局變量使用call site initializer,或者使用非全局數據去代替,又或者不使用重量的初始化,比如C++中的POD,plain old data 當然也可以用swift來改寫,因為swift全局變量首先肯定會在被使用之前初始化,但并不是在initializer中初始化,而是使用dispatch_once |
對于POD類型數據,靜態鏈接器會為DATA段預計算所有數據,所以這些數據不需要運行,也不需要fix-up,這種隱式初始化可以通過Apple LLVM 8.1 - Custom Compiler Flags=>Other Warning Flags 添加編譯器警告Flag -Wglobal-constructors 來提示此類initializer dyld運行時可以不使用鎖因為它是單線程運行的,但如果使用了dlopen,initializers運行會相應發生改變,同時需要打開鎖以應對多線程運行的情況,這也意味著處理不好可能會有死鎖的危險;另外也不要在initializer中啟動線程 |