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

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

圖是 Apple 在 WWDC 上展現的 PPT,是對 main 以前啓動所作事的一個簡單總結。main 以後的啓動時間如何考量呢?這個更多靠你們本身定義,有的人把 main 到 didFinishLaunching 結束的這一段時間做爲指標,有的人把 main 到第一個 ViewController 的 viewDidAppear 做爲考量指標。無論如何,我以爲都是必定程度上能夠反映問題的。github

Xcode 測量 pre-main 時間

對於如何測試啓動時間,Xcode 提供了一個很讚的方法,只須要在 Edit scheme -> Run -> Arguments 中將環境變量 DYLD_PRINT_STATISTICS 設爲 1,就能夠看到 main 以前各個階段的時間消耗。swift

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

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

就拿咱們引入的動態庫來講, AFNetworking 會優先 load ,被依賴的動態庫會優先 load。下面是我本身打點測試的結果,LDXlog 被 Bigger 依賴,因此 AFNetworking 最先 load ,而後是 LDXlog,依次按照 Load Commands 順序加載。app

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…
相關文章
相關標籤/搜索