这是我参与8月更文挑战的第9天,活动详情查看: 8月更文挑战
KVO简介
KVO全称为Key-Value Observing,意思就是键值观察;KVO是一种机制,它允许允许其他对象的指定属性发生变化时,通知对象;想要了解键值观察,必须先要理解键值编码也就是KVC;
KVC是键值编码,在对象创建完成后,可以动态的给对象的属性赋值,而KVO是键值观察,提供了一套监听机制,当对象的指定的属性被修改后,对象会收到通知,所以可以看出KVO是基于KVC的基础上对对象属性的动态变化进行监听;
这听起来好像跟NSNotificationCenter有些类似,那么他们有什么区别呢?
KVO和NotificationCenter区别
- 相同点
- 两者都是
观察者模式,都用来监听; - 都能实现
一对多的操作;
- 两者都是
- 不同点
KVO用于监听对象属性的变化,并且属性名是通过字符串NSString来进行查找的;NSNotificationCenter的监听也就是POST操作我们可以控制,而KVO是由系统控制的;KVO可以记录新旧值的变化;
KVO的使用
KVO基本使用
- 注册观察者
[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
- 监听KVO回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if ([keyPath isEqualToString:@"nickName"]) {
NSLog(@"%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- 移除观察者
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"nickName" context:NULL];
}
context的使用
KVO官方文档中关于context的介绍如下
简要来说就是context是一个包含任意数据的指针,这些数据会在相应的更改通知中回传给观察者。我们可以指定context为NULL,从而使用keyPath即键路径来确定更改的通知的来源,但是这种方法可能会导致某些对象发生问题,比如该对象的父类也监听了相同的keyPath;所以我们可以为每一个需要观察的keyPath传建一个不同的context,从而完全跳过对keyPath的比较,直接使用context进行更有效的通知解析;context更安全也更具可扩展性,从而大大提升性能和代码的可读性;
我们通过代码来直观的感受一下:
- 未使用
context时代码逻辑:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[Person alloc] init];
self.student = [[Student alloc] init];
[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
[self.student addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if ([keyPath isEqualToString:@"nickName"] && object == self.person) {
NSLog(@"Person:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
} else if ([keyPath isEqualToString:@"nickName"] && object == self.student) {
NSLog(@"Student:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"nickName" context:NULL];
[self.student removeObserver:self forKeyPath:@"nickName" context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.person.nickName = @"昵称";
self.student.nickName = @"王同学";
}
为了监听Person和Student两个类的相同的nickName属性,我们在接收监听回调时,为了区分nickName来自于哪个对象,除了使用keyPath只要,还需要使用object来判断来源对象,才能准确区分是谁的nickName发生了变化;
- 使用
context时代码逻辑:
//定义context
static void *PersonNameNickContext = &PersonNameNickContext;
static void *StudentNameNickContext = &StudentNameNickContext;
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[Person alloc] init];
self.student = [[Student alloc] init];
[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:PersonNameNickContext];
[self.student addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:StudentNameNickContext];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == PersonNameNickContext) {
NSLog(@"Person:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
} else if (context == StudentNameNickContext) {
NSLog(@"Student:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"nickName" context:PersonNameNickContext];
[self.student removeObserver:self forKeyPath:@"nickName" context:StudentNameNickContext];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.person.nickName = @"昵称";
self.student.nickName = @"王同学";
}
上述方法是不会触发
KVO的;
仅仅通过context就能判断是哪个对象的nickName发生了变化;
KVO使用细节
移除观察者的必要性
KVO官方文档中关于removeObserver的说明如下:
解释:
通过向被观察者发送removeObserver:forKeyPath:context:消息,指定观察对象,键路径和context,可以删除一个键值观察者;
在接收到removeObserver:forKeyPath:context:消息后,观察对象将不再接收指定键路径和对象的任何observeValueForKeyPath:ofObject:change:context消息;
当移除观察者时,需要注意以下几点:
- 如果没有注册观察者,被移除时会导致
NSRangeException,可以通过调用一次removeObserver:forKeyPath:context:来应对addObserver:forKeyPath:options:context:;如果在项目中这种方式不可行时,可以把removeObserver:forKeyPath:context: call放在try/catch块中来处理潜在的异常; - 当被释放时,观察者不会自动释放自己。被观察的对象继续发送通知,而忽略了其状态。但是,与发送到已释放的对象的其他消息一样,更改通知也会触发内存访问异常。因此,应该确保观察者在从内存中消失之前将自己删除。
- 该协议无法询问对象是观察者还是被观察者。构造代码以避免与发布相关的错误,一种典型的模式就是在观察者初始化期间(
init或者viewDidLoad中)注册为观察者,并在释放过程中(通常是在dealloc中)注销,以确保添加和删除消息是成对的,并确保观察者在注册之前被取消注册,并从内存中释放。
总的来说就是,KVO注册观察者和移除观察者需要是成对出现的,如果只注册而不移除,会出现崩溃
图中的Person采用单例是为了防止释放,演示崩溃现象
崩溃的原因是,由于第一次注册KVO观察者之后没有移除,再次进入界面,会导致第二次注册KVO观察者,由于之前注册的对象并没有释放,导致重复的注册观察者,此时会收到属性值变化的通知,会出现找不到通知对象。
所以,为了防止出现这种情况,建议在dealloc中移除观察者。需要注意的是,如果使用了context,那么移除时也要使用相同的context,否则将会崩溃,抛出名为NSRangeException的异常:
KVO的自动触发和手动触发
- 自动触发
// 自动开关
+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
return YES;
}
方法返回YES时,表示可以监听,返回NO,表示不可以监听;
- 手动触发
- (void)setNickName:(NSString *)nickName {
[self willChangeValueForKey:@"nickName"];
_nickName = nickName;
[self didChangeValueForKey:@"nickName"];
}
当自动触发方法返回NO时,我们可以通过这种方式实现手动触发;
KVO观察:一对多
KVO观察中的一对多,意思是通过注册一个观察者,可以监听到多个属性的变化;
比如,下载文件的时候,我们经常需要根据总下载量totalData和当前下载量writtenData来计算出下载进度downloadProgress,有两种方法都可以达到目的:
- 第一种,分别给两个属性添加观察者,当其中任何一个发生变化时,计算当前的下载进度
downloadProgress; - 第二种,实现
keyPathsForValuesAffectingValueForKey方法,将两个观察者合二为一,即观察当前的downloadProgress,当totalData和writtenData任意一个值发生改变即会发送通知:
@implementation Person
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"downloadProgress"]) {
NSArray *affectingKeys = @[@"totalData", @"writtenData"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
- (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];
}
@end
@implementation SecondViewController
static void *PersondownloadProgressContext = &PersondownloadProgressContext;
- (void)viewDidLoad {
[super viewDidLoad];
self.navigationItem.title = NSStringFromClass(self.class);
self.view.backgroundColor = [UIColor whiteColor];
self.person = [[Person alloc] init];
[self.person addObserver:self forKeyPath:@"downloadProgress" options:NSKeyValueObservingOptionNew context:PersondownloadProgressContext];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == PersondownloadProgressContext) {
NSLog(@"Person:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"downloadProgress" context:PersondownloadProgressContext];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.person.writtenData += 10;
}
@end
KVO观察可变数组
KVO是基于KVC基础之上的,所以可变数组添加元素,直接调用addObject方法是不会触发setter方法的,所以通过addObject这种方法添加元素时无法监听到数组的变化的;
@implementation SecondViewController
static void *PersondateArrayContext = &PersondateArrayContext;
- (void)viewDidLoad {
[super viewDidLoad];
self.navigationItem.title = NSStringFromClass(self.class);
self.view.backgroundColor = [UIColor whiteColor];
self.person = [[Person alloc] init];
self.person.dataArray = [[NSMutableArray alloc] initWithCapacity:0];
[self.person addObserver:self forKeyPath:@"dataArray" options:NSKeyValueObservingOptionNew context:PersondateArrayContext];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
NSLog(@"--->%@", change);
}
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"dataArray" context:PersondateArrayContext];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.person.dataArray addObject:@"1"];
}
在KVC官方文档中针对可变数组类型进行了说明,需要通过mutableArrayValueForKey方法:
修改代码如下:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[[self.person mutableArrayValueForKey:@"dataArray"] addObject:@"1"];
}
运行结果:
数组改变时,可以被监听到;
其中kind表示键值变化的类型,是一个枚举类型:
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1, // 设置值
NSKeyValueChangeInsertion = 2, // 插入
NSKeyValueChangeRemoval = 3, // 移除
NSKeyValueChangeReplacement = 4, // 替换
};
所以,一般属性的kind为1;
KVO原理探索
KVO官方文档中对KVO的原理描述如下:
解析:
KVO的自动键值观察是使用isa-swizzling技术实现的;isa指针,顾名思义,指向维护调度表的对象的类。这个调度表本质上包含指向类实现的方法的指针,以及其他数据;- 当
对象的属性注册为观察者时,将会修改被观察对象的isa指针,指向一个中间类而不是真正的类。因此,isa指针的值不一定反映实例的实际类; - 不应该依赖
isa指针来决定类的成员。相反,应该使用类方法来确定对象实例的类;
KVO代码调试
属性观察
在上文中,我们测试了属性nickName的修改可以被KVO监听到,那么成员变量是否也能监听到呢?
给Person类添加名为name的成员变量:
@interface Person : NSObject {
@public
NSString *name;
}
@property (nonatomic, copy) NSString *nickName;
@end
分别为name和nickName都添加KVO监听:
[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:PersonNameNickContext];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:PersonNameContext];
运行结果:
KVO只能监听属性,不能对成员变量进行监听;而属性和成员变量的区别在于属性比成员变量多一个setter方法,而KVO监听的就是setter方法;
中间类NSKVONotifying_Xxxx
根据KVO官方文档的描述,在注册观察者之后,观察对象的isa指针发生了变化,接下来通过代码来验证一下:
在注册成为观察者之前,实例对象person的isa指向Person;
在注册成为观察者之后,实例对象person的isa指向NSKVONotifying_Person;
在注册成功观察者之后,实例对象的
isa指向了一个中间类NSKVONotifying_Xxxx,isa的指针指向确实发生了变化
NSKVONotifying_Xxxx 研究
NSKVONotifying_Xxxx与观察者的关系
那么NSKVONotifying_Xxxx究竟是什么呢?
通过上述两张图,我们可以确定NSKVONotifying_Person确实是,在person对象注册成为观察者之后,系统在底层自动生成的,那么这个类和Person有什么关系呢?
我们首先来遍历一下Person在注册观察者前后的子类是否有变化:
注册为观察者之前,
Person只有一个子类Student,注册观察者之后,Person多了一个名为NSKVONotifying_Person的子类,而NSKVONotifying_Person没有子类;
NSKVONotifying_Xxxx的方法列表
那么,NSKVONotifying_Person中都有那些方法呢:
可以看到,自动生成的类NSKVONotifying_Person中,有四个方法,分别是setNickName,class,dealloc,_isKVOA:
setNickName观察对象的setter方法class类型dealloc是否释放(该dealloc执行时,将isa重新指向Person)_isKVOA判断是否是KVO生成的一个辨识码
那么这些方法是继承来的还是重写了父类的方法呢?
创建一个继承与类Person的类Student:
@interface Student : Person
@end
@implementation Student
@end
然后,分别打印Student和NSKVONotifying_Person的方法列表:
Student的方法列表没有打印(集成的方法无法在子类的方法列表中遍历出来),说明NSKVONotifying_Person中的方法是重写了父类的方法
NSKVONotifying_Xxxx的释放问题
既然,系统自动创建了NSKVONotifying_Person
经过验证,在dealloc中移除观察者之后,isa指针重新指向了Person类,那么NSKVONotifying_Person是否被销毁了呢?
我们在当前界面的上一级界面,打印Person的子类的情况查看一下:
可以看到,即使dealloc方法执行了,观察者已经被移除,回到上级界面之后,NSKVONotifying_Person依然存在;
中间类一旦生成,考虑到重用问题,之后会一直存在,并不会销毁;
setter方法归属问题
在之前我们已经验证过,KVO监听的是setter方法,中间类NSKVONotifying_Person也重写了setter方法,那么我们最终修改的setter方法究竟是NSKVONotifying_Person的还是Person的呢?
可以看到,在移除观察者时,isa已经指向了Person,而且nickName的值也改变了,那么此时的setter方法是Person的;
接下来,我们通过观察变量值改变验证一下:
运行项目,断点,观察_nickName值的改变:
继续运行项目,触发监听:
bt打印堆栈信息:
所以最终调用的setter是Person的setNickName方法;