簡介: Aspect使用了OC的消息轉發流程,有必定的性能消耗。本文做者使用C++設計語言,並使用libffi進行核心trampoline函數的設計,實現了一個iOS AOP框架——Lokie。相比於業內熟知的Aspects,性能上有了明顯的提高。本文將分享Lokie的具體實現思路。git
不自覺的想起本身從業的這十幾年,如白駒過隙。如今談到上還熟悉的的語言以ASM/C/C++/OC/JS/Lua/Ruby/Shell等爲主,其餘的基本上都是用時拈來過期忘,語言這種東西變化是在太快了, 不過大致換湯不換藥,我感受近幾年來全部的語言隱隱都有一種大統一的走勢,一旦有個特性不錯,你會在不一樣的語言中都找到這種技術的影子。因此我對使用哪一種語言並非很執着,不過C/C++是信仰罷了 : )github
工做中大部分用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無鎖同步的技術,對於線程同步開銷也下降了很多。api
經過一些真機的性能數據來看,以iPhone 7P爲例, Aspect百萬次調用消耗爲6s左右,而相同場景Lokie開銷僅有0.35s左右, 從測試數據上來看,性能提高仍是很是顯著的。緩存
我是個急性子,看書的時候也是喜歡先看代碼。因此我先帖lokie的開源地址:安全
https://github.com/alibaba/Lokie多線程
喜歡翻代碼的同窗能夠先去看看。app
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
這兩個方法的參數是同樣的,提供了對類方法和成員方法的切片化支持。
拿一個場景來看看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。
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的定義以下所示:
#if !OBJC_OLD_DISPATCH_PROTOTYPES typedef void (*IMP)(void /* id, SEL, ... */ ); #else typedef 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函數,完成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/sh cc -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 ./portal b jump_test r
在我機器上,是以下的跳轉地址, 你的地址可能和個人不太同樣,不過不要緊,這並不影響咱們的分析。
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_test portal`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
至此, 函數調用已經被成功轉發了。