iOS 啟動(dòng)優(yōu)化 + 監(jiān)控實(shí)踐

一、背景

距離上次啟動(dòng)優(yōu)化(啟動(dòng)任務(wù)分級(jí))相隔差不多2年時(shí)間了,雖然一直保持在之前的啟動(dòng)速度,但是每個(gè)版本排查啟動(dòng)增量會(huì)耗費(fèi)不少時(shí)間,想做一個(gè)自動(dòng)化的啟動(dòng)監(jiān)控流程來(lái)降低這方面的時(shí)間成本,在啟動(dòng)監(jiān)控開發(fā)中又發(fā)現(xiàn)部分啟動(dòng)可優(yōu)化,于是就順便把啟動(dòng)也優(yōu)化了一下。

本文主要涉及以下幾方面:

  • 啟動(dòng)優(yōu)化:?jiǎn)?dòng)流程、如何優(yōu)化、push啟動(dòng)優(yōu)化、二進(jìn)制重排、后續(xù)計(jì)劃
  • 自動(dòng)化啟動(dòng)監(jiān)控

二、成果

1、啟動(dòng)優(yōu)化:在iPhone8Plus上自測(cè),從點(diǎn)擊圖標(biāo)到首頁(yè)圖片完全加載由之前的1.2s減少到0.51s。測(cè)試同學(xué)分別在iPhone6和iPhone8上面驗(yàn)證,總啟動(dòng)耗時(shí)相比線上版本減少了 50%-60% 。

2、啟動(dòng)監(jiān)控:每晚固定的時(shí)間點(diǎn),設(shè)備會(huì)自動(dòng)啟動(dòng)應(yīng)用10次,將啟動(dòng)數(shù)據(jù)上傳并diff上一天的數(shù)據(jù),將diff數(shù)據(jù)增量超標(biāo)的方法通過郵件發(fā)送到代碼提交者的郵箱,提示對(duì)應(yīng)同學(xué)修改。

下圖為8plus優(yōu)化后的啟動(dòng)


三、優(yōu)化思路

1、如何定義啟動(dòng)開始和結(jié)束時(shí)間?

在做優(yōu)化之前,需要將啟動(dòng)耗時(shí)的計(jì)算標(biāo)準(zhǔn)規(guī)范統(tǒng)一化,這樣才好衡量啟動(dòng)耗時(shí)以及優(yōu)化的效果。

1.1 啟動(dòng)流程

根據(jù)下圖,定義出啟動(dòng)開始時(shí)間為用戶點(diǎn)擊icon,結(jié)束時(shí)間為首頁(yè)數(shù)據(jù)展示完成


1.2 計(jì)算啟動(dòng)開始和結(jié)束時(shí)間
1.2.1 測(cè)試標(biāo)準(zhǔn):

使用錄屏工具對(duì)app啟動(dòng)進(jìn)行錄制,通過QuickTime Plyaer的修剪功或?qū)⒄咭曨l解幀計(jì)算,以點(diǎn)擊appicon變灰為啟動(dòng)開始時(shí)間、以首頁(yè)圖片完全展示為結(jié)束時(shí)間,計(jì)算兩個(gè)時(shí)間的差即為總啟動(dòng)時(shí)間。

1.2.2 代碼如何統(tǒng)計(jì):
  • 啟動(dòng)時(shí)間:通過當(dāng)前進(jìn)程標(biāo)識(shí)(NSProcessInfo\processIdentifier),讀取進(jìn)程信息內(nèi)的進(jìn)程創(chuàng)建時(shí)間(__p_starttime)為啟動(dòng)時(shí)間。
+ (NSTimeInterval)processStartTime
{   // 單位是毫秒
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
        
    } else {
        NSAssert(NO, @"無(wú)法取得進(jìn)程的信息");
        return 0;
    }
}

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}
  • 結(jié)束時(shí)間:以首頁(yè)的所有圖片全部加載完成為結(jié)束時(shí)間,hook圖片下載方法,在啟動(dòng)完成前將所有調(diào)用該方法的url存入數(shù)組,圖片下載完成之后移出數(shù)組,當(dāng)數(shù)組內(nèi)元素個(gè)數(shù)為0時(shí),代表首頁(yè)的圖片下載完成,即為結(jié)束時(shí)間,以下為hook的偽代碼:
- (void)hook_setImageWithUrl:(NSString *)url completed:(completedBlock)completed
{
    // 啟動(dòng)已經(jīng)完成執(zhí)行hook前邏輯
    if (YYLaunchSteps.launchFinished) {
        [self hook_setimageWithUrl...];
        return;
    }
    [LaunchImageArray addobject:url];
    completedBlock newCompletedBlock = ^(...) {
        [LaunchImageArray removeObject:url];
        if (LaunchImageArray.count == 0) { // 數(shù)組個(gè)數(shù)為0代表全部圖片下載完成
            YYLaunchSteps.launchFinished = YES;
        }
        if (completed) {
            completed(...);
        }
    }
    [self hook_setImageWithUrl:url completed:newCompletedBlock];
    
}

2、優(yōu)化兩步驟

2.1 找

根據(jù)啟動(dòng)流程,找出app啟動(dòng)時(shí)耗時(shí)較大的、啟動(dòng)流程中不需要的方法。

2.2 改

對(duì)耗時(shí)較高的方法,進(jìn)行耗時(shí)細(xì)分,尋找可優(yōu)化部分,進(jìn)行修改。對(duì)啟動(dòng)流程中不需要的方法,進(jìn)行懶加載或者延后到啟動(dòng)完成之后執(zhí)行。

3、pre-main優(yōu)化

pre-main的整個(gè)流程可以看前面的圖,已經(jīng)有非常成熟且多的資料對(duì)這個(gè)流程進(jìn)行了說明,這里就不重復(fù)了,總之這個(gè)階段我們能做的有:

  • 1、Load dylibs階段:減少或者合并dylibs,將動(dòng)態(tài)庫(kù)換成靜態(tài)庫(kù)。
  • 2、Rebase/Bind階段:減少類、方法、分類數(shù)量
  • 3、Objc setup階段:沒啥可做的
  • 4、Initializers階段:優(yōu)化+load方法、減少構(gòu)造器函數(shù)(constructor),減少C++靜態(tài)全局變量
    以上部分,其實(shí)在上一次啟動(dòng)優(yōu)化(兩年前)就已經(jīng)做的差不多了(沒有處理過的建議先處理一下),比如公司內(nèi)部的sdk已經(jīng)全部換成靜態(tài)庫(kù)了,category、load方法也處理過,刪除無(wú)用類、方法、資源這個(gè)在很早以前做包體積優(yōu)化的時(shí)候已經(jīng)做得比較徹底了,并且現(xiàn)在也有一套自動(dòng)化流程來(lái)管理每天包體積的增量,所以整個(gè)pre-main的啟動(dòng)優(yōu)化能做的非常少,不過為了突破原有的優(yōu)化過的速度,也做了一些苦力活,本身因?yàn)轫?xiàng)目歷史悠久,并且代碼數(shù)量較大庫(kù)較多,導(dǎo)致pre-main的整個(gè)耗時(shí)比較高,于是想衡量一下每個(gè)庫(kù)的引入對(duì)整個(gè)項(xiàng)目的啟動(dòng)造成了多大的影響,通過創(chuàng)建一個(gè)新的工程,分別將podfile里面的庫(kù)一個(gè)個(gè)的導(dǎo)入進(jìn)新項(xiàng)目,然后大概的評(píng)估每個(gè)庫(kù)帶來(lái)的pre-main耗時(shí),步驟就是:

首先在xcode設(shè)置環(huán)境變量 DYLD_PRINT_STATISTICS 為1,這個(gè)能輸出pre-main的耗時(shí),然后。

  • 1、podfile 添加 podA
  • 2、pod update
  • 3、重啟設(shè)備(重要?。?,xcode運(yùn)行新項(xiàng)目,記錄pre-main耗時(shí),比較未添加podA庫(kù)時(shí)的耗時(shí)差值,然后重復(fù)1、2、3步驟,大概統(tǒng)計(jì)出每個(gè)庫(kù)引入項(xiàng)目帶來(lái)的pre-main耗時(shí)影響。

得出每個(gè)庫(kù)大概的耗時(shí)之后,我們?cè)u(píng)估出有一些庫(kù)并不那么重要并且耗時(shí)達(dá)到幾十毫秒的例如某Refresh庫(kù)等(本身有一套類似邏輯),我們將它移除并且修改使用的部分。對(duì)耗時(shí)較高方便推動(dòng)修改的庫(kù)推動(dòng)優(yōu)化(不方便推動(dòng)的去提需求容易被打,注意安全 ??)這一步大概移除了3-5個(gè)庫(kù)。

4、main階段優(yōu)化

main階段的優(yōu)化第一步找出可優(yōu)化的任務(wù),提供三種找的方式:

方案一:

通過走查代碼,看哪些任務(wù)在整個(gè)啟動(dòng)鏈路上是不必要的,進(jìn)行延后,使用插樁打點(diǎn)的方式通過NSLog輸出每個(gè)方法執(zhí)行后的時(shí)間統(tǒng)計(jì)每個(gè)方法的耗時(shí),對(duì)耗時(shí)高的進(jìn)行耗時(shí)細(xì)分,可拆解的進(jìn)行拆解,可延后的進(jìn)行延后。這個(gè)方案比較直接和簡(jiǎn)單,如果沒有進(jìn)行任務(wù)的優(yōu)先級(jí)排序,這個(gè)方式也能加快啟動(dòng)速度,缺點(diǎn)就是無(wú)法找出一些依賴關(guān)系導(dǎo)致的一些不必要的任務(wù)執(zhí)行。

