如何实现一个iOS AOP框架?

2020 年 8 月 26 日

如何实现一个iOS AOP框架?

前言


不自觉的想起自己从业的这十几年,如白驹过隙。现在谈到上还熟悉的的语言以 ASM/C/C++/OC/JS/Lua/Ruby/Shell 等为主,其他的基本上都是用时拈来过时忘,语言这种东西变化是在太快了, 不过大体换汤不换药,我感觉近几年来所有的语言隐隐都有一种大统一的走势,一旦有个特性不错,你会在不同的语言中都找到这种技术的影子。所以我对使用哪种语言并不是很执着,不过 C/C++是信仰罢了 : )


Lokie


工作中大部分用 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 的开源地址:


https://github.com/alibaba/Lokie


喜欢翻代码的同学可以先去看看。


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
复制代码


至此, 函数调用已经被成功转发了。


本文转载自公众号阿里技术(ID:ali_tech)。


原文链接


如何实现一个iOS AOP框架?


2020 年 8 月 26 日 14:05820

评论

发布
暂无评论
发现更多内容

第五周作业

晨光

第5周 技术选型:技术选型能力反映了架构师的综合水平(一)

陆不得

分布式缓存架构设计

李广富

架构师训练营第五周作业

hiqian

vue3.0 全局API的变化

志学Python

Vue Vue3

架构师训练营 week5 - 学习总结

devfan

采用负载均衡技术总结

superman

架构师训练营 第五周 【学习总结】

小K

2020-07-04-第五周学习总结

路易斯李李李

架构师训练营 - 第五周 - 总结

sljoai

极客大学架构师训练营 第五周

Week5-作业

龙7

架构师训练营 - 第五周学习总结

hellohuan

极客大学架构师训练营

一致性哈希在分布式缓存上的实践

hellohuan

极客大学架构师训练营

缓存总结

ashuai1106

架构师 极客大学架构师训练营

第五周总结

晨光

架构师 0 期 | 一致性 Hash 算法

刁架构

极客大学架构师训练营

《架构师训练营》第五周 命题作业

一致性Hash实现

olderwei

极客大学架构师训练营

架构师训练营第五周课后作业

竹森先生

极客大学 极客大学架构师训练营

架构师训练营 -Week 05 命题作业

华乐彬

极客大学架构师训练营 作业 一致性Hash算法

一致性hash算法

ashuai1106

极客大学架构师训练营

week5-作业一致性HASH算法的JAVA实现

蒜泥精英

分布式和异步的技术选型

拈香(曾德政)

缓存 分布式 极客大学架构师训练营 技术选型 异步

第五周命题作业

Geek_a327d3

Vue3 Composition API如何替换Vue Mixins

志学Python

Vue Vue3 composition-api

架构师训练营 - 第五周作业

teslə

架构师训练营第五周-总结

人世间

极客大学架构师训练营

第五周总结

Geek_a327d3

第五周作业

重新来过

架构师训练营第5周作业

0x12FD16B

java实现一致性 hash 算法

李广富

如何实现一个iOS AOP框架?-InfoQ