開源 | 如何實現一個iOS AOP框架?

小顧iOSer發表於2021-07-24

阿里技術:

iOS乾貨:Github

導讀:Aspect使用了OC的訊息轉發流程,有一定的效能消耗。本文作者使用C++設計語言,並使用libffi進行核心trampoline函式的設計,實現了一個iOS AOP框架——Lokie。相比於業內熟知的Aspects,效能上有了明顯的提升。本文將分享Lokie的具體實現思路

前言

不自覺的想起自己從業的這十幾年,如白駒過隙。現在談到上還熟悉的的語言以ASM/C/C++/OC/JS/Lua/Ruby/Shell等為主,其他的基本上都是用時拈來過時忘,語言這種東西變化是在太快了, 不過大體換湯不換藥,我感覺近幾年來所有的語言隱隱都有一種大統一的走勢,一旦有個特性不錯,你會在不同的語言中都找到這種技術的影子。所以我對使用哪種語言並不是很執著,不過C/C++是信仰罷了 : )

L****okie

工作中大部分用OC和Ruby、Shell之類的東西,前段時間一直想找一款合適的iOS下能用的AOP框架。iOS業內比較被熟知的應該就是Aspect了。但是Aspect效能比較差,Aspect的trampoline函式借助了OC語言的訊息轉發流程,函式呼叫使用了NSInvocation,我們知道,這兩樣都是效能大戶。有一份測試資料,基本上NSInvocation的呼叫效率是普通訊息傳送效率的100倍左右。事實上,Aspect只能適用於每秒中呼叫次數不超過1000次的場景。當然還有一些其他的庫,雖然效能有所提升,但不支援多執行緒場景,一旦加鎖,效能又有明顯的損耗。

找來找去也沒有什麼趁手的庫,於是想了想,自己寫一個吧。於是Lokie便誕生了。

Lokie的設計基本原則只有兩條,第一高效,第二執行緒安全。為了滿足高效這一設計原則,Lokie一方面採用了高效的C++設計語言,標準使用C++14。C++14因引入了一些非常棒的特性比如MOV語義,完美轉發,右值引用,多執行緒支援等使得與C++98相比,效能有了顯著的提升。另一方面我們拋棄了對OC訊息轉發和NSInvocation的依賴,使用libffi進行核心trampoline函式的設計,從而直接從設計上就砍倒效能大戶。此外,對於執行緒鎖的實現也使用了輕量的CAS無鎖同步的技術,對於執行緒同步開銷也降低了不少。

通過一些真機的效能資料來看,以iPhone 7P為例, Aspect百萬次呼叫消耗為6s左右,而相同場景Lokie開銷僅有0.35s左右, 從測試資料上來看,效能提升還是非常顯著的。

我是個急性子,看書的時候也是喜歡先看程式碼。所以我先帖lokie的開源地址:Github

喜歡翻程式碼的同學可以先去看看。

Lokie的標頭檔案非常簡單, 如下所示只有兩個方法和一個LokieHookPolicy的列舉。

#import <Foundation/Foundation.h>typedef enum : NSUInteger {    LokieHookPolicyBefore = 1 << 0,    LokieHookPolicyAfter = 1 << 1,    LokieHookPolicyReplace = 1 << 2,} LokieHookPolicy;@interface NSObject (Lokie)+ (BOOL) Lokie_hookMemberSelector:(NSString *) selecctor_name                           withBlock: (id) block                              policy:(LokieHookPolicy) policy;+ (BOOL) Lokie_hookClassSelector:(NSString *) selecctor_name                                  withBlock: (id) block                                     policy:(LokieHookPolicy) policy;-(NSArray*) lokie_errors;@end
複製程式碼

這兩個方法的引數是一樣的,提供了對類方法和成員方法的切片化支援。

  • selector_name:是你感興趣的selector名稱,通常我們可以通過NSStringFromSelector 這個API來獲取。

  • block:是要具體執行的命令,block的引數和返回值我們稍後討論。

  • policy:指定了想要在該selector執行前,執行後執行block,或者是乾脆覆蓋原方法。

監控效果

拿一個場景來看看Lokie的威力。比如我們想監控所有的頁面生命週期,是否正常。

比如專案中的 VC 基類叫 BasePageController,designated initializer 是 @selector(initWithConfig)。

我們暫時把這段測試程式碼放在application: didFinishLaunchingWithOptions中,AOP就是這麼任性!這樣我們在app初始化的時候對所有的BasePageController物件生命週期的開始和結束點進行了監控,是不是很酷?