// 通過這個(gè)方式來(lái)統(tǒng)計(jì)每個(gè)方法同步的耗時(shí)
CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
[self doSomething];
NSLog(@"doSomething : %f",CFAbsoluteTimeGetCurrent() - start);

// 可以在main函數(shù)調(diào)用的時(shí)候設(shè)置一個(gè)全局開始時(shí)間,
// 在其他類里面通過extern關(guān)鍵字取main的時(shí)間,如在main.m內(nèi):
CFAbsoluteTime kAppStartTime;
int main(int argc, char *argv[])
{
    kAppStartTime = CFAbsoluteTimeGetCurrent();
}
// someClass.m
extern CFAbsoluteTime kAppStartTime;
CFAbsoluteTime duration = (CFAbsoluteTimeGetCurrent() - kAppStartTime);
方案二:

通過hook objc_msgSend方法,統(tǒng)計(jì)main-->首頁(yè)圖片完全加載的所有方法以及耗時(shí),按照火焰圖需要的數(shù)據(jù)格式生成一個(gè)json文件,將該json文件傳入分析工具chrome://tracing/生成火焰圖,通過以下火焰圖,我們可以非常方便的看到啟動(dòng)時(shí)執(zhí)行了哪些方法和耗時(shí)的多少,接下來(lái)需要分析每個(gè)任務(wù)在啟動(dòng)時(shí)調(diào)用的必要性然后再針對(duì)其進(jìn)行優(yōu)化,以下為未優(yōu)化時(shí)的火焰圖:


從左到右為啟動(dòng)時(shí)間軸,從上到下為:方法A里面調(diào)用了方法B、C、D。方法A就在最上層,BCD就在下一層,例如APPDelegate的swizzied_didFinishLuanch方法里面調(diào)用了YYlaunch...和MainTabbarController。以此類推可以找到最終調(diào)用到了哪個(gè)方法導(dǎo)致的耗時(shí)。

方案三:APP Launch工具

APP Launch工具是目前來(lái)說啟動(dòng)優(yōu)化最強(qiáng)最全面的檢測(cè)工具并且它也是蘋果官方推薦的官方地址,他同時(shí)包含了Time Profile 以及 System Trace的功能,火焰圖只抓了主線程(可以抓其他線程,但是查看沒這么方便)并且還有一些非常隱晦的耗時(shí)操作也沒法抓獲,直接使用這個(gè)工具來(lái)做啟動(dòng)優(yōu)化也是完全可行的,簡(jiǎn)單介紹以下這個(gè)工具的用法:

  • 首先在Xcode的build settings 中Debug Information Format 設(shè)置為 DWARF with dsYM File (用于符號(hào)化地址)
  • Xcode編譯運(yùn)行項(xiàng)目
  • 通過 Xcode --> Open Developer Tool --> Instruments --> APP Launch 啟動(dòng)應(yīng)用(這樣可以直接運(yùn)行debug包),APPLaunch會(huì)啟動(dòng)應(yīng)用5秒后自動(dòng)關(guān)閉應(yīng)用。
  • 如果得到的分析數(shù)據(jù)沒有符號(hào)化,在APP Launch選擇屏幕左上角的file --> Symbols 選擇亮綠燈的符號(hào), 重新在在APP Launch運(yùn)行項(xiàng)目。


大概是上圖的操作方式,得出主線程的所有任務(wù)耗時(shí)時(shí)間,每個(gè)任務(wù)根據(jù)圖右側(cè)的堆棧挨個(gè)排查是否是啟動(dòng)鏈路中可優(yōu)化的(幾毫秒的也別放過)。

對(duì)找到的耗時(shí)任務(wù)進(jìn)行修改

通過以上介紹的方案,可以找出可優(yōu)化的任務(wù),舉幾個(gè)可以借鑒優(yōu)化的例子:
1.懶加載/延后對(duì)應(yīng)方法:在didFinishlanched方法較早的地方有挺多手動(dòng)hook的方法,有一些是可以優(yōu)化的,比如hook了路由的跳轉(zhuǎn),作用是啟動(dòng)之后在直播間相關(guān)組件沒有初始化完成而執(zhí)行進(jìn)入直播間操作會(huì)導(dǎo)致異常,但是在啟動(dòng)時(shí)是沒有路由操作的,這種hook可以延后到initialize方法第一次執(zhí)行路由的時(shí)候。

2.預(yù)加載圖片:通過app luanch的動(dòng)態(tài)圖最后停留的部分,可以得到有21ms(而火焰圖統(tǒng)計(jì)的在45ms左右)的耗時(shí)是在tabitem設(shè)置圖片的時(shí)候,總共5個(gè)tab,10張圖片。耗時(shí)主要是來(lái)自 imageNamed: 的解碼操作。這個(gè)可以優(yōu)化嗎?

