iOS App 的一種規範啓動項執行流程方案

目錄

前言
現狀
    一. 目前的 App 啓動項執行流程
    二. 存在的問題
優化方案
    一. demo
    二. 基本思想
    三. 技術原理
    四. 技術實現
        1. attribute
        2. 編譯期寫入數據
        3. 運行時讀出數據
        4. 讀出數據要注意的地方(use_frameworks!)
    五. 總結
後記ios

前言

隨着業務的不斷髮展,咱們的 App 啓動要作的事情愈來愈多,啓動時間也隨之變長,維護成本愈來愈高,調試不方便,須要一套更好的方案來管理啓動項。咱們這裏說的啓動項是指 App 啓動過程當中須要被完成的某項工做,好比某個 SDK 的初始化、某個功能的預加載等。git

現狀

一. 目前的 App 啓動項執行流程

如今咱們 App 跟大多數 App 同樣,啓動的時候都是集中化管理啓動項的,以下圖,將一堆啓動項寫在一塊兒,並且分散在不少文件中。github

二. 存在的問題

1. + (void)load 方法

目前不少模塊裏面都有使用 load 方法,用於在 main 函數執行前作一些配置,可是 load 方法的使用會帶來一些弊端,主要有以下幾點bash

  • 沒法 Patch
  • 不能審計耗時
  • 調用 UIKit 相關方法會致使部分類提前初始化
  • main 函數以前執行,並且是主線程徹底阻塞式執行,極大增長啓動時間

通過排查,發現其實不少 load 函數裏面作的事情其實能夠延後執行,放到首頁渲染完成之後再去作,好比一些路由註冊,還有非首頁相關業務裏面 load 部分代碼也能夠日後延遲執行。網絡

Xcode 提供了一個方法能夠看到 main 以前各個階段的時間消耗,只須要在 Edit scheme -> Run -> Arguments 中將環境變量 DYLD_PRINT_STATISTICS 設爲1。還有一個方法獲取更詳細的時間,須要將環境變量 DYLD_PRINT_STATISTICS_DETAILS 設爲1便可。app

咱們的 App 啓動時間分佈以下圖,能夠看出 load 都1.1秒了。函數

2. 維護困難

  • 全部啓動項都要預先寫在好幾個文件裏面,查看整個流程須要在幾個文件裏面來回切換,這種中心化的寫法會致使代碼臃腫,難以閱讀和維護,新人若是要從頭開始瞭解啓動流程,很是費勁。
  • 啓動流程沒有明肯定義各個階段,新的啓動項缺少添加範式,修改風險大,若是要加一個啓動項,不一樣的人爲了保險都往前面加,但願保證本身模塊能儘早執行,那些真正須要提早執行的啓動項之後也會變成後面執行了。
  • 啓動項都寫在一個文件裏面,你們都來修改這個文件,容易出現衝突,誤操做,尤爲是在大型團隊裏面。之後啓動項不要了還要手動去刪除,這個容易遺漏。
  • 不能精細化管理,一個模塊啓動可能有兩三個任務,可是隻有一個須要在 main 以前執行,其餘能夠日後放,這種分階段的初始化很差實現。

優化方案

一. demo

能夠先下載 demo 看看,注意 demo 中 Podfile 是使用動態庫集成方式哦。 github.com/guohongwei7…性能

二. 基本思想

咱們但願的是啓動項維護方式可插拔,啓動項、業務模塊之間不耦合,咱們稱之爲啓動項的自注冊:一個啓動項定義在子業務模塊內部,被封裝成一個方法,而且自聲明啓動階段,不須要一箇中心文件集中設置全部啓動項。測試

三. 技術原理

那麼如何給一個啓動項聲明聲明啓動階段呢?又如何在正確的時機觸發啓動項的執行呢?在代碼上一個啓動項最終都會對應到一個函數的執行,因此在運行時只要能得到函數的指針,就能夠觸發啓動項。優化方案的核心原理就是在編譯時把數據(如函數指針)寫入到可執行文件的__GHW 段中,運行時再從 __GHW 段取出數據進行相應操做,即調用函數。優化

程序源代碼被編譯以後主要分爲兩個段:程序指令和程序數據。代碼段屬於程序指令,data 和 .bss 節屬於數據段。

Mach-O 的組成結構如上圖所示,包含了 Header、Load commands、Data(包含 Segment 的具體數據),咱們平時瞭解到的可執行文件、庫文件、Dsym 文件、動態庫、動態連接器都是這種格式的。

四. 技術實現

1. attribute

Clang 提供了不少的編譯函數,它們能夠完成不一樣的功能,其中一項就是 section() 函數,section() 函數提供了二進制段的讀寫能力,它能夠將一些編譯期就能夠肯定的常量寫入數據段。在具體的實現中,主要分爲編譯期和運行時兩部分。在編譯期,編譯器會將標記了 attribute((section())) 的數據寫到指定的數據段中,例如寫一個{key(key表明不一樣的啓動階段), *pointer} 對到數據段。到運行時,在合適的時間節點,在根據 key 讀取出函數指針,完成函數的調用。

Clang Attributes 是 Clang 提供的一種源碼註解,方便開發者向編譯器表達某種要求,參與控制如 Static Analyzer、Name Mangling、Code Generation 等過程,通常以 attribute_(xxx) 的形式出如今代碼中;爲方便使用,一些經常使用屬性也被 Cocoa 定義成宏,好比在系統頭文件中常常出現的 NS_CLASS_AVAILABLE_IOS(9_0) 就是 attribute(availability(...)) 這個屬性的簡單寫法。編譯器提供了咱們一種 attribute((section("xxx段,xxx節")的方式讓咱們將一個指定的數據儲存到咱們須要的節當中。

used

used的做用是告訴編譯器,我聲明的這個符號是須要保留的。被used修飾之後,意味着即便函數沒有被引用,在Release下也不會被優化。若是不加這個修飾,那麼Release環境連接器會去掉沒有被引用的段。

section

一般狀況下,編譯器會將對象放置於DATA段的data或者bss節中。可是,有時咱們須要將數據放置於特殊的節中,此時section能夠達到目的。

constructor

constructor:顧名思義,加上這個屬性的函數會在可執行文件(或 shared library)load時被調用,能夠理解爲在 main() 函數調用前執行。

constructor 和 +load 都是在 main 函數執行前調用,但 +load 比 constructor 更加早一點,由於 dyld(動態連接器,程序的最初起點)在加載 image(能夠理解成 Mach-O 文件)時會先通知 objc runtime 去加載其中全部的類,每加載一個類時,它的 +load 隨之調用,所有加載完成後,dyld 纔會調用這個 image 中全部的 constructor 方法。因此 constructor 是一個幹壞事的絕佳時機:

更多相關知識能夠參考

liumh.com/2018/08/18/…
www.jianshu.com/p/965f6f903…
https://nshipster.com/attribute/

2. 編譯期寫入數據

首先咱們定義函數存儲的結構體,以下,function 是函數指針,指向咱們要寫入的函數,key 爲附帶的信息,後期能夠擴展,好比執行優先級,優先級高的函數優先執行。

struct GHW_Function {
    char *key;
    void (*function)(void);
};
複製代碼

定義函數 GHWStage_A ,裏面是須要在 Stage_A 階段要執行的任務。

static void _GHWStage_A () {
    printf("ModuleA:Stage_A");

}
複製代碼

將包含函數指針的結構體寫入到咱們指定的數據區指定的段 __GHW, 指定的節 ___Stage_A,方法以下

__attribute__((used, section("__GHW,__Stage_A"))) \
static const struct GHW_Function __FStage_A = (struct GHW_Function){(char *)(&("Stage_A")), (void *)(&_GHWStage_A)}; \
複製代碼

上面步驟看起來很煩,並且代碼晦澀難懂,因此要使用宏來定義一下,以下

#define GHW_FUNCTION_EXPORT(key) \
static void _GHW##key(void); \
__attribute__((used, section("__GHW,__"#key""))) \
static const struct GHW_Function __F##key = (struct GHW_Function){(char *)(&#key), (void *)(&_GHW##key)}; \
static void _GHW##key \
複製代碼

而後咱們將函數寫入數據區方式變得很簡單了,仍是上面的代碼,寫入指定的段 __GHW, 指定的節 ___Stage_A,方法以下

GHW_FUNCTION_EXPORT(Stage_A)() {
    printf("ModuleA:Stage_A");
}
複製代碼

如今能夠很是方便簡單了。

將工程打包,而後用 MachOView 打開 Mach-O 文件,能夠看出數據寫入到相關數據區了,以下

3. 運行時讀出數據

啓動項也須要根據所完成的任務被分類,有些啓動項是須要剛啓動就執行的操做,如 Crash 監控、統計上報等,不然會致使信息收集的缺失;有些啓動項須要在較早的時間節點完成,例如一些提供用戶信息的 SDK、定位功能的初始化、網絡初始化等;有些啓動項則能夠被延遲執行,如一些自定義配置,一些業務服務的調用、支付 SDK、地圖 SDK 等。咱們所作的分階段啓動,首先就是把啓動流程合理地劃分爲若干個啓動階段,而後依據每一個啓動項所作的事情的優先級把它們分配到相應的啓動階段,優先級高的放在靠前的階段,優先級低的放在靠後的階段。

若是要覆蓋到 main 以前的階段,以前咱們是使用 load 方法,如今使用 attribute 的 constructor 屬性也能夠實現這個效果,並且更方便,優點以下

  • 全部 Class 都已經加載完成
  • 不用像 load 還得掛在在一個 Class 中

相關代碼以下

__attribute__((constructor))
void premain() {
    [[GHWExport sharedInstance] executeArrayForKey:@"pre_main"];
}
複製代碼

表示在 main 以前去獲取數據區 pre_main 節的函數指針執行。
app willFinish 和 didFinish 階段也能夠執行相關代碼,以下

- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [[GHWExport sharedInstance] executeArrayForKey:@"Stage_A"];
    return YES;
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [GHWExport.sharedInstance executeArrayForKey:@"Stage_B"];
}
複製代碼

4. 讀出數據要注意的地方(use_frameworks!)

實際在讀的時候要根據咱們 podfile 裏面集成方式而有所區別,若是 Podfile 裏面有 use_frameworks! 則是動態庫集成方式,若是註釋掉的話就是靜態庫的集成方式,靜態庫集成方式比較好辦,由於只有一個主二進制文件,寫入的數據區也是寫到這個裏面,只須要從這一個二進制文件裏面讀取就能夠了。代碼以下

void GHWExecuteFunction(char *key, char *appName) {
    Dl_info info;
    dladdr((const void *)&GHWExecuteFunction, &info);

    const GHWExportValue mach_header = (GHWExportValue)info.dli_fbase;
    const GHWExportSection *section = GHWGetSectByNameFromHeader((void *)mach_header, "__GHW", key);
    if (section == NULL) return;

    int addrOffset = sizeof(struct GHW_Function);
    for (GHWExportValue addr = section->offset;
         addr < section->offset + section->size;
         addr += addrOffset) {

        struct GHW_Function entry = *(struct GHW_Function *)(mach_header + addr);
        entry.function();
    }
}
複製代碼

可是若是是動態庫集成的各個組件,那麼打成包之後各個組件最後跟主二進制文件是分開的,各個組件寫入的數據區跟主二進制不是在一塊兒的,而是寫入到本身二進制文件裏面相應的數據區,所以咱們在讀的時候須要遍歷全部動態庫。咱們 App 啓動時會加載全部動態庫,一共有 569個,其中 83 個是 Podfile 裏面集成的,其餘都是系統庫。這些庫的路徑也有區別,Podfile 集成進去的庫路徑相似下面這樣

/private/var/containers/Bundle/Application/70C36D61-CD7A-49F7-A690-0C8B3D36C36A/HelloTrip.app/Frameworks/AFNetworking.framework/AFNetworking
/private/var/containers/Bundle/Application/70C36D61-CD7A-49F7-A690-0C8B3D36C36A/HelloTrip.app/Frameworks/APAddressBook.framework/APAddressBook
/private/var/containers/Bundle/Application/70C36D61-CD7A-49F7-A690-0C8B3D36C36A/HelloTrip.app/Frameworks/AliyunOSSiOS.framework/AliyunOSSiOS
複製代碼

系統庫相似下面這樣

/System/Library/Frameworks/AddressBookUI.framework/AddressBookUI
/System/Library/Frameworks/AVFoundation.framework/AVFoundation
/System/Library/Frameworks/AssetsLibrary.framework/AssetsLibrary
/usr/lib/libresolv.9.dylib
複製代碼

所以根據路徑裏面是否包含 /HelloTrip.app/ 來判斷是否 Podfile 集成的庫,是的話就去找對應的數據區。

通過屢次測試,咱們 App 在沒有過濾路徑狀況下遍歷動態庫上的耗時以下

0.002198934555053711
0.002250075340270996
0.003002047538757324
0.006783008575439453
0.002267003059387207
0.003368020057678223
0.003902077674865723

有過濾路徑狀況下遍歷時間以下:

0.0004119873046875
0.0007159709930419922
0.0004429817199707031
0.0004270076751708984
0.0004940032958984375
0.0004789829254150391
0.0004340410232543945
0.0004389286041259766

可見過濾狀況下遍歷一次全部動態庫不到一毫秒,徹底能夠接受。所以若是 Podfile 中開啓了 use_frameworks! ,使用動態庫集成方式,那麼讀取 section 數據具體代碼以下

void GHWExecuteFunction(char *key, char *appName) {
    int num = _dyld_image_count();
    for (int i = 0; i < num; i++) {
        const char *name = _dyld_get_image_name(i);
        if (strstr(name, appName) == NULL) {
            continue;
        }
        const struct mach_header *header = _dyld_get_image_header(i);
//        printf("%d name: %s\n", i, name);

        Dl_info info;
        dladdr(header, &info);

        const GHWExportValue dliFbase = (GHWExportValue)info.dli_fbase;
        const GHWExportSection *section = GHWGetSectByNameFromHeader(header, "__GHW", key);
        if (section == NULL) continue;
        int addrOffset = sizeof(struct GHW_Function);
        for (GHWExportValue addr = section->offset;
             addr < section->offset + section->size;
             addr += addrOffset) {

            struct GHW_Function entry = *(struct GHW_Function *)(dliFbase + addr);
            entry.function();
        }
    }
}
複製代碼

五. 總結

在啓動流程中,在啓動階段 Stage_A 觸發全部註冊到 Stage_A 時間節點的啓動項,經過對這種方式,幾乎沒有任何額外的輔助代碼,咱們用一種很簡潔的方式完成了啓動項的自注冊。若是要查看 Stage_A 階段的啓動項,直接在項目裏面搜索便可,方便快捷,不會遺漏。

後續須要肯定啓動項的添加 & 維護規範,啓動項分類原則,優先級和啓動階段,目的是管控性能問題增量,保證優化成果。

後記

歡迎提一塊兒探討技術問題,以爲能夠給我點個 star,謝謝。
微博:黑化肥發灰11
簡書地址:www.jianshu.com/u/fb5591dbd…
掘金地址:juejin.im/user/595b50…

相關文章
相關標籤/搜索