iOS深思篇 | 啟動時間的度量和優化

一. 簡介

App的啟動時間是衡量一個App性能的重要指標,或者可以說是App性能的第一印象。在這篇文章中,我們將要介紹啟動時間的相關知識和打點統計。

二. 啟動優化

2.1 App啟動方式

首先了解一下App的啟動方式分為兩類:

1. 冷啟動:從零開始啟動App
2. 熱啟動:App已經存在內存當中,但是后臺存活著,再次點擊圖標啟動App

之后測試也依照這兩種啟動方式進行測試。一般來說啟動時間(點擊圖標 -> 顯示Launch Screen -> Launch Screen消失)在小于400ms是最佳的,并且系統限制了啟動時間不可以大于20s,否則會因為watchdog(看門狗)機制被殺掉。
在不同的生命周期時,超時時間的限制會有所差別:

生命周期 超時時間
啟動 Launch 20 s
恢復 Resume 10 s
懸掛 Suspend 10 s
退出 Quit 6 s
后臺 Background 10 min

2.2 App啟動流程

啟動流程一般劃分為pre-main(main函數之前)和main函數之后;

2.2.1 pre-main
以上是Apple展示的pre-main階段

該階段各個時期的任務以及優化方法:

階段 工作 優化
Load dylibs Dyld從主執行文件的header獲取到需要加載的所依賴動態庫列表,然后它需要找到每個 dylib,而應用所依賴的 dylib 文件可能會再依賴其他 dylib,所以所需要加載的是動態庫列表一個遞歸依賴的集合 1.盡量不使用內嵌(embedded)的dylib,加載內嵌dylib性能開銷較大;2.合并已有的dylib和使用靜態庫(static archives),減少dylib的使用個數;3.懶加載dylib,但是要注意dlopen()可能造成一些問題,且實際上懶加載做的工作會更多
Rebase和Bind 1. Rebase在Image內部調整指針的指向。在過去,會把動態庫加載到指定地址,所有指針和數據對于代碼都是對的,而現在地址空間布局是隨機化,所以需要在原來的地址根據隨機的偏移量做一下修正。2. Bind是把指針正確地指向Image外部的內容。這些指向外部的指針被符號(symbol)名稱綁定,dyld需要去符號表里查找,找到symbol對應的實現 1.減少ObjC類(class)、方法(selector)、分類(category)的數量;2.減少C++虛函數的的數量(創建虛函數表有開銷);3.使用Swift structs(內部做了優化,符號數量更少)
Objc setup 1.注冊Objc類 (class registration);2.把category的定義插入方法列表 (category registration);3.保證每一個selector唯一 (selector uniquing) 減少 Objective-C Class、Selector、Category 的數量,可以合并或者刪減一些OC類
Initializers 1.Objc的+load()函數;2.C++的構造函數屬性函數;3.非基本類型的C++靜態全局變量的創建(通常是類或結構體) 1.少在類的+load方法里做事情,盡量把這些事情推遲到+initiailize;2.減少構造器函數個數,在構造器函數里少做些事情;3.減少C++靜態全局變量的個數

對于pre-main階段,Xcode提供了各個階段時間消耗的方法, Product -> Scheme -> Edit Scheme -> Environment Variables 中將環境變量 DYLD_PRINT_STATISTICS設為1;

Total pre-main time: 955.81 milliseconds (100.0%)
         dylib loading time:  97.42 milliseconds (10.1%)
        rebase/binding time:  55.08 milliseconds (5.7%)
            ObjC setup time:  68.65 milliseconds (7.1%)
           initializer time: 734.45 milliseconds (76.8%)
           slowest intializers :
             libSystem.B.dylib :   7.65 milliseconds (0.8%)
    libMainThreadChecker.dylib :  36.33 milliseconds (3.8%)
                              ...

這里額外補充一下其他的dyld環境變量參數:

變量 描述
DYLD_PRINT_STATISTICS_DETAILS 打印啟動時間等詳細參數
DYLD_PRINT_SEGMENTS 日志段映射
DYLD_PRINT_INITIALIZERS 日志圖像初始化要求
DYLD_PRINT_BINDINGS 日志符號綁定
DYLD_PRINT_APIS 日志dyld API調用(例如,dlopen)
DYLD_PRINT_ENV 打印啟動環境變量
DYLD_PRINT_OPTS 打印啟動時命令行參數
DYLD_PRINT_LIBRARIES_POST_LAUNCH 日志庫加載,但僅在main運行之后
DYLD_PRINT_LIBRARIES 日志庫加載
DYLD_IMAGE_SUFFIX 首先搜索帶有這個后綴的庫

這個方法確實很方便,但是我們如果想要自己度量per-main階段的時間消耗,又如何統計呢?
由于我們主要針對冷啟動進行優化,就先介紹一下冷啟動的流程:

冷啟動的流程

可以將其歸納為三個階段:

1. dyld:加載鏡像,動態庫
2. RunTime方法
3. main函數初始化

從圖中可以看出開發者在main之前可以處理的是Run Image Initializers階段(對應Apple展示圖中的initializers階段),load加載、__attribute__((constructor))和C++靜態對象初始化;

load耗時監測