由于imageNamed方法是有緩存機(jī)制的,并且它也是線程安全的,所以可以在一個(gè)更早的時(shí)機(jī)將啟動(dòng)需要的圖片在子線程進(jìn)行解碼。通過hook imageNamed方法得到啟動(dòng)時(shí)候所需的本地圖片,在一個(gè)較早的時(shí)機(jī)進(jìn)行 圖片預(yù)加載:

// 目前我們是在appdelegate的didFinshedLaunch方法內(nèi)執(zhí)行
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    NSArray *preloadImage = @[@"image1",@"image2"...];
    for (NSString *imageName in preloadImage) {
        [UIImage imageNamed:imageName];
    }
});
    
// 可以通過方案一的方式分別獲取耗時(shí)來(lái)評(píng)估預(yù)加載是否有效,驗(yàn)證使用預(yù)加載之后耗時(shí)由40ms減少到了3ms
- (void)setAllTabbarItems
{
    CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
    [self setItemImage...];
    NSLog(@"setAllTabbarItems : %f",CFAbsoluteTimeGetCurrent() - start);
}

還有一個(gè)容易忽略的點(diǎn):我們的下拉刷新控件上面有一個(gè)圖片動(dòng)畫組,進(jìn)行解碼也會(huì)很耗時(shí),可以 延后整個(gè)下拉刷新控件的設(shè)置 到啟動(dòng)后而不是全部將圖片丟到預(yù)加載。還有一些取數(shù)據(jù)庫(kù)緩存、沙盒緩存的操作也可以提前到這個(gè)子線程預(yù)加載。
3.延后自動(dòng)登錄:自動(dòng)登錄成功之后會(huì)發(fā)一個(gè)通知,有的地方收到這個(gè)通知之后會(huì)有拉配置等耗時(shí)的操作,在啟動(dòng)過程中是不需要自動(dòng)登錄的(如果首頁(yè)的請(qǐng)求需要傳uid之類的可以先緩存),把自動(dòng)登錄邏輯放在啟動(dòng)完成之后。因?yàn)槲覀兊淖詣?dòng)登錄方式比較隱蔽且觸發(fā)地方較多,在自動(dòng)登錄的位置打個(gè)斷點(diǎn),運(yùn)行程序看啟動(dòng)流程中哪些步驟會(huì)導(dǎo)致登錄操作,對(duì)其進(jìn)行優(yōu)化。

4.預(yù)請(qǐng)求首頁(yè)數(shù)據(jù):通過火焰圖分析,中間有兩段較長(zhǎng)時(shí)間主線程差不多處于空閑,是否可以優(yōu)化?是因?yàn)橹骶€程被其他線程掛起了嗎?最終得出結(jié)論,是因?yàn)檫@時(shí)候在請(qǐng)求首頁(yè)數(shù)據(jù),等待數(shù)據(jù)渲染首頁(yè),首頁(yè)的網(wǎng)絡(luò)請(qǐng)求是在首頁(yè)的viewdidload方法執(zhí)行的,可以改到didFinishLaunchingWithOptions較早的時(shí)機(jī)預(yù)請(qǐng)求首頁(yè)數(shù)據(jù),縮短主線程空閑段的時(shí)間,在預(yù)請(qǐng)求的時(shí)候我們還要考慮一個(gè)網(wǎng)絡(luò)資源競(jìng)爭(zhēng)的問題,可以通過自定義的NSURLProtocol攔截找出啟動(dòng)時(shí)的所有NSURLSession請(qǐng)求,盡量保證首頁(yè)的預(yù)請(qǐng)求為第一個(gè)請(qǐng)求,并且延后不必要的網(wǎng)絡(luò)請(qǐng)求,我們有攔截到某sdk初始化時(shí)直接發(fā)了很多請(qǐng)求以及我們的IP直連相關(guān)邏輯,導(dǎo)致預(yù)請(qǐng)求首頁(yè)的效果并不明顯,(如何評(píng)估預(yù)請(qǐng)求效果?其實(shí)就是記錄首頁(yè)請(qǐng)求返回時(shí)時(shí)間點(diǎn),然后減去main函數(shù)時(shí)間點(diǎn)得到從main-->數(shù)據(jù)返回的時(shí)間差)修改ip直連以及sdk的請(qǐng)求之后,首頁(yè)數(shù)據(jù)返回提前了150-200ms,而在iPhone8plus上本身啟動(dòng)就1s多,啟動(dòng)速度直接就提升了15%。其次還有因?yàn)槲覀兊氖醉?yè)使父子控制器的構(gòu)造,在當(dāng)前顯示的子控制器加載的時(shí)候會(huì)去預(yù)加載/渲染左右兩邊的控制器,在啟動(dòng)流程中將這個(gè)步驟延后到啟動(dòng)完成(這個(gè)耗時(shí)也比較高)。

