iOS開發進階:啓動優化及二進制重排初探

應用的(冷)啓動過程主要分爲兩個階段:pre-main階段、從main到首屏加載完成的階段。

一、pre-main階段優化

這個階段主要是做動態庫的加載、地址的綁定、OC註冊和相關初始化的工作。我們可以在scheme->Arguments->Environment Variables中添加環境變量 DYLD_PRINT_STATISTICS,並設置爲YES,再次運行打印啓動時各個操作的時間:

  • dylib loading time:動態庫的加載,包括系統動態庫和三方動態庫,這個部分的優化空間並不大,可以減少三方庫的使用,或者合併自己的三方庫。
  • rebase/binding time:地址綁定
  • ObjC setup time:OC註冊,讀取二進制data的內容,找到OC相關的信息;完成OC類名與類的關係映射、維護sel-imp的關係映射,protocol/category等插入宿主類列表中等。
  • initializer time:load方法的調用和attribute((constructor))函數的調用。

關於rebase/binding

在計算器發展的早期,也就是物理內存階段,操作系統會默認將整個應用的數據一次性加載進物理內存(內存條),應用內訪問到到地址都是物理地址。這樣做應用確實加載出來了,但是也存在一些弊端:1.每個應用的數據都需要佔用內存空間,如果應用不退出,這一塊內存就會被一直佔用着,那麼開啓的程序多了之後內存就不夠用了,只能殺掉之前的程序在開啓新程序。2.由於使用的是物理內存地址,那麼應用內可以訪問全局內存中的其他數據,非常不安全。

爲了解決物理內存時代內存不夠用和不安全的問題,科學家研究出了虛擬內存方案。

  • 應用程序內訪問的都是虛擬內存地址,操作系統使用硬件管理單元(MMU)來翻譯地址,將虛擬內存地址映射到物理內存地址。物理地址由操作系統來管理。
  • 內存分頁(PAGE),應用的數據是一頁一頁加載到內存中的,沒有使用到數據是不會被加載的。如果使用到的數據頁沒有被加載到內存叫做缺頁異常(Page Fault)/缺頁中斷,操作系統會看是否有空閒位置,結合頁面置換算法將數據頁插入到空閒位置或者覆蓋掉不活躍的內存,操作系統處理缺頁異常的速度非常快,都收毫秒級別的。這樣以來,提供給應用的虛擬內存的地址是連續的,而物理內存中針對單個應用的數據就是不連續的。
  • 內存分頁中iOS App PAGE的大小是16K(從iPhone 6s開始),Mac App PAGE的大小是4K。
  • 每個應用的虛擬地址空間是8G,可以可以使用的是4G空間。如果繼續向上訪問,訪問的數據搜是nil(既沒有物理物理內存與之對應)。

虛擬內存的技術方案解決了內存物理內存時代的的問題:

  • 關於內存不夠用的問題:操作系統通過頁面置換算法將不活躍的內存覆蓋掉,內存可以被反覆使用,解決了內存不夠用的問題。
  • 關於不安全問題:應用只能訪問自己進程空間內的地址,進程之間安全隔離。

關於rebase:爲了提高安全性,又引入了地址空間佈局隨機化(ASLR)技術,每次應用啓動都是生成一個隨機的初始地址,代碼在虛擬內存中的地址是ASLR+Offset,其中Offset在編譯完成之後就固定了。程序啓動時ASLR+Offset的過程就叫重定位(rebase)。

關於binding:應用程序會訪問外部外部,但是外部代碼並沒有在我們的二進制文件中,所以需要根據符號找到對應的地址,並且將地址跟符號綁定在一起。iOS的綁定是懶加載綁定,加載動態庫的時候不會立即綁定,只有用到的時候纔去查找找並綁定,這個過程通過libStsyem中的dyld_stub_binder完成,一個符號只用查找並綁定一次,後面再用到的時候就用已經綁定的地址。整個的過程叫做綁定(binding)。

進程間通信:操作系統提供專門的接口用於跨進程通信。

基於以上虛擬內存和內存分頁懶加載的技術特點,儘管操作系統處理一次缺頁異常是幾毫秒,如果在某一瞬間發生大量的缺頁異常,比如幾百、幾千缺頁異常,積少成多也會消耗不少的時間。而大量缺頁異常最可能發生的時機就是應用冷啓動的瞬間。

用一個工程做測試:
測試方式:Xcode->Product->Profile->System Trace。用System Trace這個工具做測試,進入這個工具後,點擊左上角運行按鈕,運行結束後再點擊一下,然後在下方列表中找到自己的應用,點開之後找到Main Thread選中,下方再切換到Summary:Virtual Memory,展開All


從上圖可以看到這次啓動共發生Page Fault次數爲504次,耗時104.27ms,平均一次206.88us。多次測試結果會存在差異,冷啓動過程測試才具備一定參考性。

基於上面存在的問題,如果啓動過程中發生Page Fault的次數越少,則也相應的越快,如果我們將啓動的時候調用的函數方法等放在前面幾個頁中就可以相應的檢查page fault的發生。但是dyld加載文件的順序默認是跟編譯度讀取文件的事情一致,有沒有一種方案可以干預編譯器讀取代碼的順序呢?解決這個問題需要用到二進制重排技術。

二進制重排

1.查看Link-Map.text文件
在Build-Settings的Path to Link File中輸入link-map的地址$(TARGET_TEMP_DIR)/$(PRODUCT_NAME)-LinkMap-$(CURRENT_VARIANT)-$(CURRENT_ARCH).txt,然後開啓write Link Map File爲YES。
然後運行代碼,再根據上面的路徑找到剛纔的link-map的文件:


如上可以看到是按照Compile Sources中順序讀取的:

文件內部按照代碼的順序從上往下讀取函數定義。

2.認爲干預編譯器讀取代碼的順序
定義一個oc.order文件放在根目錄,文件內容

_unknown_method_1
_main
-[AppDelegate application:didFinishLaunchingWithOptions:]
-[ViewController initialize]
_unknown_method_2

再在Build-Setting的Linking -> Order File中添加剛纔的文件./oc.order,再次運行代碼並查看link map文件:


這次的結果顯示我們指定的幾個方法函數都在最前面了,並且我們定義的兩個不存在的函數_unknown_method_1_unknown_method_2也沒有報錯。

這個操作就實現了二進制重排,如果能將啓動的時候調用的函數都手機起來寫入這個.order文件,那麼就達到了啓動優化的目的了。

對於一個小的小項目,我們可以根據這個思路簡單玩一下搞明白它的原理,也沒優化的必要。但是對於微信或者抖音這樣一個大的項目這種優化結果就是立竿見影的。蘋果爲我們提供了相關優化的方案和接口,即clang插妝。

clang插樁

clang插樁相關資料可以這個文檔,點擊獲取

我們找到其中的Tracing PCs部分,閱讀文檔可以得知,我們需要在編譯選項裏邊進行設置:Build Setting -> Apple Clang - Custom Compiler Flags -> Other C Flags添加-fsanitize-coverage=trace-pc-guard。並且實現兩個函數:

//引入頭文件
#include <stdint.h>
#include <stdio.h>
#include <sanitizer/coverage_interface.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.
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;  // Duplicate the guard check.

  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
  // This function is a part of the sanitizer run-time.
  // To use it, link with AddressSanitizer or other sanitizer.
  //__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

再次運行代碼發現__sanitizer_cov_trace_pc_guard被多次調用了,後續每次調用函數都會來到這裏。這裏執行完畢之後相應的函數纔會真正的執行。是的,所有的方法都被hook住了,也就是相當於在原有的每個方法的邊緣插入了__sanitizer_cov_trace_pc_guard(...)的調用。

接下來的重要就在__sanitizer_cov_trace_pc_guard__builtin_return_address(0)函數。該函數返回了被調用函數的堆棧信息,我們可以通過對戰信息還原函數的信息。代碼如下:

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;
    void *PC = __builtin_return_address(0);
    Dl_info info;
    dladdr(PC, &info);
    printf("fname=%s\nfbase=%p\nsname=%s\nsaddr=%p\n\n\n\n", info.dli_fname, info.dli_fbase, info.dli_sname, info.dli_saddr);
}

重新運行一下代碼,得到如下的信息:



發現了新大陸,這裏可以看到c函數、類方法、實例方法、block都被攔截下來了。
買下來需要做的事情就是將這些數據收集起來,然後去重、在根據.order所需要的格式做一些格式化,生成.order文件就可以使用了。

還有一點需要注意__sanitizer_cov_trace_pc_guard的回調可能在主線程,也可能在子線程,所以這裏要想完整的保證調用的順序需要使用原子特性保證線程安全。

  • 引入頭文件 #import <libkern/OSAtomic.h>
  • 定義一個新的結構體:
typedef struct{
    void *pc;
    void *next;
}SYNode;
  • 定義一個鏈表static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
  • 修改__sanitizer_cov_trace_pc_guard()
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;
    void *PC = __builtin_return_address(0);
    SYNode *node = malloc(sizeof(SYNode));
    *node = (SYNode){PC, NULL};
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}
  • 啓動完畢後觸發數據的收集整理:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSMutableArray <NSString *>*array = [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);
        if([name hasPrefix:@"+["] || [name hasPrefix:@"-["]){
            //OC方法
            [array addObject:name];
        }
        else {
            //函數
            [array addObject:[@"_" stringByAppendingString:name]];
        }
    }
    NSMutableArray *funcs = [NSMutableArray array];
    for(NSString *name in array) {
        if(![funcs containsObject:name]){
            [funcs addObject:name];
        }
    }
    [funcs removeObject:@"-[ViewController touchesBegan:withEvent:]"];
    NSLog(@"%@", funcs);
    
    NSData *data = [[funcs componentsJoinedByString:@"\n"]  dataUsingEncoding:NSUTF8StringEncoding];
    NSString *file = [NSTemporaryDirectory() stringByAppendingPathComponent:@"nx.order"];
    [[NSFileManager defaultManager] createFileAtPath:file contents:data attributes:nil];
}

這裏要注意 這裏的while循環會導致-[ViewController touchesBegan:withEvent:]被不斷調用,造成死循環。修改Other C Flages-fsanitize-coverage=func,trace-pc-guard

運行如上代碼,點擊一下屏幕,然後找到nx.order文件:

一鍵生成排序函數,然後把文件拷貝出來放在工程裏邊,在Build Setting裏邊配置Order File即可。

swift的怎麼捕獲?
Other Swift Flags中配置:-sanitize=trace-pc-guard-sanitize=undefined

上線的時候怎麼做?
上線之前先把Other C Flags、Other Swift Flags設置後生成並導出排序文件,然後清理掉Other C Flags、Other Swift Flags。並將導出的文件配置到Order File中進行打包。
Link Map文件的路徑去掉,Link Map的開關關掉。

二、首屏加載優化

如上我們通過啓動過程中dyld做的事情來優化啓動時間,這些優化都是毫秒級別的,能優化的空間也是有極限的。用戶可感知的更快是指從點擊圖標到應用的首頁展示出來這個過程快,所以首屏的加載優化也是相當重要。
這部分大多跟業務強相關,需要具體情況具體分析。下面將結合我自己的理解與實踐經歷做如下梳理,僅做參考。

1.從本地緩存中讀取首頁的數據
  • 如果首頁的內容是非UGC的,或者說實時性不是特別強的,我們可以將成功請求的數據緩存到本地磁盤,下一次全新啓動可以優先從本地磁盤讀取數據並渲染到屏幕上,通過通過接口獲取遠程最新數據(並存儲到磁盤),如果本次數據與已經渲染的數據不一致,則刷新用戶界面即可。
2.拆分接口請求或合併接口請求:
  • 如果首頁的數據是分多個模塊(或微服務)且模塊之間是弱相關的,沒有依賴關係的,這樣把所有數據放在一個接口,勢必會增加後端查詢量,導致接口響應過慢。對於這種情況,我們可以按照邏輯劃分,在子線程發出請求,數據響應後在子線程完成相關預處理,再回到主線程更新UI;哪個請求先回來就先渲染哪個模塊的數據。
  • 如果首頁的數據相關性很大,或有依賴關係,則可以將有依賴的接口合併爲一個接口,以減少接口請求的實踐,當然這需要後端的配合。
3.使用骨架屏等方案
  • 在網絡請求過程中展示骨架屏會給用戶一種數據即將展示出來的感覺。
4.延遲初始化三方服務
  • 第三方服務SDK可以在子線程進行初始化的,可以放在子線程完成,必須放在主線程初始化的,可以延遲幾秒再初始化。
5.延遲請求相關接口
  • 很多應用首屏都會做版本升級提醒或者拉取全局配置信息,如果是單獨的接口,則可以延遲請求數據。全局配置信息可以在每次請求到遠程最新數據時緩存到本地磁盤,應用啓動後優先讀取本地的數據,並且延遲請求遠程數據。(打包上架時可以獲取一份最新的數據放在工程中,以解決首次沒有緩存的問題)。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章