1. KVO的使用
KVO
(Key-Value Observing),也就是我们常说的键值监听
,可以用于监听某个对象属性值的改变。KVO
使用比较简单,如下所示定义了一个含有2个属性的Student
类,然后声明一个实例对象,并添加一个观察者监听某个属性,当被监听的属性发生变化时就会调用观察者的observeValueForKeyPath: ofObject: change: context:
方法。当不需要监听的时候需要移除观察者。
// Student.h文件
@interface Student : NSObject
@property (nonatomic , strong) NSString *name;
@property (nonatomic , strong) NSMutableArray booksArr;
@end
// 使用Student类的文件
- (void)test{
self.stu1 = [[Student alloc] init];
// 添加观察者监听name的变化
[self.stu1 addObserver:self
forKeyPath:@"name"
options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
context:NULL];
NSLog(@"name改变前");
self.stu1.name = @"Jack";
NSLog(@"name改变后");
}
// 当监听属性发生变化时的回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"keyPath:%@,change-->%@",keyPath,change);
}
- (void)dealloc{
// 移除观察者
[self.stu1 removeObserver:self forKeyPath:@"name"];
}
// ********************打印结果********************
2020-01-05 09:42:32.371008+0800 GCDDemo[13375:567451] name改变前
2020-01-05 09:42:32.371618+0800 GCDDemo[13375:567451] keyPath:name,change-->{
kind = 1;
new = Jack;
old = "<null>";
}
2020-01-05 09:42:32.371895+0800 GCDDemo[13375:567451] name改变后
2. KVO底层实现原理
KVO
的实现过程实际上是利用了OC的runtime
机制,当一个实例对象(比如上面的self.stu1
)添加观察者时,底层根据该实例对象所属的类动态添加了一个类(动态添加的类名就是在原来类的类名前加上NSKVONotifying_
前缀),这个类是继承自原来的类的。上面实例的底层实现过程如下:
2.1、self.stu1
添加观察者时,底层就利用runtime
动态生成一个叫NSKVONotifying_Student
的类,这个类继承自Student
类,并重写了以下实例方法:
- 重写
class
方法,不重写的话调用这个方法返回的是NSKVONotifying_Student
这个类,重写后返回的是原本的Student
类。苹果这么做的目的是为了隐藏KVO
的实现细节。 - 重写
dealloc
方法,在这个方法里面做一些收尾的工作。 - 重写
_isKVOA
方法,这是一个私有方法,我们不必关心 - 重写被监听属性的
setter方法
,上面案例只监听了name
属性,所以只需重写setName:
方法。重写setter是实现KVO
的关键,在setter方法里面实际是调用的Foundation
框架下的_NSSet***ValueAndNotify
方法(***表示不是一个固定的,这个和监听的属性的类型有关,比如是属性是int类型的话这里就是__NSSetIntValueAndNotify,所包含的类型会在后面列出来)。
2.2、然后将self.stu1
这个实例对象的isa
改为指向NSKVONotifying_Student
(原本是指向Student
类的)。
2.3、当我们设置被监听属性的值时self.stu1.name = @"Jack"
,是调用的setName:
方法,前面说了setName:
方法被重写了,所以实际上调用的是_NSSetObjectValueAndNotify
这个方法。这个方法实现苹果是没有开源的,无法得知其具体实现,不过可以猜出其实现流程大致如下:
- 首先调用
[self willChangeValueForKey:@"name"];
这个方法。 - 然后调用原先的setter方法的实现(比如
_name = name;
); - 再调用
[self didChangeValueForKey:@"name"];
这个方法。 - 最后在
didChangeValueForKey:
这个方法中调用观察者的observeValueForKeyPath: ofObject: change: context:
方法来通知观察者属性值发生了变化。
Foundation
框架下的_NSSet***ValueAndNotify
系列方法列表如下:
_NSSetBoolValueAndNotify
_NSSetCharValueAndNotify
_NSSetDoubleValueAndNotify
_NSSetFloatValueAndNotify
_NSSetIntValueAndNotify
_NSSetLongLongValueAndNotify
_NSSetLongValueAndNotify
_NSSetObjectValueAndNotify
_NSSetPointValueAndNotify
_NSSetRangeValueAndNotify
_NSSetRectValueAndNotify
_NSSetShortValueAndNotify
_NSSetSizeValueAndNotify
_NSSetUnsignedCharValueAndNotify
_NSSetUnsignedIntValueAndNotify
_NSSetUnsignedLongLongValueAndNotify
_NSSetUnsignedLongValueAndNotify
_NSSetUnsignedShortValueAndNotify
3. KVO底层实现的验证
3.1 我们怎么知道添加观察者时动态添加了一个类?
这个其实我们只需要打印一下再添加观察者之前和之后实例对象所属的类就知道了。不过前面已经说过了,动态添加的类重写了class
方法,所以我们不能通过这个方法来获取一个实例对象的类,而要通过runtime
的object_getClass()
这个API来获取:
- (void)test1{
self.stu1 = [[Student alloc] init];
NSLog(@"观察前- [self.stu1 class] -->%@",[self.stu1 class]);
NSLog(@"观察前- object_getClass(self.stu1) -->%@",object_getClass(self.stu1));
[self.stu1 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
NSLog(@"观察后- [self.stu1 class] -->%@",[self.stu1 class]);
NSLog(@"观察后- object_getClass(self.stu1) -->%@",object_getClass(self.stu1));
}
// ********************打印结果********************
2020-01-05 10:51:00.584299+0800 GCDDemo[14497:600230] 观察前- [self.stu1 class] -->Student
2020-01-05 10:51:00.584690+0800 GCDDemo[14497:600230] 观察前- object_getClass(self.stu1) -->Student
2020-01-05 10:51:00.592797+0800 GCDDemo[14497:600230] 观察后- [self.stu1 class] -->Student
2020-01-05 10:51:00.593064+0800 GCDDemo[14497:600230] 观察后- object_getClass(self.stu1) -->NSKVONotifying_Student
3.2 如何知道重写了哪些方法?
这里我们需要用到runtime
的一些API来获取一个类对象里面存储的方法列表信息,下面我们先封装一个方法来获取这些信息,然后把监听前和监听后的方法列表打印出来。
- (void)test2{
self.stu1 = [[Student alloc] init];
NSLog(@"观察前方法列表-->%@",[self methodNamesOfClass:object_getClass(self.stu1)]);
[self.stu1 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
NSLog(@"观察后方法列表-->%@",[self methodNamesOfClass:object_getClass(self.stu1)]);
}
// 传入一个类,将类中方法列表的方法名拼接换成字符串返回
- (NSString *)methodNamesOfClass:(Class)cls{
unsigned int count;
// 获取方法列表
Method *methodList = class_copyMethodList(cls, &count);
NSString *methodNamesStr = @"";
// 遍历方法列表将方法名拼接成字符串
for (int i = 0; i < count; i++) {
Method method = methodList[i];
NSString *methodName = NSStringFromSelector(method_getName(method));
methodNamesStr = [methodNamesStr stringByAppendingFormat:@"%@ ,",methodName];
}
// 释放
free(methodList);
return methodNamesStr;
}
// ********************打印结果********************
2020-01-05 10:56:43.077817+0800 GCDDemo[14606:603376] 观察前方法列表-->.cxx_destruct ,name ,setName: ,age ,setAge: ,
2020-01-05 10:56:43.078483+0800 GCDDemo[14606:603376] 观察后方法列表-->setName: ,class ,dealloc ,_isKVOA ,
3.3 怎么知道重写setter方法是调用的哪个方法?
这里我们同样需要用到runtime
的API,首先通过class_getInstanceMethod()
函数来获取setter方法的Method
,然后再调用method_getImplementation()
来得到setter方法的IMP
。
不过我们首先打印的是IMP
的地址,想要看IMP
的具体信息我们需要打一个断点调出LLDB
,然后借助LLDB
来打印具体信息。比如在监听前的IMP
地址是0x10967d4c0
,就可以在LLDB
中输入p (IMP)0x10967d4c0
来打印具体信息。从下面可以看出监听前setter方法就是正常的,监听后就变成了_NSSetObjectValueAndNotify
。
- (void)test1{
self.stu1 = [[Student alloc] init];
NSLog(@"监听前的setter方法IMP-->%p",[self IMPWithSelector:@selector(setName:)]);
[self.stu1 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:NULL];
NSLog(@"监听后的setter方法IMP-->%p",[self IMPWithSelector:@selector(setName:)]);
}
// 获取一个方法的IMP
- (IMP)IMPWithSelector:(SEL)selector{
Class cls = object_getClass(self.stu1);
Method methon = class_getInstanceMethod(cls, selector);
IMP imp = method_getImplementation(methon);
return imp;
}
// ********************打印结果********************
2020-01-05 11:25:40.485792+0800 GCDDemo[15032:617260] 监听前的setter方法IMP-->0x10967d4c0
2020-01-05 11:25:40.489656+0800 GCDDemo[15032:617260] 监听后的setter方法IMP-->0x7fff25701c8a
(lldb) p (IMP)0x10967d4c0
(IMP) $0 = 0x000000010967d4c0 (GCDDemo`-[Student setName:] at Student.h:15)
(lldb) p (IMP)0x7fff25701c8a
(IMP) $1 = 0x00007fff25701c8a (Foundation`_NSSetObjectValueAndNotify)
4. KVO小结
KVO
的核心是动态生成一个继承自原类的类,然后将实例对象的isa
指向这个类。然后重写了监听属性的setter方法,在原有setter方法的前面调用willChangeValueForKey
方法,在原有setter方法的后面调用didChangeValueForKey
。
所以我们要判断某个操作是否会触发KVO
关键在于它是否调用了监听属性的setter方法。比如上面的例子,self.stu1.name = @"Jack";
这种方式就是调用setter方法,所以它会触发KVO
。但是下面这几种方式是不会触发KVO
的:
- 采用给成员变量赋值的方式,
self.stu1->_name = @"Jack";
(前提是需要将成员变量_name给暴露出去才能在外面访问),这种方式是不会触发KVO
的,因为它没有调用setter方法。 - 对于集合类型,集合里面数据的更新是不会触发
KVO
的。比如[self.stu1.booksArr addObject:@"book1"]
这样的操作,它同样没有调用setBooksArr:
方法,所以不会触发KVO
。 - 如果所监听的属性是一个自定义的OC对象,比如有个
Dog
类里面有个age
属性,Student
类里面有个Dog
类型的属性dog
,如果我们监听dog
这个属性,当dog
的age
发生变化时并不会触发KVO
,因为它不会调用setDog:
方法。
上面这几种情况,如果我们也想触发KVO
的话,我们可以手动触发,也就是在原有方法的前面和后面分别加上willChangeValueForKey
和didChangeValueForKey
这两个方法。就比如最后这个例子,我们可以这样写:
[self.stu1 willChangeValueForKey:@"dog"];
self.stu1.dog.age = 3;
[self.stu1 didChangeValueForKey:@"dog"];
最后还有一点要说明,通过KVC方式设置属性值也是会触发KVO的
。比如[self.stu1 setValue:@"Jack" forKey:@"name"];
这样写是可以触发KVO
的,这应该是苹果在KVC
实现中调用了willChangeValueForKey
和didChangeValueForKey
这两个方法。