原文:橘子不酸丶http://www.zyiner.com/article/5
前言
最近由于體驗感覺我們的app啟動時間過長,因此做了APP的啟動優化。本次優化主要從三個方面來做了啟動時間的優化,main之后的耗時方法優化、premain的+load方法優化、二進制重排優化premain時間。
通常我們對于啟動時間的定義為從用戶點擊app到看到首屏的時間。因此對于啟動時間優化就是遵循一個原則:盡早讓用戶看到首頁內容。
app啟動過程
iOS應用的啟動可分為pre-main階段和main()階段,pre-main階段為main函數執行之前所做的操作,main階段為main函數到首頁展示階段。其中系統做的事情為:
premain
- 加載所有依賴的Mach-O文件(遞歸調用Mach-O加載的方法)
- 加載動態鏈接庫加載器dyld(dynamic loader)
- 定位內部、外部指針引用,例如字符串、函數等
- 加載類擴展(Category)中的方法
- C++靜態對象加載、調用ObjC的 +load 函數
- 執行聲明為attribute((constructor))的C函數
main
- 調用main()
- 調用UIApplicationMain()
- 調用applicationWillFinishLaunching
通常的premain階段優化即為刪減無用的類方法、減少+load操作、減少attribute((constructor))的C函數、減少啟動加載的動態庫。而main階段的優化為將啟動時非必要的操作延遲到首頁顯示之后加載、統計并優化耗時的方法、對于一些可以放在子線程的操作可以盡量不占用主線程。
一、耗時方法優化
1.統計啟動時的耗時方法
我們可以通過Instruments的TimeProfile來統計啟動時的主要方法耗時,Call Tree->Hide System Libraries過濾掉系統庫可以查看主線程下方法的耗時。
也可以通過打印時間的方式來統計各個函數的耗時。
double launchTime = CFAbsoluteTimeGetCurrent();
[SDWebImageManager sharedManager];
NSLog(@"launchTime = %f秒", CFAbsoluteTimeGetCurrent() - launchTime);
這一階段就是需要對啟動過程的業務邏輯進行梳理,確認哪些是可以延遲加載的,哪些可以放在子線程加載,以及哪些是可以懶加載處理的。同時對耗時比較嚴重的方法進行review并提出優化策略進行優化。
二、+load方法優化以及刪減不用的類
2.1 +load方法統計
同樣的我們可以通過Instruments來統計啟動時所有的+load方法,以及+load方法所用耗時
我們可以對不必要的+load方法進行優化,比如放在+initialize里。不必要的+load進行刪減。
2.2 使用__attribute優化+load方法
由于在我們的工程中存在很多的+load方法,而其中一大部分為cell模板注冊的+load方法(我們的每一個cell對應一個模板,然后該模板對應一個字符串,在啟動時所有的模板方法都在+load中注冊對應的字符串即在字典中存儲字符串和對應的cell模板,然后動態下發展示對應的cell)。
即存在這種場景,在啟動時需要大量的在+load中注冊key-value。
此時可以使用__attribute((used, section("__DATA,"#sectname" ")))的方式在編譯時寫入"TempSection"的DATA段一個字符串。此字符串為key:value格式的字典轉json。對應著key和value。
#ifndef ZYStoreListTemplateSectionName
#define ZYStoreListTemplateSectionName "ZYTempSection"
#endif
#define ZYStoreListTemplateDATA(sectname) __attribute((used, section("__DATA,"#sectname" ")))
#define ZYStoreListTemplateRegister(templatename,templateclass) \
class NSObject; char * k##templatename##_register ZYStoreListTemplateDATA(ZYTempSection) = "{ \""#templatename"\" : \""#templateclass"\"}";
/**
通過ZYStoreListTemplateRegister(key,classname)注冊處理模板的類名(類必須是ZYStoreListBaseTemplate子類)
【注意事項】
該方式通過__attribute屬性在編譯期間綁定注冊信息,運行時讀取速度快,注冊信息在首次觸發調用時讀取,不影響pre-main時間
該方式注冊時‘key’字段中不支持除下劃線'_'以外的符號
【使用示例】
注冊處理模板的類名:@ZYStoreListTemplateRegister(baseTemp,ZYStoreListBaseTemplate)
**/
在使用時@ZYStoreListTemplateRegister(baseTemp,ZYStoreListBaseTemplate)即為在編譯期間綁定注冊信息。
讀取使用__attribute在編譯期間寫入的key-value字符串。 關于__attribute詳情可以參考__attribute黑魔法
#pragma mark - 第一次使用時讀取ZYStoreListTemplateSectionName的__DATA所有數據
+ (void)readTemplateDataFromMachO {
//1.根據符號找到所在的mach-o文件信息
Dl_info info;
dladdr((__bridge void *)[self class], &info);
//2.讀取__DATA中自定義的ZYStoreListTemplateSectionName數據
#ifndef __LP64__
const struct mach_header *mhp = (struct mach_header*)info.dli_fbase;
unsigned long templateSize = 0;
uint32_t *templateMemory = (uint32_t*)getsectiondata(mhp, "__DATA", ZYStoreListTemplateSectionName, &templateSize);
#else /* defined(__LP64__) */
const struct mach_header_64 *mhp = (struct mach_header_64*)info.dli_fbase;
unsigned long templateSize = 0;
uint64_t *templateMemory = (uint64_t*)getsectiondata(mhp, "__DATA", ZYStoreListTemplateSectionName, &templateSize);
#endif /* defined(__LP64__) */
//3.遍歷ZYStoreListTemplateSectionName中的協議數據
unsigned long counter = templateSize/sizeof(void*);
for(int idx = 0; idx < counter; ++idx){
char *string = (char*)templateMemory[idx];
NSString *str = [NSString stringWithUTF8String:string];
if(!str)continue;
//NSLog(@"config = %@", str);
NSData *jsonData = [str dataUsingEncoding:NSUTF8StringEncoding];
NSError *error = nil;
id json = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
if (!error) {
if ([json isKindOfClass:[NSDictionary class]] && [json allKeys].count) {
NSString *templatesName = [json allKeys][0];
NSString *templatesClass = [json allValues][0];
if (templatesName && templatesClass) {
[self registerTemplateName:templatesName templateClass:NSClassFromString(templatesClass)];
}
}
}
}
}
這樣我們就可以優化大量的重復+load方法。而且使用__attribute屬性為編譯期間綁定注冊信息,運行時讀取速度快,注冊信息在首次觸發調用時讀取,不影響pre-main時間。
三、二進制重排
自從抖音團隊分享了這篇 抖音研發實踐:基于二進制文件重排的解決方案 APP啟動速度提升超15% 啟動優化文章后 , 二進制重排優化 pre-main 階段的啟動時間自此被大家廣為流傳。
當進程訪問一個虛擬內存Page而對應的物理內存卻不存在時,會觸發一次 缺頁中斷(Page Fault)。
二進制重排,主要是優化我們啟動時需要的函數非常分散在各個頁,啟動時就會多次Page Fault造成時間的損耗。
3.1 獲取Order File
本次主要是通過Clang靜態插樁的方式,獲取到所有的啟動時調用的函數符號,導出為OrderFile。
Target -> Build Setting -> Custom Complier Flags -> Other C Flags
添加
-fsanitize-coverage=func,trace-pc-guard
參數
然后實現hook代碼獲取所有啟動的函數符號。啟動后在首頁顯示之后,可以通過觸發下邊-getAllSymbols方法獲取所有符號。
#import "dlfcn.h"
#import <libkern/OSAtomic.h>
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.
}
//原子隊列
static OSQueueHead symboList = OS_ATOMIC_QUEUE_INIT;
static BOOL isEnd = NO;
//定義符號結構體
typedef struct{
void * pc;
void * next;
}SymbolNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//if (!*guard) return; // Duplicate the guard check.
if (isEnd) {
return;
}
void *PC = __builtin_return_address(0);
SymbolNode * node = malloc(sizeof(SymbolNode));
*node = (SymbolNode){PC,NULL};
//入隊
// offsetof 用在這里是為了入隊添加下一個節點找到 前一個節點next指針的位置
OSAtomicEnqueue(&symboList, node, offsetof(SymbolNode, next));
}
- (void)getAllSymbols {
isEnd = YES;
NSMutableArray<NSString *> * symbolNames = [NSMutableArray array];
while (true) {
//offsetof 就是針對某個結構體找到某個屬性相對這個結構體的偏移量
SymbolNode * node = OSAtomicDequeue(&symboList, offsetof(SymbolNode, next));
if (node == NULL) break;
Dl_info info;
dladdr(node->pc, &info);
NSString * name = @(info.dli_sname);
// 添加 _
BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
NSString * symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
//去重
if (![symbolNames containsObject:symbolName]) {
[symbolNames addObject:symbolName];
}
}
//取反
NSArray * symbolAry = [[symbolNames reverseObjectEnumerator] allObjects];
NSLog(@"%@",symbolAry);
//將結果寫入到文件
NSString * funcString = [symbolAry componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"linkSymbols.order"];
NSData * fileContents = [funcString dataUsingEncoding:NSUTF8StringEncoding];
BOOL result = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
if (result) {
NSLog(@"linkSymbol result %@",filePath);
}else{
NSLog(@"linkSymbol result文件寫入出錯");
}
}
由于我們的工程為pod工程,如果只在主工程里添加other c flags只能獲取到主工程層下的所有啟動函數,如果要獲取所有的包含依賴pod中啟動函數符號則需要在每一個pod target設置other c flags參數。
我們可以通過添加pod腳本來對每一個target添加other c flags參數。
在podfile最后添加腳本來為每一個target添加編譯參數。注意可以過濾掉Debug環境才加載的庫。
post_install do |installer|
pods_project = installer.pods_project
build_settings = Hash[
'OTHER_CFLAGS' => '-fsanitize-coverage=func,trace-pc-guard'
# ,'OTHER_SWIFT_FLAGS' => '-sanitize=undefined -sanitize-coverage=func'
]
pods_project.targets.each do |target|
# if !target.name.include?('Pods-')
if !target.name.include?('Pods-') and target.name != 'LookinServer' and target.name != 'DoraemonKit' and target.name != 'DoraemonKit-DoraemonKit'
# 修改build_settings
target.build_configurations.each do |config|
build_settings.each do |pair|
key = pair[0]
value = pair[1]
if config.build_settings[key].nil?
config.build_settings[key] = ['']
end
if !config.build_settings[key].include?(value)
config.build_settings[key] << value
end
end
end
puts '[Other C Flags]: ' + target.name + ' success.'
end
end
end
重新install之后所有的pod target都會添加上other c flags參數。然后就可以獲取到所有的函數符號(注意如果是二進制庫則還是會獲取不到)。
3.1 設置Order File
通過objc的源碼可以看到objc也是通過設置order file設置編譯順序的。
我們可以在主工程的Target -> Build Setting -> Linking -> Order File
添加上述步驟導出的函數符號列表linkSymbols.order。
$(SRCROOT)/linkSymbols.order
這里可以根據根目錄路徑然后尋找,不必把orderfile添加到工程bundle里。如果添加到工程里則會被打包到ipa里。我們可以只是放在工程文件夾下,只在編譯的時候根據路徑引用就可以了。
設置完orderfile之后我們可以通過設置write link map file屬性為YES來找到編譯時生成的符號($Project)-LinkMap-normal-arm64.txt
。
修改完畢后 clean 一下 , 運行工程 , Products - show in finder, 找到 macho 的上上層目錄。
找到結尾為arm64.txt的文件并打開。
Intermediates -> project_ios.build -> Debug-iphoneos -> project_ios.build -> project_ios-LinkMap-normal-arm64.txt
($Project)-LinkMap-normal-arm64.txt
文件里在#Symbols
之后為函數符號鏈接的順序,可以驗證一下重排是否成功。
最后可以看一下我們重排之后的效果,Instruments下System Trace下Page Fault的次數和耗時:
總結
最后在看一下本次優化的效果。圖中為iPhone6s Plus重啟后第一次啟動的優化前后截屏。
參考文章: