iOS Objective-C KVO 详解
1. KVO
KVO
即Key-Value Observing
是苹果提供给开发者的一套键值观察的API,KVO
是一种机制,它允许将其他对象的指定属性的更改通知给对象。KVO
是建立在KVC
的基础上的,对于KVC
的原理及应用可以查看我的上一篇文章。下面我们来详细的介绍KVO
1.1 KVO 可以观察什么属性?
根据KVO
官方文档的定义,我们可以知道可观察的属性分为以下三种:
- attributes: 简单属性,比如基本数据类型,字符串布尔值等,诸如
NSNumber
和其他一些不可变类型,比如NSColor
也被认为是简单属性。 - to-one relationships: 一对一关系,一个属性的值取决于另一个值。比如一个人的全名由姓氏和名字组成,其中任一一个改变都会影响全名的改变;其实下载进度也跟这个类似,下载量和总量的任一改变都会改变下载进度。
- to-many relationships: 一对多关系,在KVO中不支持多对多的关系键值路径。一对多关系主要是集合对象属性,通常就是
NSArray
或者NSSet
等,但是涉及改变就是他们的可变类型。比如有一个部门,有一个员工数组,员工有薪资属性,部门有个总工资属性,我想监听总工资的变化,其实就是监听员工数组的改变,再其改变后可以通过KVC
的数组操作符计算总工资的变化,然后手动调用willchange
去触发总工资改变的监听
对于一对一和一对多的关系可以查看苹果官方文档,进一步了解和示例代码的查看。
1.2 KVO 的三个步骤
举个例子,如上图所示Person
对象有个Account
属性,而Account
对象又有balance
和interestRate
两个属性。现在我们想实现一个功能:当余额和利率变化的时候需要通知到用户,其实用户可以通过轮询的方式定期去查询Account
对象中的balance
和interestRate
,但是这种方式不仅不及时而且效率低,消耗大,更好的方式是使用KVO
,使Person
对象像收到通知一样能及时的知道余额和利率的变动。
另外要实现KVO
的前提是被观察对象时符合KVO
机制的,一般来说,继承于NSObject
根类的对象及其属性都自动符合KVO
机制。当然我们也可以自己去实现,使其同样符合KVO
机制,这就是Manual Change Notification
(手动变更通知),所以KVO
包含Automatic Change Notification
(自动变更通知)和Manual Change Notification
(手动变更通知)两种机制。
KVO合规性官方文档
- 首先是注册观察者
将观察者实例Person
与观察实例Account
注册在一起。Person
对每个观察到的键路径向Account
发送一个addObserver:forKeyPath:options:context:消息,将自己命名为观察者。这里observer
(监听者)、keyPath
(被监听者)、options
(监听策略)、context
(上下文)。
- 被观察者触发回调
为了接收Account
的变更通知,Person
需要实现observeValueForKeyPath:ofObject:change:context:
方法。Account
将在任何改变的时候想Person
发送该消息,Person
可以根据通知做出相应的措施。
- 移除观察
最后,当不需要监听的时候就可以通过removeObserver:forKeyPath:
方法移除监听,但是移除必须在监听者对象销毁前执行。
1.3 KVO
三个方法解析
1.3.1 注册观察者
- (void)addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(nullable void *)context;
- observer: 观察者,一般都是
self
- keyPath: 被观察者的属性
- options:
NSKeyValueObservingOptions
的组合,它指定观察通知中会回调什么值 - context: 上下文,这里是一个
nullable void *
类型的参数,我们通常会传nil
,其实应该传NULL
,官方文档也说应该传NULL
。其实这里我们可以传一个void *
类型的指针,用来区分相同path
的不同对象的观察。传值示例:static void *PersonNameContext = &PersonNameContext;
NSKeyValueObservingOptions:的四个枚举值
- NSKeyValueObservingOptionNew: 表明通知中的更改字典应该提供新的属性值,如果有的话。
- NSKeyValueObservingOptionOld: 表明通知中的更改字典应该包含旧的属性值,如果有的话。
- NSKeyValueObservingOptionInitial: 在属性发生变化后立即通知观察者,这个过程甚至早于观察者注册是时候。如果在注册的时候配置了
NSKeyValueObservingOptionNew
,那么在通知的更改字典中也会包含NSKeyValueChangeNewKey
,但是不会包括NSKeyValueChangeOldKey
。(在初始通知中,观察到的属性值可能是旧的,但是对于观察者来说是新的)其实简单来说就是这个枚举值会在属性变化前先触发一次observeValueForKeyPath
回调。 - NSKeyValueObservingOptionPrior: 这个会先后连续出发两次
observeValueForKeyPath
回调。同时在回调中的可变字典中会有一个布尔值的key - notificationIsPrior
来标识属性值是变化前还是变化后的。如果是变化后的回调,那么可变字典中就只有new
的值了,如果同时制定了NSKeyValueObservingOptionNew
的话。如果你需要启动手动KVO
的话,你可以指定这个枚举值然后通过willChange
实例方法来观察属性值。在出发observeValueForKeyPath
回调后再去调用willChange
可能就太晚了。
下面我们来验证一下NSKeyValueObservingOptions
几个key
会有什么样的结果。
初始实现代码:
static void *PersonNameContext = &PersonNameContext;
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [LGPerson new];
self.person.name = @"nameA";
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{
if (context == PersonNameContext) {
NSLog(@"person name change %@ - %@",self, change);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
self.person.name = @"nameB";
}
NSKeyValueObservingOptionNew:
NSKeyValueObservingOptionOld:
NSKeyValueObservingOptionInitial:
NSKeyValueObservingOptionInitial
会触发两次回调,第一次是在属性改变前,第二次是在属性改变后。但是并没有返回任何的旧值和新值。其实第一次是在我们调用addObserver:forKeyPath:
后就打印了的。这与name
是否赋初始值没有关系,有没有初值都会打印。
Initial | New | Old:
此时还是触发了两次回调,只不过第一次返回的新值其实就是旧值,就是我们初始化时的值,第二次返回即包含了新值,也包含了旧值。其实我们包含new
在第二次就会返回新值,包含old
就会返回旧值,如果不包含就不会返回。如果不包含new
第一次就不会返回新值。
NSKeyValueObservingOptionPrior:
这是也是触发了两次回调,不过这两次回调是在值改变后触发的,并且第一次多返回了一个notificationIsPrior
值。
Prior | New | Old:
此时还是触发了两次回调,同样在第一次回调中包含notificationIsPrior
值。并且第一次回调中多了旧值,第二次回调中即包含旧值也包含新值。同样我们包含new
在第二次就会返回新值,包含old
就会返回旧值,如果不包含就不会返回。如果不包含old
第一次就不会返回旧值。
1.3.2 观察者接收通知
- (void)observeValueForKeyPath:(nullable NSString *)keyPath
ofObject:(nullable id)object
change:(nullable NSDictionary *)change
context:(nullable void *)context;
除了change
其他参数跟上面注册观察时的相同。
change
包含五个key
,如下:
key | value | 描述 |
---|---|---|
NSKeyValueChangeKindKey | NSNumber类型 | 1:Setting,2:Insertion,3:Removal,4:Replacement |
NSKeyValueChangeNewKey | id | 变化后的新值 |
NSKeyValueChangeOldKey | id | 变化后的旧值 |
NSKeyValueChangeIndexesKey | NSIndexSet | 插入、删除或替换的对象的索引 |
NSKeyValueChangeNotificationIsPriorKey | NSNumber boolValue | Option为Prior时标识属性值是变化前和还是变化后的 |
NSKeyValueChangeKindKey对应的枚举:
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};
1.3.3 移除观察
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
移除和注册时一一对应的,有两个方法一个是带context
参数的,另一个是不带的。在观察者生命周期结束前,一定要移除观察,如果没有移除,KVO
机制会给一个不存在的对象发送变化回调消息导致野指针错误。另外也不能重复移除注册,重复移除会导致crash
,当然为了避免crash
我们可以把移除放在@try里面去执行。
1.4 自动观察与手动观察
默认情况下,我们只需要按照上面的步骤就可以实现属性的观察,其实这是由系统完全控制的,属于自动观察。其实KVO
还给我们提供了手动观察的选项。
如果我们想要开启手动观察就要通过重写类方法+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key
,如果返回YES
就是自动观察,返回NO
就是手动观察,根据方法的我们还可以判断key
值对不同的key
分别实现自动观察和手动观察。
// 自动开关
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
BOOL automatic = NO;
if ([theKey isEqualToString:@"balance"]) {
automatic = NO;
}
else {
automatic = [super automaticallyNotifiesObserversForKey:theKey];
}
return automatic;
}
对于需要手动观察的key
在改变前需要调用willChangeValueForKey
方法,在改变后需要调用didChangeValueForKey
方法,如果不调用,就不会触发KVO
的监听。
示例代码:
- (void)setBalance:(double)theBalance {
[self willChangeValueForKey:@"balance"];
_balance = theBalance;
[self didChangeValueForKey:@"balance"];
}
- 我们可以通过提前检查是否已更改来最大程度的减少发送不必要的通知:
官方示例:
- (void)setBalance:(double)theBalance {
if (theBalance != _balance) {
[self willChangeValueForKey:@"balance"];
_balance = theBalance;
[self didChangeValueForKey:@"balance"];
}
}
- 如果单个操作导致更改多个键,则必须嵌套更改通知
官方示例:
- (void)setBalance:(double)theBalance {
[self willChangeValueForKey:@"balance"];
[self willChangeValueForKey:@"itemChanged"];
_balance = theBalance;
_itemChanged = _itemChanged+1;
[self didChangeValueForKey:@"itemChanged"];
[self didChangeValueForKey:@"balance"];
}
- 对于有序的一对多关系,不仅必须指定已更改的键,还必须指定更改的类型和所涉及对象的索引。
- 改变的类型的键值是一个
NSKeyValueChange
类型的枚举值,有三个分别是:NSKeyValueChangeInsertion
、NSKeyValueChangeRemoval
和NSKeyValueChangeReplacement
。 - 受影响对象的索引作为NSIndexSet对象传递。
- 改变的类型的键值是一个
示例代码:
- (void)removeTransactionsAtIndexes:(NSIndexSet *)indexes {
[self willChange:NSKeyValueChangeRemoval
valuesAtIndexes:indexes forKey:@"transactions"];
// Remove the transaction objects at the specified indexes.
[self didChange:NSKeyValueChangeRemoval
valuesAtIndexes:indexes forKey:@"transactions"];
}
1.5 Registering Dependent Keys(注册从属关系的键值)
在许多情况下,一个属性的值取决于另一对象中一个或多个其他属性的值。如果一个属性的值发生更改,则派生属性的值也应标记为更改。如何确保为这些从属属性发布键值观察通知取决于关系的基数。 这个在上面已经有所提到,这里在通过举例进行详细的说明。
1.5.1 一对一关系
要自动触发一对一关系的通知,您应该重写 keyPathsForValuesAffectingValueForKey:
或实现遵循其定义的用于注册从属键的模式的合适方法。
例如,一个人的全名取决于名字和姓氏。返回全名的方法可以编写如下:
- (NSString *)fullName {
return [NSString stringWithFormat:@"%@ %@",firstName, lastName];
}
fullName当firstName或lastName属性更改时,必须通知观察该属性的应用程序,因为它们会影响属性的值。
第一种方法是我们通过重写keyPathsForValuesAffectingValueForKey:
指定fullName
的属性取决于lastName
和firstName
属性。通常我们应该调用super
并返回一个集合,该集合包括这样做所导致的集合中的其他任何成员免受干扰。
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"fullName"]) {
NSArray *affectingKeys = @[@"lastName", @"firstName"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
第二种方法是通过实现遵循命名约定的类方法keyPathsForValuesAffecting
来实现相同的结果,其中
是依赖值的属性名称(首字母大写)。实现代码如下:
+ (NSSet *)keyPathsForValuesAffectingFullName {
return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}
对于分类我们只能以第二种方法进行实现因为我们不能再分类中覆盖keyPathsForValuesAffectingValueForKey:
的实现。
1.5.2 一对多关系
keyPathsForValuesAffectingValueForKey:
方法不支持包含多对多关系的键路径。那么对于这种关系的键值路径我们该如何处理呢?
例如我们有个Department
(部门),他又一个employees
(员工数组)对象,部门跟员工有很多关系,但是Employee
(员工)具有salary
(薪资)属性,这时我们希望部门有个totalSalary
(总工资)属性,那么这个属性取决于员工数组中所有员工的薪资,我们也不能使用keyPathsForValuesAffectingTotalSalary
和employees.salary
作为键返回。
此时我们可以使用键值观察将父项(在此示例中为Department
)注册为所有子项(在此示例中为employees
)的相关属性的观察者。您必须作为观察者添加和删除父对象,因为要在关系中添加或删除子对象。在该observeValueForKeyPath:ofObject:change:context:
方法中,您将响应更改来更新从属值,如以下代码片段所示:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == totalSalaryContext) {
[self updateTotalSalary];
}
else
// deal with other observations and/or invoke super...
}
- (void)updateTotalSalary {
[self setTotalSalary:[self valueForKeyPath:@"[email protected]"]];
}
- (void)setTotalSalary:(NSNumber *)newTotalSalary {
if (totalSalary != newTotalSalary) {
[self willChangeValueForKey:@"totalSalary"];
_totalSalary = newTotalSalary;
[self didChangeValueForKey:@"totalSalary"];
}
}
- (NSNumber *)totalSalary {
return _totalSalary;
}
另外如果您使用的是Core Data
,则可以将父项注册到应用程序的通知中心,作为其托管对象上下文的观察者。父母应以类似于观察键值的方式响应孩子发布的相关变更通知。
2. KVO 底层原理探索
由于KVO的实现并没有开源,我们首先看看官方文档是怎么说的:
Automatic key-value observing is implemented using a technique called isa-swizzling. 【译:】自动键值观察使用的是一种叫做
isa-swizzling
的技术。
The isa pointer, as the name suggests, points to the object's class which maintains a dispatch table. This dispatch table essentially contains pointers to the methods the class implements, among other data.【译:】isa
指针,顾名思义,指向的是对象所属的类,这个类维护了一个哈希表,这个哈希表实质上包含指向该类实现的方法的指针以及其他数据。
When an observer is registered for an attribute of an object the isa pointer of the observed object is modified, pointing to an intermediate class rather than at the true class. As a result the value of the isa pointer does not necessarily reflect the actual class of the instance.【译:】在位对象的属性注册观察者时,将修改观察对象的isa
指针,指向中间类而不是真实的类,因为isa
的值不一定反映的是实例的实际的类。
You should never rely on the isa pointer to determine class membership. Instead, you should use the class method to determine the class of an object instance.【译:】所以我们永远不要依靠isa
指针来确定类成员,所以我们应该使用class
方法确定对象实例的类。
2.1 中间类(派生类)
根据官方文档的内容我们可以知道,在KVO
的底层实现中会生成一个中间类,此时我们实例对象的isa
就指向了这个中间类,那么我们就来验证一下:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[LGPerson alloc] init];
NSLog(@"注册KVO前%@---%s", NSStringFromClass([self.person class]), object_getClassName(self.person));
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"注册KVO后%@---%s", NSStringFromClass([self.person class]), object_getClassName(self.person));
}
打印结果如下:
通过打印结果可以看出在注册KVO
观察后通过Objective-C
方法打印的的类名仍然是LGPerson
,但是在注册后通过Runtime API
打印的确有不同了,所以说Objective-C
方法对class
方法进行了封装,让我们在开发过程中对中间类无感知,但是底层确实是实现了一个中间类就是NSKVONotifying_xxx
。其实我们也可以通过打印对象的isa
来验证,至此我们就验证了官方文档所说的内容。
那么这个中间类跟我们的类有什么关系呢?我们不妨打印一下类和它的子类来看看。
打印类实现代码:
NSLog(@"注册KVO前");
[self printClasses:[LGPerson class]];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"注册KVO后");
[self printClasses:[LGPerson class]];
- (void)printClasses:(Class)cls{
/// 注册类的总数
int count = objc_getClassList(NULL, 0);
/// 创建一个数组, 其中包含给定对象
NSMutableArray *mArray = [NSMutableArray arrayWithObject:cls];
/// 获取所有已注册的类
Class* classes = (Class*)malloc(sizeof(Class)*count);
objc_getClassList(classes, count);
for (int i = 0; i
可以看到在LGPerson
的子类中有这个中间类,所以说这个中间类是类的子类。
2.2 KVO 观察
我们知道KVO
是观察属性的变化,那么属性的本质是成员变量+getter
+setter
,getter
是取值的,并不会修改值,值的变化发生在setter
和给成员变量赋值两种情况。那么我们分别测试一下这两种情况哪一种会触发KVO
的观察。
声明代码:
@interface LGPerson : NSObject{
@public
NSString *name;
}
@property (nonatomic, copy) NSString *nickName;
@end
验证代码和结果:
通过上图我们可以看到直接给实例变量赋值并不会触发KVO
的监听,但是直接给属性赋值就触发了KVO
的监听,其实给属性赋值就是调用setter
方法,所以说KVO
底层是观察的setter
方法。
2.3 中间类都有哪些方法
我们分别打印原始类和中间类中的方法进行查看:
实现代码:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[LGPerson alloc] init];
NSLog(@"原始类中的方法");
[self printClassAllMethod:[LGPerson class]];
[self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"派生类中的方法");
[self printClassAllMethod:NSClassFromString(@"NSKVONotifying_LGPerson")];
}
printClassAllMethod 代码:
- (void)printClassAllMethod:(Class)cls{
unsigned int count = 0;
Method *methodList = class_copyMethodList(cls, &count);
for (int i = 0; i
打印结果:
我们可以看到中间类中有属性的setter
方法,class
方法,dealloc
方法以及_isKVOA
方法。这里的setter
方法是重写了原始类的方法,其余的都是重写的NSObject
方法。
- 对于重写
setter
应该是在setter
方法中触发监听回调,已经给原始类中属性赋值 - 对于重写
class
,这里也就验证了我们在上面打印class
时为什么都是原始类的名称。这样是为了隐藏中间类的存在,让开发者在使用过程中保持一致性。 - 对于重写
dealloc
应该是移除监听时需要处理一些逻辑 - 对于重写
_isKVOA
方法应该是返回是否是KVO
的值
2.3 isa 何时指回原始类
其实这很容易想到,当我们移除所有观察后就意味着我们不需要观察了,此时在指向中间类也就没什么意义了。下面我们进行验证。
验证代码:
- (void)dealloc{
NSLog(@"移除观察前%@",object_getClass(self.person));
[self.person removeObserver:self forKeyPath:@"nickName"];
NSLog(@"移除观察后%@",object_getClass(self.person));
[self printClasses:[LGPerson class]];
}
打印结果:
我们通过代码和lldb
进行了验证在移除观察后isa
即指回了原始的类。另外我们也验证了指回后是否销毁中间类,显然中间类并没有被销毁。其实这也很正常,因为创建一个类还是非常耗费性能的,虽然移除了观察,但是也不能保证不再重新开始观察,既然创建了就让它留着吧,如果下次继续开始监听就不用重新创建了,也就提高了性能。
3. 自定义KVO
至此我们就基本分析完毕了KVO
,那么我们可以自己来实现以下。
搁置了!!!
- FaceBook 的 KVOController
- 根据原生的
KVC
和KVO
反汇编而编写的 DIS_KVC_KVO - 开源的
GNUStep
的libs-base
(最接近APPLE源码的)gnustep/libs-base
4.总结
-
KVO
是苹果提供给开发者的一套键值观察的API -
KVO
由注册观察者,监听通知,移除观察三个步骤组成 - 有自动观察和手动观察两种模式
- 对于可变集合需要通过
mutableXXXValueForKey
的相关方法触发更改 - 我们还可以注册从属关系的键值观察,
KVO
支持一对一和一对多两种 -
KVO
本质是isa-swizzling
技术,通过生成中间类(派生类)来实现属性的观察 - 中间类会重写属性的
setter
方法以及重写class
方法,dealloc
方法和_isKVOA
方法