前言
“这是我参与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;可以注册任何你感兴趣的东西,而且由开发者控制,且只能记录开发者传递的参数;
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: