前提,在之前的兩篇文章中,大致介紹了一些基本概念以及啟動優化的思路,下面來著重介紹一個pre-main階段
的優化方案,即二進制重排,這個方案最開始是由于抖音的這篇文章抖音研發實踐:基于二進制文件重排的解決方案 APP啟動速度提升超15%火起來的。
二進制重排原理
在虛擬內存部分,我們知道,當進程訪問一個虛擬內存page,而對應的物理內存不存在時,會觸發缺頁中斷(Page Fault)
,因此阻塞進程。此時就需要先加載數據到物理內存,然后再繼續訪問。這個對性能是有一定影響的。
基于Page Fault,我們思考,App在冷啟動過程中,會有大量的類、分類、三方等需要加載和執行,此時的產生的Page Fault所帶來的的耗時是很大的。以WeChat為例,我們來看下,在啟動階段的Page Fault的次數
-
CMD+i
快捷鍵,選擇System Trace
測Page Fault-1 -
點擊啟動(啟動前需要重啟手機,清除緩存數據),第一個界面出來后,停掉,按照下圖中操作
測Page Fault-2
從圖中可以看出WeChat發生的PageFault有2800+次,可想而知,這個是非常影響性能
的。 然后我們再通過Demo查看方法在編譯時期的排列順序,在ViewController中按下列順序定義以下幾個方法
@implementation ViewController
void test1(){
printf("1");
}
void test2(){
printf("2");
}
- (void)viewDidLoad {
[super viewDidLoad];
test1();
}
+(void)load{
printf("3");
test2();
}
@end
-
在
Build Setting -> Write Link Map File
設置為YES
設置 -
CMD+B編譯demo,然后在對應的路徑下查找
link map
文件,如下所示,可以發現 類中函數的加載順序是從上到下
的,而文件
的順序是根據Build Phases -> Compile Sources
中的順序加載的
加載順序
從上面的Page Fault的次數以及加載順序,可以發現其實導致Page Fault次數過多的根本原因是啟動時刻需要調用的方法,處于不同的Page導致的
。因此,我們的優化思路就是:將所有啟動時刻需要調用的方法,排列在一起,即放在一個頁中,這樣就從多個Page Fault變成了一個Page Fault
。這就是二進制重排的核心原理
,如下所示
注意:在iOS生產環境的app,在發生Page Fault進行重新加載時,iOS系統還會對其做一次
簽名驗證
,因此 iOS 生產環境的 Page Fault 比Debug環境下所產生的耗時更多。
二進制重排實踐
下面,我們來進行具體的實踐,首先理解幾個名詞
Link Map
Linkmap是iOS編譯過程的中間產物,記錄了二進制文件的布局
,需要在Xcode的Build Settings
里開啟Write Link Map File
,Link Map主要包含三部分:
Object Files
生成二進制用到的link單元的路徑和文件編號Sections
記錄Mach-O每個Segment/section的地址范圍Symbols
按順序記錄每個符號的地址范圍
ld
ld
是Xcode使用的鏈接器,有一個參數order_file
,我們可以通過在Build Settings -> Order File
配置一個后綴為order的文件路徑。在這個order文件中,將所需要的符號按照順序寫在里面,在項目編譯時,會按照這個文件的順序進行加載,以此來達到我們的優化
所以二進制重排的本質就是對啟動加載的符號進行重新排列
。
到目前為止,原理我們基本弄清楚了,如果項目比較小,完全可以自定義一個order文件,將方法的順序手動添加,但是如果項目較大,涉及的方法特別多,此時我們如何獲取啟動運行的函數呢?有以下幾種思路
1、hook objc_msgSend
:我們知道,函數的本質是發送消息,在底層都會來到objc_msgSend
,但是由于objc_msgSend的參數是可變的,需要通過匯編
獲取,對開發人員要求較高。而且也只能拿到OC
和 swift中@objc
后的方法2、靜態掃描
:掃描Mach-O
特定段和節里面所存儲的符號以及函數數據3、Clang插樁
:即批量hook,可以實現100%符號覆蓋,即完全獲取swift、OC、C、block
函數
Clang 插樁
llvm內置了一個簡單的代碼覆蓋率檢測(SanitizerCoverage
)。它在函數級、基本塊級和邊緣級插入對用戶定義函數的調用。我們這里的批量hook,就需要借助于SanitizerCoverage
。
關于 clang 的插樁覆蓋的官方文檔如下 : clang 自帶代碼覆蓋工具 文檔中有詳細概述,以及簡短Demo演示。
- 【第一步:配置】開啟
SanitizerCoverage
OC項目,需要在:
在 Build Settings
里的 “Other C Flags
” 中添加-fsanitize-coverage=func,trace-pc-guard
如果是Swift項目,還需要額外在 “
Other Swift Flags
” 中加入-sanitize-coverage=func
和-sanitize=undefined
所有鏈接到 App 中的二進制都需要開啟
SanitizerCoverage
,這樣才能完全覆蓋到所有調用。也可以通過
podfile
來配置參數
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
end
end
end
- 【第二步:重寫方法】新建一個OC文件
CJLOrderFile
,重寫兩個方法-
__sanitizer_cov_trace_pc_guard_init
方法-
參數1
start
是一個指針,指向無符號int類型,4個字節,相當于一個數組的起始位置
,即符號的起始位置(是從高位往低位讀)
參數1-start -
參數2 stop,由于數據的地址是往下讀的(即
從高往低讀
,所以此時獲取的地址并不是stop真正的地址,而是標記的最后的地址,讀取stop時,由于stop占4個字節,stop真實地址 = stop打印的地址-0x4
)
參數2-stop -
stop內存地址中存儲的值表示什么?在增加一個方法/塊/c++/屬性的方法(多3個),發現其值也會增加對應的數,例如增加一個test1方法
stop值含義
-
-
__sanitizer_cov_trace_pc_guard
方法 ,主要是捕獲所有的啟動時刻的符號,將所有符號入隊參數
guard
是一個哨兵,告訴我們是第幾個被調用的
符號的存儲需要借助于
鏈表
,所以需要定義鏈表節點CJLNode
,通過
OSQueueHead
創建原子隊列,其目的是保證讀寫安全通過
OSAtomicEnqueue
方法將node入隊
,通過鏈表的next指針可以訪問下一個符號
-
//原子隊列,其目的是保證寫入安全,線程安全
static OSQueueHead queue = OS_ATOMIC_QUEUE_INIT;
//定義符號結構體,以鏈表的形式
typedef struct {
void *pc;
void *next;
}CJLNode;
/*
- 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;
if (start == stop || *start) return;
printf("INIT: %p - %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++) {
*x = ++N;
}
}
/*
可以全面hook方法、函數、以及block調用,用于捕捉符號,是在多線程進行的,這個方法中只存儲pc,以鏈表的形式
- guard 是一個哨兵,告訴我們是第幾個被調用的
*/
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// if (!*guard) return;//將load方法過濾掉了,所以需要注釋掉
//獲取PC
/*
- PC 當前函數返回上一個調用的地址
- 0 當前這個函數地址,即當前函數的返回地址
- 1 當前函數調用者的地址,即上一個函數的返回地址
*/
void *PC = __builtin_return_address(0);
//創建node,并賦值
CJLNode *node = malloc(sizeof(CJLNode));
*node = (CJLNode){PC, NULL};
//加入隊列
//符號的訪問不是通過下標訪問,是通過鏈表的next指針,所以需要借用offsetof(結構體類型,下一個的地址即next)
OSAtomicEnqueue(&queue, node, offsetof(CJLNode, next));
}
- 【第三步:獲取所有符號并寫入文件】
-while循環
從隊列中取出符號,處理非OC方法的前綴,存到數組中- 數組
取反
,因為入隊存儲的順序是反序的 - 數組
去重
,并移除本身方法的符號 - 將數組中的符號轉成字符串并寫入到
cjl.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:@"cjl.order"];
NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (completion) {
completion(success ? filePath : nil);
}
});
}
- 【第四步:在
didFinishLaunchingWithOptions
方法最后調用】需要注意的是,這里的調用位置是由你決定的,一般來說,是第一個渲染的界面
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[self test11];
getOrderFile(^(NSString *orderFilePath) {
NSLog(@"OrderFilePath:%@", orderFilePath);
});
return YES;
}
- (void)test11{
}
此時的cjl.order
中只有這三個方法
- 【第五步:拷貝文件,放入指定位置,并配置路徑】一般將該文件放入主項目路徑下,并在
Build Settings -> Order File
中配置./cjl.order
,下面是配置前后的對比(上邊是配置前的熟悉怒,下邊是配置后符號順序的)
對比
注意點:避免死循環
-
Build Settings -> Other C Flags
的如果配置的是-fsanitize-coverage=trace-pc-guard
,在while循環
部分會出現死循環
(我們在touchBegin
方法中調試)
避免死循環-1 -
我們打開匯編調試,發現有3個
__sanitizer_cov_trace_pc_guard
的調用
避免死循環-2- 第一次是bl 是
touchBegin
避免死循環-3 - 第三次 bl 是
printf
- 第二次 bl 是因為
while 循環
。 即 只要是跳轉,就會被hook
,即有bl、b
的指令,就會被hook
避免死循環-4
- 第一次是bl 是
解決方式:將BuildSetting中的other C Flags的-fsanitize-coverage=trace-pc-guard
,改成-fsanitize-coverage=func,trace-pc-guard
參考鏈接