Aspect使用了OC的消息转发流程,有一定的性能消耗。本文作者使用C++设计语言,并使用libffi设计了核心的trampoline功能,并实现了一个iOSAOP框架——Lokie。与业界知??名的Aspects相比,性能有了明显的提升。本文将分享Lokie的具体实现思路。前言不自觉地想起自己职业生涯的这十年,仿佛时光荏苒。现在说到仍然为世人所熟悉的语言,ASM/C/C++/OC/JS/Lua/Ruby/Shell是主要的语言。是的,但一般来说,汤是不变的。感觉这几年所有的语言都有统一的趋势。一旦一个特性做好了,你就会在不同的语言中找到这个技术的影子。所以我不是很执着于使用哪种语言,但C/C++只是一种信仰:)Lokie的大部分工作都使用OC、Ruby、Shell之类的,我一直在寻找一个合适的可以在下面使用的iOS前段时间。AOP框架。Aspect在iOS界应该是小有名气。但是Aspect的性能比较差。Aspect的trampoline函数使用OC语言的消息转发流程,函数调用使用NSInvocation。我们知道它们都是高性能的。有一个测试数据,基本上NSInvocation的调用效率是普通消息发送的100倍左右。实际上,Aspect只能应用于不超过每秒1000次调用的场景。当然,还有一些其他的库。虽然性能有所提升,但不支持多线程场景。一旦锁定,性能将大大降低。看了一圈也没有方便的库,想了想自己写了一个。因此,Lokie诞生了。Lokie的设计基本原则只有两条,第一是高效,第二是线程安全。为了满足高效的设计原则,Lokie采用高效的C++设计语言,标准使用C++14。与C++98相比,C++14由于引入了MOV语义、完美转发、右值引用和多线程支持等强大功能,性能有了显着提高。另一方面,我们摒弃了对OC消息转发和NSInvocation的依赖,使用libffi来设计核心的trampoline功能,直接从设计上砍掉大佬。另外,线程锁的实现也采用了轻量级的CAS无锁同步技术,也减少了很多线程同步的开销。根据一些真实设备的性能数据,以iPhone7P为例,百万次调用Aspect的成本约为6s,而Lokie在相同场景下的成本仅为0.35s左右。从测试数据来看,性能提升还是非常显着的。性子急,看书也喜欢先看代码。所以我先贴出lokie的开源地址:https://github.com/alibaba/Lokie喜欢翻代码的同学可以先去看看。Lokie的头文件非常简单,只有两个方法和一个LokieHookPolicy枚举,如下所示。typedefenum:NSUInteger{LokieHookPolicyBefore=1<<0,LokieHookPolicyAfter=1<<1,LokieHookPolicyReplace=1<<2,}LokieHookPolicy;@interfaceNSObject(Lokie)+(BOOL)Lokie_hookMemberSelector:(NSString*)selector_namewithBlock:(:)blockpolicy(LokieHookPolicy)policy;+(BOOL)Lokie_hookClassSelector:(NSString*)selecctor_namewithBlock:(id)blockpolicy:(LokieHookPolicy)policy;-(NSArray*)lokie_errors;@end这两个方法的参数是一样的,提供类Slicing支持对于方法和成员方法。selecctor_name:是你感兴趣的选择器的名称,通常我们可以通过NSStringFromSelectorAPI获取。block:是具体要执行的命令,block的参数和返回值后面会讲到。policy:指定要在选择器执行前后执行block,或者干脆重写原来的方法。监控效果拍个场景,看看Lokie的威力。比如我们要监控所有页面的生命周期是否正常。比如项目中的VC基类叫做BasePageController,指定初始化器是@selector(initWithConfig)。我们暂时把这段测试代码放在application:didFinishLaunchingWithOptions中,AOP就是这么任性!这样,我们在app初始化的时候就监听了所有BasePageController对象生命周期的起点和终点。是不是很酷?Classcls=NSClassFromString(@"BasePageController");[clsLokie_hookMemberSelector:@"initWithConfig:"withBlock:^(idtarget,NSDictionary*param){NSLog(@"%@",param);NSLog(@"Lokie:%@iscreated",target);}policy:LokieHookPolicyAfter];[clsLokie_hookMemberSelector:@"dealloc"withBlock:^(idtarget){NSLog(@"Lokie:%@isdealloc",target);}policy:LokieHookPolicyBefore];block参数定义很有意思,第一个参数是永恒的id目标,选择器发送的对象,其余参数与选择器保持一致。比如“initWithConfig:”有个参数,类型是NSDNSDictionary*,所以我们把^(idtarget,NSDictionary*param)传给initWithConfig:,而dealloc没有参数,所以block变成了^(idtarget)。也就是说,在blockcallback中,你可以拿到当前对象和执行这个方法的参数上下文,基本上已经给你提供了足够的信息。返回值也很容易理解。使用LokieHookPolicyReplace替换原方法时,block的返回值必须与原方法一致。使用其他两个标志时,没有返回值,直接使用void即可。另外,我们可以多次hook同一个方法,比如这样:部分代码会在调用viewDidAppear之前执行");}policy:LokieHookPolicyBefore];[clsLokie_hookMemberSelector:@"viewDidAppear:"withBlock:^(idtarget,BOOLani){NSLog(@"LOKIE:这部分代码会在调用之后执行viewDidAppear被称为");}policy:LokieHookPolicyAfter];细心的你是不是觉得,如果我们用一个时间戳来记录前后两次,就可以很容易的得到某个函数的执行时间。前面两个简单的小例子也算是一个介绍。AOP在监控和日志方面还是很强大的。实现原理整个AOP的实现是基于iOS的runtime机制,以libffi创建的trampoline函数为核心。所以这里我也说说iOS运行时的一些事情。这部分可能很多人都比较熟悉。OC运行时有几个基本概念:SEL、IMP、Method。SELtypedefstructobjc_selector*SEL;typedefid(*IMP)(id,SEL,...);structobjc_method{SELmethod_name;char*method_types;IMPmethod_imp;};typedefstructobjc_method*Method;objc_selector这个结构很有意思,我在源码里没找到他代码定义。不过大家可以通过翻阅代码推测objc_selector的实现。在objc-sel.m中,有两个函数代码如下:constchar*sel_getName(SELsel){if(!sel)return"";return(constchar*)(constvoid*)sel;}sel_getName这个函数出现率还是很高的。从它的实现来看,sel和constchar*可以直接相互转换,第二个函数更清晰:staticSEL__sel_registerName(constchar*name,intcopy);//!在__sel_registerName中有一个方法可以直接通过constchar*name获取SEL...if(!result){result=sel_alloc(name,copy);}...//!sel_alloc实现staticSELsel_alloc(constchar*name,boolcopy){选择锁定。assertWriting();return(SEL)(copy?strdupIfMutable(name):name);}看到这里,我们基本可以推测objc_selector的定义应该类似于下面的形式:typedefstruct{charselector[XXX];void*unknown;...}objc_selector;为了提高效率,selecor的搜索使用字符串的hash值作为key,比直接使用字符串进行索引搜索效率更高。//!objc4-208版哈希算法staticCFHashCode_objc_hash_selector(constvoid*v){if(!v)return0;return(CFHashCode)_objc_strhash(v);}static__inline__unsignedint_objc_strhash(constunsignedchar*s){unsignedinthash=0;for(;;){inta=*s++;if(0==a)break;hash+=(hash<<8)+a;}returnhash;}//!objc4-723哈希算法staticunsigned_mapStrHash(NXMapTable*table,constvoid*key){unsignedhash=0;unsignedchar*s=(unsignedchar*)key;/*unsignedtoavoidasign-extend*//*unrolltheloop*/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;}returnxorHash(hash);}staticINLINEunsignedxorHash(unsignedhash){unsignedxored=(hash&0xffff)^(hash>>16);return((xored*65521)+hash);}至于为什么要专门创建一个objc_selector,我觉得官方应该强调一下SEL和constchar是不同的类型。IMPIMP的定义如下:#if!OBJC_OLD_DISPATCH_PROTOTYPEStypedefvoid(*IMP)(void/*id,SEL,...*/);#elsetypedefid_Nullable(*IMP)(id_Nonnull,SEL_Nonnull,...);#endifAfterLLVM6.0添加了OBJC_OLD_DISPATCH_PROTOTYPES,您需要在构建设置中将EnableStrictCheckingofobjc_msgSendCalls设置为NO才能使用objc_msgSend(idself,SELop,...)。有的同学在调用objc_msgSend时,编译器会报如下错误,就是这个原因。Toomanyargumentstofunctioncall,expected0,have2IMP是一个函数指针,是最后一个方法调用的执行指令的入口点。objc_method可以说是非常关键了。也是OC语言运行时methodswizzling设计的基石。通过objc_method将函数地址、函数签名和函数名进行封装关联。当类方法真正执行时,通过选择器名称,找到对应的IMP。同样,我们也可以通过在运行时替换选择器名称对应的IMP来满足一些特殊需求。明确了消息发送机制的三个概念之后,我们继续说消息发送机制。我们知道,在给对象发送消息时,有一个关键的函数叫objc_msgSend,这个函数具体是干什么的,下面简单说一下。//!objc_msgSend函数定义idobjc_msgSend(idself,SELop,...);该函数内部采用汇编语言编写,针对不同的硬件系统提供相应的实现代码。不同版本的实现应该是有区别的,包括函数名和实现(我查的版本是objc4-208)。objc_msgSend首先做的是检查消息发送对象self是否为空,如果为空则什么都不做直接返回。这就是为什么在对象为nil时发送消息不会崩溃的原因。这些测试完成后,会通过self->isa->cache在缓存中找到selector对应的方法(方法存放在缓存中),如果找到则直接调用Method->method_imp.如果没有找到,则进入下一个处理流程,调用一个名为class_lookupMethodAndLoadCache的函数。该函数的定义如下:IMP_class_lookupMethodAndLoadCache(Classcls,SELsel){...if(methodPC==NULL){//!在这里指定消息转发入口//Classandsuperclassesdonotrespond--useforwardingsmt=malloc_zone_malloc(_objc_create_zone(),sizeof(structobjc_method));smt->method_name=sel;smt->method_types="";smt->method_imp=&_objc_msgForward;_cache_fill(cls,smt,sel);methodPC=&_objc_msgForward;}...}这部分消息转发机制的动态方法分析,备份接收者,消息重定向应该是很多面试官喜欢问的环节:),我想大家一定这部分内容大家都熟悉,这里不再赘述。tramline函数的实现在下面的内容中,我们简单介绍一下如何从汇编的角度实现一个tramline函数,完成C函数级别的函数转发。以x86指令集为例,其他类型原理类似。从汇编的角度来看,跳转一个函数最直接的方式就是插入一条jmp指令。在x86指令集中,每条指令都有自己的指令长度。例如jmp指令的长度为5,其中包括一个字节的指令代码和一个四字节的相对偏移量。假设我们手头有两个函数A和B。如果我们想把B的调用转发给A,毫无疑问jmp指令可以帮上忙。接下来我们要解决的问题是如何计算这两个函数的相对偏移量。我们可以这样考虑这个问题,但是当cpu遇到jmp的时候,它的执行动作是ip=ip+5+relativeoffset。为了更直接的说明这个问题,我们来看下面的汇编函数(不熟悉汇编的也不要着急,这个函数什么都不做,只是跳转而已)。你也可以跟我一起做,先写一个jump_test.s,里面定义了一个什么都不做的函数。先看汇编代码文件:(jump_test.s)被翻译成C函数,即voidjump_test(){return;}..global_jump_test_jump_test:jmpjlable#!为了测试jmp指令的偏移量,人为添加了几个nopnopnopnopjlable:rep;ret接下来,我们正在创建一个C文件:在这个文件中,我们调用了刚刚创建的jump_test函数。#includeexternvoidjump_test();intmain(){jump_test();}最后编译链接。我们创建一个build.sh来生成可执行文件portal。#!/bin/shcc-c-omain.omain.cas-ojump_test.ojump_test.scc-oportalmain.cjump_test.o我们使用lldb来加载调试刚刚生成的prtal文件,并在函数jump_test上设置断点。lldb./portalbjump_testr是我机器上的如下跳转地址,你的地址可能和我的不一样,但是没关系,不影响我们分析。Process22830launched:'./portal'(x86_64)Process22830stopped*thread#1,queue='com.apple.main-thread',stopreason=breakpoint1.1frame#0:0x0000000100000f9fportal`jump_testportal`jump_test:->0x100000f9f<+0>:jmp0x100000fa7;jlable0x100000fa4<+5>:nop0x100000fa5<+6>:nop0x100000fa6<+7>:nop演示到这里,我们已经成功的从汇编的角度看到了一些我们想要的东西。首先看当前ip是0x100000f9f,此时我们程序集中使用的jlable已经计算出来,成为新的目标地址(0x100000fa7)。我们知道新的ip是在当前ip上加上offset计算出来的,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的操作码,接下来的4个字节就是我们计算的偏移量。最后给出了一个完整的例子。汇编分析和C代码打包放在一起。#include#includeintnew_add(inta,intb){returna+b;}intadd(inta,intb){printf("my_addorgiscalled!\n");return0;}typedefstruct{uint8_tjmp;uint32_toff;}__attribute__((packed))tramp_line_code;voiddohook(void*src,void*dst){vm_protect(mach_task_self(),(vm_address_t)src,5,0,VM_PROT_ALL);tramp_line_codejshort;jshort.jmp=0xe9;jshort.off=(uint32_t)(long)dst-(uint32_t)(long)src-0x5;memcpy(my_add,(constvoid*)&jshort,sizeof(tramp_line_code));vm_protect(mach_task_self(),(vm_address_t)src,5,0,VM_PROT_READ|VM_PROT_EXECUTE);}intmain(){dohook(add,new_add);intc=添加(10,20);//!该函数默认实现返回0,hook后返回30printf("resis%d\n",c);return0;}编译脚本(macOS系统):gcc-oportal./main.c执行:./portalOutput:resis30至此,函数调用转发成功。【本文为专栏作者《阿里巴巴官方技术》原创稿件,转载请联系原作者】点此查看作者更多好文