5.緩存首頁(yè)數(shù)據(jù):預(yù)請(qǐng)求可以提前數(shù)據(jù)返回時(shí)間,而使用緩存能直接去掉網(wǎng)絡(luò)請(qǐng)求的耗時(shí),常見的為先使用緩存再用請(qǐng)求的數(shù)據(jù)刷新界面,體驗(yàn)效果很差,如果直接就使用緩存則效果會(huì)很好,但是啟動(dòng)間隔太久會(huì)導(dǎo)致首頁(yè)的主播大部分都已經(jīng)下播了,于是我們給緩存設(shè)置了一個(gè)有效時(shí)期(目前定義為3-5分鐘),如果本次啟動(dòng)距離上次緩存的數(shù)據(jù)時(shí)間相差不超過這個(gè)時(shí)期,則直接使用緩存,超過了則使用預(yù)加載的值。

6.首頁(yè)分段式加載:我們的首頁(yè)主要結(jié)構(gòu)分為頂部的搜索框,以及下面的數(shù)據(jù)快,顯然更重要的是下面數(shù)據(jù)快的展示,于是可以延后搜索框的加載,不過因?yàn)橛绊懖惶螅?0ms)然后產(chǎn)品對(duì)這個(gè)方案不太支持,就沒上了,如果你的app有這種明顯的多個(gè)段落,也可以優(yōu)先保證重要的段先展示出來(lái)。

APP Launch 工具的威力

做完以上的優(yōu)化之后,我們?cè)偈褂肁PP Launch工具檢測(cè)一下是否有其他可優(yōu)化的地方,這里使用了system trace相關(guān)的功能。在檢測(cè)的數(shù)據(jù)內(nèi)點(diǎn)擊下圖的三角形,展開應(yīng)用的所有線程,然后找到主線程。


通過上圖我們可以看到有一個(gè)等待鎖的操作導(dǎo)致主線程被block了47ms(有時(shí)候測(cè)是80ms),我們需要找到原因然后處理它,比較簡(jiǎn)單的找的方式就是看看在主線程被block的這段時(shí)間,哪個(gè)子線程在執(zhí)行任務(wù),把工具檢測(cè)到的線程都看一下,很容易就找到了某個(gè)子線程正在執(zhí)行某個(gè)任務(wù),而且存在中斷->執(zhí)行->中斷這種反復(fù)調(diào)用中,我們對(duì)其進(jìn)行修改,最終



這段耗時(shí)由188ms減少到了78ms。其他的block也一并看了一下,系統(tǒng)行為無(wú)法調(diào)整。

5、點(diǎn)擊push啟動(dòng)優(yōu)化

將啟動(dòng)任務(wù)分為了高、中、低三個(gè)優(yōu)先級(jí),其中高優(yōu)先級(jí)是應(yīng)用啟動(dòng)必須的,中優(yōu)先級(jí)定義為進(jìn)直播間、跳轉(zhuǎn)頁(yè)面必須的,低優(yōu)先級(jí)為啟動(dòng)完成后執(zhí)行的任務(wù)。

優(yōu)化點(diǎn)擊push進(jìn)落地頁(yè),其實(shí)也就是用前面介紹的方法優(yōu)化中優(yōu)先級(jí)的任務(wù),其次通過push進(jìn)直播間時(shí),用戶是期望優(yōu)先看到直播內(nèi)容,由于首頁(yè)的請(qǐng)求、加載和渲染會(huì)占用資源,所以可以在push進(jìn)直播間的鏈路上,將首頁(yè)的請(qǐng)求延后至從直播間退出的時(shí)候。

6、二進(jìn)制重排

二進(jìn)制重排的原理:



通過APP Launch檢測(cè)缺頁(yè)中斷次數(shù),由于應(yīng)用啟動(dòng)后會(huì)在內(nèi)存有緩存,所以需要重啟設(shè)備清空內(nèi)存緩存來(lái)檢測(cè)。



