新聞中心
Aspect使用了OC的消息轉發(fā)流程,有一定的性能消耗。本文作者使用C++設計語言,并使用libffi進行核心trampoline函數的設計,實現了一個iOS AOP框架——Lokie。相比于業(yè)內熟知的Aspects,性能上有了明顯的提升。本文將分享Lokie的具體實現思路。

成都創(chuàng)新互聯公司長期為成百上千客戶提供的網站建設服務,團隊從業(yè)經驗10年,關注不同地域、不同群體,并針對不同對象提供差異化的產品和服務;打造開放共贏平臺,與合作伙伴共同營造健康的互聯網生態(tài)環(huán)境。為橋東企業(yè)提供專業(yè)的成都網站建設、做網站,橋東網站改版等技術服務。擁有10多年豐富建站經驗和眾多成功案例,為您定制開發(fā)。
前言
不自覺的想起自己從業(yè)的這十幾年,如白駒過隙?,F在談到上還熟悉的的語言以ASM/C/C++/OC/JS/Lua/Ruby/Shell等為主,其他的基本上都是用時拈來過時忘,語言這種東西變化是在太快了, 不過大體換湯不換藥,我感覺近幾年來所有的語言隱隱都有一種大統(tǒng)一的走勢,一旦有個特性不錯,你會在不同的語言中都找到這種技術的影子。所以我對使用哪種語言并不是很執(zhí)著,不過C/C++是信仰罷了 : )
Lokie
工作中大部分用OC和Ruby、Shell之類的東西,前段時間一直想找一款合適的iOS下能用的AOP框架。iOS業(yè)內比較被熟知的應該就是Aspect了。但是Aspect性能比較差,Aspect的trampoline函數借助了OC語言的消息轉發(fā)流程,函數調用使用了NSInvocation,我們知道,這兩樣都是性能大戶。有一份測試數據,基本上NSInvocation的調用效率是普通消息發(fā)送效率的100倍左右。事實上,Aspect只能適用于每秒中調用次數不超過1000次的場景。當然還有一些其他的庫,雖然性能有所提升,但不支持多線程場景,一旦加鎖,性能又有明顯的損耗。
找來找去也沒有什么趁手的庫,于是想了想,自己寫一個吧。于是Lokie便誕生了。
Lokie的設計基本原則只有兩條,第一高效,第二線程安全。為了滿足高效這一設計原則,Lokie一方面采用了高效的C++設計語言,標準使用C++14。C++14因引入了一些非常棒的特性比如MOV語義,完美轉發(fā),右值引用,多線程支持等使得與C++98相比,性能有了顯著的提升。另一方面我們拋棄了對OC消息轉發(fā)和NSInvocation的依賴,使用libffi進行核心trampoline函數的設計,從而直接從設計上就砍倒性能大戶。此外,對于線程鎖的實現也使用了輕量的CAS無鎖同步的技術,對于線程同步開銷也降低了不少。
通過一些真機的性能數據來看,以iPhone 7P為例, Aspect百萬次調用消耗為6s左右,而相同場景Lokie開銷僅有0.35s左右, 從測試數據上來看,性能提升還是非常顯著的。
我是個急性子,看書的時候也是喜歡先看代碼。所以我先帖lokie的開源地址:
??https://github.com/alibaba/Lokie??
喜歡翻代碼的同學可以先去看看。
Lokie的頭文件非常簡單, 如下所示只有兩個方法和一個LokieHookPolicy的枚舉。
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這兩個方法的參數是一樣的,提供了對類方法和成員方法的切片化支持。
- selecctor_name:是你感興趣的selector名稱,通常我們可以通過NSStringFromSelector 這個API來獲取。
- block:是要具體執(zhí)行的命令,block的參數和返回值我們稍后討論。
- policy:指定了想要在該selector執(zhí)行前,執(zhí)行后執(zhí)行block,或者是干脆覆蓋原方法。
監(jiān)控效果
拿一個場景來看看Lokie的威力。比如我們想監(jiān)控所有的頁面生命周期,是否正常。
比如項目中的 VC 基類叫 BasePageController,designated initializer 是 @selector(initWithConfig)。
我們暫時把這段測試代碼放在application: didFinishLaunchingWithOptions中,AOP就是這么任性!這樣我們在app初始化的時候對所有的BasePageController對象生命周期的開始和結束點進行了監(jiān)控,是不是很酷?
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被發(fā)送的對象,剩下的參數和selector保持一致。比如 "initWithConfig:" 有一個參數,類型是NSDNSDictionary *, 所以我們對 initWithConfig: 傳遞的是^(id target, NSDictionary *param),而dealloc是沒有參數的,所以block變成了^(id target)。換句話說,在block回調當中,你可以拿到當前的對象,以及執(zhí)行這個方法的參數上下文,這基本上可以為你提供了足夠的信息。
對于返回值也很好理解,當你使用LokieHookPolicyReplace對原方法進行替換的時候,block的返回值一定和原方法是一致的。用其他兩個flag的時候,無返回值,使用void即可。
另外我們可以對同一個方法進行多次hook,比如像這個樣子:
Class cls = NSClassFromString(@"BasePageController");
[cls Lokie_hookMemberSelector:@"viewDidAppear:" withBlock:^(id target, BOOL ani){
NSLog(@"LOKIE: viewDidAppear 調用之前會執(zhí)行這部分代碼");
}policy:LokieHookPolicyBefore];
[cls Lokie_hookMemberSelector:@"viewDidAppear:" withBlock:^(id target, BOOL ani){
NSLog(@"LOKIE: viewDidAppear 調用之后會執(zhí)行這部分代碼");
}policy:LokieHookPolicyAfter];
細心的你有木有感覺到,如果我們用個時間戳記錄前后兩次的時間,獲取某個函數的執(zhí)行時間就會非常容易。
前面兩個簡單的小例子算是拋磚引玉吧, AOP在做監(jiān)控、日志方面來說功能還是非常強大的。
實現原理
整個AOP的實現是基于iOS的runtime機制以及l(fā)ibffi打造的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 "";
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_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 是一個函數指針,它是最終方法調用是的執(zhí)行指令入口。
objc_method可以說是非常關鍵了,它也是OC語言可以在運行期進行method swizzling 的設計基石, 通過objc_method 把函數地址,函數簽名以及函數名稱打包做個關聯, 在 真正執(zhí)行類方法的時候,通過selector名稱,查找對應的IMP。同樣,我們也可以通過在運行期替換某個selector 名稱與之對應的IMP來完成一些特殊的需求。
消息發(fā)送機制
這三個概念明確了之后,我們繼續(xù)聊下消息發(fā)送機制。我們知道當向某個對象發(fā)送消息的時候,有一個關鍵函數叫objc_msgSend, 這個函數里到底干了些什么事情, 我們簡單聊一聊。
//! objc_msgSend 函數定義
id objc_msgSend(id self, SEL op, ...);
這個函數內部是用匯編寫的,針對不同的硬件系統(tǒng)提供了相應的實現代碼。不同的版本實現應該是存在差異, 包括函數名稱和實現(我查閱的版本是 objc4-208)。
objc_msgSend首先第一件事就是檢測消息發(fā)送對象self是否為空,如果為空,直接返回,啥事不做。這也就是為什么對象為nil時,發(fā)送消息不會崩潰的原因。做完這些檢測之后,會通過self->isa->cache去緩存里查找selector對應的Method, (cache里面存放的是Method ),查找到的話直接調用Method->method_imp。沒有找到的話進入下一個處理流程,調用一個名為class_lookupMethodAndLoadCache的函數。
這個函數的定義如下所示:
IMP _class_lookupMethodAndLoadCache (Class cls, SEL sel)
{
...
if (methodPC == NULL)
{
//! 這里指定消息轉發(fā)入口
// 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;
}
...
}
消息轉發(fā)機制這部分動態(tài)方法解析,備援接收者,消息重定向應該是很多面試官都喜歡問的環(huán)節(jié) : ) ,我想大家肯定是比較熟悉這部分內容,這里就不再贅述了。
trampline函數的實現
接下來的內容,我們簡單介紹下,從匯編的視角出發(fā),如何實現一個trampline函數,完成c函數級別的函數轉發(fā)。以x86指令集為例,其他類型原理也相似。
從匯編的角度來看,函數的跳轉,最直接的方式就是插入jmp指令。x86指令集中,每條指令都有自己的指令長度,比如說jmp指令, 長度為5,其中包含一個字節(jié)的指令碼,4個字節(jié)的相對偏移量。假定我們手頭有兩個函數A和B, 如果想讓B的調用轉發(fā)到A上去, 毫無疑問,jmp指令是可以幫上忙的。接著我們要解決的問題是如何計算出這兩個函數的相對偏移量。這個問題我們可以這樣考慮, 但cpu碰到jmp的時候,它的執(zhí)行動作為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
接著,我們在創(chuàng)建一個C文件:在這個文件里,我們調用剛才創(chuàng)建的jump_test函數。
#include
extern void jump_test();
int main(){
jump_test();
}
最后就是編譯鏈接了, 我們創(chuàng)建一個build.sh生成可執(zhí)行文件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個字節(jié), 剛好就是跳轉到三個nop指令之后。做了個簡單的驗證之后,我們把這個等式做個變形,于是得到 offset = new_ip - old_ip - 5; 當我們知道 A函數和B函數之后,就很容易算出jmp的操作數是多少了。
講到這里,函數的跳轉思路就非常清晰了,我們想在調用A的時候,實際跳轉到B。比如我們有個C api, 我們希望每次調用這個api的時候,實際上跳轉到我們自定義的函數里面, 我們需要把這個api的前幾個字節(jié)修改下,直接jmp到我們自己定義的函數中。前5個字節(jié)第一個當然就是jmp的操作碼了,后面四個字節(jié)是我們計算出的偏移量。
最后給出一個完整的例子。匯編分析以及C代碼一并打包放上來。
#include
#include
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;
}
編譯腳本(系統(tǒng) macOS):
gcc -o portal ./main.c
執(zhí)行: ./portal
輸出: res is 30
至此, 函數調用已經被成功轉發(fā)了。
【本文為專欄作者“阿里巴巴官方技術”原創(chuàng)稿件,轉載請聯系原作者】
??戳這里,看該作者更多好文??
新聞標題:如何實現一個iOSAOP框架?
轉載源于:http://m.fisionsoft.com.cn/article/dpsjdpg.html


咨詢
建站咨詢
