提示:阅读本文需要对isa
和superclass
指针非常熟悉,如果你还不是很清楚的话,可以参考我的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<UITouch *> *)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<NSKeyValueChangeKey,id> *)change context:(void *)context
{
NSLog(@"监听到了%@的%@属性值发生了改变 - %@ - %@",object, keyPath, change, context);
}
@end
以上是KVO的简单使用过程,我们对person1
增加了监听,打印结果如下
由于没有对person2设置监听,所以日志里面看不到有关person2值改变的信息。
我们在touchesBegan
方法里面简单地改变了person1
和person2
的age
属性值,
self.person1.age = 21;
self.person1.age = 21;
本质就是调用setter方法,
[self.person1 setAge:21];
[self.person1 setAge:21];
,
而且我们知道系统为属性自动生成的set方法(以这里的age属性为例)其实很简单,就是
说到这里,我们肯定都会好奇,既然从本质上,person1
和person2
都是调用了setAge
方法,同样的代码同样的步骤,KVO是如何实现对person1的监听的呢?
将代码跑一下,我们可以发现,无论person1
还是person2
,确实都走了setAge
方法,但是方法是一样的,所以KVO的秘密肯定不在setAge
方法里面。那看来肯定就是在实例对象身上做文章了。
我们在调试器中打印一下person1
、person2
的isa指针
可以看出,person1
加上KVO
监听之后,它的isa
指针指向了一个叫NSKVONotifying_CLPerson
的class对象
,而没有加监听的person2
的isa
则正常指向CLPerson
。
NSKVONotifying_CLPerson
不是我们创建的类,它是系统在我们使用KVO给某一个对象增加监听是,利用Runtime技术动态新增的一个类,它是对象原来所属类的一个子类
我们借助下面两幅图来先了解一下他们的结构关系
这是没有添加KVO监听的
person2
的对象结构图
这是添加了KVO监听的
person1
的对象结构图
我们通过KVO
给person1
增加监听之后,系统在person1
和CLPerson
的class
对象中间,利用runtime
动态创建了一个NSKVONotifying_CLPerson
类对象,然后将person1
的isa
指针指向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];
的调用轨迹:
- 向
person1
发送setAge
消息 - 通过
person1
的isa
找到NSKVONotifying_CLPerson
的类对象,调用它的setAge
方法。 setAge
中,调用_NSSetIntValueAndNotify()
函数_NSSetIntValueAndNotify()
中,先调用[self willChangeValueForKey:@"age"];
,再调用父类(CLPerson
)的setAge
方法[super setAge:age];
,最后调用[self didChangeValueForKey:@"age"];
。[self didChangeValueForKey:@"age"];
方法里面对监听器进行通知,也就是回调它的监听代理方法- 整个过程结束。
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));
这个就证明了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之后,
person1
的setAge:
方法实现地址变了。如果要查看方法更具体一点的信息,可以通过p (IMP)<具体的方法实现地址>
来打印方法信息。
如图,如果是正常的方法,打印信息会显示方法所在的具体模块下的具体文件内的的第几行。我们得以验证,添加KVO之后,person1
的setAge:
方法确实是调用了_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)));
我们可以发现,person1
和person2
无论是class
对象还是meta-class
对象,都是不一样的,因此说明,在添加了KVO之后,person1
的isa
所指向的NSKVONotifying_CLPerson
的这个类,有自己的对应的class
对象和meta-class
对象,是一个完整的类。
关于Foundation框架
我们上面介绍了,KVO添加属性监听之后,person1
的setAge:
方法内部调用了一个Foundation
函数_NSSetIntValueAndNotify ()
。因为Foundation
是苹果提供的一个动态库,除了Foundation
的.h
文件外,我们无法查看其.m
里面的源代码,但是借助一些逆向工具,我们还是可以窥探他的一些内部细节,这里关于逆向工程的话题我们不作展开,总之,通过抽取Foundation
的.framework
文件(也就是编译成010101机器码的二进制动态库),我们可以在它里找到_NSSetIntValueAndNotify ()
方法,同时,还发现有很多相似的方法
从规律上,我们猜测,根据属性不同的类型,会使用不同的被监听的对象的setAge
方法会调用不同的_NSSetXXXValueAndNotify ()
方法来处理对应属性值的变化。
我们把age属性的类型编程Double
试试。
确实,我们又发现了一个_NSSetDoubleValueAndNotify
方法。
上面我们也总结道_NSSetXXXValueAndNotify
方法的内部逻辑
我们也来证明一下。
#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
是它的父类,我们可以对它加以修改,以上代码中,我们给几个关键方法都加上日志信息,就可以追踪到他们的调用轨迹。运行程序,日志如下
日志结果清晰显示了
_NSSetXXXValueAndNotify
函数内部的调用逻辑,与我们的结论吻合。
关于KVO子类的一些细节
我们前面的图例里面,总结了,KVO监听对象所产生的子类里面,除了有setter
方法,还有class
、dealloc
、_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监听之后,就可以调用这个方法进行打印,结果如下
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));
可以看到,
[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
函数,该函数内部的主要逻辑是
- 调用
willChangeValueForKey:
- 调用父类(也就是
instance
对象被监听之前,isa
所指向的class
)的setter
方法,进行成员变量赋值- 调用
didChangeValueForKey:
方法,该方法内部会触发监听器(observer
)的监听方法(observeValueForKeyPath: ofObject: change: context:
)
如何手动触发KVO
手动调用willChangeValueForKey:
和didChangeValueForKey:
即可
直接修改成员变量会触发KVO吗?
触发KVO的条件是通过属性值修改,触发了setter
方法,从而触发KVO回调方法,因此直接修改属性对应的成员变量值,不会触发KVO。