項(xiàng)目在編譯生成二進(jìn)制代碼的時(shí)候,默認(rèn)是按照鏈接的Object File(.o)順序?qū)懳募?,按照Object File內(nèi)部的函數(shù)順序?qū)懞瘮?shù)。 鏈接的順序就是:build phases --> Compile Sources 里面的順序??梢酝ㄟ^xcode設(shè)置 Build Settings --> Write Link Map File 為YES,生成Link map文件,然后在link map的# Symbols:段查看符號(hào)鏈接的順序。
有了以上理論知識(shí),我們實(shí)現(xiàn)二進(jìn)制重排要做的就是在編譯的時(shí)候,將啟動(dòng)需要的符號(hào)都排在一起,生成可執(zhí)行文件,這樣在分頁(yè)加載到內(nèi)存時(shí)盡量少的觸發(fā)缺頁(yè)中斷。
  • 如何調(diào)整項(xiàng)目編譯時(shí)的符號(hào)順序?XCode使用的鏈接器叫做ld,ld有個(gè)參數(shù)叫order_file,只要有這個(gè)文件并將文件的路徑告訴XCode,XCode編譯的時(shí)候就會(huì)按照文件中的符號(hào)順序打包二進(jìn)制可執(zhí)行文件。
  • 如何獲取啟動(dòng)時(shí)需要的符號(hào)?其實(shí)就是獲取啟動(dòng)時(shí)調(diào)用的所有方法,clang有提供對(duì)應(yīng)的2個(gè)APIclang地址,簡(jiǎn)單說就是在Other C falg 添加參數(shù)-fsanitize-coverage=func,trace-pc-guard,實(shí)現(xiàn)兩個(gè)方法,__sanitizer_cov_trace_pc_guard_init,以及__sanitizer_cov_trace_pc_guard,第一個(gè)是初始化方法,第二個(gè)是每調(diào)用一個(gè)方法就會(huì)被攔截到,然后記錄下啟動(dòng)時(shí)攔截到的所有方法,這樣就獲取到了啟動(dòng)時(shí)所需要的符號(hào),將符號(hào)寫入并生成order_file文件,在Build Settings -->Order file將文件路徑設(shè)置進(jìn)去。

之后可以通過分析linkmap的# Symbols:段確認(rèn)符號(hào)是否有調(diào)整,確認(rèn)有調(diào)整之后對(duì)成果進(jìn)行檢驗(yàn):在iOS13 iPhone8plus上無(wú)論是檢測(cè)的page fault次數(shù)/耗時(shí),還是啟動(dòng)耗時(shí),使用二進(jìn)制重排與不使用相差很小很小,大概就是每次測(cè)量的波動(dòng)范圍內(nèi),不知道是否是iOS13 的dyld2升級(jí)到dyld3已經(jīng)優(yōu)化過了(有關(guān)dyld升級(jí)優(yōu)化感興趣可以自行搜索了解)。所以最終我們也是放棄了二進(jìn)制重排。

7、啟動(dòng)優(yōu)化后續(xù)計(jì)劃

1.啟動(dòng)模塊化,目前所有的啟動(dòng)項(xiàng)都集中在一個(gè)類里面,光+import頭文件就200行,所以在下個(gè)版本會(huì)將啟動(dòng)項(xiàng)按業(yè)務(wù)分成多個(gè)模塊進(jìn)行處理。
2.推動(dòng)其他sdk進(jìn)行優(yōu)化,特別是子線程占用較多的需要控制一下線程數(shù)量,目前相關(guān)sdk也在處理中。

四、啟動(dòng)監(jiān)控

流程

為了可以監(jiān)控到日常開發(fā)過程中啟動(dòng)耗時(shí)變化,監(jiān)控了啟動(dòng)過程中的方法調(diào)用耗時(shí),通過每天構(gòu)建對(duì)比當(dāng)天版本和昨天版本的差異分析耗時(shí)原因,流程如下:


  • Jenkins 編譯構(gòu)建,構(gòu)建完成后,上報(bào) LinkMap
  • 打包完成后,通過 ios-deploy,真機(jī)安裝 App
  • 啟動(dòng) Appium, 用于多次啟動(dòng) App
  • 運(yùn)行測(cè)試腳本,通過控制 Appium, Appium 控制設(shè)備,重復(fù)冷啟動(dòng)多次,上報(bào)數(shù)據(jù),取平均值,減少浮動(dòng)影響
  • 分析數(shù)據(jù),耗時(shí)新增,減少,增加和 Diff 等
  • 分析結(jié)果郵件發(fā)送
  • 優(yōu)化代碼

分析報(bào)告

第一部分是 Pre-Main 和 首頁(yè)圖片加載完成耗時(shí), 如下:



二部分是通過對(duì)比兩個(gè)版本的啟動(dòng)耗時(shí)數(shù)據(jù)進(jìn)行 Diff, 啟動(dòng)過程中,如果當(dāng)前版本的方法在對(duì)比版本沒有出現(xiàn),就認(rèn)為是新增方法



第三部分是已存在方法耗時(shí)變化

第四部分是庫(kù)在啟動(dòng)過程中,占用的耗時(shí)



第五部分是 + load 方法,占用的耗時(shí)

實(shí)現(xiàn)

通過 Hook 記錄啟動(dòng)階段方法和對(duì)應(yīng)方法的耗時(shí)

統(tǒng)計(jì) Pre-Main 和首頁(yè)圖片加載完成耗時(shí)

