1、示例源码

  1. - (void)viewDidLoad {
  2. [super viewDidLoad];
  3. ZLPerson *p1 = [[ZLPerson alloc] init];
  4. ZLPerson *p2 = [[ZLPerson alloc] init];
  5. p1.age = 1;
  6. p1.age = 2;
  7. p2.age = 2;
  8. // self 监听 p1 的 age 属性
  9. NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
  10. [p1 addObserver:self forKeyPath:@"age" options:options context:nil];
  11. p1.age = 10;
  12. [p1 removeObserver:self forKeyPath:@"age"];
  13. }
  14. - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
  15. NSLog(@"监听到 %@ 的 %@ 改变了 %@", object, keyPath,change);
  16. }

示例对 p1 进行了 KVO 监听,当 p1 发生改变,即调用 observeValueForKeyPath 方法,从而打印以下信息。

image.png

通过上述代码可以发现,一旦 age 属性的值发生改变,就会通知到监听者。我们知道赋值操作都是调用 se 方法,我们可以重写 ZLPerson 类中 ageset 方法,观察 KVO 是否是在 set 方法内部做了一些操作来通知监听者。

  1. - (void)setAge:(NSInteger)age {
  2. NSLog(@"override setAge");
  3. _age = age;
  4. }

我们发现即使重写了 set 方法,p1 除了调用 set 方法之外还会执行监听者的 observeValueForKeyPath 方法。

根据上述实验推测:KVO 在运行时对 p1 对象进行了改动,使 p1 对象在调用 setAge 方法时做了一些额外的操作。所以问题出在对象身上,两个对象可能本质上并不一样。下面我们来探索一下 KVO 内部是如何实现的。

2、KVO 的实现分析

首先分别在添加 KVO 前后打上断点,以观察添加 KVO 前后 p1 对象有何不同。

image.png

通过打印对象的类型,我们发现,p1 对象的 isa 指针由之前的指向类对象 ZLPerson 变成了指向类对象 NSKVONotifying_ZLPerson。相应地,p2 对象没有改变。因此我们可以推测,p1 对象的 isa 发生改变后,其执行的 setAge 也发生了改变。

我们知道,p2 在调用 setAge 方法时,首先会通过 p2 对象的 isa 指针找到 ZLPerson 类对象,然后在类对象中找到 setAge 方法,最终找到方法对应的实现。

但是,p1 对象的 isa 在添加 KVO 之后已经指向了 NSKVONotifying_ZLPerson 类对象,NSKVONotifying_ZLPerson 则是 ZLPerson 的子类。NSKVONotifying_ZLPersonruntime 在运行时生成的。因此,p1 对象在调用 setAge 方法时必然会根据 p1isa 找到 NSKVONotifying_ZLPerson,并在 NSKVONotifying_ZLPerson 中找到 setAge 方法及其实现。

我们通过 lldb 指令,去调试中间的变化过程,使用 watchpoint set variable p1->_age 指令。

image.png

watchpoint: 观察变量或者属性 这是一个非常有用的东西,我们经常遇到,某一个变量,不知道什么时候值被改掉了,就可以使用这个东西去定位。

根据以上调试结果,NSKVONotifying_ZLPerson 中的 setAge 方法中其实调用了 Foundation 框架中 C 语言函数 _NSSetLongLongValueAndNotify_NSSetLongLongValueAndNotify 内部的操作大致是:首先调用 willChangeValueForKey 方法,然后调用父类的 setAge 方法对成员变量赋值,最后调用 didChangeValueForKey 方法。didChangeValueForKey 方法中会调用监听者的监听方法,最终调用监听者的 observeValueForKeyPath 方法。

3、KVO 原理验证

前面我们已经通过断点打印类名的方式验证了:p1 对象在添加 KVO 后,其 isa 指针会指向一个通过 runtime 创建的 ZLPerson 的子类 NSKVONotifying_ZLPerson

下面我们可以通过打印方法实现的地址来看一下 p1p2setAge 方法实现的地址在添加 KVO 前后有什么变化。

image.png

执行上述代码,可以发现:在添加 KVO 之前,p1p2setAge 方法实现的地址是相同的;在添加 KVO 之后, p1setAge 方法实现的地址发生了改变。通过打印方法实现可以证明,p1setAge 方法的实现由 ZLPerson 类方法中的 setAge 方法转换成了 Foundation 框架中的 C 函数 _NSSetLongLongValueAndNotify

事实上,Foundation 框架中很多例如 _NSSetBoolValueAndNotify_NSSetCharValueAndNotify_NSSetFloatValueAndNotify_NSSetLongValueAndNotify 等函数。

为了查看 Foundation 框架中的相关函数,我们找到 Foundation 文件,通过命令行查询:

  1. nm Foundation | grep ValueAndNotify

image.png

4、中间类内部结构

