如何實(shí)現(xiàn)一個(gè)iOS AOP框架?
Aspect使用了OC的消息轉(zhuǎn)發(fā)流程,有一定的性能消耗。本文作者使用C++設(shè)計(jì)語(yǔ)言,并使用libffi進(jìn)行核心trampoline函數(shù)的設(shè)計(jì),實(shí)現(xiàn)了一個(gè)iOS AOP框架——Lokie。相比于業(yè)內(nèi)熟知的Aspects,性能上有了明顯的提升。本文將分享Lokie的具體實(shí)現(xiàn)思路。
前言
不自覺(jué)的想起自己從業(yè)的這十幾年,如白駒過(guò)隙?,F(xiàn)在談到上還熟悉的的語(yǔ)言以ASM/C/C++/OC/JS/Lua/Ruby/Shell等為主,其他的基本上都是用時(shí)拈來(lái)過(guò)時(shí)忘,語(yǔ)言這種東西變化是在太快了, 不過(guò)大體換湯不換藥,我感覺(jué)近幾年來(lái)所有的語(yǔ)言隱隱都有一種大統(tǒng)一的走勢(shì),一旦有個(gè)特性不錯(cuò),你會(huì)在不同的語(yǔ)言中都找到這種技術(shù)的影子。所以我對(duì)使用哪種語(yǔ)言并不是很執(zhí)著,不過(guò)C/C++是信仰罷了 : )
Lokie
工作中大部分用OC和Ruby、Shell之類的東西,前段時(shí)間一直想找一款合適的iOS下能用的AOP框架。iOS業(yè)內(nèi)比較被熟知的應(yīng)該就是Aspect了。但是Aspect性能比較差,Aspect的trampoline函數(shù)借助了OC語(yǔ)言的消息轉(zhuǎn)發(fā)流程,函數(shù)調(diào)用使用了NSInvocation,我們知道,這兩樣都是性能大戶。有一份測(cè)試數(shù)據(jù),基本上NSInvocation的調(diào)用效率是普通消息發(fā)送效率的100倍左右。事實(shí)上,Aspect只能適用于每秒中調(diào)用次數(shù)不超過(guò)1000次的場(chǎng)景。當(dāng)然還有一些其他的庫(kù),雖然性能有所提升,但不支持多線程場(chǎng)景,一旦加鎖,性能又有明顯的損耗。
找來(lái)找去也沒(méi)有什么趁手的庫(kù),于是想了想,自己寫一個(gè)吧。于是Lokie便誕生了。
Lokie的設(shè)計(jì)基本原則只有兩條,第一高效,第二線程安全。為了滿足高效這一設(shè)計(jì)原則,Lokie一方面采用了高效的C++設(shè)計(jì)語(yǔ)言,標(biāo)準(zhǔn)使用C++14。C++14因引入了一些非常棒的特性比如MOV語(yǔ)義,完美轉(zhuǎn)發(fā),右值引用,多線程支持等使得與C++98相比,性能有了顯著的提升。另一方面我們拋棄了對(duì)OC消息轉(zhuǎn)發(fā)和NSInvocation的依賴,使用libffi進(jìn)行核心trampoline函數(shù)的設(shè)計(jì),從而直接從設(shè)計(jì)上就砍倒性能大戶。此外,對(duì)于線程鎖的實(shí)現(xiàn)也使用了輕量的CAS無(wú)鎖同步的技術(shù),對(duì)于線程同步開銷也降低了不少。
通過(guò)一些真機(jī)的性能數(shù)據(jù)來(lái)看,以iPhone 7P為例, Aspect百萬(wàn)次調(diào)用消耗為6s左右,而相同場(chǎng)景Lokie開銷僅有0.35s左右, 從測(cè)試數(shù)據(jù)上來(lái)看,性能提升還是非常顯著的。
我是個(gè)急性子,看書的時(shí)候也是喜歡先看代碼。所以我先帖lokie的開源地址:
??https://github.com/alibaba/Lokie??
喜歡翻代碼的同學(xué)可以先去看看。
Lokie的頭文件非常簡(jiǎn)單, 如下所示只有兩個(gè)方法和一個(gè)LokieHookPolicy的枚舉。
這兩個(gè)方法的參數(shù)是一樣的,提供了對(duì)類方法和成員方法的切片化支持。
- selecctor_name:是你感興趣的selector名稱,通常我們可以通過(guò)NSStringFromSelector 這個(gè)API來(lái)獲取。
- block:是要具體執(zhí)行的命令,block的參數(shù)和返回值我們稍后討論。
- policy:指定了想要在該selector執(zhí)行前,執(zhí)行后執(zhí)行block,或者是干脆覆蓋原方法。
監(jiān)控效果
拿一個(gè)場(chǎng)景來(lái)看看Lokie的威力。比如我們想監(jiān)控所有的頁(yè)面生命周期,是否正常。
比如項(xiàng)目中的 VC 基類叫 BasePageController,designated initializer 是 @selector(initWithConfig)。
我們暫時(shí)把這段測(cè)試代碼放在application: didFinishLaunchingWithOptions中,AOP就是這么任性!這樣我們?cè)赼pp初始化的時(shí)候?qū)λ械腂asePageController對(duì)象生命周期的開始和結(jié)束點(diǎn)進(jìn)行了監(jiān)控,是不是很酷?
block的參數(shù)定義非常有意思, 第一個(gè)參數(shù)是永恒的id target,這個(gè)selector被發(fā)送的對(duì)象,剩下的參數(shù)和selector保持一致。比如 "initWithConfig:" 有一個(gè)參數(shù),類型是NSDNSDictionary *, 所以我們對(duì) initWithConfig: 傳遞的是^(id target, NSDictionary *param),而dealloc是沒(méi)有參數(shù)的,所以block變成了^(id target)。換句話說(shuō),在block回調(diào)當(dāng)中,你可以拿到當(dāng)前的對(duì)象,以及執(zhí)行這個(gè)方法的參數(shù)上下文,這基本上可以為你提供了足夠的信息。
對(duì)于返回值也很好理解,當(dāng)你使用LokieHookPolicyReplace對(duì)原方法進(jìn)行替換的時(shí)候,block的返回值一定和原方法是一致的。用其他兩個(gè)flag的時(shí)候,無(wú)返回值,使用void即可。
另外我們可以對(duì)同一個(gè)方法進(jìn)行多次hook,比如像這個(gè)樣子:
細(xì)心的你有木有感覺(jué)到,如果我們用個(gè)時(shí)間戳記錄前后兩次的時(shí)間,獲取某個(gè)函數(shù)的執(zhí)行時(shí)間就會(huì)非常容易。
前面兩個(gè)簡(jiǎn)單的小例子算是拋磚引玉吧, AOP在做監(jiān)控、日志方面來(lái)說(shuō)功能還是非常強(qiáng)大的。
實(shí)現(xiàn)原理
整個(gè)AOP的實(shí)現(xiàn)是基于iOS的runtime機(jī)制以及l(fā)ibffi打造的trampoline函數(shù)為核心的。所以這里我也聊聊iOS runtime的一些東西。這部分對(duì)于很多人來(lái)說(shuō),可能比較熟悉了。
OC runtime里有幾個(gè)基礎(chǔ)概念:SEL, IMP, Method。
SEL
objc_selector這個(gè)結(jié)構(gòu)體很有意思,我在源碼里面沒(méi)有找到他的定義。不過(guò)可以通過(guò)翻閱代碼來(lái)推測(cè)objc_selector的實(shí)現(xiàn)。在objc-sel.m當(dāng)中,有兩個(gè)函數(shù)代碼如下:
sel_getName這個(gè)函數(shù)出鏡率還是很高的,從它的實(shí)現(xiàn)來(lái)看,sel和const char *是可以直接互轉(zhuǎn)的,第二個(gè)函數(shù)看的則更加清晰:
看到這里,我們基本上可以推測(cè)出來(lái)objc_selector的定義應(yīng)該是類似與以下這種形式:
為了提升效率, selecor的查找是通過(guò)字符串的哈希值為key的,這樣會(huì)比直接使用字符串做索引查找更加高效。
至于為什么會(huì)專門搞出一個(gè)objc_selector, 我想官方應(yīng)該是想強(qiáng)調(diào)SEL和const char 是不同的類型。
IMP
IMP的定義如下所示:
LLVM 6.0 后增加了 OBJC_OLD_DISPATCH_PROTOTYPES,需要在 build setting 中將 Enable Strict Checking of objc_msgSend Calls 設(shè)置為NO才可以使用 objc_msgSend(id self, SEL op, ...)。有些同學(xué)在調(diào)用objc_msgSend的時(shí)候,編譯器會(huì)報(bào)如下錯(cuò)誤,就是這個(gè)原因了。
IMP 是一個(gè)函數(shù)指針,它是最終方法調(diào)用是的執(zhí)行指令入口。
objc_method可以說(shuō)是非常關(guān)鍵了,它也是OC語(yǔ)言可以在運(yùn)行期進(jìn)行method swizzling 的設(shè)計(jì)基石, 通過(guò)objc_method 把函數(shù)地址,函數(shù)簽名以及函數(shù)名稱打包做個(gè)關(guān)聯(lián), 在 真正執(zhí)行類方法的時(shí)候,通過(guò)selector名稱,查找對(duì)應(yīng)的IMP。同樣,我們也可以通過(guò)在運(yùn)行期替換某個(gè)selector 名稱與之對(duì)應(yīng)的IMP來(lái)完成一些特殊的需求。
消息發(fā)送機(jī)制
這三個(gè)概念明確了之后,我們繼續(xù)聊下消息發(fā)送機(jī)制。我們知道當(dāng)向某個(gè)對(duì)象發(fā)送消息的時(shí)候,有一個(gè)關(guān)鍵函數(shù)叫objc_msgSend, 這個(gè)函數(shù)里到底干了些什么事情, 我們簡(jiǎn)單聊一聊。
這個(gè)函數(shù)內(nèi)部是用匯編寫的,針對(duì)不同的硬件系統(tǒng)提供了相應(yīng)的實(shí)現(xiàn)代碼。不同的版本實(shí)現(xiàn)應(yīng)該是存在差異, 包括函數(shù)名稱和實(shí)現(xiàn)(我查閱的版本是 objc4-208)。
objc_msgSend首先第一件事就是檢測(cè)消息發(fā)送對(duì)象self是否為空,如果為空,直接返回,啥事不做。這也就是為什么對(duì)象為nil時(shí),發(fā)送消息不會(huì)崩潰的原因。做完這些檢測(cè)之后,會(huì)通過(guò)self->isa->cache去緩存里查找selector對(duì)應(yīng)的Method, (cache里面存放的是Method ),查找到的話直接調(diào)用Method->method_imp。沒(méi)有找到的話進(jìn)入下一個(gè)處理流程,調(diào)用一個(gè)名為class_lookupMethodAndLoadCache的函數(shù)。
這個(gè)函數(shù)的定義如下所示:
消息轉(zhuǎn)發(fā)機(jī)制這部分動(dòng)態(tài)方法解析,備援接收者,消息重定向應(yīng)該是很多面試官都喜歡問(wèn)的環(huán)節(jié) : ) ,我想大家肯定是比較熟悉這部分內(nèi)容,這里就不再贅述了。
trampline函數(shù)的實(shí)現(xiàn)
接下來(lái)的內(nèi)容,我們簡(jiǎn)單介紹下,從匯編的視角出發(fā),如何實(shí)現(xiàn)一個(gè)trampline函數(shù),完成c函數(shù)級(jí)別的函數(shù)轉(zhuǎn)發(fā)。以x86指令集為例,其他類型原理也相似。
從匯編的角度來(lái)看,函數(shù)的跳轉(zhuǎn),最直接的方式就是插入jmp指令。x86指令集中,每條指令都有自己的指令長(zhǎng)度,比如說(shuō)jmp指令, 長(zhǎng)度為5,其中包含一個(gè)字節(jié)的指令碼,4個(gè)字節(jié)的相對(duì)偏移量。假定我們手頭有兩個(gè)函數(shù)A和B, 如果想讓B的調(diào)用轉(zhuǎn)發(fā)到A上去, 毫無(wú)疑問(wèn),jmp指令是可以幫上忙的。接著我們要解決的問(wèn)題是如何計(jì)算出這兩個(gè)函數(shù)的相對(duì)偏移量。這個(gè)問(wèn)題我們可以這樣考慮, 但cpu碰到j(luò)mp的時(shí)候,它的執(zhí)行動(dòng)作為ip = ip + 5 + 相對(duì)偏移量。
為了更加直接的解釋這個(gè)問(wèn)題,我們看看下面的額匯編函數(shù)(不熟悉匯編的同學(xué)不用擔(dān)心, 這個(gè)函數(shù)沒(méi)有干任何事情,只是做一個(gè)跳轉(zhuǎn))。
你也可以跟我一起來(lái)做,先寫一個(gè)jump_test.s,定義了一個(gè)什么事情都沒(méi)做的函數(shù)。
先看看匯編代碼文件:(jump_test.s)翻譯成C函數(shù)的話,就是void jump_test(){ return ; }。
接著,我們?cè)趧?chuàng)建一個(gè)C文件:在這個(gè)文件里,我們調(diào)用剛才創(chuàng)建的jump_test函數(shù)。
最后就是編譯鏈接了, 我們創(chuàng)建一個(gè)build.sh生成可執(zhí)行文件portal 。
我們使用 lldb 加載調(diào)試剛才生成的prtal文件,并把斷點(diǎn)打在函數(shù) jump_test 上。
在我機(jī)器上,是如下的跳轉(zhuǎn)地址, 你的地址可能和我的不太一樣,不過(guò)沒(méi)關(guān)系,這并不影響我們的分析。
演示到這里的時(shí)候,我們成功的從匯編的視角,看到了一些我們想要的東西。
首先看看當(dāng)前的 ip 是 0x100000f9f, 我們匯編中使用的jlable此時(shí)已經(jīng)被計(jì)算,變成了新的目標(biāo)地址(0x100000fa7)。我們知道,新的 ip 是通過(guò)當(dāng)前 ip 加偏移算出來(lái)的, jmp的指令長(zhǎng)度是5,前面我們已經(jīng)解釋過(guò)了。所以我們可以知道下面的關(guān)系:
把從 lldb 中獲取的地址放進(jìn)來(lái),就變成了:
回頭看看匯編代碼, 我們?cè)诖a中使用了三個(gè)nop, 每個(gè)nop指令為1個(gè)字節(jié), 剛好就是跳轉(zhuǎn)到三個(gè)nop指令之后。做了個(gè)簡(jiǎn)單的驗(yàn)證之后,我們把這個(gè)等式做個(gè)變形,于是得到 offset = new_ip - old_ip - 5; 當(dāng)我們知道 A函數(shù)和B函數(shù)之后,就很容易算出jmp的操作數(shù)是多少了。
講到這里,函數(shù)的跳轉(zhuǎn)思路就非常清晰了,我們想在調(diào)用A的時(shí)候,實(shí)際跳轉(zhuǎn)到B。比如我們有個(gè)C api, 我們希望每次調(diào)用這個(gè)api的時(shí)候,實(shí)際上跳轉(zhuǎn)到我們自定義的函數(shù)里面, 我們需要把這個(gè)api的前幾個(gè)字節(jié)修改下,直接jmp到我們自己定義的函數(shù)中。前5個(gè)字節(jié)第一個(gè)當(dāng)然就是jmp的操作碼了,后面四個(gè)字節(jié)是我們計(jì)算出的偏移量。
最后給出一個(gè)完整的例子。匯編分析以及C代碼一并打包放上來(lái)。
編譯腳本(系統(tǒng) macOS):
至此, 函數(shù)調(diào)用已經(jīng)被成功轉(zhuǎn)發(fā)了。
【本文為51CTO專欄作者“阿里巴巴官方技術(shù)”原創(chuàng)稿件,轉(zhuǎn)載請(qǐng)聯(lián)系原作者】