Pre-Main 耗時(shí) = 進(jìn)入 main 函數(shù)的時(shí)間 - 進(jìn)程創(chuàng)建時(shí)間,以下是獲取進(jìn)程創(chuàng)建時(shí)間實(shí)現(xiàn)

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc *)procInfo {
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

+ (NSTimeInterval)processStartTime {
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
    } else {
        NSAssert(NO, @"無(wú)法取得進(jìn)程的信息");
        return 0;
    }
}

首頁(yè)圖片加載完成耗時(shí):Hook 圖片下載方法,在啟動(dòng)完成前將所有調(diào)用該方法的 URL 存入數(shù)組,圖片下載完成之后移除數(shù)組,當(dāng)數(shù)組內(nèi)元素個(gè)數(shù)為 0 時(shí),代表首頁(yè)第一屏的圖片下載完成,即為結(jié)束時(shí)間

Pre-Main 階段的 + load 方法、C++ static constructors 、 attribute((constructor))、 __mod_init_func section 中的函數(shù)和 OC 方法耗時(shí)統(tǒng)計(jì)
+load
項(xiàng)目中的 + load 方法或多或少對(duì)啟動(dòng)耗時(shí)有一定的影響,通過 Hook + load 方法,統(tǒng)計(jì) + load 方法耗時(shí), 主要是通過一個(gè)比 + load 方法執(zhí)行還要早的時(shí)機(jī),對(duì)定義了 load 方法的類進(jìn)行 Hook, 對(duì) load 方法的前后插入統(tǒng)計(jì)耗時(shí)的處理

mach-o 中 __DATA,__objc_nlclslist 和 __DATA,__objc_nlcatlist 這兩個(gè) Section 分別保存了 non lazy class 和 non lazy cateogry, 定義 load 方法的類和分類, 通過 getsectbynamefromheader 把定義了 load 方法的類和分類獲取出來(lái)進(jìn)行 Hook, 用最早加載的動(dòng)態(tài)庫(kù)里定義類的 load 方法,比主二進(jìn)制的 load 方法調(diào)用還要早。通過在動(dòng)態(tài)庫(kù)中 load 方法這個(gè)時(shí)機(jī)進(jìn)行 Hook


// 獲取 load 方法的類和分類
const section *nonLazyClass = GetSectByNameFromHeader((void *)mach_header, "__DATA", "__objc_nlclslist");
if (NULL != nonLazyClass) {
    for (ptr address = nonLazyClass->offset; address < nonLazyClass->offset + nonLazyClass->size; address += sizeof(const void *)) {
        Class cls = (__bridge Class)(*(void **)(mach_header + address));
    }
}
    
const section *nonLazyCategory = GetSectByNameFromHeader((void *)mach_header, "__DATA", "__objc_nlcatlist");
if (NULL != nonLazyCategory) {
    for (ptr address = nonLazyCategory->offset; address < nonLazyCategory->offset + nonLazyCategory->size; address += sizeof(const void **)) {
        struct Category *cat = (*(struct Category **)(mach_header + address));
    }
}

// 遍歷 load class 和對(duì)應(yīng) category 的 MethodList 進(jìn)行 Hook
IMP originIMP = loadMethod->imp;
IMP replaceIMP = imp_implementationWithBlock(^(__unsafe_unretained id self, SEL sel) {
    ((void (*)(id, SEL))originIMP)(self, sel);
});
loadMethod->imp = replaceIMP;
objc_msgSend

OC 的方法執(zhí)行過程會(huì)調(diào)用到 objc_msgSend, 所以對(duì)其進(jìn)行 Hook,能統(tǒng)計(jì)到 OC 方法的耗時(shí),objc_msgSend 是變參函數(shù),通過保存現(xiàn)場(chǎng),保持參數(shù)不變,調(diào)用原來(lái)的 objc_msgSend, 參考 InspectiveC 實(shí)現(xiàn)

