一. 簡介
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
該階段各個時期的任務以及優化方法:
階段 | 工作 | 優化 |
---|---|---|
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誤差 | 統計範圍 |
---|---|---|
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:
這裡說明一個問題,A4LoadMeasure
用LMAllLoadNumber
定位最後一次列印有計算誤差,稍微取巧了一下,改為在主執行緒獲取,具體可檢視demo;
attribute((constructor))和C++物件靜態初始化
__attribute__
是GNU C特色的一個編譯器屬性,可以通過iOS attribute瞭解一下;它與load,main,initialize的呼叫順序如下:
load -> attribute((constructor)) -> main -> 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 = §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);
}
}
}
複製程式碼
這裡有一個問題說明下,原文也提到了,if ( ! this->containsAddress((void*)func) )
這個判斷函式地址是否在當前image
的地址空間中,由於我們是在動態庫中做函式地址替換,替換後的函式地址都是動態庫中的了,沒有在其他 image
中,所以當其他image
執行到這個判斷時,就丟擲了異常。在demo工程中這個現象還不明顯,當工程架構複雜一些,這個問題就比較明顯了;
三. 工程說明
目前工程已支援pod引入:
pod 'A0PreMainTime'
#******子元件單獨引入***********
#pre-main階段耗時檢測
pod 'A0PreMainTime/PreMainTime'
#業務時間度量
pod 'A0PreMainTime/TimeMonitor'
複製程式碼
具體請檢視A0PreMainTime