NSKVONotifying_ZLPerson 作为 ZLPerson 的子类,其 superclass 指针指向 ZLPerson 类,其内部对 setAge 方法做了单独的实现,那么 NSKVONotifying_ZLPersonZLPerson 类的差别可能就在于其内存储的对象方法及实现不同。我们通过 runtime 分别打印 ZLPerson 类对象和 NSKVONotifying_ZLPerson 类对象内存储的对象方法。

  1. - (void)viewDidLoad {
  2. [super viewDidLoad];
  3. ZLPerson *p1 = [[ZLPerson alloc] init];
  4. ZLPerson *p2 = [[ZLPerson alloc] init];
  5. p1.age = 1;
  6. p1.age = 2;
  7. p2.age = 2;
  8. // self 监听 p1 的 age 属性
  9. NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
  10. [p1 addObserver:self forKeyPath:@"age" options:options context:nil];
  11. [self printMethods: object_getClass(p2)];
  12. [self printMethods: object_getClass(p1)];
  13. p1.age = 10;
  14. [p1 removeObserver:self forKeyPath:@"age"];
  15. }
  16. - (void)printMethods:(Class)cls {
  17. unsigned int count;
  18. Method *methods = class_copyMethodList(cls, &count);
  19. NSMutableString *methodNames = [NSMutableString string];
  20. [methodNames appendFormat:@"%@ - ", cls];
  21. for (int i = 0 ; i < count; i++) {
  22. Method method = methods[i];
  23. NSString *methodName = NSStringFromSelector(method_getName(method));
  24. [methodNames appendString:@"\n"];
  25. [methodNames appendString:methodName];
  26. }
  27. NSLog(@"%@", methodNames);
  28. free(methods);
  29. }

上述代码的打印结果如下:

image.png

可以发现,NSKVONotifying_ZLPerson 中有 4 个对象方法,分别是:

  1. setAge:
  2. class
  3. dealloc
  4. _isKVOA

NSKVONotifying_ZLPerson 重写 class 方法是为了隐藏 NSKVONotifying_ZLPerson 不被外界看到。我们在 p1 添加 KVO 之后,分别打印 p1p2 对象的 class,可以发现它们都返回 ZLPerson

  1. NSLog(@"%@, %@", [p1 class], [p2 class]);
  2. // 打印结果 ZLPerson, ZLPerson

验证 didChangeValueForKey: 内部调用 observeValueForKeyPath:ofObject:change:context: 方法
ZLPerson 类中重写 willChangeValueForKey:didChangeValueForKey: 方法,模拟它们的实现。

  1. - (void)setAge:(NSInteger)age {
  2. NSLog(@"override setAge");
  3. _age = age;
  4. }
  5. - (void)willChangeValueForKey:(NSString *)key {
  6. NSLog(@"willChangeValueForKey: - begin");
  7. [super willChangeValueForKey:key];
  8. NSLog(@"willChangeValueForKey: - end");
  9. }
  10. - (void)didChangeValueForKey:(NSString *)key {
  11. NSLog(@"didChangeValueForKey: - begin");
  12. [super didChangeValueForKey:key];
  13. NSLog(@"didChangeValueForKey: - end");
  14. }

通过运行上述代码,可以确定是在 didChangeValueForKey: 方法内部调用了监听者的 observeValueForKeyPath:ofObject:change:context: 方法。

image.png

根据上述原理,可以通过调用 willChangeValueForKey:didChangeValueForKey: 来手动触发 KVO

5、总结

5.1 KVO的实现原理

KVOObjective-C观察者模式(Observer Pattern) 的实现。当被观察对象的某个属性发生更改时,观察者对象会获得通知。有意思的是,你不需要给被观察的对象添加任何额外代码,就能使用 KVO。这是怎么做到的呢?

KVO 的实现也依赖于 Objective-C 强大的 RuntimeKVC。Apple 的 官方文档 有简单提到过 KVO 的实现。被观察对象的 isa 指针会指向一个中间类,而不是原来真正的类。

要是我们用 runtime 提供的方法去深入挖掘,所有被掩盖的细节都会原形毕露。Mike Ash 在 2009 年就做了这么个探究 文章地址

当你观察一个对象时,一个新的类会动态被创建。这个类继承自该对象的原本的类,并重写了被观察属性的 setter 方法。自然,重写的 setter 方法会负责在调用原 setter 方法之前和之后,通知所有观察对象值的更改。最后把这个对象的 isa 指针 ( isa 指针告诉 Runtime 系统这个对象的类是什么 ) 指向这个新创建的子类,对象就神奇的变成了新创建的子类的实例。

原来,这个中间类,继承自原本的那个类。不仅如此,Apple 还重写了 -class 方法,企图欺骗我们这个类没有变,就是原本那个类。

5.2 KVO的不足

KVO 很强大,没错。知道它内部实现,或许能帮助更好地使用它,或在它出错时更方便调试。

但是不足的地方也有,比如你只能通过重写 -observeValueForKeyPath:ofObject:change:context:方法来获得通知。想要提供自定义的 selector,不行;想要传一个 block,门都没有。而且你还要处理父类的情况 - 父类同样监听同一个对象的同一个属性。但有时候,你不知道父类是不是对这个消息有兴趣。虽然 context 这个参数就是干这个的,也可以解决这个问题 - 在 -addObserver:forKeyPath:options:context: 传进去一个父类不知道的 context。但总觉得框在这个 API 的设计下,代码写的很别扭。至少至少,也应该支持 block 吧。

有不少人都觉得官方 KVO 不好使的。Mike Ash 的 Key-Value Observing Done Right,以及获得不少分享讨论的 KVO Considered Harmful 都把 KVO拿出来吊打了一番。所以在实际开发中 KVO 使用的情景并不多,更多时候还是用 DelegateNotificationCenter

6、参考资料