iOS App啟動優(yōu)化(三)—— 自己做一個工具監(jiān)控App的啟動耗時

級別:★★☆☆☆
標簽:「iOS」「hook」「耗時監(jiān)控」「objc_msgSend」
作者: 647
審校: QiShare團隊


前言:
最近,小編在看戴銘老師的技術(shù)分享各吨,感覺收獲很多接癌∶列澹基于最近的學(xué)習(xí)典唇,小編總結(jié)了一些App啟動優(yōu)化上的知識點,并計劃落地一系列App啟動優(yōu)化的文章焕盟。

目錄如下:
iOS App啟動優(yōu)化(一)—— 了解App的啟動流程
iOS App啟動優(yōu)化(二)—— 使用“Time Profiler”工具監(jiān)控App的啟動耗時
iOS App啟動優(yōu)化(三)—— 自己做一個工具監(jiān)控App的啟動耗時


前兩篇介紹了《iOS App的啟動流程》秋秤、《Time Profiler工具的使用》。
本篇將介紹通過hook底層objc_msgSend來掌握所有Objective-C方法的執(zhí)行耗時脚翘。

一灼卢、什么是hook?

定義:hook是指在原有方法開始執(zhí)行時来农,換成你指定的方法鞋真。或在原有方法的執(zhí)行前后沃于,添加執(zhí)行你指定的方法灿巧。從而達到改變指定方法的目的。

例如:

  • 使用runtimeMethod Swizzle揽涮。
  • 使用Facebook所開源的fishhook框架。

前者是ObjC運行時提供的“方法交換”能力饿肺。
后者是對Mach-O二進制文件的符號進行動態(tài)的“重新綁定”蒋困,已達到方法交換的目的。

問題1: fishhook的大致實現(xiàn)思路是什么敬辣?

《iOS App啟動優(yōu)化(一)—— 了解App的啟動流程》中我們提到雪标,動態(tài)鏈接器dyld會根據(jù)Mach-O二進制可執(zhí)行文件的符號表來綁定符號零院。而通過符號表及符號名就可以知道指針訪問的地址,再通過更改指針訪問的地址就能替換指定的方法實現(xiàn)了村刨。

問題2:為什么hook了objc_msgSend就可以掌握所有objc方法的耗時告抄?

因為objc_msgSend是所有Objective-C方法調(diào)用的必經(jīng)之路,所有的Objective-C方法都會調(diào)用到運行時底層的objc_msgSend方法嵌牺。所以只要我們可以hook objc_msgSend打洼,我們就可以掌握所有objc方法的耗時。(更多詳情可看我之前寫的《iOS 編寫高質(zhì)量Objective-C代碼(二)》的第六點 —— 理解objc_msgSend(對象的消息傳遞機制)

另外逆粹,objc_msgSend本身是用匯編語言寫的募疮,蘋果已經(jīng)開源了objc_msgSend的源碼∑У可在官網(wǎng)上下載查看:objc_msgSend源碼阿浓。

二、如何hook底層objc_msgSend蹋绽?

第一階段:與fishhook框架類似芭毙,我們先要擁有hook的能力。
  • 首先卸耘,設(shè)計兩個結(jié)構(gòu)體:
    一個是用來記錄符號的結(jié)構(gòu)體退敦,一個是用來記錄符號表的鏈表。
struct rebinding {
    const char *name;
    void *replacement;
    void **replaced;
};

struct rebindings_entry {
    struct rebinding *rebindings;
    size_t rebindings_nel;
    struct rebindings_entry *next;
};
  • 其次鹊奖,遍歷動態(tài)鏈接器dyld內(nèi)所有的image苛聘,取出其中的headerslide
    以便我們接下來拿到符號表忠聚。
static int fish_rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel) {
    int retval = prepend_rebindings(&_rebindings_head, rebindings, rebindings_nel);
    if (retval < 0) {
        return retval;
    }
    // If this was the first call, register callback for image additions (which is also invoked for
    // existing images, otherwise, just run on existing images
    //首先是遍歷 dyld 里的所有的 image设哗,取出 image header 和 slide。注意第一次調(diào)用時主要注冊 callback
    if (!_rebindings_head->next) {
        _dyld_register_func_for_add_image(_rebind_symbols_for_image);
    } else {
        uint32_t c = _dyld_image_count();
        // 遍歷所有dyld的image
        for (uint32_t i = 0; i < c; i++) {
            _rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i)); // 讀取image內(nèi)的header和slider
        }
    }
    return retval;
}
  • 上一步两蟀,我們在dyld內(nèi)拿到了所有image网梢。
    接下來,我們從image內(nèi)找到符號表內(nèi)相關(guān)的segment_command_t赂毯,遍歷符號表找到所要替換的segname战虏,再進行下一步方法替換。方法實現(xiàn)如下:
static void rebind_symbols_for_image(struct rebindings_entry *rebindings,
                                     const struct mach_header *header,
                                     intptr_t slide) {
    Dl_info info;
    if (dladdr(header, &info) == 0) {
        return;
    }
    
    // 找到符號表相關(guān)的command党涕,包括 linkedit_segment command烦感、symtab command 和 dysymtab command。
    segment_command_t *cur_seg_cmd;
    segment_command_t *linkedit_segment = NULL;
    struct symtab_command* symtab_cmd = NULL;
    struct dysymtab_command* dysymtab_cmd = NULL;
    
    uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
        cur_seg_cmd = (segment_command_t *)cur;
        if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
            if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
                linkedit_segment = cur_seg_cmd;
            }
        } else if (cur_seg_cmd->cmd == LC_SYMTAB) {
            symtab_cmd = (struct symtab_command*)cur_seg_cmd;
        } else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
            dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
        }
    }
    
    if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment ||
        !dysymtab_cmd->nindirectsyms) {
        return;
    }

    // 獲得base符號表以及對應(yīng)地址
    uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
    nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
    char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
    
    // 獲得indirect符號表
    uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
    
    cur = (uintptr_t)header + sizeof(mach_header_t);
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
        cur_seg_cmd = (segment_command_t *)cur;
        if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
            if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
                strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
                continue;
            }
            for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
                section_t *sect =
                (section_t *)(cur + sizeof(segment_command_t)) + j;
                if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
                    perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
                }
                if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
                    perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
                }
            }
        }
    }
}
  • 最后膛堤,通過符號表以及我們所要替換的方法的實現(xiàn)手趣,進行指針地址替換。
    這是相關(guān)方法實現(xiàn):
static void perform_rebinding_with_section(struct rebindings_entry *rebindings,
                                           section_t *section,
                                           intptr_t slide,
                                           nlist_t *symtab,
                                           char *strtab,
                                           uint32_t *indirect_symtab) {
    uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
    void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
    for (uint i = 0; i < section->size / sizeof(void *); i++) {
        uint32_t symtab_index = indirect_symbol_indices[i];
        if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL_LOCAL ||
            symtab_index == (INDIRECT_SYMBOL_LOCAL   | INDIRECT_SYMBOL_ABS)) {
            continue;
        }
        uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
        char *symbol_name = strtab + strtab_offset;
        if (strnlen(symbol_name, 2) < 2) {
            continue;
        }
        struct rebindings_entry *cur = rebindings;
        while (cur) {
            for (uint j = 0; j < cur->rebindings_nel; j++) {
                if (strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
                    if (cur->rebindings[j].replaced != NULL &&
                        indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
                        *(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
                    }
                    indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
                    goto symbol_loop;
                }
            }
            cur = cur->next;
        }
    symbol_loop:;
    }
}

到這里肥荔,通過調(diào)用下面的方法绿渣,我們就擁有了hook的基本能力朝群。

static int fish_rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);
第二階段:通過匯編語言編寫出我們的hook_objc_msgSend方法

因為objc_msgSend是通過匯編語言寫的,我們想要替換objc_msgSend方法還需要從匯編語言下手中符。

既然我們要做一個監(jiān)控方法耗時的工具姜胖。這時想想我們的目的是什么?

我們的目的是:通過hookobjc_msgSend方法淀散,在objc_msgSend方法前調(diào)用打點計時操作右莱,在objc_msgSend方法調(diào)用后結(jié)束打點和計時操作。通過計算時間差吧凉,我們就能精準的拿到方法調(diào)用的時長隧出。

因此,我們要在原有的objc_msgSend方法的調(diào)用前后需要加上before_objc_msgSendafter_objc_msgSend方法阀捅,以便我們后期的打點計時操作胀瞪。

