在 WWDC 2016 和 2017 都有提到啟動這塊的原理和性能優化思路,可見啟動時間,對于開發者和用戶們來說是多么的重要,本文就談談如何精確的度量 App 的啟動時間,啟動時間由 main 之前的啟動時間和 main 之后的啟動時間兩部分組成。
圖是 Apple 在 WWDC 上展示的 PPT,是對 main 之前啟動所做事的一個簡單總結。main 之后的啟動時間如何考量呢?進入到 main 函數以后,我們的代碼都是從 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
函數開始執行的。一般說來,pre-main階段的定義為APP開始啟動到系統調用main函數這一段時間;main階段則代表從main函數入口到主UI框架的viewDidAppear函數調用的這一段時間。
1. main 階段的時間
main 階段的時間就是從 main 函數到第一個界面渲染完成這段時間。在開始之前,我們先來磨練一個我們自己的工具。
生活中,我們計量一段時間一般是用計時器。這里我們要想知道哪些操作,或者說哪些代碼是耗時的,我們也需要一個打點計時器。用過 profile 的朋友都知道這個工具很強大,可以使用它來分析出哪些代碼是耗時的。但是它不夠靈活,我們來看一下我們的這個計時器應該怎么設計。
如上圖所示,在時間軸上,我們從 start 開始打點計時,然后我們在第一個小紅旗那里打了一個點,記錄這段代碼的耗時,然后又在第二個小紅旗那里打了一個點,記錄這中間代碼的耗時。然后在結束的地方打一個點,然后把所有打點的結果展示出來。同時,我們為每段計時加上標注,用來區分這段時間是執行了什么操作花費的時間。這樣一來,我們就能快速精準的知道究竟是誰拖慢了啟動。
didFinishLaunchingWithOptions
一般來說,我們放到 didFinishLaunchingWithOptions
執行的代碼,有很多初始化操作,如日志,統計,SDK配置等。盡量做到只放必需的,其他的可以延遲到 MainViewController
展示完成 viewDidAppear
以后。
一、 日志、統計等必須在 APP 一啟動就最先配置的事件
二、 項目配置、環境配置、用戶信息的初始化 、推送、IM等事件
三、 其他 SDK 和配置事件
第一類,必須第一時間啟動,仍然把它留在 didFinishLaunchingWithOptions
里啟動。
第二類,這些功能在用戶進入 APP
主體的之前是必須要加載完的,我把他放到廣告頁面的 viewDidAppear
啟動。
第三類,由于啟動時間不是必須的,所以我們可以放在第一個界面的 viewDidAppear
方法里,這里完全不會影響到啟動時間。
既然思路有了,我們就開始動手吧!在這里我們需要用到一個工具 —— 打點計時器 BLStopwatch
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[[BLStopwatch sharedStopwatch] start];
...
初始化第三方 SDK
配置 APP 運行需要的環境
自己的一些工具類的初始化
...
[[BLStopwatch sharedStopwatch] splitWithDescription:@"didFinishLaunchingWithOptions"];
NSLog(@"\n啟動耗時:%@",[[BLStopwatch sharedStopwatch].splits.firstObject objectForKey:@"#1 didFinishLaunchingWithOptions"]);
return YES;
}
優化前的 didFinishLaunchingWithOptions
啟動耗時:
如何管理項目需要啟動的一些事件呢?為此,我們我專門建了一個類來負責啟動事件,為什么呢?如果不這么做,那么此次優化以后,以后再引入第三方的時候,別的同事可能很直覺的就把第三方的初始化放到了 didFinishLaunchingWithOptions
方法里,這樣久而久之, didFinishLaunchingWithOptions
又變得不堪重負,到時候又要專門花時間來做重復的優化。
/**
* 注意: 這個類負責所有的 didFinishLaunchingWithOptions 延遲事件的加載.
* 以后引入第三方需要在 didFinishLaunchingWithOptions 里初始化或者我們自己的類需要在 didFinishLaunchingWithOptions 初始化的時候,
* 要考慮盡量少的啟動時間帶來好的用戶體驗, 所以應該根據需要減少 didFinishLaunchingWithOptions 里耗時的操作.
* 第一類: 比如日志 / 統計等需要第一時間啟動的, 仍然放在 didFinishLaunchingWithOptions 中.
* 第二類: 比如用戶數據需要在廣告顯示完成以后使用, 所以需要伴隨廣告頁啟動, 只需要將啟動代碼放到 startupEventsOnADTimeWithAppDelegate 方法里.
* 第三類: 比如直播和分享等業務, 肯定是用戶能看到真正的主界面以后才需要啟動, 所以推遲到主界面加載完成以后啟動, 只需要將代碼放到 startupEventsOnDidAppearAppContent 方法里.
*/
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface FASDelayStartupTool : NSObject
/**
* 啟動伴隨 didFinishLaunchingWithOptions 啟動的事件.
* 啟動類型為:日志 / 統計等需要第一時間啟動的.
*/
+ (void)startupEventsOnAppDidFinishLaunchingWithOptions;
/**
* 啟動可以在展示廣告的時候初始化的事件.
* 啟動類型為: 用戶數據需要在廣告顯示完成以后使用, 所以需要伴隨廣告頁啟動.
*/
+ (void)startupEventsOnADTime;
/**
* 啟動在第一個界面顯示完(用戶已經進入主界面)以后可以加載的事件.
* 啟動類型為: 比如直播和分享等業務, 肯定是用戶能看到真正的主界面以后才需要啟動, 所以推遲到主界面加載完成以后啟動.
*/
+ (void)startupEventsOnDidAppearAppContent;
@property (nonatomic, strong) NSDictionary *launchOptions;
@end
NS_ASSUME_NONNULL_END
然后把 日志/統計
等需要第一時間啟動的事件封裝到 startupEventsOnAppDidFinishLaunchingWithOptions
方法中,用戶數據需要在廣告顯示完成以后的事件可以放到 startupEventsOnADTime
方法中,啟動在第一個界面顯示完(用戶已經進入主界面)以后可以加載的事件(如直播和分享等業務)可以放到 startupEventsOnDidAppearAppContent
方法中。
然后我們的 didFinishLaunchingWithOptions
方法中,只需要我們的工具類調用需要第一時間啟動事件方法 startupEventsOnAppDidFinishLaunchingWithOptions
即可。
[FASDelayStartupTool startupEventsOnAppDidFinishLaunchingWithOptions];
然后在我們的 MainViewController
展示完成 viewDidAppear
以后,工具類進行啟動在第一個界面顯示完成事件 startupEventsOnDidAppearAppContent
即可。
[FASDelayStartupTool startupEventsOnDidAppearAppContent];
優化后的 didFinishLaunchingWithOptions
啟動耗時:
2. pre-main 階段的時間
iOS啟動時間可以通過在Edit -> Run -> Environment Variables 加入 DYLD_PRINT_STATISTICS環境變量來查看
添加環境變量后,控制臺輸出信息如下圖所示:
輸出內容展示了系統調用main()函前主要進行的工作內容和時間花費,Session上也對每一階段加載過程具體內容進行了詳細的敘述。
啟動優化
dylib loading time: 對動態庫加載的時間優化.每個App都進行動態庫加載,其中系統級別的動態庫占據了絕大數,而針對系統級別的動態庫都是經過系統高度優化的,不用擔心時間的花費.開發者應該關注于自己集成到App的那些動態庫,這也是最能消耗加載時間的地方.對此Apple建議減少在App里開發者的動態庫集成或者有可能地將其多個動態庫最終集成一個動態庫后進行導入, 盡量保證將App現有的非系統級的動態庫個數保證在6個以內.微信之前就有超過6個的動態庫,現在已經優化到只剩下 6個動態庫了。
Objc setup time: 減少Objc運行初始化的時間花費.主要是類的注冊,分類的注冊,唯一選擇器的存在,以及涉及子父類內存布局的Non Fragile ivars偏移的更新,都會影響Objective-C運行時初始化的時間消耗.
initializer time: 運行初始化程序。如果使用了Objective-C的 +load 方法,請將其替換為 +initialize 方法。
Rebase/binding time: 修正調整鏡像內的指針(重新調整)和設置指向鏡像外符號的指針(綁定)。為了加快重新定位/綁定時間,我們需要更少的指針修復。
- 如果有大量(大的是20000)Objective-C類、選擇器和類別的應用程序可以增加800ms的啟動時間。
- 如果應用程序使用C++代碼,那么使用更少的虛擬函數。
- 使用Swift結構體通常也更快。可以使用swift語言,因為swift語言是靜態的,靜態語言效率更高一些。
動態語言:只寫聲明,不寫實現, 編譯不報錯,執行報錯
靜態語言:只寫聲明,不寫實現, 編譯會報錯 函數名稱就是指針! 方法直接跳轉
每個應用程序都有很大的可尋址內存,當應用程序分配內存時,即使所有的物理內存都被占用,操作系統也會提供內存。要適應此分配請求,操作系統會使用 paging(頁面調度
)或 swapping(交換
) 操作將一些物理內存中的內容復制到硬盤。之前包含數據的物理內存就可供應用程序使用,而原來的那些數據已經寫入硬盤。
如果又需要先前復制到硬盤的那塊內存,操作系統會將另一塊物理內存復制到硬盤,并將原先的舊內存再調度回內存。即使內存存在硬盤之間的調度,操作系統仍然能夠為每個應用程序映射地址空間到物理內存。操作系統的這一功能稱為 虛擬內存(virtual memory)
由于從物理內存和硬盤中復制內容很消耗時間,因此使用虛擬內存會影響性能。過多的頁面調度會降低系統的性能,這稱為抖動(thrashing)
。如果一起使用的兩個或多個對象在內存中的存儲位置離得很遠,抖動發生的可能性就會增加,因此對象實例的內存分配位置很重要。
考慮以下場景:當某個對象需要內存時可以從硬盤將它調度到物理內存。隨后該對象需要訪問另外一個對象,因此對象不位于物理內存,現在就需要調度更多內存。更壞的情況是,為第二個對象調度的內存會迫使第一個對象的內存被再次調出。由于對象會影響對方的交互,就會導致抖動。
分區用于確保分配給要同時使用的對象的內存位于相鄰位置。當需要某個對象時,另外的對象也基本上會用到。因為這些對象位于同一個分區,需要的所有對象都同時調入內存的可能性就更大,當不需要對象時,又可以將它們同時調出內存。
之前的應用直接全部塞進物理內存中,因為軟件發展比硬件快,物理內存不能夠加載全部的應用,所以出現了虛擬內存
進程的虛擬內存 通過進程映射表(mmu)翻譯內存地址 轉成 物理內存地址
內存不以字節管理,而是以頁來管理,假設有5頁數據,并不是全部加載進入內存,用到多少加載多少,用到那一塊加載那一塊,分頁加載,運到的時候,加載到物理內存中,之后點擊到之前沒用到的內存的時候,就會造成內存缺頁異常,就會把缺頁的數據加載到物理內存中,覆蓋掉內存中不活躍的數據,內存不夠用時候,會出現卡頓現象,這是系統如此設計的。一次缺頁內存感受不到,應用啟動的時候,有1000個內存缺頁的時候,內存耗時就能感受到了,減少缺頁的次數,就能夠完成啟動優化。
拋出問題:假設第一頁 第三頁 第五頁 都只有一個方法需要加載,那如何才能盡量減少內存缺頁異常呢?
Instruments
找到啟動時間,運行應用,不能只檢測main階段,要檢測從點擊應用,到出現第一個界面即停止,點擊Instruments,點擊錄制后,出現第一個頁面,馬上停止。過濾只顯示Main Thread相關,選擇Summary: Virtual Memory。
Main Thread -> Virtual Memory -> Fire Backed Page In 觀察次數:Count 時間:Duration
第一次啟動的時候,發現內存缺頁Fire Backed Page In次數(Count)非常多,殺掉進程后,第二次啟動便會發現內存缺頁次數大幅度減少,其原因你們可能認為是熱啟動,但熱啟動背后的原理是什么呢?
冷啟動:物理內存中沒有應用的運行內存
熱啟動:物理內存中已經有應用的運行內存
所以說熱啟動的同學,你們殺掉應用后,再打開N個程序,然后再打開這個應用看一下,會發現:內存缺頁的次數依然非常多,這是因為虛擬內存分頁加載應用數據到物理內存,當物理內存快滿的時候,系統會覆蓋掉內存中不活躍的數據,所以第二次打開的時候,我們第一次啟動留存在物理內存當中的數據已經非常少了,所以第二次啟動的時候,內存缺頁依然非常多。
那么現在到了我們之前拋出的問題:如何減少內存缺頁呢?
二進制重排:
鏈接器 (LD)去做的 按照符號表的順序去排列
objc4-750 源碼 libobjc.order文件就用到了二進制重排,我們就是基于order_file完成二進制重排
AppDelegate加一個load方法
ViewController加一個load方法
二進制的順序是按照 文件鏈接 順序 Build Phase -> Compile Source ,如下圖所示:
符號順序 Build Setting -> link Map (link Map.txt文件)里面有符號順序 符號也是根據文件 來 排列的,如下圖所示:(我么可以通過/Users/xxx/Library/Developer/Xcode/DerivedData找到該文件緩存目錄)
ps:Build Settings中修改Write Link Map File為YES編譯后會生成一個Link Map符號表txt文件。
Xcode 使用的鏈接器件是 ld,ld 有一個不常用的參數-order_file,通過man ld可以看到詳細文檔:
Alters the order in which functions and data are laid out. For each section in the output file, any >symbol in that section that are specified in the order file file is moved to the start of its section and >laid out in the same order as in the order file file.
可以看到,order_file 中的符號會按照順序排列在對應 section 的開始,完美的滿足了我們的需求。
我們可以利用終端在該目錄下新建(touch) 一個Order文件(ansyxpf.order),排列文件順序,如下圖所示:
Xcode 的 GUI 也提供了 order_file 選項:build -> order file 把生成的order文件加進去
然后command+shift+K clean清空一下,再編譯一下,再去link Map.txt文件中查看符號順序,你會發現:二進制重排后的符號順序如下圖所示:
大功告成,我們順利的完成了二進制重排!
如果 order_file 中的符號實際不存在會怎么樣呢?
ld 會忽略這些符號,如果提供了 link 選項-order_file_statistics,會以 warning 的形式把這些沒找到的符號打印在日志里。
那么如何獲得自己主工程和三方庫啟動相關的符號表呢?
-
Hook
: 函數方法的本質都是發送消息,其底層都是通過objc_msgSend
來實現的,objc_msgSend
是使用匯編語言編寫的,是因為其一是使用純 C 是無法編寫一個攜帶未知參數并跳轉至任意函數指針的方法,所以其參數是可變的,需要通過匯編來獲取,所以不如直接用匯編來的方便。 -
靜態掃描
:掃描Mach-O
特定段和節里面所存儲的符號以及函數數據 -
Clang插樁
:即批量Hook
,可以實現100%符號覆蓋,即完全獲取swift
、OC
、C
、block
函數
Clang插樁
LLVM
內置了一個簡單的代碼覆蓋率檢測(SanitizerCoverage
)。它在函數級、基本塊級和邊緣級插入對用戶定義函數的調用。我們這里的批量Hook
,就需要借助于SanitizerCoverage
。
關于 clang 的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細概述,以及簡短Demo演示。
1. 首先 , 添加編譯設置
OC
項目:直接搜索 Other C Flags
來到 Apple Clang - Custom Compiler Flags
中 , 添加
-fsanitize-coverage=trace-pc-guard
Swift
項目: 需要額外在 “Other Swift Flags” 中加入
-sanitize-coverage=func
-sanitize=undefined
2. 重寫方法
新建 FXOrderFile
文件,重寫 __sanitizer_cov_trace_pc_guard_init
和 __sanitizer_cov_trace_pc_guard
方法
-
__sanitizer_cov_trace_pc_guard_init
方法
/*
- start:起始位置
- stop:并不是最后一個符號的地址,而是整個符號表的最后一個地址,最后一個符號的地址=stop-4(因為是從高地址往低地址讀取的,且stop是一個無符號int類型,占4個字節)。stop存儲的值是符號的
*/
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;
}
-
參數1 start
是一個指針,指向無符號int類型,4個字節,相當于一個數組的起 始位置,即符號的起始位置(是從高位往低位讀)
參數1 -
參數2 stop
,由于數據的地址是往下讀的(即從高往低讀,所以此時獲取的地址并不是stop真正的地址,而是標記的最后的地址,讀取stop時,由于stop占4個字節,stop真實地址 = stop打印的地址-0x4)
參數2 -
stop內存地址中存儲的值表示什么?在增加一個方法/塊/c++/屬性的方法(多3個),發現其值也會增加對應的數,例如增加一個test1方法
stop含義 __sanitizer_cov_trace_pc_guard
方法
/*
可以全面hook方法、函數、以及block調用,用于捕捉符號,是在多線程進行的,這個方法中只存儲pc,以鏈表的形式
- guard 是一個哨兵,告訴我們是第幾個被調用的
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return;
//當前函數返回到上一個調用的地址!!
// __builtin_return_address 當前函數返回到哪里去 0:當前函數地址 1:當前調用者的地址
void *PC = __builtin_return_address(0);
//創建結構體!
SYNode * node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//加入結構!
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
3. 獲取所有符號并寫入文件
while
循環從隊列中取出符號,處理非 OC
方法的前綴,存到數組中
數組取反,因為入隊存儲的順序是反序的
數組去重,并移除本身方法的符號
將數組中的符號轉成字符串并寫入到 ansyxpf.order
文件中
extern void getOrderFile(void(^completion)(NSString *orderFilePath)){
collectFinished = YES;
__sync_synchronize();
NSString *functionExclude = [NSString stringWithFormat:@"_%s", __FUNCTION__];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
//創建符號數組
NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];
//while循環取符號
while (YES) {
//出隊
CJLNode *node = OSAtomicDequeue(&queue, offsetof(CJLNode, next));
if (node == NULL) break;
//取出PC,存入info
Dl_info info;
dladdr(node->pc, &info);
// printf("%s \n", info.dli_sname);
if (info.dli_sname) {
//判斷是不是OC方法,如果不是,需要加下劃線存儲,反之,則直接存儲
NSString *name = @(info.dli_sname);
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
[symbolNames addObject:symbolName];
}
}
if (symbolNames.count == 0) {
if (completion) {
completion(nil);
}
return;
}
//取反(隊列的存儲是反序的)
NSEnumerator *emt = [symbolNames reverseObjectEnumerator];
//去重
NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
NSString *name;
while (name = [emt nextObject]) {
if (![funcs containsObject:name]) {
[funcs addObject:name];
}
}
//去掉自己
[funcs removeObject:functionExclude];
//將數組變成字符串
NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
NSLog(@"Order:\n%@", funcStr);
//字符串寫入文件
NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"ansyxpf.order"];
NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (completion) {
completion(success ? filePath : nil);
}
});
}
4. 在didFinishLaunchingWithOptions方法最后調用
需要注意的是,這里的調用位置是由你決定的,一般來說,是第一個渲染的界面
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
getOrderFile(^(NSString *orderFilePath) {
NSLog(@"OrderFilePath:%@", orderFilePath);
});
return YES;
}
5. 拷貝文件,放入指定位置,并配置路徑
一般將該文件放入主項目路徑下,并在 Build Settings -> Order File
中配置 ./ansyxpf.order
成果展示
以下是我的應用二進制重排前后的Instruments分析圖!
- File Backed Page In次數就是觸發Page Fault(內存缺頁)的次數
- Page Cache Hit就是頁緩存命中的次數
附:
虛擬內存的安全問題
如果用虛擬內存,如果知道了應用的大小,映射表的數據都是從0開始,偏移地址地址不會變,知道方法地址,通過偏移地址就能知道方法的實際地址,所以出現了ASLR(隨機的偏移值),方法的實際地址就不知道了 (ASLR iOS4.3版本出現了)
ASLR:隨機偏移量
內部方法都有偏移地址
內部方法的地址:內部方法的偏移地址(0xff000) + ASLR隨機偏移量 (0x111) = 0xff111
iOS 系統的內存頁大小為16KB
MAC 系統的內存頁大小為4KB
PIC(位置無關代碼)
首先,需要理解加載域與運行域的概念。加載域是代碼存放的地址,運行域是代碼運行時的地址。為什么會產生這2個概念?這2個概念的實質意義又是什么呢?
在一些場合,一些代碼并不在儲存這部分代碼的地址上執行地址,比如說,放在norflash中的代碼可能最終是放在RAM中運行,那么中norflash中的地址就是加載域,而在RAM中的地址就是運行域。
在匯編代碼中我們常常會看到一些跳轉指令,比如說b、bl等,這些指令后面是一個相對地址而不是絕對地址,比如說b main,這個指令應該怎么理解呢?main這里究竟是一個什么東西呢?這時候就需要涉及到鏈接地址的概念了,鏈接地址實際上就是鏈接器對代碼中的變量名、函數名等東西進行一個地址的編排,賦予這些抽象的東西一個地址,然后在程序中訪問這些變量名、函數名就是在訪問一些地址。一般所說的鏈接地址都是指鏈接這些代碼的起始地址,代碼必須放在這個地址開始的地方才可以正常運行,否則的話當代碼去訪問、執行某個變量名、函數名對應地址上的代碼時就會找不到,接著程序無疑就是跑飛。但是上面說的那個b main的情形有點特殊,b、bl等跳轉指令并不是一個絕對跳轉指令,而是一個相對跳轉指令,什么意思呢?就是說,這個main標簽最后得到的只并不是main被鏈接器編排后的絕對地址,而是main的絕對地址減去當前的這個指令的絕對地址所得到的值,也就是說b、bl訪問到的是一個相對地址,不是絕對地址,因此,包括這個語句和main在內的代碼段無論是否放在它的運行域這段代碼都能正常運行。這就是所謂的位置無關代碼。
由上面的論述可以得知,如果你的這段代碼需要實現位置無關,那么你就不能使用絕對尋址指令,否則的話就是位置有關了。