![](https://i.iter01.com/images/3a28ab64b3a8751ac8f7975b962451b325a674ce4b583778f47691a5cd623599.png)
在 WWDC 2016 和 2017 都有提到啟動這塊的原理和效能優化思路,可見啟動時間,對於開發者和使用者們來說是多麼的重要,本文就談談如何精確的度量 App 的啟動時間,啟動時間由 main 之前的啟動時間和 main 之後的啟動時間兩部分組成。
![](https://i.iter01.com/images/e4f96340b00dfdafc1f63131ca1f1b89a61187e5309e06c1427b4333f97f6bce.png)
圖是 Apple 在 WWDC 上展示的 PPT,是對 main 之前啟動所做事的一個簡單總結。main 之後的啟動時間如何考量呢?這個更多靠大家自己定義,有的人把 main 到 didFinishLaunching 結束的這一段時間作為指標,有的人把 main 到第一個 ViewController 的 viewDidAppear 作為考量指標。不管如何,我覺得都是一定程度上可以反映問題的。
Xcode 測量 pre-main 時間
對於如何測試啟動時間,Xcode 提供了一個很讚的方法,只需要在 Edit scheme -> Run -> Arguments 中將環境變數 DYLD_PRINT_STATISTICS 設為 1,就可以看到 main 之前各個階段的時間消耗。
Total pre-main time: 341.32 milliseconds (100.0%)
dylib loading time: 154.88 milliseconds (45.3%)
rebase/binding time: 37.20 milliseconds (10.8%)
ObjC setup time: 52.62 milliseconds (15.4%)
initializer time: 96.50 milliseconds (28.2%)
slowest intializers :
libSystem.dylib : 4.07 milliseconds (1.1%)
libMainThreadChecker.dylib : 30.75 milliseconds (9.0%)
AFNetworking : 19.08 milliseconds (5.5%)
LDXLog : 10.06 milliseconds (2.9%)
Bigger : 7.05 milliseconds (2.0%)複製程式碼
還有一個方法獲取更詳細的時間,只需將環境變數 DYLD_PRINT_STATISTICS_DETAILS 設為 1 就可以。
total time: 1.0 seconds (100.0%)
total images loaded: 243 (0 from dyld shared cache)
total segments mapped: 721, into 93608 pages with 6173 pages pre-fetched
total images loading time: 817.51 milliseconds (78.3%)
total load time in ObjC: 63.02 milliseconds (6.0%)
total debugger pause time: 683.67 milliseconds (65.5%)
total dtrace DOF registration time: 0.07 milliseconds (0.0%)
total rebase fixups: 2,131,938
total rebase fixups time: 37.54 milliseconds (3.5%)
total binding fixups: 243,422
total binding fixups time: 29.60 milliseconds (2.8%)
total weak binding fixups time: 1.75 milliseconds (0.1%)
total redo shared cached bindings time: 29.32 milliseconds (2.8%)
total bindings lazily fixed up: 0 of 0
total time in initializers and ObjC +load: 93.76 milliseconds (8.9%)
libSystem.dylib : 2.58 milliseconds (0.2%)
libBacktraceRecording.dylib : 3.06 milliseconds (0.2%)
CoreFoundation : 1.85 milliseconds (0.1%)
Foundation : 2.61 milliseconds (0.2%)
libMainThreadChecker.dylib : 42.73 milliseconds (4.0%)
ModelIO : 1.93 milliseconds (0.1%)
AFNetworking : 18.76 milliseconds (1.7%)
LDXLog : 9.46 milliseconds (0.9%)
libswiftCore.dylib : 1.16 milliseconds (0.1%)
libswiftCoreImage.dylib : 1.51 milliseconds (0.1%)
Bigger : 3.91 milliseconds (0.3%)
Reachability : 1.48 milliseconds (0.1%)
ReactiveCocoa : 1.56 milliseconds (0.1%)
SDWebImage : 1.41 milliseconds (0.1%)
SVProgressHUD : 1.23 milliseconds (0.1%)
total symbol trie searches: 133246
total symbol table binary searches: 0
total images defining weak symbols: 30
total images using weak symbols: 69複製程式碼
線上如何度量 pre-main 時間
如果不依靠 Xcode 我們也是可以對 main 之前的時間進行一個考量的。當然,這個時間的度量更多關注的是開發者可控的啟動段。也就是第一個圖展示的 Initializer 段,在這段時間裡處理 C++ 靜態物件的 initializer、ObjC Load 方法的執行。
![](https://i.iter01.com/images/6e228eec57e8d68f544df7d1734dbb5cfe38ddf5c479815194153c87f828c72a.png)
度量 ObjC Load 方法
如何計算這一段時間呢?最容易想到的就是攔截打點,如何攔截成為難點。這裡把目光轉向 dyld 原始碼,看看有什麼發現。整個初始化過程都是從 initializeMainExecutable 方法開始的。dyld 會優先初始化動態庫,然後初始化 App 的可執行檔案。
void initializeMainExecutable()
{
// record that we've reached this step
gLinkContext.startedInitializingMainExecutable = true;
// run initialzers for any inserted dylibs
ImageLoader::InitializerTimingList initializerTimes[allImagesCount()];
initializerTimes[0].count = 0;
const size_t rootCount = sImageRoots.size();
if ( rootCount > 1 ) {
for(size_t i=1; i < rootCount; ++i) {
sImageRoots[i]->runInitializers(gLinkContext, initializerTimes[0]);
}
}
// run initializers for main executable and everything it brings up
sMainExecutable->runInitializers(gLinkContext, initializerTimes[0]);複製程式碼
那麼不難想到,只要在動態庫的 load 函式中 Hook App 中所有的 Load 函式,然後打點就可以啦。但是,現在很多專案庫都是使用 Cocoapods 管理的,並且很多都使用了 use_frameworks,那麼也就是說我們的 App 並不是一個 單一的可執行檔案,它是有主 image 檔案和很多動態庫共同組成的。按照剛才那種方法,是沒辦法統計到自己引入的動態庫的 load 函式的執行時間的。下一步要考慮的就是,如何找到最早載入的動態庫呢?然後在其 load 函式中做 Hook 就可以。
動態庫的 load 順序是與 Load Commands 順序和依賴關係息息相關的。如圖所示:
![](https://i.iter01.com/images/717062b7311236b056939a81865d7c43e84393ba7434a385df6429560ebc8672.png)
就拿我們引入的動態庫來說, AFNetworking 會優先 load ,被依賴的動態庫會優先 load。下面是我自己打點測試的結果,LDXlog 被 Bigger 依賴,所以 AFNetworking 最早 load ,然後是 LDXlog,依次按照 Load Commands 順序載入。
2017-09-23 13:45:01.683817+0800 AAALoadHook[27267:1585198] AFNetworking
2017-09-23 13:45:01.696816+0800 AAALoadHook[27267:1585198] LDXLog
2017-09-23 13:45:01.707312+0800 AAALoadHook[27267:1585198] Bigger
2017-09-23 13:45:01.708875+0800 AAALoadHook[27267:1585198] Reachability
2017-09-23 13:45:01.710732+0800 AAALoadHook[27267:1585198] REACtive
2017-09-23 13:45:01.712066+0800 AAALoadHook[27267:1585198] SDWE
2017-09-23 13:45:01.713650+0800 AAALoadHook[27267:1585198] SVProgressHUD
2017-09-23 13:45:01.714499+0800 AAALoadHook[27267:1585198] 我是主工程複製程式碼
上面的測試讓我產生一個錯覺,以為動態庫載入是和字母順序相關的,其實並不是這樣,因為我使用的都是 pod 管理的動態庫,這個順序被 CocoaPods 排序過了,所以才會有如此結果。在此感謝@冬瓜@monkey的乾貨解答。
也就是說,只要把我們的統計庫命名為 A 開頭的庫(我們的庫目前均使用 pod 管理),並在內部加入打點就可以啦。再次總結下整體的思路:
- 找到最早 load 的動態庫
- 在 load 函式中獲取 App 中的所有可執行檔案
- hook 對應的可執行檔案的 load 函式
- 統計每個 load 函式的時間、全部 load 函式的整體時間
- 上報統計分析
由於程式碼比較多,貼上過來的話部落格太長了,所以想了解原始碼的話,可以點選這個連結:github.com/joy0304/Joy…
剛才的統計還有一些要注意的事項,就是不能為了統計效能,自己卻造成了效能問題,獲取所有的類並且 Hook load 函式還是比較耗時的,控制不好反而增加了啟動時間。
度量 C++ Static Initializers
剛才提到了初始化的入口是 initializeMainExecutable,該函式會執行 ImageLoader::runInitializers 方法,然後會呼叫 ImageLoader::doInitialization,最後會執行到 doModInitFunctions 方法。
void ImageLoaderMachO::doModInitFunctions(const LinkContext& context)
{
if ( fHasInitializers ) {
const uint32_t cmd_count = ((macho_header*)fMachOData)->ncmds;
const struct load_command* const cmds = (struct load_command*)&fMachOData[sizeof(macho_header)];
const struct load_command* cmd = cmds;
for (uint32_t i = 0; i < cmd_count; ++i) {
if ( cmd->cmd == LC_SEGMENT_COMMAND ) {
const struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
const struct macho_section* const sectionsStart = (struct macho_section*)((char*)seg + sizeof(struct macho_segment_command));
const struct macho_section* const sectionsEnd = §ionsStart[seg->nsects];
for (const struct macho_section* sect=sectionsStart; sect < sectionsEnd; ++sect) {
const uint8_t type = sect->flags & SECTION_TYPE;
if ( type == S_MOD_INIT_FUNC_POINTERS ) {
Initializer* inits = (Initializer*)(sect->addr + fSlide);
const size_t count = sect->size / sizeof(uintptr_t);
for (size_t j=0; j < count; ++j) {
Initializer func = inits[j];
// <rdar://problem/8543820&9228031> verify initializers are in image
if ( ! this->containsAddress((void*)func) ) {
dyld::throwf("initializer function %p not in mapped image for %s\n", func, this->getPath());
}
func(context.argc, context.argv, context.envp, context.apple, &context.programVars);
}
}
}
}
cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
}
}
}複製程式碼
這段程式碼實在是長,它會從 mod_init_func 這個 section 中讀取所有的函式指標,然後執行函式呼叫,這些函式指標對應的正是我們的 C++ Static Initializers 和 __attribute__((constructor))
修飾的函式。
因為它們的執行順序在 load 函式之後,所以可以在 load 函式中把 mod_init_func 中的地址都替換成我們的 hook 函式指標,然後再把原函式指標儲存到一個全域性資料中,當執行我們的 hook 函式時,從全域性陣列中取出原函式地址執行。在這裡張貼下主要程式碼,更多可以參考這個連結:github.com/everettjf/Y…
void myInitFunc_Initializer(int argc, const char* argv[], const char* envp[], const char* apple[], const struct MyProgramVars* vars){
++g_cur_index;
OriginalInitializer func = (OriginalInitializer)g_initializer->at(g_cur_index);
CFTimeInterval start = CFAbsoluteTimeGetCurrent();
func(argc,argv,envp,apple,vars);
CFTimeInterval end = CFAbsoluteTimeGetCurrent();
}
static void hookModInitFunc(){
Dl_info info;
dladdr((const void *)hookModInitFunc, &info);
#ifndef __LP64__
const struct mach_header *mhp = (struct mach_header*)info.dli_fbase;
unsigned long size = 0;
MemoryType *memory = (uint32_t*)getsectiondata(mhp, "__DATA", "__mod_init_func", & size);
#else
const struct mach_header_64 *mhp = (struct mach_header_64*)info.dli_fbase;
unsigned long size = 0;
MemoryType *memory = (uint64_t*)getsectiondata(mhp, "__DATA", "__mod_init_func", & size);
#endif
for(int idx = 0; idx < size/sizeof(void*); ++idx){
MemoryType original_ptr = memory[idx];
g_initializer->push_back(original_ptr);
memory[idx] = (MemoryType)myInitFunc_Initializer;
}
}複製程式碼
剛才 hook load 函式時遇到的問題,對於 C++ Static Initializers 會不會存在呢?是存在的,我想要在一個動態庫中統計 App 中所有可執行檔案的 C++ Static Initializers 的執行時間,但是 dyld 中有這麼一段程式碼:
if ( type == S_MOD_INIT_FUNC_POINTERS ) {
Initializer* inits = (Initializer*)(sect->addr + fSlide);
const size_t count = sect->size / sizeof(uintptr_t);
for (size_t j=0; j < count; ++j) {
Initializer func = inits[j];
// <rdar://problem/8543820&9228031> verify initializers are in image
if ( ! this->containsAddress((void*)func) ) {
dyld::throwf("initializer function %p not in mapped image for %s\n", func, this->getPath());
}
func(context.argc, context.argv, context.envp, context.apple, &context.programVars);
}
}複製程式碼
if ( ! this->containsAddress((void*)func) ) 這裡會做一個判斷,判斷函式地址是否在當前 image 的地址空間中,因為我們是在一個獨立的動態庫中做函式地址替換,替換後的函式地址都是我們動態庫中的,並沒有在其他 image 中,所以當其他 image 執行到這個判斷時,就丟擲了異常。這個問題好像無解,所以我們的 C++ Static Initializers 時間統計稍有不足。
Xcode For Static Initializers
Apple 在 developer.apple.com/videos/play… 中公佈了一個新的追蹤 Static Initializers 時間消耗的方案, Instruments 增加了一個叫做 Static Initializer Tracing 的工具,可以方便排查每個 Static Initializer 的時間消耗。(我還沒更新最新版本,暫不實踐)
![](https://i.iter01.com/images/372163c87e1e9e8c2041eaa46d23f91732591be73096761c066d52ace5f296ba.png)
main 之後的時間度量
main 到 didFinishLaunching 結束或者第一個 ViewController 的viewDidAppear 都是作為 main 之後啟動時間的一個度量指標。這個時間統計直接打點計算就可以,不過當遇到時間較長需要排查問題時,只統計兩個點的時間其實不方便排查,目前見到比較好用的方式就是為把啟動任務規範化、粒子化,針對每個任務都有打點統計,這樣方便後期問題的定位和優化。
優化?
其實優化的,很多公司都有部落格寫道。既然談到了啟動監控就稍微寫一點個人覺得比較使用的優化方案吧。
- 目前很多專案使用 use_frameworks 的 pod 動態庫,系統的動態庫有共享快取等優化方案,但是我們的動態庫變多了的話會非常耗時,所以合併動態庫是一個有效且可行的方案
- 把啟動任務細分,不需要及時初始化,不需要在主執行緒初始化的,都選擇非同步延時載入
- 監控好 load 和 Static Initializers 的時間消耗,一不小心就容易出現幾百毫秒的時間消耗
- 還有很多其他公司實踐的方案,我都收集了下來,可以參考:github.com/joy0304/Joy…