KVO与Aspects共存研究

2,631 阅读5分钟

研究版本官方最新版本Release 1.4.2

现象

  • 在对Obj进行先KVO再Hook其setter函数后,调用obj的setter函数崩溃。
  • 反之,若先Hook再KVO,则正常运行。

原因

1.先对比两种添加顺序,导致的objisa函数列表的不同。

通过下面的函数打印出,当前的obj→isa 及其函数列表

NSLog(@"\nclass - %@\nclass methods - %@", object_getClass(self.testObj), [self allMethodsWithClass:object_getClass(self.testObj)]);
NSLog(@"\nclass - %@\nclass methods - %@", self.testObj.class, [self allMethodsWithClass:self.testObj.class]);

打印结果去除时间戳信息,显示如下

  • 先KVO再进行Hook

  • 先hook再进行KVO

对比可以发现,如果先KVO再hook ,KVO会先动态生成一个NSKVONotifying_ 前缀的子类,并且会重写setName: ,添加class 等函数,将class 的返回结果指向父类。 Aspect在hook的时候,如果发现obj.classobject_getClass(obj) 不相等的话(正常发生在对象被KVO重写过isa),则不会动态地再建立一个子类,而直接会原地hook,添加aspects__setName:forwardInvocation:,交换aspects__setName:setName: 的函数实现。(此时交换的是已经被KVO过的NSKVONotifying_AKObjectsetName:函数实现)

此时对name 进行赋值的时候,对obj 调用setName: ,在Aspect中如果不是使用指定AspectPositionInstead的option替换原方法实现,则会走原方法实现,则将对obj调用交换后的方法名aspects__setName:(对应原方法实现),NSKVONotifying_AKObject在处理aspects__setName:时候会调用其父类的方法,但是并不会保存父类原来的setter的方法名,而是直接将自己当前的selector(_cmd)当做方法名,并对父类AKObject进行调用,但是父类中并没有实现aspects__setName:,所以产生崩溃。

对于先hook再进行KVO的情况,setName:原类及其两个生成的子类都有对应的方法实现,所以可以正常响应。

解决KVO及Aspects共存的PR

PR地址:github.com/steipete/As…

git地址:github.com/doggy/Aspec…

解决共用的途径

这个PR创造性的在调用原函数实现那一步骤上做了优化,要调用原函数实现时,先交换回原始函数名及原始的函数实现,再对obj调用原函数名,此时在NSKVONotifying_AKObjectsetName:中拿到的_cmd就是原始的selector,父类中也有对应的方法实现,一切都能正常运行。然后再把invocation的selector对应子类中的方法实现交换回去,完成对原始函数实现的调用。

存在的其他问题

1.先hook再KVO,如果在清除KVO前对AspectToken进行remove,崩溃。

id token = [object aspect_hookSelector...];// add hook
[object addObserver...]; // add KVO
[token remove]; // crash

原因:在先hook再KVO这过程中,obj.isa经历的变化为

NSObjectNSObject_Aspects_NSKVONotifying_NSObject_Aspects_

假如在移除KVO前移除Aspects的hook,Aspects需要把当前子类还原为原始父类,

Aspects的处理逻辑是去除_Aspects_后缀,并将isa指向去除后缀后的类,

isa的变化过程为

NSKVONotifying_NSObject_Aspects_NSKVONotifying_NSObject

但是,此时程序中并没有存在NSKVONotifying_NSObject这个类,因此程序会崩溃。(这个坑在最新版的Aspects中仍然存在,且暂时无解决方法)

2.如果是先KVO再Hook的情况,要注意在objdealloc时才能remove调所有观察者,如果提前remove调所有观察者,hook是对NSKVONotifying_NSObject的方法hook,但移除所有观察者后就会把对应的NSKVONotifying_NSObject类销毁,此时isa

NSKVONotifying_NSObjectNSObject

原有的hook将会失效。

更好的hook方案

There's known issues with this approach, and to this date (February 2019) I STRICTLY DO NOT RECOMMEND TO USE Aspects IN PRODUCTION CODE.

既然,Aspects和KVO共存存在这么多坑,Aspects作者也不推荐在生产中使用该库。那我们有没有一个更好的代替者呢?

答案是来自饿了吗开源的Stinger

Stinger使用libffi及解析方法签名构建壳函数,替换原方法实现以感知方法调用和捕获参数;使用同一cif模板及函数指针直接执行原实现和所有切面block。 Stinger不使用消息转发指针替换原实现,hook兼容性更好;调用方法不经过消息转发过程,执行原实现及切面代码过程中无手动构建invocation等,效率更高。

测试发现,stinger在与其他hook框架(如Aspects)共用的时候,具有良好的兼容性,并且解决了上述所讲的其他存在的问题。唯一存在的问题是先KVO再Hook的情况下,由于经过KVO后,父类中不存在交换后函数名前缀为st_original_的setter的函数实现,因此在这种情况下调用setter会引起崩溃。但这并不是Hook框架存在的问题,应尽量避免先KVO再Hook的用法

下面说说Stinger是如何解决与KVO共用时,在移除KVO观察者前移除Stinger的hook,却不会引起崩溃。

上面说到Aspects移除本身所有hook后,会把自身的isa置回。但是Stinger却不会这么做,而仅仅只是把对应的hook信息清除,不会修改该对象的isa

    // 清除infos中的对应identifier的hook信息
    - (BOOL)_removeInfoForIdentifier:(STIdentifier)identifier inInfos:(NSMutableArray<id<STHookInfo>> *)infos {
      [_lock lock];
      BOOL flag = NO;
      for (int i = 0; i < infos.count; i ++) {
        id<STHookInfo> info = infos[i];
        if ([info.identifier isEqualToString:identifier]) {
          [infos removeObject:info];
          [_identifiers removeObject:identifier];
          flag = YES;
          break;
        }
      }
      [_lock unlock];
      return flag;
    }

因此保证了后面修改isa的KVO动态生成的子类一直存在。也因此移除先修改isa的Stinger功能不会影响到对应属性的KVO功能。

总结

那么,我们应该如何正确且安全地使用同时使用Aspects和KVO呢?

  1. 在对同一个对象的某一个实例变量使用Aspects及KVO时,尽量不要Remove对应的AspectToken,确保不会发生移除后的子类不存在的情况。
  2. 注意先KVO再Hook这种情况下,hook的有效期。(仅适用于加上PR115的Aspects)
  3. 如果实在觉得麻烦,那可以在需要使用Aspects和KVO共存的地方,直接换成在+(void)load中对方法进行替换实现对应功能。
  4. 最推荐的用法,使用Stinger代替,注意避免对某个实例变量的setter方法进行先KVO后hook的操作。

参考资料

  1. Hook方法的新姿势--Stinger (使用libffi实现AOP )
  2. Aspects
  3. Coexistence with KVO. Yes, it works!!