1. 程式人生 > Android開發 >iOS 啟動優化 + 監控實踐

iOS 啟動優化 + 監控實踐

一、背景

距離上次啟動優化(啟動任務分級)相隔差不多2年時間了,雖然一直保持在之前的啟動速度,但是每個版本排查啟動增量會耗費不少時間,想做一個自動化的啟動監控流程來降低這方面的時間成本,在啟動監控開發中又發現部分啟動可優化,於是就順便把啟動也優化了一下。

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

  • 1、啟動優化:啟動流程、如何優化、push啟動優化、二進位制重排、後續計劃
  • 2、自動化啟動監控

二、成果

1、啟動優化:在iPhone8Plus上自測,從點選圖示到首頁圖片完全載入由之前的1.2s減少到0.51s。測試同學分別在iPhone6和iPhone8上面驗證,總啟動耗時相比線上版本減少了 50%-60% 。

2、啟動監控:每晚固定的時間點,裝置會自動啟動應用10次,將啟動資料上傳並diff上一天的資料,將diff資料增量超標的方法通過郵件傳送到程式碼提交者的郵箱,提示對應同學修改。

下圖為8plus優化後的啟動

三、優化思路

1、如何定義啟動開始和結束時間?

在做優化之前,需要將啟動耗時的計算標準規範統一化,這樣才好衡量啟動耗時以及優化的效果。

1.1 啟動流程

根據下圖,定義出啟動開始時間為使用者點選icon,結束時間為首頁資料展示完成

1.2 計算啟動開始和結束時間

1.2.1 測試標準:

使用錄屏工具對app啟動進行錄製,通過QuickTime Plyaer的修剪功或將者視訊解幀計算,以點選appicon變灰為啟動開始時間、以首頁圖片完全展示為結束時間,計算兩個時間的差即為總啟動時間。

1.2.2 程式碼如何統計:

  • 啟動時間:通過當前程式標識(NSProcessInfo\processIdentifier),讀取程式資訊內的程式建立時間(__p_starttime)為啟動時間。
+ (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,@"無法取得程式的資訊"); 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; } 複製程式碼
  • 結束時間:以首頁的所有圖片全部載入完成為結束時間,hook圖片下載方法,在啟動完成前將所有呼叫該方法的url存入陣列,圖片下載完成之後移出陣列,當陣列內元素個數為0時,代表首頁的圖片下載完成,即為結束時間,以下為hook的虛擬碼:
- (void)hook_setImageWithUrl:(NSString *)url completed:(completedBlock)completed
{
    // 啟動已經完成執行hook前邏輯
    if (YYLaunchSteps.launchFinished) {
        [self hook_setimageWithUrl...];
        return;
    }
    [LaunchImageArray addobject:url];
    completedBlock newCompletedBlock = ^(...) {
        [LaunchImageArray removeObject:url];
        if (LaunchImageArray.count == 0) { // 陣列個數為0代表全部圖片下載完成
            YYLaunchSteps.launchFinished = YES;
        }
        if (completed) {
            completed(...);
        }
    }
    [self hook_setImageWithUrl:url completed:newCompletedBlock];
    
}
複製程式碼

2、優化兩步驟

2.1 找

根據啟動流程,找出app啟動時耗時較大的、啟動流程中不需要的方法。

2.2 改

對耗時較高的方法,進行耗時細分,尋找可優化部分,進行修改。對啟動流程中不需要的方法,進行懶載入或者延後到啟動完成之後執行。

3、pre-main優化

pre-main的整個流程可以看前面的圖,已經有非常成熟且多的資料對這個流程進行了說明,這裡就不重複了,總之這個階段我們能做的有:

  • 1、Load dylibs階段:減少或者合併dylibs,將動態庫換成靜態庫。
  • 2、Rebase/Bind階段:減少類、方法、分類數量
  • 3、Objc setup階段:沒啥可做的
  • 4、Initializers階段:優化+load方法、減少構造器函式(constructor),減少C++靜態全域性變數

以上部分,其實在上一次啟動優化(兩年前)就已經做的差不多了(沒有處理過的建議先處理一下),比如公司內部的sdk已經全部換成靜態庫了,category、load方法也處理過,刪除無用類、方法、資源這個在很早以前做包體積優化的時候已經做得比較徹底了,並且現在也有一套自動化流程來管理每天包體積的增量,所以整個pre-main的啟動優化能做的非常少,不過為了突破原有的優化過的速度,也做了一些苦力活,本身因為專案歷史悠久,並且程式碼數量較大庫較多,導致pre-main的整個耗時比較高,於是想衡量一下每個庫的引入對整個專案的啟動造成了多大的影響,通過建立一個新的工程,分別將podfile裡面的庫一個個的匯入進新專案,然後大概的評估每個庫帶來的pre-main耗時,步驟就是:

首先在xcode設定環境變數 DYLD_PRINT_STATISTICS 為1,這個能輸出pre-main的耗時,然後。

  • 1、podfile 新增 podA
  • 2、pod update
  • 3、重啟裝置(重要!),xcode執行新專案,記錄pre-main耗時,比較未新增podA庫時的耗時差值,然後重複1、2、3步驟,大概統計出每個庫引入專案帶來的pre-main耗時影響。

得出每個庫大概的耗時之後,我們評估出有一些庫並不那麼重要並且耗時達到幾十毫秒的例如某Refresh庫等(本身有一套類似邏輯),我們將它移除並且修改使用的部分。對耗時較高方便推動修改的庫推動優化(不方便推動的去提需求容易被打,注意安全 ⚠️)這一步大概移除了3-5個庫。

4、main階段優化

main階段的優化第一步找出可優化的任務,提供三種找的方式:

方案一:

通過走查程式碼,看哪些任務在整個啟動鏈路上是不必要的,進行延後,使用插樁打點的方式通過NSLog輸出每個方法執行後的時間統計每個方法的耗時,對耗時高的進行耗時細分,可拆解的進行拆解,可延後的進行延後。這個方案比較直接和簡單,如果沒有進行任務的優先順序排序,這個方式也能加快啟動速度,缺點就是無法找出一些依賴關係導致的一些不必要的任務執行。

// 通過這個方式來統計每個方法同步的耗時
CFAbsoluteTime start = CFAbsoluteTimeGetCurrent();
[self doSomething];
NSLog(@"doSomething : %f",CFAbsoluteTimeGetCurrent() - start);

// 可以在main函式呼叫的時候設定一個全域性開始時間,
// 在其他類裡面通過extern關鍵字取main的時間,如在main.m內:
CFAbsoluteTime kAppStartTime;
int main(int argc,char *argv[])
{
    kAppStartTime = CFAbsoluteTimeGetCurrent();
}
// someClass.m
extern CFAbsoluteTime kAppStartTime;
CFAbsoluteTime duration = (CFAbsoluteTimeGetCurrent() - kAppStartTime);
複製程式碼

方案二:

通過hook objc_msgSend方法,統計main-->首頁圖片完全載入的所有方法以及耗時,按照火焰圖需要的資料格式生成一個json檔案,將該json檔案傳入分析工具chrome://tracing/生成火焰圖,通過以下火焰圖,我們可以非常方便的看到啟動時執行了哪些方法和耗時的多少,接下來需要分析每個任務在啟動時呼叫的必要性然後再針對其進行優化,以下為未優化時的火焰圖:

從左到右為啟動時間軸,從上到下為:方法A裡面呼叫了方法B、C、D。方法A就在最上層,BCD就在下一層,例如APPDelegate的swizzied_didFinishLuanch方法裡面呼叫了YYlaunch...和MainTabbarController。以此類推可以找到最終呼叫到了哪個方法導致的耗時。

方案三:APP Launch工具

APP Launch工具是目前來說啟動優化最強最全面的檢測工具並且它也是蘋果官方推薦的官方地址,他同時包含了Time Profile 以及 System Trace的功能,火焰圖只抓了主執行緒(可以抓其他執行緒,但是檢視沒這麼方便)並且還有一些非常隱晦的耗時操作也沒法抓獲,直接使用這個工具來做啟動優化也是完全可行的,簡單介紹以下這個工具的用法:

  • 首先在Xcode的build settings 中Debug Information Format 設定為 DWARF with dsYM File (用於符號化地址)
  • Xcode編譯執行專案
  • 通過 Xcode --> Open Developer Tool --> Instruments --> APP Launch 啟動應用(這樣可以直接執行debug包),APPLaunch會啟動應用5秒後自動關閉應用。
  • 如果得到的分析資料沒有符號化,在APP Launch選擇螢幕左上角的file --> Symbols 選擇亮綠燈的符號,重新在在APP Launch執行專案。

大概是上圖的操作方式,得出主執行緒的所有任務耗時時間,每個任務根據圖右側的堆疊挨個排查是否是啟動鏈路中可優化的(幾毫秒的也別放過)。

對找到的耗時任務進行修改

通過以上介紹的方案,可以找出可優化的任務,舉幾個可以借鑑優化的例子:

  1. 懶載入/延後對應方法:在didFinishlanched方法較早的地方有挺多手動hook的方法,有一些是可以優化的,比如hook了路由的跳轉,作用是啟動之後在直播間相關元件沒有初始化完成而執行進入直播間操作會導致異常,但是在啟動時是沒有路由操作的,這種hook可以延後到initialize方法第一次執行路由的時候。

  2. 預載入圖片:通過app luanch的動態圖最後停留的部分,可以得到有21ms(而火焰圖統計的在45ms左右)的耗時是在tabitem設定圖片的時候,總共5個tab,10張圖片。耗時主要是來自 imageNamed: 的解碼操作。這個可以優化嗎?

由於imageNamed方法是有快取機制的,並且它也是執行緒安全的,所以可以在一個更早的時機將啟動需要的圖片在子執行緒進行解碼。通過hook imageNamed方法得到啟動時候所需的本地圖片,在一個較早的時機進行 圖片預載入

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

還有一個容易忽略的點:我們的下拉重新整理控制元件上面有一個圖片動畫組,進行解碼也會很耗時,可以 延後整個下拉重新整理控制元件的設定 到啟動後而不是全部將圖片丟到預載入。還有一些取資料庫快取、沙盒快取的操作也可以提前到這個子執行緒預載入。

  1. 延後自動登入:自動登入成功之後會發一個通知,有的地方收到這個通知之後會有拉配置等耗時的操作,在啟動過程中是不需要自動登入的(如果首頁的請求需要傳uid之類的可以先快取),把自動登入邏輯放在啟動完成之後。因為我們的自動登入方式比較隱蔽且觸發地方較多,在自動登入的位置打個斷點,執行程式看啟動流程中哪些步驟會導致登入操作,對其進行優化。

  2. 預請求首頁資料:通過火焰圖分析,中間有兩段較長時間主執行緒差不多處於空閒,是否可以優化?是因為主執行緒被其他執行緒掛起了嗎?最終得出結論,是因為這時候在請求首頁資料,等待資料渲染首頁,首頁的網路請求是在首頁的viewdidload方法執行的,可以改到didFinishLaunchingWithOptions較早的時機預請求首頁資料,縮短主執行緒空閒段的時間,在預請求的時候我們還要考慮一個網路資源競爭的問題,可以通過自定義的NSURLProtocol攔截找出啟動時的所有NSURLSession請求,儘量保證首頁的預請求為第一個請求,並且延後不必要的網路請求,我們有攔截到某sdk初始化時直接發了很多請求以及我們的IP直連相關邏輯,導致預請求首頁的效果並不明顯,(如何評估預請求效果?其實就是記錄首頁請求返回時時間點,然後減去main函式時間點得到從main-->資料返回的時間差)修改ip直連以及sdk的請求之後,首頁資料返回提前了150-200ms,而在iPhone8plus上本身啟動就1s多,啟動速度直接就提升了15%。其次還有因為我們的首頁使父子控制器的構造,在當前顯示的子控制器載入的時候會去預載入/渲染左右兩邊的控制器,在啟動流程中將這個步驟延後到啟動完成(這個耗時也比較高)。

  3. 快取首頁資料:預請求可以提前資料返回時間,而使用快取能直接去掉網路請求的耗時,常見的為先使用快取再用請求的資料重新整理介面,體驗效果很差,如果直接就使用快取則效果會很好,但是啟動間隔太久會導致首頁的主播大部分都已經下播了,於是我們給快取設定了一個有效時期(目前定義為3-5分鐘),如果本次啟動距離上次快取的資料時間相差不超過這個時期,則直接使用快取,超過了則使用預載入的值。

  4. 首頁分段式載入:我們的首頁主要結構分為頂部的搜尋框,以及下面的資料快,顯然更重要的是下面資料快的展示,於是可以延後搜尋框的載入,不過因為影響不太大(20ms)然後產品對這個方案不太支援,就沒上了,如果你的app有這種明顯的多個段落,也可以優先保證重要的段先展示出來。

APP Launch 工具的威力

做完以上的優化之後,我們再使用APP Launch工具檢測一下是否有其他可優化的地方,這裡使用了system trace相關的功能。在檢測的資料內點選下圖的三角形,展開應用的所有執行緒,然後找到主執行緒。

appLaunch.png

通過上圖我們可以看到有一個等待鎖的操作導致主執行緒被block了47ms(有時候測是80ms),我們需要找到原因然後處理它,比較簡單的找的方式就是看看在主執行緒被block的這段時間,哪個子執行緒在執行任務,把工具檢測到的執行緒都看一下,很容易就找到了某個子執行緒正在執行某個任務,而且存在中斷->執行->中斷這種反覆呼叫中,我們對其進行修改,最終

這段耗時由188ms減少到了78ms。其他的block也一併看了一下,系統行為無法調整。

5、點選push啟動優化

將啟動任務分為了高、中、低三個優先順序,其中高優先順序是應用啟動必須的,中優先順序定義為進直播間、跳轉頁面必須的,低優先順序為啟動完成後執行的任務。

優化點選push進落地頁,其實也就是用前面介紹的方法優化中優先順序的任務,其次通過push進直播間時,使用者是期望優先看到直播內容,由於首頁的請求、載入和渲染會佔用資源,所以可以在push進直播間的鏈路上,將首頁的請求延後至從直播間退出的時候。

6、二進位制重排

二進位制重排的原理:

通過APP Launch檢測缺頁中斷次數,由於應用啟動後會在記憶體有快取,所以需要重啟裝置清空記憶體快取來檢測。

專案在編譯生成二進位製程式碼的時候,預設是按照連結的Object File(.o)順序寫檔案,按照Object File內部的函式順序寫函式。 連結的順序就是:build phases --> Compile Sources 裡面的順序。可以通過xcode設定 Build Settings --> Write Link Map File 為YES,生成Link map檔案,然後在link map的# Symbols:段檢視符號連結的順序。

有了以上理論知識,我們實現二進位制重排要做的就是在編譯的時候,將啟動需要的符號都排在一起,生成可執行檔案,這樣在分頁載入到記憶體時儘量少的觸發缺頁中斷。

  • 如何調整專案編譯時的符號順序?XCode使用的連結器叫做ld,ld有個引數叫order_file,只要有這個檔案並將檔案的路徑告訴XCode,XCode編譯的時候就會按照檔案中的符號順序打包二進位制可執行檔案。
  • 如何獲取啟動時需要的符號?其實就是獲取啟動時呼叫的所有方法,clang有提供對應的2個APIclang地址,簡單說就是在Other C falg 新增引數-fsanitize-coverage=func,trace-pc-guard,實現兩個方法,__sanitizer_cov_trace_pc_guard_init,以及__sanitizer_cov_trace_pc_guard,第一個是初始化方法,第二個是每呼叫一個方法就會被攔截到,然後記錄下啟動時攔截到的所有方法,這樣就獲取到了啟動時所需要的符號,將符號寫入並生成order_file檔案,在Build Settings -->Order file將檔案路徑設定進去。

之後可以通過分析linkmap的# Symbols:段確認符號是否有調整,確認有調整之後對成果進行檢驗:在iOS13 iPhone8plus上無論是檢測的page fault次數/耗時,還是啟動耗時,使用二進位制重排與不使用相差很小很小,大概就是每次測量的波動範圍內,不知道是否是iOS13 的dyld2升級到dyld3已經優化過了(有關dyld升級優化感興趣可以自行搜尋瞭解)。所以最終我們也是放棄了二進位制重排。

7、啟動優化後續計劃

  1. 啟動模組化,目前所有的啟動項都集中在一個類裡面,光+import標頭檔案就200行,所以在下個版本會將啟動項按業務分成多個模組進行處理。
  2. 推動其他sdk進行優化,特別是子執行緒佔用較多的需要控制一下執行緒數量,目前相關sdk也在處理中。

四、啟動監控

流程

為了可以監控到日常開發過程中啟動耗時變化,監控了啟動過程中的方法呼叫耗時,通過每天構建對比當天版本和昨天版本的差異分析耗時原因,流程如下:

  • Jenkins 編譯構建,構建完成後,上報 LinkMap
  • 打包完成後,通過 ios-deploy,真機安裝 App
  • 啟動 Appium,用於多次啟動 App
  • 執行測試指令碼,通過控制 Appium,Appium 控制裝置,重複冷啟動多次,上報資料,取平均值,減少浮動影響
  • 分析資料,耗時新增,減少,增加和 Diff 等
  • 分析結果郵件傳送
  • 優化程式碼

分析報告

第一部分是 Pre-Main 和 首頁圖片載入完成耗時,如下:

第二部分是通過對比兩個版本的啟動耗時資料進行 Diff,啟動過程中,如果當前版本的方法在對比版本沒有出現,就認為是新增方法

第三部分是已存在方法耗時變化

第四部分是庫在啟動過程中,佔用的耗時

第五部分是 + load 方法,佔用的耗時

實現

通過 Hook 記錄啟動階段方法和對應方法的耗時

統計 Pre-Main 和首頁圖片載入完成耗時

Pre-Main 耗時 = 進入 main 函式的時間 - 程式建立時間,以下是獲取程式建立時間實現

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc *)procInfo {
    int cmd[4] = {CTL_KERN,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,@"無法取得程式的資訊");
        return 0;
    }
}
複製程式碼