想知道load方法執行的時間,就不可避免的需要獲取+load類和分類的方法。目前我了解到的也是有兩種,一種是通過runtime api,去讀取對應鏡像下所有類及其元類,并逐個遍歷元類的實例方法,如果方法名稱為load,則執行hook操作,代表庫是AppStartTime;一種是和 runtime一樣,直接通過getsectiondata函數,讀取編譯時期寫入mach-o文件DATA段的__objc_nlclslist__objc_nlcatlist節,這兩節分別用來保存no lazy class列表和no lazy category列表,所謂的no lazy結構,就是定義了+load方法的類或分類,代表庫是A4LoadMeasure

先說一下兩種方案對比結果:


load 方案對比
load誤差 統計范圍
AppStartTime 100ms左右
A4LoadMeasure 50ms左右 類和分類

從測試結果來看,當然我們會選擇后者,還統計了分類load加載。而且從性能上看,前者會for循環調用object_getClass()方法,該方法會觸發類的realize 操作,給類開辟可讀寫的信息存儲空間、調整成員變量布局、插入分類方法屬性等操作,簡單來說就是讓類變成可用(realized)狀態,這樣當有大量的類進行該操作時,會額外增加per-main時間,造成不必要的開銷。

//獲取no lazy class 列表和 no lazy category 列表
static NSArray <LMLoadInfo *> *getNoLazyArray(const struct mach_header *mhdr) {
    NSMutableArray *noLazyArray = [NSMutableArray new];
    unsigned long bytes = 0;
    Class *clses = (Class *)getDataSection(mhdr, "__objc_nlclslist", &bytes);
    for (unsigned int i = 0; i < bytes / sizeof(Class); i++) {
        LMLoadInfo *info = [[LMLoadInfo alloc] initWithClass:clses[i]];
        if (!shouldRejectClass(info.clsname)) [noLazyArray addObject:info];
    }
    
    bytes = 0;
    Category *cats = getDataSection(mhdr, "__objc_nlcatlist", &bytes);
    for (unsigned int i = 0; i < bytes / sizeof(Category); i++) {
        LMLoadInfo *info = [[LMLoadInfo alloc] initWithCategory:cats[i]];
        if (!shouldRejectClass(info.clsname)) [noLazyArray addObject:info];
    }
    
    return noLazyArray;
}

//hook 類和分類的 +load 方法
static void hookAllLoadMethods(LMLoadInfoWrapper *infoWrapper) {
    unsigned int count = 0;
    Class metaCls = object_getClass(infoWrapper.cls);
    Method *methodList = class_copyMethodList(metaCls, &count);
    for (unsigned int i = 0, j = 0; i < count; i++) {
        Method method = methodList[i];
        SEL sel = method_getName(method);
        const char *name = sel_getName(sel);
        if (!strcmp(name, "load")) {
            LMLoadInfo *info = nil;
            if (j > infoWrapper.infos.count - 1) {
                info = [[LMLoadInfo alloc] initWithClass:infoWrapper.cls];
                [infoWrapper insertLoadInfo:info];
                LMAllLoadNumber++;
            } else {
                info = infoWrapper.infos[j];
            }
            ++j;
            swizzleLoadMethod(infoWrapper.cls, method, info);
        }
    }
    free(methodList);
}

Tip:
這里說明一個問題,A4LoadMeasureLMAllLoadNumber定位最后一次打印有計算誤差,稍微取巧了一下,改為在主線程獲取,具體可查看demo;

attribute((constructor))和C++對象靜態初始化

__attribute__是GNU C特色的一個編譯器屬性,可以通過iOS attribute了解一下;它與load,main,initialize的調用順序如下:

load -> attribute((constructor)) -> main -> initialize

好了,接下來,我們再次對比下這兩個三方庫:


initialize 方案對比
static initialize誤差
AppStartTime 30ms左右
A4LoadMeasure 40ms左右

統計下來,兩者數據差不多,最主要的是打印了方法指針;A4LoadMeasure統計的方案我不敢茍同,只是在__attribute__((constructor))方法作用域前后打點就是C++ Static Initializers端所用時間?這波兒操作看不懂了;獲取__mod_init_func(初始化的全局函數地址)段更值得認同;

簡單介紹下初始化函數大致執行順序如下:

initializeMainExecutable -> ImageLoader::runInitializers -> ImageLoader::doInitialization -> ImageLoaderMachO::doModInitFunctions

最后一個函數是主要處理的邏輯,下面??附上代碼:

//該函數主要負責處理__DATA下的__mod_init_func
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);
        }
    }
}

這里有一個問題說明下,原文也提到了,if ( ! this->containsAddress((void*)func) )這個判斷函數地址是否在當前image的地址空間中,由于我們是在動態庫中做函數地址替換,替換后的函數地址都是動態庫中的了,沒有在其他 image中,所以當其他image執行到這個判斷時,就拋出了異常。在demo工程中這個現象還不明顯,當工程架構復雜一些,這個問題就比較明顯了;

三. 工程說明

目前工程已支持pod引入:

pod 'A0PreMainTime'

#******子組件單獨引入***********
#pre-main階段耗時檢測
pod 'A0PreMainTime/PreMainTime'
#業務時間度量
pod 'A0PreMainTime/TimeMonitor'

具體請查看A0PreMainTime

學習:

計算 +load 方法的耗時

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

App啟動時間優化

iOS啟動時間優化

手淘iOS性能優化探索

美團外賣iOS App冷啟動治理

擴展:

dyld詳解

提交到AppStore問題:不支持的體系結構x86

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容