arm64 有 31 個 64 bit 的整數(shù)型寄存器,分別用 x0 到 x30 表示饲鄙。主要的實現(xiàn)思路是:

  • 入棧參數(shù)凄诞,參數(shù)寄存器是 x0~ x7。對于 objc_msgSend 方法來說忍级,x0 第一個參數(shù)是傳入對象帆谍,x1 第二個參數(shù)是選擇器 _cmd。syscall 的 number 會放到 x8 里轴咱。
  • 交換寄存器中保存的參數(shù)汛蝙,將用于返回的寄存器 lr 中的數(shù)據(jù)移到 x1 里。
  • 使用 bl label 語法調(diào)用 pushCallRecord 函數(shù)朴肺。
  • 執(zhí)行原始的 objc_msgSend窖剑,保存返回值。
  • 使用 bl label 語法調(diào)用 popCallRecord 函數(shù)戈稿。
  • 返回

里面涉及到的一些匯編指令:

指令 含義
stp 同時寫入兩個寄存器西土。
mov 將值賦值到一個寄存器。
ldp 同時讀取兩個寄存器鞍盗。
sub 將兩個寄存器的值相減
add 將兩個寄存器的值相加
ret 從子程序返回主程序

詳細代碼如下:

#define call(b, value) \
__asm volatile ("stp x8, x9, [sp, #-16]!\n"); \
__asm volatile ("mov x12, %0\n" :: "r"(value)); \
__asm volatile ("ldp x8, x9, [sp], #16\n"); \
__asm volatile (#b " x12\n");

#define save() \
__asm volatile ( \
"stp x8, x9, [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");

#define load() \
__asm volatile ( \
"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, x9, [sp], #16\n" );

#define link(b, value) \
__asm volatile ("stp x8, lr, [sp, #-16]!\n"); \
__asm volatile ("sub sp, sp, #16\n"); \
call(b, value); \
__asm volatile ("add sp, sp, #16\n"); \
__asm volatile ("ldp x8, lr, [sp], #16\n");

#define ret() __asm volatile ("ret\n");

__attribute__((__naked__))
static void hook_objc_msgSend() {
    // Save parameters.
    save() // stp入棧指令 入棧參數(shù)需了,參數(shù)寄存器是 x0~ x7。對于 objc_msgSend 方法來說般甲,x0 第一個參數(shù)是傳入對象肋乍,x1 第二個參數(shù)是選擇器 _cmd。syscall 的 number 會放到 x8 里敷存。
    
    __asm volatile ("mov x2, lr\n");
    __asm volatile ("mov x3, x4\n");
    
    // Call our before_objc_msgSend.
    call(blr, &before_objc_msgSend)
    
    // Load parameters.
    load()
    
    // Call through to the original objc_msgSend.
    call(blr, orig_objc_msgSend)
    
    // Save original objc_msgSend return value.
    save()
    
    // Call our after_objc_msgSend.
    call(blr, &after_objc_msgSend)
    
    // restore lr
    __asm volatile ("mov lr, x0\n");
    
    // Load original objc_msgSend return value.
    load()
    
    // return
    ret()
}

這時候住拭,每當(dāng)?shù)讓诱{(diào)用hook_objc_msgSend方法時,會先調(diào)用before_objc_msgSend方法,再調(diào)用hook_objc_msgSend方法滔岳,最后調(diào)用after_objc_msgSend方法。

單個方法調(diào)用挽牢,流程如下圖:

舉一反“三”谱煤,然后多層方法調(diào)用的流程,就變成了下圖:

這樣禽拔,我們就能拿到每一層方法調(diào)用的耗時了刘离。

三、如何使用這個工具睹栖?

第一步硫惕,在項目中,導(dǎo)入QiLagMonitor類庫野来。

第二步恼除,在所需要監(jiān)控的控制器中,導(dǎo)入QiCallTrace.h頭文件曼氛。

  [QiCallTrace start]; // 1. 開始

  // your codes(你所要測試的代碼區(qū)間)

  [QiCallTrace stop]; // 2. 停止
  [QiCallTrace save]; // 3. 保存并打印方法調(diào)用棧以及具體方法耗時豁辉。

PS:目前該工具只能hook所有objc方法,并計算出區(qū)間內(nèi)的所有方法耗時舀患。暫不支持swift方法的監(jiān)聽徽级。

本文源碼:Demo

最后,我是站在iOS業(yè)界巨人的肩膀上完成了App啟動優(yōu)化(一)聊浅、(二)餐抢、(三),感謝戴銘老師精彩的技術(shù)分享低匙。
另附上旷痕,戴銘老師課程鏈接:《iOS開發(fā)高手課》


推薦文章:
iOS 給UILabel添加點擊事件
用SwiftUI給視圖添加動畫
用SwiftUI寫一個簡單頁面
iOS 控制日志的開關(guān)
iOS App中可拆卸一個framework的兩種方式
自定義WKWebView顯示內(nèi)容(一)
Swift 5.1 (7) - 閉包
Swift 5.1 (6) - 函數(shù)
Swift 5.1 (5) - 控制流
Xcode11 新建工程中的SceneDelegate
iOS App啟動優(yōu)化(二)—— 使用“Time Profiler”工具監(jiān)控App的啟動耗時
iOS App啟動優(yōu)化(一)—— 了解App的啟動流程

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市努咐,隨后出現(xiàn)的幾起案子苦蒿,更是在濱河造成了極大的恐慌,老刑警劉巖渗稍,帶你破解...
    沈念sama閱讀 216,744評論 6 502
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件佩迟,死亡現(xiàn)場離奇詭異,居然都是意外死亡竿屹,警方通過查閱死者的電腦和手機报强,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,505評論 3 392
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來拱燃,“玉大人秉溉,你說我怎么就攤上這事。” “怎么了召嘶?”我有些...
    開封第一講書人閱讀 163,105評論 0 353
  • 文/不壞的土叔 我叫張陵父晶,是天一觀的道長。 經(jīng)常有香客問我弄跌,道長甲喝,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,242評論 1 292
  • 正文 為了忘掉前任铛只,我火速辦了婚禮埠胖,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘淳玩。我一直安慰自己直撤,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 67,269評論 6 389
  • 文/花漫 我一把揭開白布蜕着。 她就那樣靜靜地躺著谋竖,像睡著了一般。 火紅的嫁衣襯著肌膚如雪侮东。 梳的紋絲不亂的頭發(fā)上圈盔,一...
    開封第一講書人閱讀 51,215評論 1 299
  • 那天,我揣著相機與錄音悄雅,去河邊找鬼驱敲。 笑死,一個胖子當(dāng)著我的面吹牛宽闲,可吹牛的內(nèi)容都是我干的众眨。 我是一名探鬼主播,決...
    沈念sama閱讀 40,096評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼容诬,長吁一口氣:“原來是場噩夢啊……” “哼娩梨!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起览徒,我...
    開封第一講書人閱讀 38,939評論 0 274
  • 序言:老撾萬榮一對情侶失蹤狈定,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后习蓬,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體纽什,經(jīng)...
    沈念sama閱讀 45,354評論 1 311
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,573評論 2 333
  • 正文 我和宋清朗相戀三年躲叼,在試婚紗的時候發(fā)現(xiàn)自己被綠了芦缰。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 39,745評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡枫慷,死狀恐怖让蕾,靈堂內(nèi)的尸體忽然破棺而出浪规,到底是詐尸還是另有隱情,我是刑警寧澤探孝,帶...
    沈念sama閱讀 35,448評論 5 344
  • 正文 年R本政府宣布笋婿,位于F島的核電站,受9級特大地震影響再姑,放射性物質(zhì)發(fā)生泄漏萌抵。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,048評論 3 327
  • 文/蒙蒙 一元镀、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧霎桅,春花似錦栖疑、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,683評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至揭糕,卻和暖如春萝快,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背著角。 一陣腳步聲響...
    開封第一講書人閱讀 32,838評論 1 269
  • 我被黑心中介騙來泰國打工揪漩, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人吏口。 一個月前我還...
    沈念sama閱讀 47,776評論 2 369
  • 正文 我出身青樓奄容,卻偏偏與公主長得像,于是被迫代替她去往敵國和親产徊。 傳聞我的和親對象是個殘疾皇子昂勒,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,652評論 2 354