前言
“这是我参与8月更文挑战的第14天,活动详情查看:8月更文挑战”
资源准备
KVO
的基本介绍
KVO
全称Key-Value Observing
,是 Objective-C
对观察者设计模式的一种实现。KVO
提供一种机制,发生更改时,对象会获得通知,并作出相应处理;【且不需要给被观察的对象添加任何额外代码,就能使用KVO
机制】。KVO
是基于KVC
基础之上,所以必须首先了解KVC
,KVC
已经在上篇文章分析过了。
1、那么KVO
和KVC
的差异是什么了:
-
KVC
是键值编码,由NSKeyValueCoding
非正式协议所调起的机制。在对象创建完成后,可以动态的给对象属性赋值; -
KVO
是键值观察,一种监听机制。当指定的对象的属性被修改后,对象会收到通知。所以,KVO
是基于KVC
的基础上,对属性动态变化的监听;
2、KVO
和NSNotificatioCenter
的差异又是什么了:
-
相同点:首先他们都是观察者模式,都用于监听,且都能实现一对多操作;
-
不同点:
- kvo:
只能用于监听对象属性的变化,能发出消息由系统控制,还能自动记录新旧值变化; NSNotificatioCenter
;可以注册任何你感兴趣的东西,而且由开发者控制,且只能记录开发者传递的参数;
- kvo:
API
介绍
API
使用,分为注册观察者、接收变更通知、移除观察者:
1、注册观察者
:使用方法向被观察对象注册观察者
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
复制代码
参数分析:
-
第一个
observer
是添加的监听者的对象,当监听的属性发生改变时会通知这个对象; -
第二个
keyPath
是监听的属性,不能传nil
; -
第三个
options
指明通知发出的时机以及change
中的键值:NSKeyValueObservingOptionNew
把更改之前的值提供给处理方法NSKeyValueObservingOptionOld
把更改之后的值提供给处理方法NSKeyValueObservingOptionInitial
把初始化的值提供给处理方法,一旦注册,立马就会调用一次。通常它会带有新值,而不会带有旧值。NSKeyValueObservingOptionPrior
分2次调用。在值改变之前和值改变之后
-
第四个是
context
上下文
2、接收变更通知
:在观察者内部实现以接受更改通知消息
- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;
复制代码
3、移除观察者
:当观察者不再应该接收消息时,应该使用方法取消注册观察者。至少在观察者从内存中释放之后调用这个方法
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
复制代码
API
介绍
context
的使用
注册观察者方法addObserver:forKeyPath:options:context:
中的context
可以传入任意数据,并且可以在监听方法中接收到这个数据。也可以把context
设置为NULL
,这样就完全依赖keyPath
字符串来确定更改通知的来源。如果是观察同一个类里面的不同属性,那么在接收的地方就需要做比较复杂的区分;同时也可能会导致父类由于不同原因也在观察相同键路径的对象出现问题。
用案例更直观些:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [DMPerson new];
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (object == self.person) {
if ([keyPath isEqualToString:@"nick"]) {
NSLog(@"nick 改变了\n%@",change);
} else if ([keyPath isEqualToString:@"name"]) {
NSLog(@"name 改变了\n%@",change);
}
}
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.person.nick = [NSString stringWithFormat:@"%@+",self.person.nick];
self.person.name = [NSString stringWithFormat:@"%@*",self.person.name];
}
复制代码
同时观察LGPerson
对象的两个属性name
和nick
,context
设置为 NULL
,在observeValueForKeyPath
就需要繁杂点的判断,我们需要先判断object
是不是LGPerson
,再判断keyPath
是nick
还是name
。而且打印出来的结果,还是一样的,如下如:
接下来,我们就设置下context
这个参数:
static void *PersonNickContext = &PersonNickContext;
static void *PersonNameContext = &PersonNameContext;
复制代码
在注册的时候,再把 context
添加上:
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:PersonNickContext];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:PersonNameContext];
复制代码
在接收消息处,就可以直接通过 context
进行判断了:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == PersonNickContext) {
NSLog(@"通过context nick 改变了\n%@",change);
} else if (context == PersonNameContext) {
NSLog(@"通过context name 改变了\n%@",change);
}
}
复制代码
再看下此时打印的结果:
从结果上看还是一样的,那么就说明 context
实际上就是一个标志,让我们能够更加简单,直接的判断是哪个属性的改变回调的。context
使之变得更安全、更可扩展的方法,也提高了通知解析的效率,同时也确保接收到的通知是发送给观察者的,而不是父类的。
移除观察者
在使用 KVO
的时,如果不需要使用了,将会移除观察者,也就是执行removeObserver:forKeyPath:context:
方法,那么观察对象将不再接收observeValueForKeyPath:ofObject:change:context:
指定keyPath
和对象的任何消息。而这一般都是放在dealloc
方法中。那么如果我们不移除,是不是可以呢?
我们可以写一个简单的页面,给一个能够跳转到第二个界面的入口,然后在第二个界面时间下面代码:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [LGPerson new];
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.person.nick = @"hellow world";
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"LGDetailViewController :%@",change);
}
复制代码
然后每次进入第二个界面,点击一下屏幕后,就返回第一个界面,最后再重复几次。看打印结果如下:
每次都能打印,没啥太大的问题。
如果我们把 LGPerson
对象替换成 LGStudent
(是一个单例) ,再看效果,代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor orangeColor];
self.student = [LGStudent shareInstance];
// weak observer
[self.student addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.student.name = @"hello word";
}
#pragma mark - KVO回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"LGDetailViewController :%@",change);
}
复制代码
再按照上面的步骤,再反复执行下:
发现会报错。为什么了?我们可以在官方文档上面找到我们想要的答案:
**An observer does not automatically remove itself
when deallocated. The observed object continues to
send notifications, oblivious to the state of the
observer. However, a change notification, like any
other message, sent to a released object, triggers
a memory access exception. You therefore ensure
that observers remove themselves before disappearing
from memory.**
大致的意思是:当移除之后,观察者不会自动删除自己,所以当属性发生改变时,
会继续发送通知,然而此时观察者已经被释放掉了,最终造成了内存的访问异常。
因此,需要保证当观察者被销毁时,将观察者移除。
复制代码
-
当我们第一次进入
LGDetailViewController
的时候,是注册了观察者,点击屏幕触发回调,没有任何问题,然后再返回上层页面 -
当我们第二次进入
LGDetailViewController
的时候,我们第一次进入页面注册的观察者已经被销毁,但是由于LGStudent
是个单例,没被释放掉,所以依旧会发送回调消息,最终导致内存访问异常,应用崩溃。这就需要调用dealloc
来进行释放。
所以,移除时观察者不会自动将其自身移除。被观察的对象继续发送通知,而忽略了观察者的状态。但是,发送到已释放对象的更改通知与任何其他消息一样,会触发内存访问异常。因此,要确保观察者在从内存中消失之前将自己移除。
KVO
的自动/手动触发
当时我们使用 KVO
时,一般情况默认是自动监听模式,而当我们想改变成手动监听模式的时候,我们需要在被监听的对象中实现automaticallyNotifiesObserversForKey
方法:
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
//可以根据不同的key值,来区分使用自动还是手动监听
if ([key isEqualToString:@"nick"]) {
return YES;
}
return NO;
}
复制代码
如果返回 YES
,就是自动模式,如果返回NO
,则表示全部使用手动监听,这是我们在上面的案例中直接去触摸屏幕就没有任何响应了。
实现手动观察者通知,请willChangeValueForKey:
在更改值之前和didChangeValueForKey:
更改值之后调用,代码如下:
- (void)setNick:(NSString *)nick {
[self willChangeValueForKey:@"nick"];
_nick = nick;
[self didChangeValueForKey:@"nick"];
}
复制代码
则会进行手动监听到.
观察多个因素影响的属性
多个因素影响,也就是一对多的关系,通过注册一个KVO
观察者,可以监听多个属性的变化。
我们可以用常见的下载进度
为例:
下载进度 = 当前下载数currentData / 总下载数totalData
;
所以currentData
和totalData
任意值的改变,都会影响到下载进度。
分别观察totalData
和currentData
两个属性,当其中一个属性的值发生变化,计算当前下载进度downloadProgress
;
在被观察对象LGPerson
中,实现keyPathsForValuesAffectingValueForKey:
方法,合并currentData
和totalData
属性的观察;
我们通过案例描述下:
- 在
LGPerson
中的代码:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"downloadProgress"]) {
NSArray *affectingKeys = @[@"totalData", @"writtenData"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
// 自动开关
+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
return YES;
}
- (NSString *)downloadProgress{
if (self.writtenData == 0) {
self.writtenData = 10;
}
if (self.totalData == 0) {
self.totalData = 100;
}
return [[NSString alloc] initWithFormat:@"%f",1.0f*self.writtenData/self.totalData];
}
复制代码
- 在二级界面中:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [LGPerson new];
// 下载的进度 = 已下载 / 总下载
[self.person addObserver:self forKeyPath:@"downloadProgress" options:(NSKeyValueObservingOptionNew) context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.person.writtenData += 10;
self.person.totalData += 1;
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"%@",change);
}
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"nick" context:NULL];
}
复制代码
我们再看下打印结果,我们一共触摸了三次屏幕:
通过keyPathsForValuesAffectingValueForKey
方法中,将downloadProgress
关联的两个因素totalData
和writtenData
,再通过setByAddingObjectsFromArray
关联起来,那么每次totalData
或者writtenData
改变时,都会自动通知观察者downloadProgress
改变了。而第一次打印了三次的原因只是因为我们当totalData
为0时,设置他为100,就多调用了一次。
监听可变数组
我们可以使用KVO
来监听可变数组,因为KVO
基于KVC
基础之上,所以我们可以根据KVC
的文档中所说明的对集合对象访问定义的三种不同的代理方法:
-
第一个就是:
mutableArrayValueForKey:
和mutableArrayValueForKeyPath:
。它们返回一个行为类似于NSMutableArray
对象的代理对象; -
第二个就是:
mutableSetValueForKey:
和mutableSetValueForKeyPath:
。它们返回一个行为类似于NSMutableSet
对象的代理对象; -
第三个就是:
mutableOrderedSetValueForKey:
和mutableOrderedSetValueForKeyPath:
。它们返回一个行为类似于NSMutableOrderedSet
对象的代理对象。
案例代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [LGPerson new];
self.person.dateArray = [NSMutableArray arrayWithCapacity:1];
[self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
if(self.person.dateArray.count == 0){
[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"1"];
} else {
[[self.person mutableArrayValueForKey:@"dateArray"] removeObjectAtIndex:0];
}
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"%@",change);
}
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"dateArray"];
}
复制代码
运行打印结果:
注
:kind
表示键值变化的类型,执行addObject
时,kind
打印值为2
。执行removeObjectAtIndex
时,kind
打印值为3
我们再看看kind
的定义的枚举信息:
/* Possible values in the NSKeyValueChangeKindKey entry in change dictionaries. See the comments for -observeValueForKeyPath:ofObject:change:context: for more information.
*/
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1, // 赋值
NSKeyValueChangeInsertion = 2, // 插入
NSKeyValueChangeRemoval = 3, // 移除
NSKeyValueChangeReplacement = 4, // 替换
};
复制代码
KVO
的底层原理
根据官方文档可以知道:
自动键值观察是使用称为isa-swizzling
的技术实现的。
-
该
isa
指针,顾名思义,指向对象的类,它保持一个调度表。该调度表主要包含指向类实现的方法的指针,以及其他数据。 -
当观察者为对象的属性注册时,被观察对象的
isa
指针被修改,指向中间类
而不是真正的类
。因此,isa
指针的值不一定反映实例的实际类。 -
不应该依赖
isa
指针来确定类的成员。相反,应该使用该class
方法来确定实例对象的类。
isa
的变化
我们通过代码调试打印,来看下情况:
通过lldb
调试,在添加注册KVO
观察者 addObserver
前后,打印person
所属的类对象发生改变。我们知道,实例对象
和类
的关系实际上就是实例对象的isa
指向了类对象
。所以这里我们可以推断,self.person
在调用addObserver
方法后,已经从LGPerson
类的实例对象,变成了NSKVONotifying_LGPerson
的实例对象。
NSKVONotifying_x
的创建时机
通过上面的这个案例打印,我们需要验证NSKVONotifying_LGPerson
类是本来就存在,还是添加KVO
临时生成的了。
注:objc_getClass 是 runtime 的 api ,一定要导入头文件才可以正常使用,#import <objc/runtime.h>。
复制代码
通过打印,知道在添加KVO
后,会临时生成NSKVONotifying_LGPerson
类,并将实例对象的isa
指向该类。那么NSKVONotifying_LGPerson
的父类是什么了?我们再通过 lldb
调试再看看:
此时,我们知道,NSKVONotifying_LGPerson
的父类,竟然是 LGPerson
。
也可以通过下面这句代码打印:
NSLog(@"NSKVONotifying_LGPerson的Superclass:%@",class_getSuperclass(objc_getClass("NSKVONotifying_LGPerson")));
复制代码
NSKVONotifying_x
中的方法
接下来,遍历下NSKVONotifying_LGPerson
类中的所有方法,代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[LGPerson alloc] init];
[self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
unsigned int intCount;
Method *methodList = class_copyMethodList(objc_getClass("NSKVONotifying_LGPerson"), &intCount);
for (unsigned int intIndex=0; intIndex<intCount; intIndex++) {
Method method = methodList[intIndex];
NSLog(@"SEL:%@,IMP:%p",NSStringFromSelector(method_getName(method)), method_getImplementation(method));
}
}
复制代码
打印的结果:
根据打印结果,可以知道,NSKVONotifying_LGPerson
重写了父类的setNickName
方法,同时还重写了NSObject
类的class
、dealloc
、_isKVOA
方法。
重写class
方法的目的
目的:为了隐藏KVO
生成的中间类。
调用中间类的class
方法,返回的还是原始类对象的地址,我们对比打印出添加 KVO
前 和添加之后的情况,代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[LGPerson alloc] init];
Class cls = self.person.class;
NSLog(@"添加KVO观察者之前:%s, %p, %@", object_getClassName(self.person), &cls, cls);
[self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
cls = self.person.class;
NSLog(@"添加KVO观察者之后:%s, %p, %@", object_getClassName(self.person), &cls, cls);
}
复制代码
再看下打印结果:
通过结果可以知道,使用中间类重写后的class
方法,获取的还是LGPerson
类,这样做就好像 KVO
所做的一切都不存在一样。
重写dealloc
方法的目的
目的:为了在移除观察者之后,将实例对象的isa
重新指向原始类对象。
查看下调用移除观察者方法removeObserver
前后,实例对象所发生的变化,代码如下:
再看下打印结果,我们可以发现,这样做,是使得中间类的class
方法,配合dealloc
方法,成功替换了实例对象的isa
指向,并且对开发者毫无痕迹。
NSKVONotifying_x
类是否被销毁
根据上面的分析,当移除观察者之后,实例对象的isa
指向原始类对象,此时中间类NSKVONotifying_LGPerson
的任务已经完成了,它是否会进行销毁呢?
那么我们在点击屏幕和 dealloc
两个地方,分别添加打印 NSKVONotifying_LGPerson
类信息的代码。
代码如下:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"中间类:%@", objc_getClass("NSKVONotifying_LGPerson"));
}
复制代码
根据打印结果,可以知道,当移除观察者之后,中间类并没有直接销毁。可能考虑再次添加观察者,可以对其进行重用。
重写_isKVOA
方法的目的
目的:是为了标记是否为添加KVO
时,生成的中间类。
使用KVC
方法 [self.person valueForKey:@"isKVOA"]
,打印原始类和中间类的isKVOA
,代码如下:
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"添加KVO观察者之前:%s,_isKVOA:%@", object_getClassName(self.person), [self.person valueForKey:@"isKVOA"]);
[self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"添加KVO观察者之后:%s,_isKVOA:%@", object_getClassName(self.person), [self.person valueForKey:@"isKVOA"]);
}
复制代码
我们看下打印结果:
进行了标记,添加KVO观察者之前,是 0
,添加KVO观察者之后,是 1
。
重写setter
方法的目的
重写setter
方法,中间类负责调用KVO
相关的系统函数,然后调用父类的setter
方法,保证原始类中的属性赋值成功。当一切都结束以后,中间类继续调用系统函数,最后调用KVO
的回调通知。
KVO
只能监听属性
那么我们通过有无 setter
方法的属性变量,添加 KVO
作对比,看看结果如何,代码如下:
我们知道 name
属性是没有 setter
方法的。而 nickName
有 setter
方法,而 nickName
,根据上面的分析,是重写了 setter
方法的。
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[LGPerson alloc] init];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
unsigned int intCount;
Method *methodList = class_copyMethodList(objc_getClass("NSKVONotifying_LGPerson"), &intCount);
for (unsigned int intIndex = 0; intIndex < intCount; intIndex++) {
Method method = methodList[intIndex];
NSLog(@"SEL:%@,IMP:%p",NSStringFromSelector(method_getName(method)), method_getImplementation(method));
}
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.person->name = @"changeNewName";
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"%@",change);
}
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"name"];
}
复制代码
运行打印执行的方法结果:
根据结果分析,发现没有setter
方法,而且点击屏幕也没有收到KVO
的监听回调,但是做了值的修改。所以可以得出结论,KVO
只能监听属性,无法监听成员变量。同时也可以得出,在KVO
生成的类中对name
的修改影响到了原始类。
setter
方法的调用流程
- 第一步:执行到断点处;
- 第二步:通过
lldb
调试,输入watchpoint set variable self->_person->_nickName
命令; - 第三步:放开断点,且继续运行,再点击屏幕;
- 第四步:通过
lldb
调试,输入 bt 查看堆栈信息。
根据堆栈信息,我们可以知道,在调用LGPerson
的setNickName
方法之前,调用Foundation
(不开源)库中三个系统方法,分别是:
-
Foundation
-[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:maybeNewValuesDict:usingBlock:]:
-
Foundation
-[NSObject(NSKeyValueObservingPrivate) _changeValueForKey:key:key:usingBlock:]:
-
Foundation
_NSSetObjectValueAndNotify:
在汇编中,_NSSetObjectValueAndNotify
调用主要如下图:
接着调用LGPerson
的setNickName
方法。
当成功修改nickName
属性,再次调用Foundation
库中两个系统方法:
-
Foundation`NSKeyValueDidChange:
-
Foundation`NSKeyValueNotifyObserver:
最后调用KVO
的回调通知:observeValueForKeyPath:ofObject:change:context: