iOS冷啟動優(yōu)化之二進制重排 和 Clang插樁技術(shù)

1.冷啟動

1.1 什么是冷啟動?

冷啟動是指內(nèi)存中不包含該應(yīng)用程序相關(guān)的數(shù)據(jù),必須要從磁盤載入到內(nèi)存中的啟動過程。

注意:重新打開 APP但指, 不一定就是冷啟動。

  1. 當(dāng)內(nèi)存不足抗楔,APP被系統(tǒng)自動殺死后棋凳,再啟動就是冷啟動。
  2. 如果在重新打開 APP 之前连躏,APP 的相關(guān)數(shù)據(jù)還存儲在內(nèi)存中剩岳,這時再打開 APP,就是熱啟動
  3. 冷啟動與熱啟動是由系統(tǒng)決定的入热,我們無法決定拍棕。
  4. 當(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橱脸。

下圖是我項目的加載耗時:

1

耗時過程分為以下4部分:

  1. 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個了竖瘾。
  2. 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)綁定在一起。
  1. Objc setup time: 注冊所有 OC類 耗時抹沪, 類越多耗時越多刻肄,有人統(tǒng)計過2萬個自定義的OC的類,大概耗時800毫秒融欧。刪除不用的類敏弃,可以減少耗時。
  2. initializer time: load方法 和 C++構(gòu)造函數(shù)的耗時. 減少重寫load方法噪馏,盡量將事情延遲到 main 方法以后麦到,可以減少耗時绿饵。
  3. 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)排序是什么樣的墩弯?

  1. 在 viewController 中,先隨便寫幾個方法寞射。
1
  1. 再看下源文件的編譯順序
1

接下來查看 Link Map文件查看符號順序渔工, 查看方式:

  1. 打開link map
1
  1. 編譯生成link map 文件
  2. 找到link map 文件
  • 項目目錄中,生成的 app 右鍵桥温,show in Finder
1
  • 找到 app 的上上級目錄
1
  • 進入Intermediates.noindex -> TraceDemo.build -> Debug-iphonesimulator -> TraceDemo.build -> TraceDemo-LinkMap-normal-x86_64.txt
1
  1. 打開link map 文件引矩,找到自己的類及方法的名字
1

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頁赦颇。這樣耗時就會大大降低二鳄。

1

2.2.3 如何進行二進制重排?

1. 二進制重排的方法

在項目編譯生成二進制文件的時候媒怯,找到啟動時需要的方法,并且將它們放在一起 重新排序沪摄,這就是二進制重排躯嫉。

兩個關(guān)鍵點: 找到啟動時需要方法 & 方法 的重排序


2.方法的重排序:

重排序其實很簡單。xcode已經(jīng)為我們提供了這個機制杨拐,它使用的鏈接器叫做 ld, ld有一個參數(shù)叫做Order File, 我們可以通過配置order文件祈餐,來使編譯時生成的二進制的文件的Link Map種的符號順序,按照我們指定的順序排列生成哄陶。而且 libobjc 實際上也做了二進制重排 帆阳。

1

【第一步】在項目根目錄下建一個xxx.order的文件,里面寫上按照自己想排列的順序,寫上方法或者函數(shù)的名字蜒谤。(如果寫了一個不存在的符號山宾,也不會報錯,會被自動過濾掉~)

1

【第二步】在 Build Settings 搜索order file 的文件鳍徽。將項目根目錄創(chuàng)建的文件资锰,設(shè)置上去。

1

【第三步】重新編譯阶祭,查看 Link Map 文件的順序绷杜,果然,按照我們指定的順序排列啦濒募!

1

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
1
1
1
  • 【第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ù)字。

1

我們可以從這個函數(shù)中知道, 當(dāng)前項目中自定義的功能輸入塊的數(shù)量跛锌。

  • 【第3步】編譯器會在生成二進制文件的時候弃秆,在每個func調(diào)用之初,插入以下代碼
__sanitizer_cov_trace_pc_guard(&guard_variable)

也就是說,每個方法在執(zhí)行的時候菠赚,都會調(diào)用上面這個方法脑豹。 接下來:

      1. 我們要實現(xiàn)這個方法,并在這個方法里衡查,獲取到本方法結(jié)束后要返回的地址
// 獲取到本方法結(jié)束后签餐,要返回的地址去姻采,這個地址包含在被hook的方法內(nèi)部裤纹,但不是被hook 的方法的首地址
void *PC = __builtin_return_address(0);
1
      1. 并將地址保存一個系統(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));
1

每個 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:

  1. 查看一下項目的缺頁異常數(shù)量辞嗡。注意需要卸載 APP 或者重啟手機,來保證這個APP完全沒有被加載到內(nèi)存中滞造,因為如果物理內(nèi)存中有該APP的數(shù)據(jù)续室,
  2. 打開 Instrument -> System Trace
1

3.選擇真機、項目断部、點擊啟動猎贴,當(dāng)?shù)谝粋€頁面顯示出來后,點擊停止。

  1. xcode 12搜索main thread, 選擇Virtual Memory她渴,File Backed Page in 就是缺頁異常的數(shù)量
1

優(yōu)化前:項目的缺頁遺產(chǎn)數(shù)量是427


優(yōu)化后:

1

優(yōu)化前:項目的缺頁遺產(chǎn)數(shù)量是286


減少了啟動時大概40%的缺頁異常~


3.自動更新order 文件

隨著代碼迭代达址,order文件需要更新,每次手動更新很麻煩趁耗,所以需要自動更新沉唠。

1
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ù)谝粋€頁面顯示出來后,點擊停止罢屈。

1

青山不改嘀韧,綠水常流。謝謝大家缠捌!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
禁止轉(zhuǎn)載锄贷,如需轉(zhuǎn)載請通過簡信或評論聯(lián)系作者。
  • 序言:七十年代末曼月,一起剝皮案震驚了整個濱河市谊却,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌哑芹,老刑警劉巖炎辨,帶你破解...
    沈念sama閱讀 218,386評論 6 506
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異聪姿,居然都是意外死亡碴萧,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,142評論 3 394
  • 文/潘曉璐 我一進店門咳燕,熙熙樓的掌柜王于貴愁眉苦臉地迎上來勿决,“玉大人,你說我怎么就攤上這事招盲〉退酰” “怎么了?”我有些...
    開封第一講書人閱讀 164,704評論 0 353
  • 文/不壞的土叔 我叫張陵曹货,是天一觀的道長咆繁。 經(jīng)常有香客問我,道長顶籽,這世上最難降的妖魔是什么玩般? 我笑而不...
    開封第一講書人閱讀 58,702評論 1 294
  • 正文 為了忘掉前任,我火速辦了婚禮礼饱,結(jié)果婚禮上坏为,老公的妹妹穿的比我還像新娘究驴。我一直安慰自己,他們只是感情好匀伏,可當(dāng)我...
    茶點故事閱讀 67,716評論 6 392
  • 文/花漫 我一把揭開白布洒忧。 她就那樣靜靜地躺著,像睡著了一般够颠。 火紅的嫁衣襯著肌膚如雪熙侍。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,573評論 1 305
  • 那天履磨,我揣著相機與錄音蛉抓,去河邊找鬼。 笑死剃诅,一個胖子當(dāng)著我的面吹牛巷送,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播综苔,決...
    沈念sama閱讀 40,314評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼惩系,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了如筛?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,230評論 0 276
  • 序言:老撾萬榮一對情侶失蹤抒抬,失蹤者是張志新(化名)和其女友劉穎杨刨,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體擦剑,經(jīng)...
    沈念sama閱讀 45,680評論 1 314
  • 正文 獨居荒郊野嶺守林人離奇死亡妖胀,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,873評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了惠勒。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片赚抡。...
    茶點故事閱讀 39,991評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖纠屋,靈堂內(nèi)的尸體忽然破棺而出涂臣,到底是詐尸還是另有隱情,我是刑警寧澤售担,帶...
    沈念sama閱讀 35,706評論 5 346
  • 正文 年R本政府宣布赁遗,位于F島的核電站,受9級特大地震影響族铆,放射性物質(zhì)發(fā)生泄漏岩四。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,329評論 3 330
  • 文/蒙蒙 一哥攘、第九天 我趴在偏房一處隱蔽的房頂上張望剖煌。 院中可真熱鬧材鹦,春花似錦、人聲如沸耕姊。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,910評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽箩做。三九已至莽红,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間邦邦,已是汗流浹背安吁。 一陣腳步聲響...
    開封第一講書人閱讀 33,038評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留燃辖,地道東北人鬼店。 一個月前我還...
    沈念sama閱讀 48,158評論 3 370
  • 正文 我出身青樓,卻偏偏與公主長得像黔龟,于是被迫代替她去往敵國和親妇智。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,941評論 2 355

推薦閱讀更多精彩內(nèi)容