Class cls = NSClassFromString(@"BasePageController");[cls Lokie_hookMemberSelector:@"initWithConfig:"                    withBlock:^(id target, NSDictionary *param){                        NSLog(@"%@", param);                        NSLog(@"Lokie: %@ is created", target);} policy:LokieHookPolicyAfter];[cls Lokie_hookMemberSelector:@"dealloc" withBlock:^(id target){        NSLog(@"Lokie: %@ is dealloc", target);} policy:LokieHookPolicyBefore];
複製程式碼

block的引數定義非常有意思, 第一個引數是永恆的id target,這個selector被髮送的物件,剩下的引數和selector保持一致。比如 "initWithConfig:" 有一個引數,型別是NSDNSDictionary *, 所以我們對 initWithConfig: 傳遞的是^(id target, NSDictionary *param),而dealloc是沒有引數的,所以block變成了^(id target)。換句話說,在block回撥當中,你可以拿到當前的物件,以及執行這個方法的引數上下文,這基本上可以為你提供了足夠的資訊。

對於返回值也很好理解,當你使用LokieHookPolicyReplace對原方法進行替換的時候,block的返回值一定和原方法是一致的。用其他兩個flag的時候,無返回值,使用void即可。

另外我們可以對同一個方法進行多次hook,比如像這個樣子:

Class cls = NSClassFromString(@"BasePageController"); [cls Lokie_hookMemberSelector:@"viewDidAppear:" withBlock:^(id target, BOOL ani){        NSLog(@"LOKIE: viewDidAppear 呼叫之前會執行這部分程式碼"); }policy:LokieHookPolicyBefore]; [cls Lokie_hookMemberSelector:@"viewDidAppear:" withBlock:^(id target, BOOL ani){        NSLog(@"LOKIE: viewDidAppear 呼叫之後會執行這部分程式碼"); }policy:LokieHookPolicyAfter];
複製程式碼

細心的你有木有感覺到,如果我們用個時間戳記錄前後兩次的時間,獲取某個函式的執行時間就會非常容易。

前面兩個簡單的小例子算是拋磚引玉吧, AOP在做監控、日誌方面來說功能還是非常強大的。

實現原理

整個AOP的實現是基於iOS的runtime機制以及libffi打造的trampoline函式為核心的。所以這裡我也聊聊iOS runtime的一些東西。這部分對於很多人來說,可能比較熟悉了。

OC runtime裡有幾個基礎概念:SEL, IMP, Method。

SEL

typedef struct objc_selector  *SEL;typedef id  (*IMP)(id, SEL, ...);struct objc_method {    SEL method_name;    char *method_types;                IMP method_imp;} ;typedef struct objc_method *Method;
複製程式碼

objc_selector這個結構體很有意思,我在原始碼裡面沒有找到他的定義。不過可以通過翻閱程式碼來推測objc_selector的實現。在objc-sel.m當中,有兩個函式程式碼如下:

const char *sel_getName(SEL sel) {    if (!sel) return "<null selector>";    return (const char *)(const void*)sel;}
複製程式碼

sel_getName這個函式出鏡率還是很高的,從它的實現來看,sel和const char *是可以直接互轉的,第二個函式看的則更加清晰:

static SEL __sel_registerName(const char *name, int copy) ;//! 在 __sel_registerName 中有通過const char *name 直接得到 SEL 的方法...if (!result) {    result = sel_alloc(name, copy);}...//! sel_alloc的實現static SEL sel_alloc(const char *name ,bool copy){    selLock.assertWriting();    return (SEL)(copy ? strdupIfMutable(name):name);}
複製程式碼

看到這裡,我們基本上可以推測出來objc_selector的定義應該是類似與以下這種形式:

typedef struct {     char  selector[XXX];     void *unknown;      ...}objc_selector;
複製程式碼

為了提升效率, selecor的查詢是通過字串的雜湊值為key的,這樣會比直接使用字串做索引查詢更加高效。

//!objc4-208  版本的雜湊演算法static CFHashCode _objc_hash_selector(const void *v) {    if (!v) return 0;    return (CFHashCode)_objc_strhash(v);}static __inline__ unsigned int _objc_strhash(const unsigned char *s) {    unsigned int hash = 0;    for (;;) {  int a = *s++;  if (0 == a) break;  hash += (hash << 8) + a;    }    return hash;}

//! objc4-723 版本的hash演算法static unsigned _mapStrHash(NXMapTable *table, const void *key) {    unsigned    hash = 0;    unsigned char *s = (unsigned char *)key;    /* unsigned to avoid a sign-extend */    /* unroll the loop */    if (s) for (; ; ) {  if (*s == '\0') break;  hash ^= *s++;  if (*s == '\0') break;  hash ^= *s++ << 8;  if (*s == '\0') break;  hash ^= *s++ << 16;  if (*s == '\0') break;  hash ^= *s++ << 24;    }    return xorHash(hash);}static INLINE unsigned xorHash(unsigned hash) {    unsigned xored = (hash & 0xffff) ^ (hash >> 16);    return ((xored * 65521) + hash);}
複製程式碼

至於為什麼會專門搞出一個objc_selector, 我想官方應該是想強調SEL和const char 是不同的型別。

IMP

IMP的定義如下所示:

#if !OBJC_OLD_DISPATCH_PROTOTYPEStypedef void (*IMP)(void /* id, SEL, ... */ ); #elsetypedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...); #endif
複製程式碼

LLVM 6.0 後增加了 OBJC_OLD_DISPATCH_PROTOTYPES,需要在 build setting 中將 Enable Strict Checking of objc_msgSend Calls 設定為NO才可以使用 objc_msgSend(id self, SEL op, ...)。有些同學在呼叫objc_msgSend的時候,編譯器會報如下錯誤,就是這個原因了。

Too many arguments to function call, expected 0, have 2
複製程式碼

IMP 是一個函式指標,它是最終方法呼叫是的執行指令入口。

objc_method可以說是非常關鍵了,它也是OC語言可以在執行期進行method swizzling 的設計基石, 通過objc_method 把函式地址,函式簽名以及函式名稱打包做個關聯, 在 真正執行類方法的時候,通過selector名稱,查詢對應的IMP。同樣,我們也可以通過在執行期替換某個selector 名稱與之對應的IMP來完成一些特殊的需求。

訊息傳送機制

這三個概念明確了之後,我們繼續聊下訊息傳送機制。我們知道當向某個物件傳送訊息的時候,有一個關鍵函式叫objc_msgSend, 這個函式裡到底幹了些什麼事情, 我們簡單聊一聊。

//! objc_msgSend 函式定義id objc_msgSend(id self, SEL op, ...);
複製程式碼

這個函式內部是用匯編寫的,針對不同的硬體系統提供了相應的實現程式碼。不同的版本實現應該是存在差異, 包括函式名稱和實現(我查閱的版本是 objc4-208)。

objc_msgSend首先第一件事就是檢測訊息傳送物件self是否為空,如果為空,直接返回,啥事不做。這也就是為什麼物件為nil時,傳送訊息不會崩潰的原因。做完這些檢測之後,會通過self->isa->cache去快取裡查詢selector對應的Method, (cache裡面存放的是Method ),查詢到的話直接呼叫Method->method_imp。沒有找到的話進入下一個處理流程,呼叫一個名為class_lookupMethodAndLoadCache的函式。

這個函式的定義如下所示:

IMP _class_lookupMethodAndLoadCache (Class  cls, SEL sel) {    ...        if (methodPC == NULL)        {            //!  這裡指定訊息轉發入口            // Class and superclasses do not respond -- use forwarding            smt = malloc_zone_malloc (_objc_create_zone(), sizeof(struct objc_method));            smt->method_name    = sel;            smt->method_types   = "";            smt->method_imp     = &_objc_msgForward;            _cache_fill (cls, smt, sel);            methodPC = &_objc_msgForward;       }    ...}
複製程式碼

訊息轉發機制這部分動態方法解析,備援接收者,訊息重定向應該是很多面試官都喜歡問的環節 : ) ,我想大家肯定是比較熟悉這部分內容,這裡就不再贅述了。

trampline函式的實現

接下來的內容,我們簡單介紹下,從彙編的視角出發,如何實現一個trampline函式,完成c函式級別的函式轉發。以x86指令集為例,其他型別原理也相似。

從彙編的角度來看,函式的跳轉,最直接的方式就是插入jmp指令。x86指令集中,每條指令都有自己的指令長度,比如說jmp指令, 長度為5,其中包含一個位元組的指令碼,4個位元組的相對偏移量。假定我們手頭有兩個函式A和B, 如果想讓B的呼叫轉發到A上去, 毫無疑問,jmp指令是可以幫上忙的。接著我們要解決的問題是如何計算出這兩個函式的相對偏移量。這個問題我們可以這樣考慮, 但cpu碰到jmp的時候,它的執行動作為ip = ip + 5 + 相對偏移量。

為了更加直接的解釋這個問題,我們看看下面的額彙編函式(不熟悉彙編的同學不用擔心, 這個函式沒有幹任何事情,只是做一個跳轉)。

你也可以跟我一起來做,先寫一個jump_test.s,定義了一個什麼事情都沒做的函式。

先看看彙編程式碼檔案:(jump_test.s)翻譯成C函式的話,就是void jump_test(){ return ; }。

.global _jump_test _jump_test:    jmp   jlable    #!為了測試jmp指令偏移量,人為的給加幾個nop    nop    nop     nop jlable:    rep;ret
複製程式碼

接著,我們在建立一個C檔案:在這個檔案裡,我們呼叫剛才建立的jump_test函式。

#include <stdio.h>extern void jump_test();int main(){    jump_test();}
複製程式碼

最後就是編譯連結了, 我們建立一個build.sh生成可執行檔案portal 。

#! /bin/shcc -c  -o main.o main.c as -o jump_test.o jump_test.s cc -o  portal main.c jump_test.o
複製程式碼

我們使用 lldb 載入除錯剛才生成的prtal檔案,並把斷點打在函式 jump_test 上。

lldb ./portalb jump_testr
複製程式碼

在我機器上,是如下的跳轉地址, 你的地址可能和我的不太一樣,不過沒關係,這並不影響我們的分析。

Process 22830 launched: './portal' (x86_64)Process 22830 stopped* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1    frame #0: 0x0000000100000f9f portal`jump_testportal`jump_test:->  0x100000f9f <+0>: jmp    0x100000fa7               ; jlable    0x100000fa4 <+5>: nop        0x100000fa5 <+6>: nop        0x100000fa6 <+7>: nop
複製程式碼

演示到這裡的時候,我們成功的從彙編的視角,看到了一些我們想要的東西。

首先看看當前的 ip 是 0x100000f9f, 我們彙編中使用的jlable此時已經被計算,變成了新的目標地址(0x100000fa7)。我們知道,新的 ip 是通過當前 ip 加偏移算出來的, jmp的指令長度是5,前面我們已經解釋過了。所以我們可以知道下面的關係:

new_ip = old_ip + 5 + offset;
複製程式碼

把從 lldb 中獲取的地址放進來,就變成了:

0x100000fa7 = 0x100000f9f + 5 + offset ==> offset = 3.
複製程式碼

回頭看看彙編程式碼, 我們在程式碼中使用了三個nop, 每個nop指令為1個位元組, 剛好就是跳轉到三個nop指令之後。做了個簡單的驗證之後,我們把這個等式做個變形,於是得到 offset = new_ip - old_ip - 5; 當我們知道 A函式和B函式之後,就很容易算出jmp的運算元是多少了。

講到這裡,函式的跳轉思路就非常清晰了,我們想在呼叫A的時候,實際跳轉到B。比如我們有個C api, 我們希望每次呼叫這個api的時候,實際上跳轉到我們自定義的函式裡面, 我們需要把這個api的前幾個位元組修改下,直接jmp到我們自己定義的函式中。前5個位元組第一個當然就是jmp的操作碼了,後面四個位元組是我們計算出的偏移量。

最後給出一個完整的例子。彙編分析以及C程式碼一併打包放上來。

#include <stdio.h>#include <mach/mach.h>int  new_add(int a, int b){    return a+b;}int add(int a, int b){    printf("my_add org is called!\n");    return 0;}typedef struct{  uint8_t jmp;  uint32_t off;} __attribute__((packed)) tramp_line_code;void dohook(void *src, void *dst){    vm_protect(mach_task_self(), (vm_address_t)src, 5, 0, VM_PROT_ALL);    tramp_line_code jshort;    jshort.jmp = 0xe9;    jshort.off = (uint32_t)(long)dst - (uint32_t)(long)src - 0x5;    memcpy(my_add, (const void*)&jshort, sizeof(tramp_line_code));    vm_protect(mach_task_self(), (vm_address_t)src, 5, 0, VM_PROT_READ|VM_PROT_EXECUTE);}int main(){    dohook(add, new_add);    int c = add(10, 20); //!  該函式預設實現是返回 0, hook之後,返回 30    printf("res is %d\n", c);    return 0;}
複製程式碼

編譯指令碼(系統 macOS):

gcc -o portal ./main.c執行: ./portal輸出: res is 30
複製程式碼

至此, 函式呼叫已經被成功轉發了。

更多文章收入於Github

相關文章