KVO的底层原理

提示:阅读本文需要对isasuperclass指针非常熟悉,如果你还不是很清楚的话,可以参考我的isa和superclass的总结.
什么是KVO?

KVO全称是Key-Value Observing,俗称“键值监听”,可用于监听某个对象属性值的改变。
KVO的本质分析 先看如下代码
#import "ViewController.h" #import "CLPerson.h" @interface ViewController () @property (nonatomic, strong) CLPerson *person1; @property (nonatomic, strong) CLPerson *person2; @end@implementation ViewController- (void)viewDidLoad { [super viewDidLoad]; self.person1 = [[CLPerson alloc] init]; self.person1.age = 1; self.person2 = [[CLPerson alloc] init]; self.person2.age = 2; //给person1对象添加kvo监听 NSKeyValueObservingOptions options = NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew; [self.person1 addObserver:self forKeyPath:@"age" options:options context:@"test context"]; }- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { self.person1.age = 21; self.person2.age = 22; }- (void)dealloc { [self.person1 removeObserver:self forKeyPath:@"age"]; }//当监听对象的属性值发生改变时,就会调用 - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { NSLog(@"监听到了%@的%@属性值发生了改变 - %@ - %@",object, keyPath, change, context); }@end

以上是KVO的简单使用过程,我们对person1增加了监听,打印结果如下
KVO的底层原理
文章图片

由于没有对person2设置监听,所以日志里面看不到有关person2值改变的信息。
我们在 touchesBegan方法里面简单地改变了 person1person2age属性值,
self.person1.age = 21;
self.person1.age = 21;
本质就是调用setter方法,
[self.person1 setAge:21];
[self.person1 setAge:21];
而且我们知道系统为属性自动生成的set方法(以这里的age属性为例)其实很简单,就是 KVO的底层原理
文章图片

说到这里,我们肯定都会好奇,既然从本质上, person1person2都是调用了 setAge方法,同样的代码同样的步骤,KVO是如何实现对person1的监听的呢?
将代码跑一下,我们可以发现,无论person1还是person2,确实都走了setAge方法,但是方法是一样的,所以KVO的秘密肯定不在setAge方法里面。那看来肯定就是在实例对象身上做文章了。
我们在调试器中打印一下person1、person2的isa指针

KVO的底层原理
文章图片

可以看出, person1加上 KVO监听之后,它的 isa指针指向了一个叫 NSKVONotifying_CLPersonclass对象,而没有加监听的 person2isa则正常指向 CLPerson
NSKVONotifying_CLPerson不是我们创建的类,它是系统在我们使用KVO给某一个对象增加监听是,利用Runtime技术动态新增的一个类,它是对象原来所属类的一个子类 KVO的底层原理
文章图片

我们借助下面两幅图来先了解一下他们的结构关系

KVO的底层原理
文章图片
这是没有添加KVO监听的person2的对象结构图

KVO的底层原理
文章图片
这是添加了KVO监听的person1的对象结构图
我们通过KVOperson1增加监听之后,系统在person1CLPersonclass对象中间,利用runtime动态创建了一个NSKVONotifying_CLPerson类对象,然后将person1isa指针指向NSKVONotifying_CLPerson,并且它实际上是CLPerson的子类。如上图所示,这个类对象里面,除了重写了setAge方法,还重写了class, dealloc,以及增加了_isKVOA方法。
  • setAge方法:KVO的核心魔法就在与对这个方法的重写,虽然苹果没有把这部分的实现开源,但是我们还是有办法推断出内部的大概逻辑的,这里我们先直接说结果。在重写的方法中,实际上调用了Foundation框架的一个c函数_NSSetIntValueAndNotify(),而这个函数主要就做了这么几件事,我们用为代码来理解一下
- (void)setAge:(int)age { _NSSetIntValueAndNotify(); }// 伪代码 void _NSSetIntValueAndNotify() { [self willChangeValueForKey:@"age"]; [super setAge:age]; //调用父类(CLPerson)的setAge方法 [self didChangeValueForKey:@"age"]; }- (void)didChangeValueForKey:(NSString *)key { // 通知监听器,某某属性值发生了改变 [oberser observeValueForKeyPath:key ofObject:self change:nil context:nil]; }

因此我们在来走一遍[person1 setAge:21]; 的调用轨迹:
  1. person1发送setAge消息
  2. 通过person1isa找到NSKVONotifying_CLPerson的类对象,调用它的setAge方法。
  3. setAge中,调用_NSSetIntValueAndNotify()函数
  4. _NSSetIntValueAndNotify()中,先调用[self willChangeValueForKey:@"age"]; ,再调用父类(CLPerson)的setAge方法[super setAge:age]; ,最后调用[self didChangeValueForKey:@"age"];
  5. [self didChangeValueForKey:@"age"]; 方法里面对监听器进行通知,也就是回调它的监听代理方法
  6. 整个过程结束。
KVO本质的验证
我们在之前添加KVO的代码出加上两段打印
NSLog(@"person1添加kvo监听之前\nperson1-%@\nperson2-%@", object_getClass(_person1),object_getClass(_person2)); //给person1对象添加kvo监听 NSKeyValueObservingOptions options = NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew; [self.person1 addObserver:self forKeyPath:@"age" options:options context:@"test context"]; NSLog(@"person1添加kvo监听之后\nperson1-%@\nperson2-%@", object_getClass(_person1),object_getClass(_person2));


KVO的底层原理
文章图片

这个就证明了 NSKVONotifying_CLPerson是在代码执行过程中动态生成的新类。
同样我们也可以打印一下KVO前后 setAge:方法的实现是否有变化
NSLog(@"person1添加kvo监听之前\nperson1-%p\nperson2-%p", [self.person1 methodForSelector:@selector(setAge:)],[self.person2 methodForSelector:@selector(setAge:)]); //给person1对象添加kvo监听 NSKeyValueObservingOptions options = NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew; [self.person1 addObserver:self forKeyPath:@"age" options:options context:@"test context"]; NSLog(@"person1添加kvo监听之前\nperson1-%p\nperson2-%p", [self.person1 methodForSelector:@selector(setAge:)],[self.person2 methodForSelector:@selector(setAge:)]);


KVO的底层原理
文章图片
可以看出,添加KVO之后, person1setAge:方法实现地址变了。如果要查看方法更具体一点的信息,可以通过 p (IMP)<具体的方法实现地址>来打印方法信息。
KVO的底层原理
文章图片

如图,如果是正常的方法,打印信息会显示方法所在的具体模块下的具体文件内的的第几行。我们得以验证,添加KVO之后, person1setAge:方法确实是调用了 _NSSetIntValueAndNotify()
我顺便又想到了一个问题,NSKVONotifying_CLPerson这个类的元类对象是什么?那我们来继续打印一下
//给person1对象添加kvo监听 NSKeyValueObservingOptions options = NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew; [self.person1 addObserver:self forKeyPath:@"age" options:options context:@"test context"]; NSLog(@"class对象\nperson1-%p\nperson2-%p", object_getClass(_person1),object_getClass(_person2)); NSLog(@"meta-class对象\nperson1-%p\nperson2-%p", object_getClass(object_getClass(_person1)), object_getClass(object_getClass(_person2)));


KVO的底层原理
文章图片

我们可以发现, person1person2无论是 class对象还是 meta-class对象,都是不一样的,因此说明,在添加了KVO之后, person1isa所指向的 NSKVONotifying_CLPerson的这个类,有自己的对应的 class对象和 meta-class对象,是一个完整的类。
关于Foundation框架
我们上面介绍了,KVO添加属性监听之后,person1setAge:方法内部调用了一个Foundation函数_NSSetIntValueAndNotify ()。因为Foundation是苹果提供的一个动态库,除了Foundation.h文件外,我们无法查看其.m里面的源代码,但是借助一些逆向工具,我们还是可以窥探他的一些内部细节,这里关于逆向工程的话题我们不作展开,总之,通过抽取Foundation.framework文件(也就是编译成010101机器码的二进制动态库),我们可以在它里找到_NSSetIntValueAndNotify ()方法,同时,还发现有很多相似的方法
KVO的底层原理
文章图片

从规律上,我们猜测,根据属性不同的类型,会使用不同的被监听的对象的 setAge方法会调用不同的 _NSSetXXXValueAndNotify ()方法来处理对应属性值的变化。
我们把age属性的类型编程Double试试。
KVO的底层原理
文章图片

确实,我们又发现了一个 _NSSetDoubleValueAndNotify方法。
上面我们也总结道_NSSetXXXValueAndNotify方法的内部逻辑
KVO的底层原理
文章图片

