Key Value Observing
是一种让你能够在,你关心的对象的某些属性发生变化时,得到通知的机制。我决定深入探究一下这个在 iOS 开发中常用的功能,整个系列会分为三篇,依次介绍一下功能的使用,实现的原理,以及如何自己来实现一个这样的通知机制。
本篇主要聚焦在功能的使用上,将从以下几个维度深入进行分析:
- 基本用法
- 常用接口
- 使用案例
- 案例分析
- 进阶用法
- API 概览
- 更多回调触发时机
- 观察属性链
- 观察一对一关系
- 观察一对多关系
- 手动通知
- 存在的问题及优化方案
基本用法
常用接口
KVO 的使用主要分为3个步骤:
第一步:注册观察(往观察对象中加入一个观察者,设置好要观察的内容及回调相关参数)
- (void)addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(nullable void *)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
context:(nullable void *)context;
- (void)removeObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath; // 不推荐,原因后面解释
在平常的开发工作中,上面所列的几个接口基本就能覆盖我们大多数的使用场景了,下面我们结合一个具体的例子来分析一下这三个步骤做的事情。
使用案例
假如我们现在要实现一个下拉刷新控件,我们需要往UIScrollView
上加一个PullToRefreshView
,这个 View 会随着 ScrollView 的滚动而更新自己的内容。
在直接给出KVO
的实现方案前,我想先来聊聊为什么这是一个很好的使用场景。
如果我们只是实现一个这样的页面,那么我们可以在 Controller 中实例化一个 ScrollView,并在scrollViewDidScroll:
回调中获取contentOffset
,然后去更新PullToRefreshView
的效果即可。
但实际上往往这是一个要在多个页面上实现的效果,如果按上述说法去做的话,就得一遍遍地在回调方法中去实现这个逻辑。这时候我们就会想把这部分代码放到PullToRefresh
中去实现。然而这样虽然解决了原来的重复问题,却造成了一个新的问题,delegate 无法被 controller 使用了!虽然我们还是可以用其他方法来解决这个新问题,但这时候采用KVO
去实现这个功能无疑会简单很多。
好了,终于到了 Show Me The Code 的时候了:
@interface PullToRefreshView: UIView
@property (nonatomic, weak) UIScrollView *scrollView;
@end
@implenmentation
static void * const MyContext = &MyContext; // 1
- (void)setScrollView:(UIScrollView *)scrollView {
if (_scrollView) {
[_scrollView removeObserver:self forKeyPath:@"contentOffset" context:MyContext]; // 2
}
_scrollView = scrollView;
[_scrollView addObserver:self forKeyPath:@"contentOffset" options:NSKeyValueObservingOptionNew context:MyContext]; // 3
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context != MyContext) {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
return;
} // 4
if (object == self.scrollView && [keyPath isEqualToString:@"contentOffset"]) {
/* Do Something Supposed To Do Here */
} // 5
}
- (void)dealloc {
self.scrollView = nil; // 6
}
@end
案例分析
代码中有几处标注需要额外解释一下:
注释1: context
在注册观察、处理回调以及取消观察这三个步骤中都有用到。如果直接使用NULL
,大多数情况下也不会影响使用,但是当父类和子类观察了同一个对象的同一个 keyPath 的时候,使用上就会产生冲突,子类很有可能会重复处理观察到的变化,而父类却无法得到回调。所以,我建议还是每次都要指定 context 的,而且 context 最好是唯一且私有的不可变值,这样可以在检测时确信是自己所观察到的变化。
另外,你也可以在一个类中使用多个 context,可以每个 keyPath 对应一个 context 来解除回调中对 keyPath 字符串比较的依赖。你甚至可以每个 keyPath 对应多个 context,来解决不同功能逻辑对同一个 keyPath 的 KVO 需求。
注释2: 取消观察的方法采用了有 context 的版本,这样跟注册时候的 context 一一对应,逻辑上会比没有 context 的版本更加清晰。如果没有指定 context,接口得自己猜测着去删除一个注册的 keyPath,说不定删掉的是父类所注册的那个哦。
注释3: 注册观察这里总共有4个参数,除了 context 前面已经分析过了,另外3个这里都详细讲讲。
首先是Observer
,这个参数一般都会是self
,这样注册观察、处理回调、取消回调逻辑都在一处,比较清楚,如果指向别的对象,就会产生耦合,大家可以看具体情况决定要不要指向别的对象。Observer 对象需要实现回调处理函数,如果父类和自己都没有实现回调的话,那么触发回调的时候程序就会崩溃了。
接着我们来看一下keyPath
,代码中我们直接使用了字符串常量,但这个写法其实是有安全隐患的,如果你的拼写出错了或者是观察对象属性发生变化了,你的代码逻辑就会走不通。更安全的写法通常是NSStringFromSelector(@selector(contentOffset))
,但这样无疑会增加很多额外的工作,并不是十分优雅,如何取舍,完全看你。另外,不知道你注意到没有,这里的变量名是keyPath
, 而不是key
,其中的差别,我会在后面进阶用法中进行分析。
最后是options
,这个参数会影响回调中的change
参数传递的值。常用的就是NSKeyValueObservingOptionOld
和NSKeyValueObservingOptionNew
这两个,分别会在 change 这个字典中加入 NSKeyValueChangeOldKey
和NSKeyValueChangeNewKey
这两个 key,用于获取变化前后的值,如果你想同时使用多个选项,可以用|
将它们连接在一起。由于大多数时候,你只关心变化后的值,而变化后的值又可以直接从观察对象身上取到,所以 options 也可以直接设置为 0,这样 change 字典中就只会有一个NSKeyValueChangeKindKey
。关于这个一直存在的 kindKey 以及另外几个 options 我会在后面进阶用法中进行分析。
注释4: 在处理回调的时候,如果不是自己所要观察内容的回调,请直接转发给super
。
注释5: 因为注册的所有观察都会触发同一个回调,因此你还需要在回调函数中针对不同的 object 和 keyPath 分别进行处理。如果你不想像代码中一样进行比较,也可以用我之前在注释1所说的用多 context 方案解决这个问题。
注释6: 这里的目的其实是为了取消观察,注册和取消观察必须成对出现,取消观察的次数多了或者少了都会出现问题,下面我们就来一一分析一下。
第一种情况,在没有取消观察前,观察对象就释放了。这种情况问题不大,可以先不管。
第二种情况,在没有取消观察前,观察者被释放了。这时候,被观察对象会继续对变化进行通知,而通知对象却已经释放,崩溃就会在回调触发时发生了。
第三种情况,取消观察后,又取消观察。这个情况最简单,因为找不到需要删除的观察者,程序会抛出一个NSRangeException
。
基于以上分析,我的建议是尽量做到成对的注册与取消,如果实现无法保证,基于多次删除只是抛出异常这一点,你可以试试用 try...catch...
来进行反复删除。
进阶用法
API 概览
了解了 KVO 的基本用法之后,我们来深入了解一下相关的 API,看看还能够用 KVO 来做哪些事情。
KVO相关的API都定义在NSKeyValueObserving.h
文件中,主要分为四个部分:
NSKeyValueObserving
NSKeyValueObserverRegistration
NSKeyValueObserverNotification
NSKeyValueObservingCustomization
这四个部分从命名上就可以看出各自负责的内容,基本都是以 category 的形式加在NSObject
上来实现所需的功能,除了 Registration 这个稍微特殊一点,还在NSArray
、NSSet
、NSOrderedSet
上实现了这个 category。这些额外的 category 中做的事情是重写注册观察相关的方法,在方法中抛出异常。这是因为这几个集合类都是不能直接进行 KVO 的,关于这一点我会在后面详细分析一下集合对象要如何进行 KVO。
NSArray(NSKeyValueObserverRegistration)
上还提供了下面这三个接口:
- (void)addObserver:(NSObject *)observer
toObjectsAtIndexes:(NSIndexSet *)indexes
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(nullable void *)context;
- (void)removeObserver:(NSObject *)observer
fromObjectsAtIndexes:(NSIndexSet *)indexes
forKeyPath:(NSString *)keyPath
context:(nullable void *)context;
- (void)removeObserver:(NSObject *)observer
fromObjectsAtIndexes:(NSIndexSet *)indexes
forKeyPath:(NSString *)keyPath;
这三个接口可以让你批量注册观察 array 中包含的对象,并且执行效率上会比循环调用原来的接口要好很多。
关于NSObject
上的这个4个 category,又可以分为2个部分。
第一部分是NSObject(NSKeyValueObserving)
和NSObject(NSKeyValueObserverRegistration)
。这两个 category 里所包含的公有接口就是我在基本用法中列出的那几个常用接口,大部分内容我已经在基本用法中说明过了,接下来我还会详细分析一下其余几个NSKeyValueObservingOptions
参数以及多级keyPath
的使用问题。
第二部分是NSObject(NSKeyValueObserverNotification)
和NSObject(NSKeyValueObservingCustomization)
。这两个 category 里的接口可以让我们做一些更复杂的事。
我们首先来看看NSObject(NSKeyValueObservingCustomization)
中的接口:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key;
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;
@property (nullable) void *observationInfo;
这个 category 中内容不多,但却包含了很多东西。第一个方法可以帮助我们轻松实现对to-one relatioship
的观察,我们只要重写这个方法,返回key
之间的依赖关系即可;第二个方法让我们能够不使用系统默认实现的KVO
通知机制,自己选择在合适的时候发出通知;而最后的那个property
,则是用于获取观察对象们的信息,可以帮助我们了解当前实例是否有被观察,以及更多观察者相关的信息。
最后就是定义在NSObject(NSKeyValueObserverNotification)
中通知相关的接口了:
- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;
- (void)willChange:(NSKeyValueChange)changeKind
valuesAtIndexes:(NSIndexSet *)indexes
forKey:(NSString *)key;
- (void)didChange:(NSKeyValueChange)changeKind
valuesAtIndexes:(NSIndexSet *)indexes
forKey:(NSString *)key;
- (void)willChangeValueForKey:(NSString *)key
withSetMutation:(NSKeyValueSetMutationKind)mutationKind
usingObjects:(NSSet *)objects;
- (void)didChangeValueForKey:(NSString *)key
withSetMutation:(NSKeyValueSetMutationKind)mutationKind
usingObjects:(NSSet *)objects;
这里总共有3组方法,每组两个方法,一个用于在修改前通知变化,另一个则在修改后调用,两个方法必须成对调用。第一组方法用于发出一般的变动通知,而后面两组方法发出的通知则更精细化一点。第二组方法用于处理有序的一对多关系,第三组方法处理的则是无序的一对多关系。
下面,我们结合实际需求来看看这些接口是如何使用的。
更多回调触发时机
我们先来看看如何使用注册观察接口中的options
参数,来获得更多的回调。
NSKeyValueObservingOptions
的定义是这样的:
typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
NSKeyValueObservingOptionNew = 0x01,
NSKeyValueObservingOptionOld = 0x02,
NSKeyValueObservingOptionInitial = 0x04,
NSKeyValueObservingOptionPrior = 0x08
};
前两个我们已经在基本功能中分析过了,下面我们来着重介绍一下后面两个。
如果你尝试添加NSKeyValueObservingOptionInitial
选项,你会发现在注册观察后,回调函数立即被触发了一次。如果你够仔细的话,你甚至会发现,回调函数实际上是在注册观察接口返回前就被触发了的。这个功能可以帮助你减少一次初始化相关的手动调用。有一点需要额外注意一下,这次触发的回调中,即使你添加了NSKeyValueObservingOptionOld
参数,change
字典中也不会有NSKeyValueChangeOldKey
。因为这时候对于观察者来说是没有旧值的,即使这个值是早就设置好的,对观察者来说也是新值。
NSKeyValueObservingOptionPrior
选项可以让你在观察对象的属性真正变化前触发一次回调,而原来变化后会触发的回调依旧会触发。所以在添加了这个选项后,每次观察的属性发生变化时,都会触发两次回调。第一次触发的回调中,change
字典里会包含一个NSKeyValueChangeNotificationIsPriorKey
,用来标记这次触发是在变化真正发生前触发的。同时需要注意的是,change
字典中不会包含NSKeyValueChangeNewKey
,这让这个通知的价值大大减少。
观察属性链
之前我们提到过,接口中用的参数都是叫keyPath
,而不是key
,现在我们来深究一下它们的区别。keyPath
是由一堆key
用.
连接而成的,可以用来表示一个多级的属性链。这就是说,KVO
是支持我们观察属性链的变化的。
假设我们现在观察的keyPath
是user.firstName
,那么回调会在什么情况下被触发呢?简单来说,有两种情况,一个是user
发生了变化,一个是 user 的firstName
发生了变化。
所以说,只要我们所要观察的属性链上的每一个节点都是满足KVO
条件的,那么不管属性链有多长,我们都能使用keyPath
轻松地观察到链上任何一个节点的变化。
观察一对一关系
一对一关系(to-one relationship
),简单来说,就是两个属性是一一对应的。我们来看一个例子:一个人的全名(fullName
)是由,姓(lastName
)和名(firstName
)两部分构成的。全名中只能包含一个姓,所以全名和姓是一一对应的;全名也只能包含一个名,所以全名和名也是一一对应的。当然,你可以改名,甚至改姓,但它们之间永远都是一对一的关系。
现在,我们有一个User
类,上面有firstName
,lastName
,fullName
三个属性。前两个使用ivar
进行存储,而fullName
实现如下:
- (NSString *)fullName {
return [NSString stringWithFormat:@"%@ %@", firstName, lastName];
}
所以,当一个观察者观察fullName
变化时,它应该在firstName
和lastName
发生变化时,都得到通知。但默认的KVO
实现中,只有在你直接修改fullName
属性或者调用setFullName:
方法时才会触发通知,我们得自己实现我们所要的效果。
实现方法有两种:
- 手动通知变化
- 注册依赖关系
前者我们需要再放一放,后面会有一节专门来说说如何手动通知变化,我们先来看看第二种是如何实现的:
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"fullName"]) {
NSArray *affectingKeys = @[@"lastName", @"firstName"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
例子很简单,用到的接口我们之前在 API 概览里也已经说明过了,需要注意的是,记得在重写的时候先去获取父类所注册的依赖关系,然后把自己所需的依赖关系加进去。
其实除了这个方法外,还有另一个更实用的方法可以达到这个效果,因为另一个方法不用像这个方法一样对所有 key 集中进行管理,每个 key 都有一个专门的方法去管理依赖关系,你只需要遵循这个命名规则就可以了:keyPathsForValuesAffecting<Key>
比如我们这个功能就可以这么实现:
+ (NSSet *)keyPathsForValuesAffectingFullName {
return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}
当你在 category 中添加类似的属性时,你会感谢苹果还提供了后面这种解决方案的。
我们基本已经讲清楚这个问题了,不过你还需要注意以下几点:
- 我们第二种方案的例子中虽然没有调用
super
,但是如果你是在子类中为父类添加依赖关系的话,你确认一下调用super
的必要性! - 例子中用的都是简单的
key
,但请放心添加keyPath
到依赖关系中去。 - 你可以为一对多关系的属性添加依赖关系,但你不能使用一对多关系的属性作为其他属性依赖关系的一部分。简单一点来讲,在使用时,基本上只要有集合类属性出现,你就不用考虑这种解决方案了。
观察一对多关系
一对多关系(to-many relationship
),比较常见的就是集合属性和它包含的内容的关系。所以我们先把问题简单转化为,如何观察一个可变数组内容的变化,当我们解决了这个问题,我们再回过头来分析更通用的情形。
要观察NSMutableArray
内容的变化,也有两种方案:
- 手动通知变化
- 使用集合代理对象(
collection proxy object
)
和前面一样,我们还是先把手动通知变化放一放,先来看看集合代理对象是怎么帮我们解决这个问题的。
我们知道,NSMutableArray
本身的方法是不支持KVO
的,但是我们可以通过下面这个方法,获取到一个代理对象:
- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
我们可以直接把这个代理对象看做一个支持KVO
的NSMutableArray
进行后续操作,所有原本对NSMutableArray
的操作都转为对这个代理对象的操作,这样我们就能实现我们的效果了。
现在我们回到观察一对多关系上来,除了NSMutableArray
外的其他一对多关系要怎么进行观察呢?简单来说,只要做到像NSMutableArray
那样可以获取到一个代理对象就可以了,至于要怎样才能达到这一点,我们在这里不进行展开,感兴趣的人可以看一下苹果KVC官方文档中的这部分内容。
手动通知变化
在讲解这部分内容之前,我首先得建议你尽量用自动通知的方案去解决你的问题,如果经过你的仔细思考后,还是无法解决,那么你可以试试手动通知的方案。
在进行手动通知前,你需要先禁用系统通知:
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
BOOL automatic = NO;
if ([theKey isEqualToString:@"firstName"]) {
automatic = NO;
} else {
automatic = [super automaticallyNotifiesObserversForKey:theKey];
}
return automatic;
}
接着你就可以使用我们之前介绍过的手动通知接口去通知变化了:
- (void)setFirstName:(NSString *)firstName {
[self willChangeValueForKey:@"firstName"];
_firstName = firstName;
[self didChangeValueForKey:@"firstName"];
}
当然,你的使用场景肯定不会这么简单,不然也就没有使用手动通知的必要了。基于你自己的需求,去实现你所要的通知效果吧!
存在的问题及优化方案
KVO
虽然可以帮我们解决一些切实的需求,但接口设计上还是存在比较多问题的:
- keyPath是字符串类型
- 缺少安全校验
- 频繁的字符串比较
- 所有的回调处理都在一个函数中进行
- 需要帮忙传递父类的通知
- 需要通过比较确认通知类型
- 需要手动取消观察
- 需要和注册观察完全配对,不匹配会导致崩溃
以上问题大多数可以对 API 进行二次封装解决问题,至少在使用上我们可以避免这些丑陋的问题,感兴趣的读者可以了解一下 facebook 的 KVOController。