上一篇文章学习了键值编码KVC
,KVC
是由NSKeyValueCoding非正式协议
启用的一种机制,对象采用该机制来提供对其属性的间接访问
。本篇文章重点学习KVO
,KVO
实现的基础就是KVC键值编码
。
1.KVO协议定义
KVO
全称是Key-value Observing
,翻译过来就是:键值观察
。提供了一种当其它对象属性被修改的时候能通知当前对象的机制。
-
KVO
的定义KVO
的定义都是对NSObject
的扩展来实现的(Objective-C
中有个显式的NSKeyValueObserving
类别名-分类
)。KVO
的定义在Foundation
里面,而Foundation
框架是不开源
的,只能在苹果官方文档查找。见下图:键值观察编程指南中对
KVO
进行了详细介绍,键值观察是一种机制,它允许对象在其他对象的指定属性发生更改时收到通知。它对于应用程序中模型层和控制器层之间的通信特别有用。要使用
KVO
,首先必须确保被观察对象符合KVO
。通常,如果您的对象继承自NSObject
并且您以通常的方式创建属性,则您的对象及其属性将自动符合KVO
。 -
KVO
提供的API
-
监听注册
使用方法
addObserver:forKeyPath:options:context:
向被观察对象注册观察者。必须执行以下步骤才能使对象能够接收KVO
兼容属性的键值观察通知:- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
观察者指定一个
选项参数options
和一个上下文指针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;
-
2.KVO的使用
1.监听选项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
在添加观察者的时候立即发送一个通知给观察者,见下图:
-
NSKeyValueObservingOptionInitial
在每次修改属性时,会在修改通知被发送之前预先发送一条通知给观察者,这与-
willChangeValueForKey:
被触发的时间是相对应的。这样,在每次修改属性时,实际上是会发送两条通知
。
2.上下文指针Context
addObserver:forKeyPath:options:context:
消息中的上下文指针
包含任意数据,这些数据将在相应的更改通知中传递回观察者。您可以指定NULL
并完全依赖键路径字符串
来确定更改通知的来源,但这种方法可能会导致超类由于不同原因也在观察相同键路径的对象出现问题。
如下面的案例,LGStudent
继承自LGPerson
,同时对两个对象的name
属行进行设置,通过添加上下文指针context
,可以在接收通知的地方进行过滤。见下图:
3.使用技巧
-
同一个对象重复注册为同一属
可以多次调用
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
方法,两个方法必须成对出现,否则无效。但如果我们希望自己控制通知发送的一些细节,则可以启用手动控制模式。手动控制通知提供了对
KVO
更精确控制,它可以控制通知如何以及何时被发送给观察者。采用这种方式可以减少不必要的通知,或者可以将多个修改组合到一个修改中。同时通过
+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
,我们需要了解的一点是,KVO
旨在观察关系(relationship
)而不是集合。对于不可变集合属性,我们更多的是把它当成一个整体来监听,而无法去监听集合中的某个元素的变化;对于可变集合属性,实际上也是当成一个整体,去监听它整体的变化,如添加、删除和替换元素。监听集合整体的变化,见下图案例:
如果想监听集合中数据的变化,如添加、删除和替换元素该如何处理呢?向可变数组中添加元素,这种处理方式没有效果。见下图:
我们知道
KVO
键值监听实现的基础是KVC
。我们以数组为例,在我们的Person
类中有一个dateArray
数组属性,如果我们希望响应dateArray
所有的方法,则需要实现以下方法:所以对于可变集合,我们不使用
valueForKey:
来获取对象,而是使用以下方法:- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
分别对可变数组中的元素进行添加、修改、替换,运行结果,见下图:
发现了一个问题,
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;
3.KVO实现原理
了解了NSKeyValueObserving
所提供的功能后,我们再来看看KVO
的实现机制,以便更深入地的理解KVO
。KVO
没有开源,所以我们无法从源代码的层面来分析它的实现。
我们从苹果官网的的说明中解开了一些谜团。见下图:
翻译过来:自动键值观察是使用一种称为isa-swizzling
的技术实现的。isa
指针指向维护调度表的对象的类。 该调度表主要包含指向类实现的方法的指针,以及其他数据。当观察者为对象的属性注册时,被观察对象的isa
指针被修改,指向中间类而不是真正的类。 因此,isa
指针的值不一定反映实例的实际类。
所以这里就有了探索目标:这个isa
指向的中间类是什么?kvo
观察的是setter
方法,setter
方法做了什么,调用的又是谁的setter
方法?移除监听后这个中间类是否销毁呢?
带着这些问题,进行KVO
原理探索。
-
寻找中间类
NSKVONotifying_LGPerson
首先我们通过设置断点,来逐步跟踪
person
对象isa
指针所指向的类,见下图:在添加监听之前,
person
对象对应的类是LGPerson
,添加过监听之后,person
对象isa
指向的类是NSKVONotifying_LGPerson
。这个类应该就是官网中说到的中间类
。那么这个中间类是何时创建的呢?我们在调用
addObserver:forKeyPath:options:context:
方法之前,获取NSKVONotifying_LGPerson
这个类,发现这个类并不存在。见下图:说明这个类应该是通过
runtime
在运行时动态生成的。 -
NSKVONotifying_LGPerson
和LGPerson
的关系这个中间类
NSKVONotifying_LGPerson
,与LGPerson
有什么关系呢?通过lldb
调试,打印NSKVONotifying_LGPerson
类的地址,获取其内存空间,发现NSKVONotifying_LGPerson
的父类是LGPerson
类。所以,
NSKVONotifying_LGPerson
是LGPerson
的子类。 -
中间类提供的方法
NSKVONotifying_LGPerson
中间类找到了,并且是LGPeron
的子类,那么NSKVONotifying_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 _NSSetObjectValueAndNotify
- Foundation -[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回复到原来的类上。