前言
上一篇文章学习了KVC的原理(键值编码),KVC是由NSKeyValueCoding非正式协议启用的一种机制,对象采用该机制来提供对其属性的间接访问。而KVO的实现是基于KVC键值编码,以下我们进行探讨。
准备工作
KVO协议定义
KVO全称是Key-value Observing,翻译过来就是:键值观察。提供了一种当其它对象属性被修改的时候能通知当前对象的机制。
-
KVO的定义
类似于KVC,KVO的定义都是对NSObject的扩展来实现的,KVO的定义在Foundation里面,而Foundation框架是不开源的,只能在苹果官方文档查找。见下图:

KVO(键值观察)是一种机制,它允许对象在其他对象的指定属性发生更改时收到通知。它对于应用程序中模型层和控制器层之间的通信特别有用。
注意:要使用KVO,首先必须确保被观察对象符合KVO。一般情况下,如果您的对象继承自NSObject并且您以通常的方式创建属性,则您的对象及其属性将自动符合KVO。
KVO提供的API- 监听注册
使用方法addObserver:forKeyPath:options:context:向被观察对象注册观察者。必须执行以下步骤才能使对象能够接收KVO兼容属性的键值观察通知,观察者指定一个选项参数options和一个上下文指针context来管理通知的各个方面。
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
- 接收通知
在观察者内部实现observeValueForKeyPath:ofObject:change:context:以接受更改通知消息。
- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;
- 移除监听
使用方法- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;移除观察者
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
KVO的使用
监听选项option
监听选项是由枚举NSKeyValueObservingOptions定义的:
typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
NSKeyValueObservingOptionNew = 0x01,
NSKeyValueObservingOptionOld = 0x02,
NSKeyValueObservingOptionInitial API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x04,
NSKeyValueObservingOptionPrior API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x08
}
注意:option会影响通知中,提供的更改字典的内容以及生成通知的方式
-
NSKeyValueObservingOptionNew
监听获取属性的新值,见下图:

-
NSKeyValueObservingOptionOld
监听属性获取旧值,见下图:

-
NSKeyValueObservingOptionInitial
添加观察者的时候立即发送一个通知给观察者,见下图:

在每次修改属性时,会在修改通知被发送之前预先发送一条通知给观察者,这与-willChangeValueForKey:被触发的时间是相对应的。这样,在每次修改属性时,实际上是会发送两条通知,见下图:

上下文指针Context
addObserver:forKeyPath:options:context:消息中的上下文指针包含任意数据,这些数据将在相应的更改通知中传递回观察者。您可以指定NULL并完全依赖键路径字符串来确定更改通知的来源,但这种方法可能会导致超类由于不同原因也在观察相同键路径的对象出现问题。
一下实现一个案例,LGStudent继承自LGPerson,同时对两个对象的name属行进行设置,通过添加上下文指针context,可以在接收通知的地方进行过滤。见下图:

KVO使用技巧
-
同一个对象重复注册为同一属性的观察者
可以多次调用addObserver:forKeyPath:options:context:这个方法,将同一个对象注册为同一属性的观察者(所有参数可以完全相同)。这时,即便在所有参数一致的情况下,新注册的观察者并不会替换原来观察者,而是会并存。这样,当属性被修改时,两次监听都会响应。见下面的案例分析:

通过以上的案例,KVO注册多少次就会有多少次的回调,不会覆盖相同的观察者。 移除观察者
在观察者不再需要监听属性变化时,必须调用removeObserver:forKeyPath:或removeObserver:forKeyPath:context:方法来移除观察者,这两个方法的声明如下:
- (void)removeObserver:(NSObject *)anObserver
forKeyPath:(NSString *)keyPath
- (void)removeObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
context:(void *)context
这两个方法会根据传入的参数(主要是keyPath和context)来移除观察者。移除观察者可以避免监听回调的混乱,保持良好的代码质量。
注意:如果observer没有监听keyPath属性,依然调用上面两个方法会抛出异常,见下图:

由以上案例可知,
观察者的移除是必须确认观察者已经被注册了,这样子才能调用移除观察者的方法,如果我们没有移除观察者也会出现崩溃的情况,请往下看。添加观察时,两个对象(即观察者对象及属性所属的对象)都不会被
retain,然而在这些对象被释放后,相关的监听信息却还存在,KVO做的处理是直接让程序崩溃。其实苹果官网也给出了相关说明,见下图:
- 如果尚未注册为观察者,则要求将其移除为观察者会导致
NSRangeException。
- 如果尚未注册为观察者,则要求将其移除为观察者会导致
- 解除分配时,观察者
不会自动删除自己。被观察的对象继续发送通知,而忽略了观察者的状态。但是,发送到已释放对象的更改通知与任何其他消息一样,会触发内存访问异常。因此,您要确保观察者在从记忆中消失之前将自己移除。
- 解除分配时,观察者
- 该协议没有提供询问对象是观察者还是被观察者的方法。所以在构建代码时,避免与发布相关的错误。
自动监听和手动监听
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key
默认情况下,该方法返回YES,即表示默认可以对任何类中的所有属性进行监听,可以理解为自动监听。在这种模式下,当我们修改属性的值时,KVO会自动调用以下两个方法:
- (void)willChangeValueForKey:(NSString *)key
- (void)didChangeValueForKey:(NSString *)key
开发过程中,可能不需要对所有属性进行监听,只要求选择性的观察部分属性。此时+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key方法返回NO,那么就需要对属性进行手动监听。见下面代码:
// 自动监听开关-关闭
+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
return NO;
}
- (void)setName:(NSString *)name{
[self willChangeValueForKey:@"name"];
_name = name;
[self didChangeValueForKey:@"name"];
}
此时自动监听开关已经关闭,如果需要监听person对象的name属性的变化,就需要在setter方法中添加willChangeValueForKey和didChangeValueForKey方法,两个方法必须成对出现,否则无效。
如果我们在开发过程中只是针对某几个属性进行手动接收通知,其他的不需要手动接收通知,那么我们可以精确的做到这个动作,通过+automaticallyNotifiesObserversForKey:方法可以设置对象中哪些属性需要手动处理,那么可以自动处理。见下图:

-
确保属性发生变化发送通知
如果希望只有当属性值实际被修改时发送通知,以尽量减少不必要的通知,则可以如下实现:(做属性的拦截判断)
- (void)setNick:(NSString *)nick{
if (nick != _nick){
[self willChangeValueForKey:@"nick"];
_nick = nick;
[self didChangeValueForKey:@"nick"];
}
}
补充:如果我们在setter方法之外改变了实例变量(如_nick),且希望这种修改被观察者监听到,则需要像在setter方法里面做一样的处理。这也涉及到我们通常会遇到的一个问题,在类的内部,对于一个属性值,何时用属性(self.nick)访问,而何时用实例变量(_nick)访问。一般的建议是,在获取属性值时,可以用实例变量;在设置属性值时,尽量用setter方法,以保证属性的KVO特性。当然,性能也是一个考量,在设置值时,使用实例变量比使用属性设置值的性能高不少。
-
多属性依赖
我们监听的某个属性可能会依赖于其它多个属性的变化,不管所依赖的哪个属性发生了变化,都会导致计算属性的变化。对于这种一对一(To-one)的关系,我们需要做两步操作,首先是确定计算属性与所依赖属性的关系。如我们在Person类中定义一个fullName属性,其getter方法定义如下:
- (NSString *)fullName {
return [[NSString alloc] initWithFormat:@"he's full name is :%@ , %@", self.name, self.nick];
}
定义了这种依赖关系后,需要以某种方式告诉KVO,当我们的被依赖属性修改时,会发送fullName属性被修改的通知。此时,我们需要重写NSKeyValueObserving协议的keyPathsForValuesAffectingValueForKey:方法,这个方法返回的是一个集合对象,包含了哪些影响key指定的属性依赖的属性所对应的字符串。所以对于fullName属性,该方法的实现如下:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"fullName"]) {
NSArray *affectingKeys = @[@"name", @"nick"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
看看fullName监听效果:

-
集合属性的监听
对于不可变集合属性,我们更多的是把它当成一个整体来监听,而无法去监听集合中的某个元素的变化;对于可变集合属性,实际上也是当成一个整体,去监听它整体的变化,如添加、删除和替换元素。具体案例如下:

如果想监听集合中数据的变化,如添加、删除和替换元素该如何处理呢?向可变数组中添加元素,这种处理方式没有效果。见下图:

KVO键值监听实现的基础是KVC。我们以数组为例,在我们的Person类中有一个dateArray数组属性,如果我们希望响应dateArray所有的方法,则需要实现以下方法:

所以对于可变集合,我们不使用valueForKey:来获取对象,而是使用以下方法:

由打印信息可以发现kind字段的值发生了而变化,输出值为2、3、4。这是因为,KVO机制能在集合改变的时候把详细的变化放进change字典中。
补充:集合(Set)也有一套对应的方法来实现集合代理对象,包括无序集合与有序集合;而字典则没有,对于字典属性的监听,还是只能作为一个整体来处理。
如果我们想到手动控制集合属性消息的发送,则可以使用上面提到的几个方法,即:
-willChange:valuesAtIndexes:forKey:
-didChange:valuesAtIndexes:forKey:
或
-willChangeValueForKey:withSetMutation:usingObjects:
-didChangeValueForKey:withSetMutation:usingObjects:
注意:先要把自动通知关闭,否则每次改变KVO都会被发送两次。
-
变化字典
观察者对象必须实现-observeValueForKeyPath:ofObject:change:context:方法,来对属性修改通知做相应的处理。这个方法的声明如下:
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
第三个参数,通常称之为变化字典(Change Dictionary),它记录了被监听属性的变化情况。这个字典中包含的值,会根据我们在添加观察者时设置的options参数的不同而有所不同,它包含了属性被修改的一些信息。我们可以通过以下key来获取我们想要的信息:
typedef NSString * NSKeyValueChangeKey NS_STRING_ENUM;
/*
Keys for entries in change dictionaries. See the comments for -observeValueForKeyPath:ofObject:change:context: for more information.
*/
FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeKindKey;
FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeNewKey;
FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeOldKey;
FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeIndexesKey;
FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeNotificationIsPriorKey API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0));
其中,NSKeyValueChangeKindKey的值取自于NSKeyValueChange,它的值是由以下枚举定义的:
enum {
// 设置一个新值。被监听的属性可以是一个对象,也可以是一对一关系的属性或一对多关系的属性。
NSKeyValueChangeSetting = 1,
// 表示一个对象被插入到一对多关系的属性。
NSKeyValueChangeInsertion = 2,
// 表示一个对象被从一对多关系的属性中移除。
NSKeyValueChangeRemoval = 3,
// 表示一个对象在一对多的关系的属性中被替换
NSKeyValueChangeReplacement = 4
};
typedef NSUInteger NSKeyValueChange;
KVO实现原理
在上面了解了NSKeyValueObserving所提供的功能后,我们再来看看KVO的实现机制,以便更深入地的理解KVO。KVO没有开源,所以我们无法从源代码的层面来分析它的实现。那么我们还是先查看官方的描述:

翻译过来:自动键值观察是使用一种称为
isa-swizzling的技术实现的。isa指针指向维护调度表的对象的类。 该调度表主要包含指向类实现的方法的指针,以及其他数据。当观察者为对象的属性注册时,被观察对象的isa指针被修改,指向中间类而不是真正的类。 因此,isa指针的值不一定反映实例的实际类。所以我们就提出了几个疑问,这个
isa指向的中间类是什么?kvo观察的是setter方法,setter方法做了什么,调用的又是谁的setter方法?移除监听后这个中间类是否销毁呢?带着疑问我们继续往下走。
-
寻找中间类
NSKVONotifying_LGPerson
首先我们通过设置断点,来逐步跟踪person对象isa指针所指向的类,见下图:

在添加监听之前,person对象对应的类是LGPerson,添加过监听之后,person对象isa指向的类是NSKVONotifying_LGPerson。这个类应该就是官网中说到的中间类。
那么这个中间类是何时创建的呢?我们在调用addObserver:forKeyPath:options:context:方法之前,获取NSKVONotifying_LGPerson这个类,发现这个类并不存在。见下图:

说明这个类应该是通过
runtime在运行时动态生成的。
-
NSKVONotifying_LGPerson和LGPerson的关系
通过lldb调试,打印NSKVONotifying_LGPerson类的地址,获取其内存空间,发现NSKVONotifying_LGPerson的父类是LGPerson类。

所以,NSKVONotifying_LGPerson是LGPerson的子类。(如果不明白为什么看内存就知道是父类的话建议区看看类的内存结构) 中间类提供的方法
提供下面一个辅助方法,用来获取类中的方法列表。如下:
#pragma mark **- 遍历方法-ivar-property**
- (void)printClassAllMethod:(Class)cls{
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);
}
在调用addObserver:forKeyPath:options:context:方法之后,调用该辅助方法,查看NSKVONotifying_LGPerson类中有哪些功能。见下图:

发现中间类
重写了父类的四个方法。分别是setNickName、class、dealloc、_isKVOA。
对象的
isa何时修复
通过上面的分析,我们发现在调用addObserver:forKeyPath:options:context:方法之后,对象的isa指向了一个中间类,那么isa和在重新执行LGPerson类呢?-
(void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;方法,也就是移除监听的时候。我们来验证一下:

在调用- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;方法之后,对象的isa指针重新指向了LGPerson类。
注意:在完成观察者的销毁之后,这个中间类依然存在,并没有被销毁。(为下次使用做准备,性能的考虑,避免重复创建),请继续往下看。

-
中间类中
setter方法的作用
这里setter方法做了什么,监听的是属性还是成员变量呢?我们做个监听分别采用操作属性和访问成员变量的方式,分别变更nickName和name,见下图:

说明KVO实际是通过setter方法监听的是属性。我们可以通过监听nickName成员变量来分析底层调用过程。见下图:

通过堆栈我们可以发现,在调用setNickName方法是,底层实际是调用了下面的流程: Foundation _NSSetObjectValueAndNotifyFoundation -[NSObject(NSKeyValueObservingPrivate)_changeValueForKey:key:key:usingBlock:]Foundation -[NSObject(NSKeyValueObservingPrivate)_changeValueForKeys:count:maybeOldValuesDict:maybeNewValuesDict:usingBlock:]
总结
Objective-C基于强大的run time机制来实现KVO。当第一次观察某个对象的属性时,run time会创建一个新的继承自这个对象的class的subclass。在这个新的subclass中,它会重写所有被观察的key的setter方法,然后将对象的isa指针指向新创建的class(这个指针告诉Objective-C运行时某个对象到底是什么类型的)。所以实例对象变成了新的子类的实例。完成以上操作后,通过调用setter方法进行相关属性的变化时,操作的就是这个中间的子类。但是底层依然会将对中间类操作的状态,同步到原对象中。在进行监听移除后,对象的isa回复到原来的类上,且中间类没有跟着被移除。










