我在之前的文章iOS开发中的设计模式--观察者模式中有介绍过KVO的简单使用,大家可以先去了解一下。今天呢,我们来详细分析下KVO。
跟KVC的分析一样,我们首先去查看一下KVO的官方文档,打开如下网址:
https://developer.apple.com/library/archive/navigation/
然后输入“Key value observing”关键字,就可以找到KVO的官方文档了:
文档如下:
KVO初探
KVO三部曲
我们知道,实现一个KVO有三个步骤:添加观察者、响应观察到的变化、移除观察者。
我们先来看看如何添加一个观察者。现在我想观察student对象的name属性变化:
[self.student addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
我们点进addObserver方法去看看其定义:
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
我们需要注意第四个参数context,很多同学在写代码的时候会直接将其赋值为nil,实际上,这是错误的!我们在定义中可以看到,context的类型是void *,这是一个C语言中的指针类型,而C语言中的空指针是使用NULL来表示的。nil表示的是OC中的实例对象的空指针。关于这块内容的详细对比说明,可以查看我之前的文章OC中的nil、Nil、NULL、NSNull的区别。
这个context是干什么用的呢?我们来看一下文档说明:
通过文档说明我们可以得知,context实际上是一个确定更改通知来源的标识,如果将其设置为NULL,那么在响应所观察的变化的时候就需要通过keyPath和keyPath来共同确定变化的来源,如下:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
// 在这里响应所观察的属性的变化
// 如果在添加观察者的时候将context设置为NULL,那么在这里就需要通过keyPath和object共同来确定变化的来源
}
通过keyPath和object来确定变化的来源其实是不优雅的。比如现在有一个LVPerson类:
#import <Foundation/Foundation.h>
@class LVStudent;
NS_ASSUME_NONNULL_BEGIN
@interface LVPerson : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *nick;
@property (nonatomic, copy) NSString *downloadProgress;
@property (nonatomic, assign) double writtenData;
@property (nonatomic, assign) double totalData;
@property (nonatomic, strong) NSMutableArray *dateArray;
@property (nonatomic, strong) LVStudent *st;
@end
还有一个LVStudent类,他继承自LVPerson类:
@interface LVStudent : LVPerson
+ (instancetype)shareInstance;
@end
而我在某个VC中定义了这两个类的属性:
@interface LVViewController ()
@property (nonatomic, strong) LVPerson *person;
@property (nonatomic, strong) LVStudent *student;
@end
我在这个VC中观察student对象的name属性的变化:
[self.student addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
响应如下:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
// 在这里响应所观察的属性的变化
}
此时,由于我在添加观察者的时候将contxt设置为NULL了,所以我需要通过keyPath来确定变化的来源,只有当keyPath与字符串"name"匹配的时候才会响应;此时还有一个极大的问题,由于LVStudent是继承自LVPerson,因此LVStudent会拥有LVPerson的所有属性,name就是其中之一,也就是说,student和person这两个实例对象的很多内部属性都是相同的,那么我怎么就知道这里监听到的"name"的变化是student中name属性的变化还是person中name属性的变化呢?答案是通过object来确定变化来源自哪个对象。
然后我就第一层if-else来判断变化是来自哪一个对象;第二层if-else来判断变化是来自对象中的哪一个变量。这样的写法能实现功能,但是也有很多问题:多层if-else嵌套的写法不优雅,代码可读性较差,增加编译时间。
因此,苹果是建议我们使用context来标识变化的来源,这样会更加安全、更加方便、更加优雅。至于如何使用context来标识变化来源,可以去参考苹果官方文档,地址如下:
https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/KeyValueObserving/Articles/KVOBasics.html#//apple_ref/doc/uid/20002252-SW4
文档很详细,我这里摘录一下:
接下来聊聊KVO三部曲中的最后一曲:移除观察者。一定不要切记,观察者务必在销毁的时候记得移除。
举个例子,A页面跳转到B页面,A、B页面中都有一个对象student,该student是同一个的单例对象。我在A、B页面都通过KVO监听了student单例对象的name属性的变化,然后分别进行了响应。现在我从A页面跳转到B页面,此时student单例对象的name属性的变化就有A和B两个观察者了,然后我返回A,但是在B的dealloc中并没有移除KVO的观察。返回到A页面后,针对student单例对象的name属性的变化,仍旧有A和B两个观察者,然后我在A页面改变了student单例对象的name属性的值,此时在A页面的观察和响应都没有问题,但是此时观察者B已经被销毁了,因此再使用原来的B的指针去找对应的相应方法,就会导致野指针调用,程序就会崩溃。
因此,在观察者被销毁的时候,一定要移除对应的KVO!
控制是否自动发送变化通知
其核心方法是下面的方法:
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;
该方法的定义如下:
我们看到,上面有一段注释,根据这段注释我们可以分析出如下要点:
-willChangeValueForKey:/-didChangeValueForKey:
-willChange:valuesAtIndexes:forKey:-didChange:valuesAtIndexes:forKey:
-willChangeValueForKey:withSetMutation:usingObjects:/-didChangeValueForKey:withSetMutation:usingObjects:
如果我们想要手动触发KVO,那么就需要在被观察的类里面复写+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key方法,然后返回NO。
这还不算完,你此时只是禁掉了KVO通知的自动触发,但是你还没有手动触发KVO啊,那么如何手动触发KVO呢?前面我们已经说了,是通过如下的几个方法:
-willChangeValueForKey:/-didChangeValueForKey:
-willChange:valuesAtIndexes:forKey:-didChange:valuesAtIndexes:forKey:
-willChangeValueForKey:withSetMutation:usingObjects:/-didChangeValueForKey:withSetMutation:usingObjects:
接下来我以一个例子来进行说明:
多因素复合观察
其核心方法是:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key
我们通过一个例子来进行简单介绍。
@interface LVPerson : NSObject
@property (nonatomic, copy) NSString *downloadProgress;
@property (nonatomic, assign) double writtenData;
@property (nonatomic, assign) double totalData;
@end
LVPerson类中有3个属性。writtenData和totalData分别表示已经下载下来的文件大小、总共需要下载的文件大小,这两个属性都是动态变化的。downloadProgress表示总的下载比例,它是根据writtenData/totalData计算得来。现在的要求是在writtenData和totalData改变的时候,downloadProgress也会动态改变,实现如下。
在被观察的类LVPerson中实现keyPathsForValuesAffectingValueForKey方法,如下:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
// downloadProgress是被影响的因素
if ([key isEqualToString:@"downloadProgress"]) {
// affectingKeys记录影响downloadProgress的所有子因素的集合
NSArray *affectingKeys = @[@"totalData", @"writtenData"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
然后在外界给LVPerson实例添加一个变量downloadProgress的观察者,就可以在totalData或者writtenData发生变化的时候,观察到downloadProgress的变化了:
[self.person addObserver:self forKeyPath:@"downloadProgress" options:(NSKeyValueObservingOptionNew) context:NULL];
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
// 在这里响应所观察的属性的变化
// 如果在添加观察者的时候将context设置为NULL,那么在这里就需要通过keyPath和object共同来确定变化的来源
NSLog(@"LVViewController :%@",change);
}
观察可变数组的变化
@interface LVPerson : NSObject
@property (nonatomic, strong) NSMutableArray *dateArray;
@end
LVPerson类中有一个可变数组属性dateArray。
添加观察者的代码如下:
self.person = [LVPerson new];
self.person.dateArray = [NSMutableArray arrayWithCapacity:1];
[self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];
响应变化的代码如下:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
// 在这里响应所观察的属性的变化
// 如果在添加观察者的时候将context设置为NULL,那么在这里就需要通过keyPath和object共同来确定变化的来源
NSLog(@"LVViewController :%@",change);
}
外界的变化的写法如下:
// 这里不能触发KVO
[self.person.dateArray addObject:@"1"];
// 只有KVC才能触发KVO
[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"2"];
其中,[self.person.dateArray addObject:@"1"];是不能触发KVO的,因为它没有走到KVC的方法。而KVO是建立在KVC的基础上的,所以,对于可变数组类型的属性,要使用如下方式进行监听:
// 只有KVC才能触发KVO
[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"2"];
只有KVC才能触发KVO
看下面这个例子。
@interface LVPerson : NSObject {
@public
NSString *nickName;
}
@property (nonatomic, copy) NSString *name;
@end
LVPerson类里面定义了一个实例变量nickName,和一个属性name。
给LVPerson类的实例对象self.person添加观察者,监听name和nickName的变化:
self.person = [LVPerson new];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
[self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
改变name和nickName的值:
self.person.name = @"lavie";
self.person->nickName = @"norman";
运行后,经过验证,只能观察到self.person.name属性的变化,self.person->nickName的变化是兼听不到的。原因就在于,self.person.name属性的变化是走了Setter方法,这是KVC的演变,是可以监听到的;而self.person->nickName的变化是直接修改成员变量值,因此KVO是兼听不到的。
KVO的实现细节
动态生成中间类
在KVO的官方文档(https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/KeyValueObserving/Articles/KVOImplementation.html#//apple_ref/doc/uid/20002307-BAJEAIEE)中,我们可以找到下面这一个章节,该章节用一段话讲述了KVO实现的细节,如下:
有几个要点我这边概括一下:
接下来我们验证一下。
看下面这几行代码:
我在给self.person实例对象添加KVO观察者之前打了个断点,在给self.person实例对象添加KVO观察者之后也打了个断点。
然后运行程序,当跑到第一个断点处的时候,此时还没有给self.person实例对象添加KVO观察者,我使用llvm指令调试如下:
通过打印结果我们可以知道,此时self.person.class和object_getClassName(self.person)都是LVPerson。
需要说明一下的是,通过object_getClassName(self.person)获取到的就是self.person这个实例对象里面的isa指针指向的那个类;而通过self.person.class获取到的是创建self.person实例对象的那个类。
好,接下来断点往下走,来到添加观察者之后:
此时,self.person.class是LVPerson,而object_getClassName(self.person)是NSKVONotifying_LVPerson。这个NSKVONotifying_LVPerson就是生成的中间类。这也就验证了,在被KVO观察之后,实例对象的isa指针就被修改了。
现在再来考虑一个问题,NSKVONotifying_LVPerson这个中间类与真实的类LVPerson有什么关系呢?其实,NSKVONotifying_LVPerson是LVPerson的子类。
那么如何来证明NSKVONotifying_LVPerson是LVPerson的子类呢?我们可以在给self.person实例对象添加KVO观察者之前和之后都打印一下LVPerson的子类,通过对比,看看之后是不是比之前多了个NSKVONotifying_LVPerson。
获取一个类的所有子类的代码如下:
- (void)printSubClasses:(Class)cls {
// 获取到当前注册的所有的类的总数
int count = objc_getClassList(NULL, 0);
// 创建一个数组,其中包含给定对象
NSMutableArray *mArray = [NSMutableArray arrayWithObject:cls];
// 获取所有已注册的类
Class* classes = (Class*)malloc(sizeof(Class)*count);
objc_getClassList(classes, count);
// 将传入类的所有子类筛选出来,存入数组mArray中
for (int i = 0; i<count; i++) {
if (cls == class_getSuperclass(classes[i])) {
[mArray addObject:classes[i]];
}
}
free(classes);
NSLog(@"subClasses = %@", mArray);
}
验证代码如下:
NSLog(@"添加KVO观察者之前");
[self printSubClasses:LVPerson.class];
[self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"添加KVO观察者之后");
[self printSubClasses:LVPerson.class];
打印结果如下:
2021-03-16 12:23:40.474829+0800 001---KVO初探[8993:1173846] 添加KVO观察者之前
2021-03-16 12:23:40.601446+0800 001---KVO初探[8993:1173846] subClasses = (
LVPerson,
LVStudent
)
2021-03-16 12:23:40.602012+0800 001---KVO初探[8993:1173846] 添加KVO观察者之后
2021-03-16 12:23:40.606003+0800 001---KVO初探[8993:1173846] subClasses = (
LVPerson,
"NSKVONotifying_LVPerson",
LVStudent
)
我们发现,添加KVO观察者之后确实比之前多了一个NSKVONotifying_LVPerson。
中间类中做了什么?
现在我们知道了,当一个实例对象被KVO观察之后,该对象的isa指针会被改变,指向一个动态生成的新的类,这个新的类继承自原类。
那么这个动态类里面做了什么事情呢?我们接下来分析一下。
获取一个类中的所有方法并打印:
- (void)printClassAllMethod:(Class)cls {
NSLog(@"**********Start***********");
unsigned int count = 0;
Method *methodList = class_copyMethodList(cls, &count);
for (int i = 0; i<count; i++) {
Method method = methodList[i];
SEL sel = method_getName(method);
IMP imp = class_getMethodImplementation(cls, sel);
NSLog(@"%@-%p",NSStringFromSelector(sel),imp);
}
free(methodList);
NSLog(@"**********End***********");
}
外界调用:
[self printSubClasses:LVPerson.class];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
[self printSubClasses:LVPerson.class];
[self printClassAllMethod:LVPerson.class];
[self printClassAllMethod:NSClassFromString(@"NSKVONotifying_LVPerson")];
运行之后打印结果如下:
首先,我们对比了给实例对象self.person添加KVO观察者前后LVPerson类的子类列表,发现后比前多了一个NSKVONotifying_LVPerson,这说明新生成的动态中间类就是NSKVONotifying_LVPerson。
然后我在给实例对象self.person添加KVO观察者之后,先后打印了LVPerson和NSKVONotifying_LVPerson类的方法列表。
通过比较打印出来的LVPerson和NSKVONotifying_LVPerson类的方法列表结果,不知道诸位是否有一个疑问:不是说子类可以继承父类所有的方法吗?为什么NSKVONotifying_LVPerson继承自LVPerson,但是LVPerson中的有些方法在NSKVONotifying_LVPerson中却没有打印出来呢?
子类可以继承自父类中的所有方法没有错,但是这种继承体现在子类的实例对象可以去调用父类中的方法,在方法查找的过程中通过superClass一层一层往上去找。父类的方法自然是存放在父类的methodlist中,子类的methodlist中是没有的,查找的时候,在子类的methodlist中没找到,就到父类的methodlist去找。这才是所谓的子类继承父类的所有方法的真正含义。
但是,在上面的LVPerson和NSKVONotifying_LVPerson类的方法列表结果比较中,我又发现,LVPerson和NSKVONotifying_LVPerson类的方法列表结果中都有setName:方法,这又是为什么呢?
这是因为,子类NSKVONotifying_LVPerson中复写了setName:方法。如果一个子类复写了父类中的某个方法,那么在子类和父类的methodlist中都有该方法,只不过在方法查找过程中先在子类的methodlist中找到了该方法,找到之后就不再往上继续查找了而已。
我们现在来看一下NSKVONotifying_LVPerson类中的几个方法:
setName:-0x10f8317ae
class-0x10f830271
dealloc-0x10f82ffd6
_isKVOA-0x10f82ffce
我发现,除了_isKVOA之外,其余的三个方法都是自父类中继承而来的方法,所以,我现在知道了,NSKVONotifying_LVPerson类对setName、class、dealloc这三个方法都进行了重写。
前面我不是有提到,要通过对象的class方法来获取对象的类,而不是通过isa指针:通过isa指针有可能会获取到中间的类,而通过class方法获取到的,肯定是最初创建该实例对象的那个类。为什么通过class就能获取到最初的那个类呢?这里就解释了原因了,因为在动态子类中对class方法进行了重写,它指向的就是动态子类的父类,即最初的那个类。
除了重写class方法之外,NSKVONotifying_LVPerson类中还重写了setName,大家可以想一下,为什么需要重写setName?
我们根据前面的分析也已经知道了,真正触发KVO的源头是下面这几个方法:
-willChangeValueForKey:/-didChangeValueForKey:
-willChange:valuesAtIndexes:forKey:-didChange:valuesAtIndexes:forKey:
-willChangeValueForKey:withSetMutation:usingObjects:/-didChangeValueForKey:withSetMutation:usingObjects:
所以我猜想,NSKVONotifying_LVPerson类中重写setName的原因可能就是加上了-willChangeValueForKey:/-didChangeValueForKey:,从而触发KVO。
isa的指回以及动态子类的销毁
在某个对象被KVO观测之后,该对象的isa指针会被修改。那么,这个isa指针的修改会被一致保留吗?isa指针被修改了之后会再被改回来吗?
答案是会的。当我们为对象移除了KVO观察之后,该对象的isa指针就会恢复最初始的样子了。
一般而言,我们都会在观察者的dealloc方法中移除该观察者观察的所有的对象。为了测试,我暂且不移除,并且在dealloc方法的最后打个断点,当走到断点处的时候,我再使用llvm指令获取被观测对象的isa指向,如下:
这说明,如果没有移除观察者,那么被观测对象的isa指针将永远指向动态中间类。
然后我们再来看一下移除了观察者的情形:
这说明,移除了观察者之后,会再次调整被观测对象的isa的指向,将其指向最初的原类。
现在在考虑一个问题,既然isa又被指回最初的原类了,那么那个中间子类是否会被销毁呢?答案是不会的。一旦中间子类被创建了,那么他将会一直存在缓存中,即便观察者已经被移除。
以上。