乐趣区

关于开放源代码:开源-如何实现一个iOS-AOP框架

简介: Aspect 应用了 OC 的音讯转发流程,有肯定的性能耗费。本文作者应用 C ++ 设计语言,并应用 libffi 进行外围 trampoline 函数的设计,实现了一个 iOS AOP 框架——Lokie。相比于业内熟知的 Aspects,性能上有了显著的晋升。本文将分享 Lokie 的具体实现思路。

前言

不盲目的想起本人从业的这十几年,如白驹过隙。当初谈到上还相熟的的语言以 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

这两个办法的参数是一样的,提供了对类办法和成员办法的切片化反对。

  • selecctor_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_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 函数的实现

接下来的内容,咱们简略介绍下,从汇编的视角登程,如何实现一个 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

至此,函数调用曾经被胜利转发了。

退出移动版