夜话 iOS AOP

作者: 阳_逍 | 来源:发表于2019-10-12 15:44 被阅读0次

    一直都想写一篇关于技术的文章,看看上一篇文章的写作时间,恍若隔世。
    不自觉的想起自己从业的这十几年,如白驹过隙。现在谈到上还熟悉的的语言以asm/c/c++/oc/js/lua/ruby/shell等为主,其他的基本上都是用时拈来过时忘,语言这种东西变化是在太快了, 不过大体换汤不换药,我感觉近几年来所有的语言隐隐都有一种大统一的走势,一旦有个特性不错,你会在不同的语言中都找到这种技术的影子。所以我对使用哪种语言并不是很执着,不过c/c++是信仰罢了: )

    工作大部分用OC和ruby, shell 之类的东西,前端时间一直想找一款合适的iOS下能用的AOP框架。iOS 业内比较被熟知的应该就是Aspect了。 但是Aspect性能比较差,Aspec的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语言的解释器, 目前以及可以用自己编写测试UT框架来测试它自己了,是否要开源,我还没有下定主意,我想后续就这个话题也聊几篇文章, 从词法分析,语法分析,到抽象语法树的生成等等,应该是个比较大的系列。有兴趣的同学可以持续关注。

    言归正传:
    OC runtime里有几个基础概念 SEL, IMP, Method

    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的定义如下所示:

    #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函数,完成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
    

    嘿,看到没,我们已经成功的转发了hook了my_add。

    不知不觉写了这么多,能看到这里的大家也幸苦了,表扬下,哈哈

    Lokie的开源地址我也附在这吧,省的再去翻了
    开源地址: https://github.com/alibaba/Lokie

    2019.09.07 北京 凌晨一点半 今天就先到这吧,歇了

    相关文章

      网友评论

        本文标题:夜话 iOS AOP

        本文链接:https://www.haomeiwen.com/subject/mqgqmctx.html