1.冷啟動
1.1 什么是冷啟動?
冷啟動是指內(nèi)存中不包含該應(yīng)用程序相關(guān)的數(shù)據(jù),必須要從磁盤載入到內(nèi)存中的啟動過程。
注意:重新打開 APP但指, 不一定就是冷啟動。
- 當(dāng)內(nèi)存不足抗楔,APP被系統(tǒng)自動殺死后棋凳,再啟動就是冷啟動。
- 如果在重新打開 APP 之前连躏,APP 的相關(guān)數(shù)據(jù)還存儲在內(nèi)存中剩岳,這時再打開 APP,就是熱啟動
- 冷啟動與熱啟動是由系統(tǒng)決定的入热,我們無法決定拍棕。
- 當(dāng)然設(shè)備重啟以后,第一次打開 APP 的過程勺良,一定是冷啟動绰播。
1.2 如何統(tǒng)計冷啟動耗時?
一般來講尚困,統(tǒng)計 APP 啟動時長蠢箩,以 main 函數(shù)為節(jié)點 ,分兩個大階段:
- main 函數(shù)之后的代碼,是我們自己寫的事甜,我們可以自行統(tǒng)計進入 main 函數(shù)到第一個界面顯示的耗時谬泌。
- 在 main 函數(shù)里打印一下當(dāng)前的時間,
- 在第一個要顯示的控制器的 viewDidLoad 方法中打印一下當(dāng)前時間
- 兩個時間的差值逻谦,即為main函數(shù)后的加載時長掌实。
- main 函數(shù)之前,為 pre-main 階段邦马,由于是系統(tǒng)在做事情贱鼻,這段時間的 耗時,我們沒辦法直接統(tǒng)計勇婴,需要查 看系統(tǒng)給我們的反饋忱嘹。
1.2.1 pre-main階段都做了什么?
接下來看一下項目中的 pre-main 階段的耗時耕渴。
- 查看系統(tǒng)給的反饋需要 增加一個環(huán)境變量,
- 增加路徑:在 Xcode -> Edit Scheme -> Run -> Arguments -> Environment Variables 中齿兔,
- 增加一個環(huán)境變量 DYLD_PRINT_STATISTICS:1橱脸。
下圖是我項目的加載耗時:
耗時過程分為以下4部分:
- dylib loading time : 是指動態(tài)庫加載的耗時础米,系統(tǒng)的動態(tài)庫做過優(yōu)化,耗時較少添诉。 蘋果官方推薦最多不要超過6個外部動態(tài)庫屁桑,多余6個,需要考慮合并動態(tài)庫栏赴,合并動態(tài)庫對于啟動時期的優(yōu)化蘑斧,非常有效。 像微信的動態(tài)庫早期有八九個须眷,現(xiàn)在也優(yōu)化成6個了竖瘾。
- rebase/binding
- rebase:是指地址的 偏移修正耗時。
- 在編譯生成二進制文件的時候花颗,每個函數(shù)都有一個地址捕传,這個地址是相對于二進制文件的偏移地址。
- 在啟動時扩劝,也就是在二進制文件在加載到虛擬內(nèi)存的時候庸论,為了安全起見,蘋果有個安全機制(ASLR)棒呛,會在整個二進制文件的最前面聂示,隨機加一個偏移值。
- 比如 A 函數(shù)簇秒,相對于二進制文件的偏移值是 0x003催什。 啟動時,整個二進制文件被分配了一個隨機值0x100宰睡。 那么 A 函數(shù)在內(nèi)存中的實際地址是 0x003 + 0x100 = 0x103蒲凶。
- 偏移修正指的就是計算方法在虛擬內(nèi)存中的地址的過程!
- binding: 動態(tài)庫的方法綁定拆内,是指將方法名字與方法的實現(xiàn)進行綁定過程的耗時旋圆。
- 比如 NSLog 方法,在加載的時候需要先找到Foundation庫麸恍,再找到庫里的NSLog的方法的實現(xiàn)灵巧,將方法名字和方法實現(xiàn)綁定在一起。
- Objc setup time: 注冊所有 OC類 耗時抹沪, 類越多耗時越多刻肄,有人統(tǒng)計過2萬個自定義的OC的類,大概耗時800毫秒融欧。刪除不用的類敏弃,可以減少耗時。
- initializer time: load方法 和 C++構(gòu)造函數(shù)的耗時. 減少重寫load方法噪馏,盡量將事情延遲到 main 方法以后麦到,可以減少耗時绿饵。
- slowest intializers : 指出了最耗時的幾個庫是下面的6個庫(最后一個是我的項目)。
1.2.2 pre-main階段耗時優(yōu)化方法總結(jié):
- 減少外部動態(tài)庫的數(shù)量
- 不用的類和方法瓶颠,刪除
- 類盡量使用懶加載拟赊,也就是盡量不要重寫load方法。
- 啟動時加載的數(shù)據(jù)使用多線程
- 使用純代碼粹淋。不用xib storyboard(要額外進行代碼解析轉(zhuǎn)換和頁面的渲染)
以上方法吸祟,都是和自己的項目代碼息息相關(guān)的優(yōu)化方案。不同項目具體是實施動作不一樣桃移。
還有一個優(yōu)化方法屋匕,不管是什么項目,實施動作都一樣 谴轮,對什么項目都有效炒瘟,那就是二進制重排!
2. 二進制重排
學(xué)習(xí)二進制重排第步,首先要知道數(shù)據(jù)是如何加載到內(nèi)存中的 疮装。
我們已經(jīng)知道數(shù)據(jù)加載到內(nèi)存的過程,當(dāng)虛擬內(nèi)存頁還沒有對應(yīng)的物理內(nèi)存頁時粘都,會出現(xiàn)缺頁異常(PageFault)廓推。
當(dāng)冷啟動時,物理內(nèi)存中是沒有數(shù)據(jù)的翩隧,這時會出現(xiàn)大量的缺頁異常樊展,在iOS生產(chǎn)環(huán)境的app,在發(fā)生Page Fault進行重新加載時堆生,iOS系統(tǒng)還會對其做一次簽名驗證专缠,因此 iOS 生產(chǎn)環(huán)境的 Page Fault 比Debug環(huán)境下所產(chǎn)生的耗時更多
這里有沒有優(yōu)化空間呢?接下來就是優(yōu)化方案:二進制重排淑仆!
在了解二進制重排之前涝婉,再了解下在項目編譯生成二進制文件的時候,類及其內(nèi)部方法實現(xiàn)的排列順序是什么樣的呢蔗怠?
2.2.1 二進制文件中方法實現(xiàn)排序是什么樣的墩弯?
- 在 viewController 中,先隨便寫幾個方法寞射。
- 再看下源文件的編譯順序
接下來查看 Link Map文件查看符號順序渔工, 查看方式:
- 打開link map
- 編譯生成link map 文件
- 找到link map 文件
- 項目目錄中,生成的 app 右鍵桥温,show in Finder
- 找到 app 的上上級目錄
- 進入Intermediates.noindex -> TraceDemo.build -> Debug-iphonesimulator -> TraceDemo.build -> TraceDemo-LinkMap-normal-x86_64.txt
- 打開link map 文件引矩,找到自己的類及方法的名字
5.我們可以直觀的看出 link map中符號的順序,類是以源文件的編譯順序,從上到下按序排列脓魏。方法名是以類中方法的書寫順序兰吟,由上到下排序通惫。
2.2.2 為什么需要二進制重排茂翔?
從源碼的執(zhí)行順序上看,應(yīng)該是 load -> test2 -> viewDidLoad -> test1.
但是二進制文件中符號的順序是方法從上到下的書寫順序履腋,沒有按照調(diào)用順序去排列珊燎。
在冷啟動分頁加載二進制文件時,發(fā)現(xiàn)很多頁中都有啟動時需要用到的方法遵湖,那么即使頁里面也存在啟動時不需要的方法悔政,但是由于內(nèi)存是分頁管理的,要加載就要整頁加載延旧。這樣就導(dǎo)致了大量不需要在 pre-main 階段執(zhí)行的方法谋国,也會被加載到內(nèi)存中,增加了啟動的耗時迁沫。
例如芦瘾,啟動需要加載100個頁,每個頁可以包含20個方法集畅。但是每個頁里只有2個方法是啟動時后用到的近弟。這樣實際上啟動時必須要的方法是2 * 100 = 200個,如果將這200個方法緊挨著放在一起挺智,那么只需要2頁祷愉。比100個頁,減少了98頁赦颇。這樣耗時就會大大降低二鳄。
2.2.3 如何進行二進制重排?
1. 二進制重排的方法
在項目編譯生成二進制文件的時候媒怯,找到啟動時需要的方法,并且將它們放在一起 重新排序沪摄,這就是二進制重排躯嫉。
兩個關(guān)鍵點: 找到啟動時需要方法 & 方法 的重排序
2.方法的重排序:
重排序其實很簡單。xcode已經(jīng)為我們提供了這個機制杨拐,它使用的鏈接器叫做 ld, ld有一個參數(shù)叫做Order File, 我們可以通過配置order文件祈餐,來使編譯時生成的二進制的文件的Link Map種的符號順序,按照我們指定的順序排列生成哄陶。而且 libobjc 實際上也做了二進制重排 帆阳。
【第一步】在項目根目錄下建一個xxx.order的文件,里面寫上按照自己想排列的順序,寫上方法或者函數(shù)的名字蜒谤。(如果寫了一個不存在的符號山宾,也不會報錯,會被自動過濾掉~)
【第二步】在 Build Settings 搜索order file 的文件鳍徽。將項目根目錄創(chuàng)建的文件资锰,設(shè)置上去。
【第三步】重新編譯阶祭,查看 Link Map 文件的順序绷杜,果然,按照我們指定的順序排列啦濒募!
3. 靜態(tài)插樁 - 找到冷啟動時的所有方法
接下來鞭盟,需要做的就是寫入 order 文件里的符號了,我們不可能手寫上所有的啟動時需要的執(zhí)行的符號瑰剃,這里的所有符號包括齿诉,調(diào)用的方法、函數(shù)晌姚、C++構(gòu)造方法粤剧、swift方法、block舀凛。
這里使用 LLVM 內(nèi)置的簡單代碼覆蓋率檢測工具(SanitizerCoverage)俊扳。它在邊緣、 函數(shù)猛遍、基本塊 級別上插入對用戶定義函數(shù)的調(diào)用馋记。
-
edge
(默認(rèn)):檢測邊緣(所有的指令跳轉(zhuǎn)都會被插入對用戶定義函數(shù)的調(diào)用, 如循環(huán)、分支判斷懊烤、方法函數(shù)等)梯醒。 -
bb
:檢測基本塊。 -
func
:僅將檢測每個 功能的輸入塊(這個就是我們要重排序的符號)腌紧。
按照文檔茸习,
- 【第1步】搜索并設(shè)置 Other C Flags/ Other C++ Flags 為 ******-fsanitize-coverage=func,trace-pc-guard** (這里要用func, 不能用默認(rèn)的edge, 不然會造成死循環(huán))。
- 如果有swift 壁肋,需要設(shè)置 Other Swift Flags 設(shè)置為 **** -sanitize-coverage=func -sanitize=undefined
- 【第2步】編譯器將插入對模塊構(gòu)造函數(shù)的調(diào)用号胚,所以我們要實現(xiàn)這個方法:
__sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop);
通過打印start, stop 地址的內(nèi)容,從 start 地址開始浸遗,到 stop 地址的前4位猫胁,存儲的是 uint32 的 1-19的數(shù)字。
我們可以從這個函數(shù)中知道, 當(dāng)前項目中自定義的功能輸入塊的數(shù)量跛锌。
- 【第3步】編譯器會在生成二進制文件的時候弃秆,在每個func調(diào)用之初,插入以下代碼:
__sanitizer_cov_trace_pc_guard(&guard_variable)
也就是說,每個方法在執(zhí)行的時候菠赚,都會調(diào)用上面這個方法脑豹。 接下來:
- 我們要實現(xiàn)這個方法,并在這個方法里衡查,獲取到本方法結(jié)束后要返回的地址
// 獲取到本方法結(jié)束后签餐,要返回的地址去姻采,這個地址包含在被hook的方法內(nèi)部裤纹,但不是被hook 的方法的首地址
void *PC = __builtin_return_address(0);
- 并將地址保存一個系統(tǒng)的原子隊列( ( 底層實際上是個棧結(jié)構(gòu) , 利用隊列結(jié)構(gòu) + 原子性來保證順序 ))中逢捺,使用原子隊列筑悴,是為了防止多線程資源搶奪们拙。原子隊列的存值方法如下:
// 將結(jié)構(gòu)體存入到原子隊列中。
// offsetof(type,member) 返回結(jié)構(gòu)體中成員的偏移值阁吝,由于指針PC是8字節(jié)砚婆,所以這里返回8字節(jié)。
// 詳見下圖
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
每個 SYNode 首地址都距離上一個偏移 PC 所占的字節(jié)數(shù)突勇。這樣做的妙處就是装盯,每個 SYNode 的 next 的地址,恰巧就是下一個結(jié)構(gòu)體的地址甲馋。這樣方便獲取隊列里面的所有數(shù)據(jù)埂奈。
- 【第4步】我們在點擊屏幕的事件中
- 把存儲到原子隊列中的地址遍歷出來,
- 根據(jù)地址獲取當(dāng)前地址所在的方法的名稱并存入數(shù)組中定躏,
typedef struct dl_info {
const char *dli_fname; /* 所在文件 */
void *dli_fbase; /* 文件地址 */
const char *dli_sname; /* 符號名稱 */
void *dli_saddr; /* 函數(shù)起始地址 */
} Dl_info;
//這個函數(shù)能通過函數(shù)內(nèi)部地址找到函數(shù)符號
int dladdr(const void *, Dl_info *);
- 由于原子隊列是棧結(jié)構(gòu)账磺,先進后出,所以我們需要將數(shù)組倒序排列
- 由于方法可能會被多次調(diào)用痊远,我們需要去重
- 再將最后我們當(dāng)前點擊屏幕的方法刪除掉
- 將方法名字的數(shù)組垮抗,轉(zhuǎn)成字符串,寫到沙盒文件中
完整代碼如下:
//
// ViewController.m
// TraceDemo
//
// Created by hank on 2020/3/16.
// Copyright ? 2020 hank. All rights reserved.
//
#import "ViewController.h"
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
#import "TraceDemo-Swift.h"
@interface ViewController ()
@end
@implementation ViewController
+(void)initialize
{
}
void(^block1)(void) = ^(void) {
};
void test(){
block1();
}
+(void)load
{
}
- (void)viewDidLoad {
[super viewDidLoad];
[SwiftTest swiftTestLoad];
test();
}
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
NSMutableArray <NSString *> * symbolNames = [NSMutableArray array];
while (YES) {
SYNode * node = OSAtomicDequeue(&symbolList, offsetof(SYNode, 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];
[symbolNames addObject:symbolName];
}
//取反
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:[NSString stringWithFormat:@"%s",__FUNCTION__]];
//將數(shù)組變成字符串
NSString * funcStr = [funcs componentsJoinedByString:@"\n"];
NSString * filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"demo.order"];
NSData * fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
[[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
NSLog(@"%@",funcStr);
}
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 symbolList = OS_ATOMIC_QUEUE_INIT;
//定義符號結(jié)構(gòu)體
typedef struct {
void *pc;
void *next;
}SYNode;
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
// 會導(dǎo)致load 方法被return
// if (!*guard) return;
// 獲取到本方法結(jié)束后碧聪,要返回的地址去冒版,這個地址包含在被hook的方法內(nèi)部,但不是被hook 的方法的首地址
void *PC = __builtin_return_address(0);
SYNode *node = malloc(sizeof(SYNode));
*node = (SYNode){PC,NULL};
//進入
OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
@end
2.2.4 如何驗證二進制重排的效果逞姿?
1.查看缺頁異常數(shù)量Page Fualt:
- 查看一下項目的缺頁異常數(shù)量辞嗡。注意需要卸載 APP 或者重啟手機,來保證這個APP完全沒有被加載到內(nèi)存中滞造,因為如果物理內(nèi)存中有該APP的數(shù)據(jù)续室,
- 打開 Instrument -> System Trace
3.選擇真機、項目断部、點擊啟動猎贴,當(dāng)?shù)谝粋€頁面顯示出來后,點擊停止。
- xcode 12搜索main thread, 選擇Virtual Memory她渴,File Backed Page in 就是缺頁異常的數(shù)量
優(yōu)化前:項目的缺頁遺產(chǎn)數(shù)量是427
優(yōu)化后:
優(yōu)化前:項目的缺頁遺產(chǎn)數(shù)量是286
減少了啟動時大概40%的缺頁異常~
3.自動更新order 文件
隨著代碼迭代达址,order文件需要更新,每次手動更新很麻煩趁耗,所以需要自動更新沉唠。
brew install ios-deploy
APP_ORDER_DIR=appOrderDir
APP_ORDER=./$APP_ORDER_DIR/Documents/app.order
mkdir $APP_ORDER_DIR
ios-deploy --download=/Documents --bundle_id $PRODUCT_BUNDLE_IDENTIFIER --to ./$APP_ORDER_DIR
if [ -e $APP_ORDER ] ;then
cp -f $APP_ORDER ./Resource/app.order
fi
rm -r $APP_ORDER_DIR
【補充xcode13】查看缺頁異常的方式
選擇真機、項目苛败、點擊啟動满葛,當(dāng)?shù)谝粋€頁面顯示出來后,點擊停止罢屈。