static void replacementObjc_msgSend() {
  __asm__ volatile (
      // 保存 q0-q7 
      "stp q6, q7, [sp, #-32]!\n"
      "stp q4, q5, [sp, #-32]!\n"
      "stp q2, q3, [sp, #-32]!\n"
      "stp q0, q1, [sp, #-32]!\n"
      // 保存 x0-x8, lr
      "stp x8, lr, [sp, #-16]!\n"
      "stp x6, x7, [sp, #-16]!\n"
      "stp x4, x5, [sp, #-16]!\n"
      "stp x2, x3, [sp, #-16]!\n"
      "stp x0, x1, [sp, #-16]!\n"
      "mov x2, x1\n"
      "mov x1, lr\n"
      "mov x3, sp\n"
      // 調(diào)用 preObjc_msgSend
      "bl __Z15preObjc_msgSendP11objc_objectmP13objc_selectorP9RegState_\n"
      "mov x9, x0\n"
      "mov x10, x1\n"
      "tst x10, x10\n"
      // 讀取 x0-x8, lr
      "ldp x0, x1, [sp], #16\n"
      "ldp x2, x3, [sp], #16\n"
      "ldp x4, x5, [sp], #16\n"
      "ldp x6, x7, [sp], #16\n"
      "ldp x8, lr, [sp], #16\n"
      // 讀取 q0-q7
      "ldp q0, q1, [sp], #32\n"
      "ldp q2, q3, [sp], #32\n"
      "ldp q4, q5, [sp], #32\n"
      "ldp q6, q7, [sp], #32\n"
      "b.eq Lpassthrough\n"
      // blr 調(diào)用原始 objc_msgSend
      "blr x9\n"
      // 保存 x0-x9
      "stp x0, x1, [sp, #-16]!\n"
      "stp x2, x3, [sp, #-16]!\n"
      "stp x4, x5, [sp, #-16]!\n"
      "stp x6, x7, [sp, #-16]!\n"
      "stp x8, x9, [sp, #-16]!\n"
      // 保存 q0-q7
      "stp q0, q1, [sp, #-32]!\n"
      "stp q2, q3, [sp, #-32]!\n"
      "stp q4, q5, [sp, #-32]!\n"
      "stp q6, q7, [sp, #-32]!\n"
      // 調(diào)用 postObjc_msgSend hook.
      "bl __Z16postObjc_msgSendv\n"
      "mov lr, x0\n"
      // 讀取 q0-q7
      "ldp q6, q7, [sp], #32\n"
      "ldp q4, q5, [sp], #32\n"
      "ldp q2, q3, [sp], #32\n"
      "ldp q0, q1, [sp], #32\n"
       // 讀取 x0-x9
      "ldp x8, x9, [sp], #16\n"
      "ldp x6, x7, [sp], #16\n"
      "ldp x4, x5, [sp], #16\n"
      "ldp x2, x3, [sp], #16\n"
      "ldp x0, x1, [sp], #16\n"
      "ret\n"
      "Lpassthrough:\n"
      "br x9"
    );
}

C++ static constructors 、 attribute((constructor))、 _modinit_func section 中的函數(shù)

__mod_init_func 存儲(chǔ)初始化相關(guān)的函數(shù)地址,
__mod_init_func 是在 DATA 段,Pointer 指向的區(qū)域是 TEXT 段, 項(xiàng)目中的這類函數(shù)很多,這些函數(shù)會(huì)在 Pre-Main 階段執(zhí)行,但是基本都不耗時(shí), 通過 getsectiondata(machHeader, "__DATA", "__mod_init_func", &size),讀取函數(shù)指針,用 hook 函數(shù)指針替換原來(lái)的函數(shù)指針,把原來(lái)的函數(shù)地址記錄在全局?jǐn)?shù)組中,hook 函數(shù)從數(shù)組中根據(jù) index 調(diào)用本該執(zhí)行的函數(shù)


void myinit(int argc, char **argv, char **envp) {}

__attribute__((section("__DATA, __mod_init_func"))) typeof(myinit) *__init = myinit;

YYTestClass test = YYTestClass();

__attribute__((constructor)) void testConstructor() {}
void HookInitFuncInitializer(int argc, const char *argv[], const char *envp[], const char *apple[], const struct ProgramVarsStr *vars) {
    ++CurrentPointerIndex;
    InitializerType f = (InitializerType)Initializer[CurrentPointerIndex];
    f(argc, argv, envp, apple, vars);
    
    NSString *symbol = [NSString stringWithFormat:@"%p", f];
    Dl_info info;
    if (0 != dladdr(f, &info)) {
        NSString *sname = @(info.dli_sname);
        if (sname.length > 0) {
            symbol = sname;
        }
    }
}

static void HookModInitFunc() {
    Dl_info info;
    dladdr(HookModInitFunc, &info);
    yy_mach_header *machHeader = info.dli_fbase;
    unsigned long size = 0;
    pointer *p = (pointer *)getsectiondata(machHeader, "__DATA", "__mod_init_func", &size);
    int count = (int)(size / sizeof(void *));
    for (int i = 0; i < count; ++i) {
        pointer ptr = p[i];
        Initializer[i] = ptr;
        p[i] = (pointer)HookInitFuncInitializer;
    }
}
庫(kù)耗時(shí)統(tǒng)計(jì)

LinkMap 中取到 Object files 部分,獲取到 libAFNetworking.a(AFHTTPSessionManager.o) 部分,然后解析成 AFNetworking 和 AFHTTPSessionManager,通過這種方式能粗略的統(tǒng)計(jì)到是那個(gè)庫(kù),庫(kù)里有包含的類,進(jìn)而統(tǒng)計(jì)出那個(gè)方法屬于該庫(kù),這個(gè)方法統(tǒng)計(jì)不到類的命名不對(duì)應(yīng)文件名,或常見 Category 那些情況等

.../Products/Debug-iphoneos/AFNetworking/libAFNetworking.a(AFHTTPSessionManager.o)
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容