这是我参与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
方法;