首頁圖片載入完成耗時:Hook 圖片下載方法,在啟動完成前將所有呼叫該方法的 URL 存入陣列,圖片下載完成之後移除陣列,當陣列內元素個數為 0 時,代表首頁第一屏的圖片下載完成,即為結束時間

Pre-Main 階段的 + load 方法、C++ static constructors 、 attribute((constructor))、 __mod_init_func section 中的函式和 OC 方法耗時統計

+ load

專案中的 + load 方法或多或少對啟動耗時有一定的影響,通過 Hook + load 方法,統計 + load 方法耗時,主要是通過一個比 + load 方法執行還要早的時機,對定義了 load 方法的類進行 Hook,對 load 方法的前後插入統計耗時的處理

mach-o__DATA,__objc_nlclslist__DATA,__objc_nlcatlist 這兩個 Section 分別儲存了 non lazy classnon lazy cateogry,定義 load 方法的類和分類,通過 getsectbynamefromheader 把定義了 load 方法的類和分類獲取出來進行 Hook,用最早載入的動態庫裡定義類的 load 方法,比主二進位制的 load 方法呼叫還要早。通過在動態庫中 load 方法這個時機進行 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,"__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 和對應 category 的 MethodList 進行 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 的方法執行過程會呼叫到 objc_msgSend,所以對其進行 Hook,能統計到 OC 方法的耗時,objc_msgSend 是變參函式,通過儲存現場,保持引數不變,呼叫原來的 objc_msgSend,參考 InspectiveC 實現

static void replacementObjc_msgSend() {
  __asm__ volatile (
      // 儲存 q0-q7 
      "stp q6,q7,[sp,#-32]!\n"
      "stp q4,q5,#-32]!\n"
      "stp q2,q3,#-32]!\n"
      "stp q0,q1,#-32]!\n"
      // 儲存 x0-x8,lr
      "stp x8,lr,#-16]!\n"
      "stp x6,x7,#-16]!\n"
      "stp x4,x5,#-16]!\n"
      "stp x2,x3,#-16]!\n"
      "stp x0,x1,#-16]!\n"
      "mov x2,x1\n"
      "mov x1,lr\n"
      "mov x3,sp\n"
      // 呼叫 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,[sp],#16\n"
      "ldp x2,#16\n"
      "ldp x4,#16\n"
      "ldp x6,#16\n"
      "ldp x8,#16\n"
      // 讀取 q0-q7
      "ldp q0,#32\n"
      "ldp q2,#32\n"
      "ldp q4,#32\n"
      "ldp q6,#32\n"
      "b.eq Lpassthrough\n"
      // blr 呼叫原始 objc_msgSend
      "blr x9\n"
      // 儲存 x0-x9
      "stp x0,#-16]!\n"
      "stp x8,x9,#-16]!\n"
      // 儲存 q0-q7
      "stp q0,#-32]!\n"
      "stp q6,#-32]!\n"
      // 呼叫 postObjc_msgSend hook.
      "bl __Z16postObjc_msgSendv\n"
      "mov lr,x0\n"
      // 讀取 q0-q7
      "ldp q6,#32\n"
      "ldp q0,#32\n"
       // 讀取 x0-x9
      "ldp x8,#16\n"
      "ldp x0,#16\n"
      "ret\n"
      "Lpassthrough:\n"
      "br x9"
    );
}
複製程式碼

C++ static constructors 、 attribute((constructor))、 _modinit_func section 中的函式

__mod_init_func 儲存初始化相關的函式地址,__mod_init_func 是在 DATA 段,Pointer 指向的區域是 TEXT 段,專案中的這類函式很多,這些函式會在 Pre-Main 階段執行,但是基本都不耗時,通過 getsectiondata(machHeader,"__DATA","__mod_init_func",&size),讀取函式指標,用 hook 函式指標替換原來的函式指標,把原來的函式地址記錄在全域性陣列中,hook 函式從陣列中根據 index 呼叫本該執行的函式

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,"__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;
    }
}
複製程式碼

庫耗時統計

LinkMap 中取到 Object files 部分,獲取到 libAFNetworking.a(AFHTTPSessionManager.o) 部分,然後解析成 AFNetworkingAFHTTPSessionManager,通過這種方式能粗略的統計到是那個庫,庫裡有包含的類,進而統計出那個方法屬於該庫,這個方法統計不到類的命名不對應檔名,或常見 Category 那些情況等

.../Products/Debug-iphoneos/AFNetworking/libAFNetworking.a(AFHTTPSessionManager.o)
複製程式碼

內容同事和我共同完成