如何精確度量 iOS App 的啟動時間

Joy_xx發表於2017-09-23

在 WWDC 2016 和 2017 都有提到啟動這塊的原理和效能優化思路,可見啟動時間,對於開發者和使用者們來說是多麼的重要,本文就談談如何精確的度量 App 的啟動時間,啟動時間由 main 之前的啟動時間和 main 之後的啟動時間兩部分組成。

圖是 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 方法的執行。

度量 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 順序和依賴關係息息相關的。如圖所示:

就拿我們引入的動態庫來說, 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 = &sectionsStart[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 的時間消耗。(我還沒更新最新版本,暫不實踐)

main 之後的時間度量

main 到 didFinishLaunching 結束或者第一個 ViewController 的viewDidAppear 都是作為 main 之後啟動時間的一個度量指標。這個時間統計直接打點計算就可以,不過當遇到時間較長需要排查問題時,只統計兩個點的時間其實不方便排查,目前見到比較好用的方式就是為把啟動任務規範化、粒子化,針對每個任務都有打點統計,這樣方便後期問題的定位和優化。

優化?

其實優化的,很多公司都有部落格寫道。既然談到了啟動監控就稍微寫一點個人覺得比較使用的優化方案吧。

  • 目前很多專案使用 use_frameworks 的 pod 動態庫,系統的動態庫有共享快取等優化方案,但是我們的動態庫變多了的話會非常耗時,所以合併動態庫是一個有效且可行的方案
  • 把啟動任務細分,不需要及時初始化,不需要在主執行緒初始化的,都選擇非同步延時載入
  • 監控好 load 和 Static Initializers 的時間消耗,一不小心就容易出現幾百毫秒的時間消耗
  • 還有很多其他公司實踐的方案,我都收集了下來,可以參考:github.com/joy0304/Joy…

相關文章