前言
作爲開發人員,啓動是App給用戶的第一印象,對用戶體驗至關重要。任何開發的APP的業務迭代迅速,如果放任不管,啓動速度會一點點劣化。爲此iOS客戶端團隊做了大量優化工作,除了傳統的修改業務代碼方式,我們還做了些開拓性的探索,首先我們需要考慮的是,應用啓動分爲2種情況:
冷啓動:指 app 被後臺殺死後,在這個狀態打開 app,這種啓動方式叫做冷啓動,根據測試結果並非是殺掉進程後直接啓動,需要先開啓5-10個應用後,然後在啓動應用,纔會完全冷啓動
熱啓動:指 app 沒有被後臺殺死,仍然在後臺運行,通常我們再次去打開這個 app,這種啓動方式叫熱啓動。
而作爲開發人員我們主要要做的啓動優化,主要分爲2種情況:
-
2.mian函數之後優化
-
1.main函數之前優化
1.main函數之後:
main函數之後,其實大部分來源於我們的業務代碼,這裏提供一個檢測代碼中花費時間的Demo,可以自行檢測添加代碼,進行添加
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
[[BLStopwatch sharedStopwatch] start];
int a = 0;
for (int i = 0; i < 10000000; i++) {
a++;
}
[[BLStopwatch sharedStopwatch] splitWithDescription:@"didFinishLaunchingWithOptions"];
return YES;
}
- (void)viewDidLoad {
[super viewDidLoad];
//刷新時間:
[[BLStopwatch sharedStopwatch] refreshMedianTime];
int a = 0;
for (int i = 0; i < 10000000; i++) {
a++;
};
[[BLStopwatch sharedStopwatch] splitWithDescription:@"viewDidLoad"];
// Do any additional setup after loading the view.
}
-(void)viewDidAppear:(BOOL)animated{
[super viewDidAppear:animated];
//刷新時間:
[[BLStopwatch sharedStopwatch] refreshMedianTime];
int a = 0;
for (int i = 0; i < 10000000; i++) {
a++;
};
[[BLStopwatch sharedStopwatch] splitWithDescription:@"viewDidAppear"];
[[BLStopwatch sharedStopwatch] stopAndPresentResultsThenReset];
}
檢測結果:
Main階段優化:
-
能懶加載的儘量使用懶加載
-
儘可能發揮CPU的價值,儘量使用多線程進行加載;
-
啓動界面,儘可能的避免使用storyboard,或Xib(這寫都是需要轉換爲代碼的,需要耗費時間)
-
NSUserDefaults實際上是在Library文件夾下會生產一個plist文件,如果文件太大的話一次能讀取到內存中可能很耗時,這個影響需要評估,如果耗時很大的話需要拆分(需考慮老版本覆蓋安裝兼容問題)
-
每次用NSLog方式打印會隱式的創建一個Calendar,因此需要刪減啓動時各業務方打的log,或者僅僅針對內測版輸出log
2.main函數之前
想要查看一下main函數之前我們應用都做了些什麼,我們只需要在在 eidt scheme -->Run --> Arguments -->Environment Variables 添加環境變量 DYLD_PRINT_STATISTICS;
添加完成環境變量後,我們可以運行應用,可以監控main之前的運行時間:
這個是應用啓動後時間,由於模擬器之前有安裝過應用,所以這次屬於熱啓動,我們將進程殺死後,再進行檢測
我們對其中的名詞進行解釋一下:
-
dylib load time : 動態庫加載時間,儘量使用系統庫,外部庫儘量不要超過6個(蘋果建議)
-
rebase/binding :就是 macho +ASLR 進行內部地址偏移+ 外部符號綁定(例如NSLog 爲系統Founditon框架中的方法就需要外部符號綁定)
-
Objc setuptime :OC 類註冊時間 ,優化方案:減少oc類,2萬個類會增加800mm這裏優化性較小
-
initializer time : 加載包含load方法的類時間(非懶加載類) 儘量減少load 可修改爲initalizer中+ 單利方式進行加
-
slowest intializers :最慢的
-
libsystem.B.dylb : 系統庫
-
libglInterpose.dylb : 調試相關,release會去掉的
-
marsbridgentwork : 系統庫
-
zhitongti : 主進程
由此我們可以看出優化方案:
-
這裏在main函數之前我們能做的東西就比較少了,首先我們儘量減少動態庫(能合併的,可以儘量合併,不要超過6個),
-
儘量減少load 可修改爲initalizer中+ 單利方式進行加載
-
swift比oc效率高,可以嘗試用swift替換OC,老工程就不建議了
-
減少oc類,2萬個類會增加800mm,這裏的優化時間比較小,但是可以考慮將棄用類(每個工程總有一些迭代,但是已經棄用未刪除的代碼)找工具刪除
-
這裏還有一個很牛逼的優化二進制重排,在鏈接階段(編譯期間)有這麼個優化
2.1二進制重排
二進制重排,首先需要了解幾個概念:虛擬內存,物理內存;
-
物理內存:真實的硬件設備(內存條)
-
虛擬內存:利用磁盤空間虛擬出的一塊邏輯內存,用作虛擬內存的磁盤空間被稱爲交換空間(Swap Space)。(爲了滿足物理內存的不足而提出的策略)
在很久以前,還沒有虛擬內存概念的時候,程序尋址用的都是物理地址,取決於CPU的地址線條數,32位平臺的話 2^32也就是4G 。且每次開啓一個進程都給4G的物理內存。很顯然你內存小一點,這很快就分配完了,於是沒有得到分配資源的進程就只能等待,而且還有一個問題,就是我當前的應用如果使用物理內存,直接給當前地址+偏移量,有可能會訪問到別的進程,這樣也導致進程之間的不安全;
而後引入了虛擬內存極大解決了這方面的問題,一個進程運行時都會得到4G的虛擬內存。這個虛擬內存你可以認爲,每個進程都認爲自己擁有4G的空間,這只是每個進程認爲的,但是實際上,在虛擬內存對應的物理內存上,可能只對應的一點點的物理內存,實際用了多少內存,就會對應多少物理內存,引入虛擬內存主要就是解決物理內存分配,以及進程之間的安全問題;
2.1.1虛擬內存工作原理:
進程得到的這4G虛擬內存是一個連續的地址空間(這也只是進程認爲),而實際上,它通常是被分隔成多個物理內存碎片,還有一部分存儲在外部磁盤存儲器上,在需要時進行數據交換。
進程開始要訪問一個地址,它可能會經歷下面的過程:
- 每次我要訪問地址空間上的某一個地址,都需要把地址翻譯爲實際物理內存地址
- 所有進程共享這整一塊物理內存,每個進程只把自己目前需要的虛擬地址空間映射到物理內存上
- 進程需要知道哪些地址空間上的數據在物理內存上,哪些不在(可能這部分存儲在磁盤上),還有在物理內存上的哪裏,這就需要通過頁表來記錄
- 頁表的每一個表項分兩部分,第一部分記錄此頁是否在物理內存上,第二部分記錄物理內存頁的地址(如果在的話)
- 當進程訪問某個虛擬地址的時候,就會先去看頁表,如果發現對應的數據不在物理內存上,就會發生缺頁異常
- 缺頁異常的處理過程,操作系統立即阻塞該進程,並將硬盤裏對應的頁換入內存,然後使該進程就緒,如果內存已經滿了,沒有空地方了,那就找一個頁覆蓋,至於具體覆蓋的哪個頁,就需要看操作系統的頁面置換算法是怎麼設計的了。
關於虛擬內存與物理內存關係可以用下圖進行描述
頁表的工作原理如下圖所示:
- 我們的cpu想訪問虛擬地址所在的虛擬頁(VP3),根據頁表,找出頁表中第三條的值.判斷有效位。 如果有效位爲1,DRMA緩存命中,根據物理頁號,找到物理頁當中的內容,返回。
- 若有效位爲0,參數缺頁(Page fault)異常,調用內核缺頁異常處理程序。內核通過頁面置換算法選擇一個頁面作爲被覆蓋的頁面,將該頁的內容刷新到磁盤空間當中。然後把VP3映射的磁盤文件緩存到該物理頁上面。然後頁表中第三條,有效位變成1,第二部分存儲上了可以對應物理內存頁的地址的內容。
- 缺頁異常處理完畢後,返回中斷前的指令,重新執行,此時緩存命中,執行1。
- 將找到的內容映射到告訴緩存當中,CPU從告訴緩存中獲取該值,結束。
由此我們可看到當有缺頁異常(Page fault )時,就會稍微耗時,這裏我們就可以看到優化點,我們可以可以看一下符號重排一下就可以了;
xcode缺頁異常(Page fault )檢測
在我們xcode中就有檢查工具,在xcode中的instruments中有工具System Trace 中就可以進行調試
啓動這個工具之後我們到自己啓動也後,停止,然後進行分析如下操作:
這裏我們可以看到File backed Page In 就是一共有多少頁,這裏可以看到2583頁;
我們來進行一次熱啓動,直接重啓一下,看看情況:
我們可以看到熱啓動後,一共就只有116頁,這我們就可以看出,實際上熱啓動和冷啓動差距還是很大的,這樣我們就可以看出系統在啓動時需要的分頁會很多,特別是冷啓動,啓動中有大量的缺頁中斷,這就會導致耗時,我們先看一下怎樣監測頁碼;
3蘋果支持二進制排序
首先二進制重排Xcode給我們提供了一種方法,xcode中使用的鏈接器爲LD,ld中有個文件叫做order_file,如果有一個order文件,將符號順序寫入在order文件,Xcode就會將按照order中的順序進行排列;
我們可以看下蘋果官方給我們提供的源碼來查看一下:
其中order文件中內容,這個文件內容就是函數,方法的符號;
我們看下官方給的源碼中xcode如何配置這個order文件的
我們可以看到不管是在debug($(SRCROOT)/libobjc.order)還是在release($(SDKROOT)/AppleInternal/OrderFiles/libobjc.order)環境蘋果都指定了一個order文件
蘋果指定了這個文件後,他們編譯出來的二進制文件,就會按照這個裏面的符號進行排序
3.1詳解二進制排序
那麼我們自己新建個工程來嘗試一下二進制重排,新建工程,其中添加一些方法,首先我們要知道ld鏈接文件的順序還和下面這裏配置有關
這裏的順序的,所以我們需要注意這裏的列順序,例如我當前在ViewController都寫個load方法,方法內輸出控制器名稱
我們可以看到先執行了ViewController 的Load方法,如果修改了鏈接順序,就會有改變,這個大家可以自行嘗試;
接下來我們看下如何獲取到我們項目的符號。Xcode中有設置可以獲取到
1.進入build Settings 找到Linking write Link Map File 配置爲yes
2.編譯一下工程,我們可以得到編譯文件
3.show in forder並找到上級目錄Intermediates.noindex
4.進入文件夾,找到對應的txt文件
其中BinaryRearrangement-LinkMap-normal-x86_64.txt這個文件就是我們的包含符號順序的文件,打開文件進行查看
可以看出這裏的連接順序,和我們剛纔的文件順序是一致的,這裏我們需要知道的是,鏈接文件實際鏈接的是將.m文件轉換後的.o文件,繼續查看下面sections:
再往下就是重點了symoblo
在symoblo有幾個關鍵詞
- Address 爲macho中的實際代碼地址:
- Size:代碼佔用的內存
- File:文件的序號
- Name:函數的方法名稱,其中的C語言方法前面會加上下劃線
這裏就是我們的符號順序,這裏我們可以看出,函數的排序和我們實際的順序是不一樣的,我們對代碼進行分析,實際順序這幾個函數應該是:
- +[ViewController load]
- +[AppDelegate load]
- _main
- +[AppDelegate load]
然而我們看到實際文件中,和我們分析的順序是不一樣的,這就是我們要優化的點,也是我們進行二進制重排的原因,那麼我們現在嘗試重排二進制,
1.首先編輯文件alan.order內容爲:
2.放到根目錄,並進行xcode配置
3.再次編譯,查看符號文件
4.我們可以看到自己寫入在order文件中的順序已經正常加載,未寫入的,也會加載進來,並且如果你寫了一個沒有的方法,蘋果也不會加載(這裏做了容錯)
二進制重排難點
這樣我們就看到了二進制重排其實也很簡單,其實二進制重排難點主要在於,我們怎麼樣找到這些順序的符號,並生成order文件,這裏我們可以看下抖音研發實踐團隊這篇文章;根據抖音團隊這篇文章我們來繼續查看一下使用Clang插裝方式進行,首先看先Clang官網文章
根據Clang官網進行配置這裏-fsanitize-coverage=trace-pc-guard
這裏有幾個坑點:
fsanitize-coverage=trace-pc-guard直接這麼配置,while循環會一直走添加方法需要修改爲:-fsanitize-coverage=func,trace-pc-guard
添加完成後我們bulid 工程,會出現報錯
這個錯誤很容易解決,根據Clang官網加上示例代碼就可以了
extern "C" void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_t N; // Counter for the guards.
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
}
// This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
// if(*guard)
// __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
// __sanitizer_cov_trace_pc_guard(guard);
extern "C" void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
// If you set *guard to 0 this code will not be called again for this edge.
// Now you can get the PC and do whatever you want:
// store it somewhere or symbolize it and print right away.
// The values of `*guard` are as you set them in
// __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
// and use them to dereference an array or a bit vector.
void *PC = __builtin_return_address(0);
char PcDescr[1024];
// This function is a part of the sanitizer run-time.
// To use it, link with AddressSanitizer or other sanitizer.
__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
還會報錯我們將 extern ’C‘ 再次進行編譯將打印的註釋掉就可以編譯正常了
這樣就實現了靜態插裝:
在所有的函數,block 方法內部直接添加一個一行代碼調用這個函數__sanitizer_cov_trace_pc_guard,而且是第一個方法就會調用這個方法,那麼我們只需要對這個函數進行處理並將調用方法進行組裝生成order文件,再加上上一步的操作,就可以實現完全的二進制重排了。
這裏再說一個坑點,就是如果有OC工程中和swift混編的,插裝需要再添加一個配置文件纔可以將swift文件進行hook住, -sanitize-coverage=func -sanitize=undefined 工程中有swift才能找到這個配置
這裏輸出一下我的符號順序,
我們可以看到有一些是我們可以看到的方法,紅色框內部就是swift編譯後的符號,也是可以正常被HOOK到。
接下來我們看一下使用二進制重排和爲使用二進制重排的符號順序:
未使用二進制重排
使用二進制重排後的符號順序:
通過這種方式我們可以達到一定的優化效果;