我们也来证明一下。
#import "CLPerson.h"@implementation CLPerson - (void)setAge:(double)age { _age = age; NSLog(@"调用了setAge方法"); }- (void)willChangeValueForKey:(NSString *)key { [super willChangeValueForKey:key]; NSLog(@"调用了willChangeValueForKey方法"); }- (void)didChangeValueForKey:(NSString *)key { NSLog(@"开始调用了didChangeValueForKey方法"); [super didChangeValueForKey:key]; NSLog(@"didChangeValueForKey方法调用结束"); } @end

虽然我们无法修改NSKVONotifying_CLPerson的内容, 但是由于CLPerson是它的父类,我们可以对它加以修改,以上代码中,我们给几个关键方法都加上日志信息,就可以追踪到他们的调用轨迹。运行程序,日志如下

KVO的底层原理
文章图片
日志结果清晰显示了 _NSSetXXXValueAndNotify函数内部的调用逻辑,与我们的结论吻合。
关于KVO子类的一些细节
【KVO的底层原理】
KVO的底层原理
文章图片

我们前面的图例里面,总结了,KVO监听对象所产生的子类里面,除了有 setter方法,还有 classdealloc_isKVOA这么几个方法。我们分别来看一下。
首先我们先用runtime来打印一下 NSKVONotifying_CLPerson的对象方法列表
-(void)printMethodNamesOfClass:(Class)cls { //获取方法 unsigned int count; Method *methodList = class_copyMethodList(cls, &count); //用于存放方法名 NSMutableString *methodNames = [NSMutableString string]; //遍历方法 for (int i = 0; i < count; i++) { //获得方法 Method method = methodList[i]; //转换成方法名 NSString *methodName = NSStringFromSelector(method_getName(method)); //拼接方法名 [methodNames appendString:@"\n - "]; [methodNames appendString:methodName]; } //释放 free(methodList); //打印结果 NSLog(@"\n%@%@",cls, methodNames); }

在给person1增加了KVO监听之后,就可以调用这个方法进行打印,结果如下
KVO的底层原理
文章图片

  • dealloc:这个好理解,这是为了在监听结束,对象被销毁的时候,需要做的一些结束处理收尾工作。
  • class:这个方法首先我们先来看一下它的返回值
//给`person1`对象添加kvo监听 NSKeyValueObservingOptions options = NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew; [self.person1 addObserver:self forKeyPath:@"age" options:options context:@"test context"]; NSLog(@"\nClass of person1 - %@\nClass of person2 - %@",[self.person1 class],[self.person2 class]); NSLog(@"\nISA of person1 - %@\nISA of person2 - %@",object_getClass(self.person1),object_getClass(self.person2));


KVO的底层原理
文章图片
可以看到, [person1 class]方法返回的是 CLPerson类,如果系统不重写这个方法,那么这个方法返回的应该是 NSKVONotifying_CLPerson,苹果这么设计,其实原因也很简单, 就是不想让使用者知道KVO的细节,屏蔽内部实现,隐藏有关NSKVONotifying_CLPerson的信息。让使用者感觉不到KVO的存在和影响,只需要专心使用KVO的监听功能就好。不得不感慨一下苹果api在设计细节上的处理。
  • _isKVOA:告诉系统使用了KVO。
到这里,KVO底层的相关原理就基本上都呈现出来了。
面试题解答
iOS用什么方式实现对一个对象的KVO?(KVO的本质)
  • 利用Runtime API为被监听对象动态生成一个子类,并且让instance对象的isa指向这个新的子类
  • 在新的子类中重写属性的setter方法。当instance对象属性被修改的时候,该setter方法被调用
  • 在上述的setter方法里面,会调用Foundation对象的_NSSetXXXValueAndNotify函数,该函数内部的主要逻辑是
    1. 调用willChangeValueForKey:
    2. 调用父类(也就是instance对象被监听之前,isa所指向的class)的setter方法,进行成员变量赋值
    3. 调用didChangeValueForKey:方法,该方法内部会触发监听器(observer)的监听方法(observeValueForKeyPath: ofObject: change: context:
如何手动触发KVO
手动调用willChangeValueForKey:didChangeValueForKey:即可
直接修改成员变量会触发KVO吗?
触发KVO的条件是通过属性值修改,触发了setter方法,从而触发KVO回调方法,因此直接修改属性对应的成员变量值,不会触发KVO。

    推荐阅读