KVC
KVO初识
KVO
全称为Key-Value observing
,翻译为键值观察,这是一种机制,其允许其他对象的指定属性的更改通知给对象。
这里我们首先要去理解KVC,再来理解KVO。这是由于键值观察是建立在键值编码的基础上的,下面附上笔者学习KVC的笔记:【iOS】KVC
在iOS日常开发过程中,我们会使用KVO
来监听对象属性的变化,作出相应;也就是说当指定的被观察的对象的属性被修改之后,KVO
会自动通知相应的观察者。在之前的学习中,还学习过NSNotificatioCenter
,那么这两者又有什么区别呢?
- 相同点
- 二者实现的原理都是观察者模式,都是用于监听
- 都可以实现一对多的操作
- 不同点
KVO
只可以用于监听对象属性的变化,并且属性名都通过NSString
来查找,编译器并不会检查对错与补全,纯手敲会比较容易出错NSNotificatioCenter
的发送监听的操作我们可以控制,KVO
由系统控制KVO
可以记录新旧值的变化
KVO的使用
KVO的基本使用
下面我们先来看看KVO使用的三部曲:
- 注册观察者
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:NULL];//这里observer:添加的监听者的对象,当监听的属性发生改变时会通知这个对象。 keyPath:监听的属性,不能传nil。 options:指明通知发出的时机以及change中的键值。 context:是一个可选的参数,可以传任何数据。
options
:typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) { NSKeyValueObservingOptionNew = 0x01,//更改前的值 NSKeyValueObservingOptionOld = 0x02,//更改后的值 NSKeyValueObservingOptionInitial = 0x04,//观察最初的值(在注册观察服务时会调用一次触发方法) NSKeyValueObservingOptionPrior = 0x08 //分别在值修改前后触发方法(即一次修改有两次触发) };
- 实现回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"name"])
NSLog(@"%@", change);
}
- 移除观察者
[self.person removeObserver:self forKeypath:@"name"];
context的使用
在我们平时的使用中,我们一般都将 其设置为NULL,那么其究竟由什么用呢?这里我们来探究一下。
context
参数主要作用其实是为KVO
回调提供一个标识符或标记,有助于区分同一属性的不同观察者或在多个地方注册的同一个观察者。
这是在Apple官方文档中对于context
的说明,从其解释来看,使用context
是一种更安全、可扩展的方法来确保收到的通知是发送给我们观察者而不是superClass
。
如果说当我们的父类中已经有了一个name
属性,而子类中也有一个name
属性,这两者都注册对name
的观察,这个时候如果仅仅通过keyPath
已经无法区分是哪一个name
发生了变化,这个时候就可以使用context
。
//定义context
static void *PersonNameContext = &PersonNameContext;
static void *StudentNameContext = &StudentNameContext;
//注册观察者
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];
[self.child addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:StudentNameContext];
//KVO回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == PersonNameContext) {
NSLog(@"%@", change);
} else if (context == StudentNameContext) {
NSLog(@"%@", change);
}
}
移除通知的必要性
或许在我们日常的开发中,我们有时候觉得是否移除了通知都无关痛痒,但是不移除通知会带来潜在的风险。
这里我们将Person
类以单例的形式创建,这个时候如果没有移除通知我们来看看会发生什么呢?
#import "LGDetailsViewController.h"
@interface LGDetailsViewController ()
@property (nonatomic, strong) Person* person;
@end
@implementation LGDetailsViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor orangeColor];
self.person = [Person sharedInstance];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld) context:NULL];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"%@", change);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.person.name = @"wahaha";
//[self.navigationController popViewControllerAnimated:YES];
}
- (void)dealloc
{
NSLog(@"dealloc");
[self.person removeObserver:self forKeyPath:@"name"];
}
这里我们pop出这个界面再push进这个界面,这个时候就会报错:
这是由于没有移除观察,单例对象依旧存在,再次进来点击屏幕就会出现野指针报错。
KVO观察可变数组
这里我创建一个Student
类,其中有一个可变数组muArr
,现在观察他:
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
self.detaiView = [[LGDetailsViewController alloc] init];
self.stu = [[Student alloc] init];
[self.stu addObserver:self forKeyPath:@"muArr" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.stu.muArr addObject:@"1"];
NSLog(@"点击屏幕");
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"muArr"]) {
NSLog(@"%@", change);
}
}
- (void)dealloc
{
[self.stu removeObserver:self forKeyPath:@"muArr"];
}
这时候点击屏幕是不会打印的,这是为什么呢?
- 首先我们要知道
KVO
是建立在KVC
的基础上的,而可变数组直接添加方法是不会调用Setter
方法的 - 在KVC官方文档中,针对可变数组的集合类型来说,访问集合对象需要通过调用
mutableArrayValueForKey
以达到将元素添加到可变数组中的目的
这里我们应该使用mutableArrayValueForKey
方法:
[[self.stu mutableArrayValueForKey:@"muArr"] addObject:@"1"];
KVO原理
官方解释
这里我们先来看看Apple官方对于KVO的解释:
KVO
使用的是isa-swizzling
技术来实现的isa
指针指向维护分配表的对象的类,该分派表实质上包含指向该类实现的方法的指针以及其他数据- 在为对象的属性注册观察者时,将修改观察对象的
isa
指针,指向中间类而不是真实类。isa
指针的值不一定反映实例的实际类 - 您永远不应依靠
isa
指针来确定类成员身份。相反,您应该使用class
方法来确定对象实例的类
代码实现
这里我们来看看注册观察者前后的isa
的指向:
通过这两张图片我们可以看出,在观察者注册前后Student
类并没有发生变化,但是实例对象的isa
指向发生了变化。
那么动态生成的中间类NSKVONotifying_Student
和Student
是一个什么关系呢?
我们在注册观察者前后来分别调用打印一下子类的方法,看看有什么结果:
- (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<count; i++) {
if (cls == class_getSuperclass(classes[i])) {
[mArray addObject:classes[i]];
}
}
free(classes);
NSLog(@"classes = %@", mArray);
}
当我们在注册观察者前后分别调用这个方法的时候,我们来看看打印结果:
这里我们可以发现NSKVONotifying_Student
是Student
的子类。
动态子类的方法
下面我们来看看动态子类中有哪些方法,我们通过rutime-API
来打印一下动态子类和观察类的方法:
-(void) printClassAllMethod:(Class) cls {
NSLog(@"*********************");
unsigned int count = 0;
Method *methodList = class_copyMethodList(cls, &count);
for (int i = 0; i < count; i++) {
Method method = methodList[i];
SEL sel = method_getName(method);
IMP imp = class_getMethodImplementation(cls, sel);
NSLog(@"%@-%p",NSStringFromSelector(sel),imp);
}
free(methodList);
}
这里我直接给出打印Student
和NSKVONotifying_Student
的结果:
这里我们可以看到,中间类重写了
setmuArr
、class
、dealloc
这几个方法,并且添加了一个叫_isKVOA
的方法,来区分是不是系统通过KVO
自动生成的
观察者被移除isa指向
上述我们看了当注册观察者之后会生成一个中间类,那么当我们移除观察者之后,这里又会指回原本的类
如何预防KVO崩溃
导致KVO崩溃的原因
通常我们在KVO的日常使用中会遇到以下几个原因造成KVO崩溃:
- KVO添加次数和移除次数并不匹配:
- 移除未注册的观察者,造成崩溃
- 重复移除了多次,移除次数多于添加的次数,从而导致崩溃
- 重复添加了多次,这种情况并不会导致KVO崩溃,但当观察对象发生改变的时候,会被观察多次
- 添加和删除的顺序不一致会导致崩溃
- 添加了观察者,但是并没有实现
observerValueForKeyPath:ofObject:context
方法,这会导致崩溃 - 添加或者移除时
keypath==nil
,导致崩溃 - 被观察者提前释放,被观察者在
dealloc
的时候还注册着KVO,导致崩溃的产生
当我们面对后两种情况的时候:
- Swizzle
observeValueForKeyPath:ofObject:change:context:
方法,在调用原方法外包一层try catch
就可以解决第二个的崩溃 - swizzle
addObserver:forKeyPath:options:context:
、removeObserver:forKeyPath:
、removeObserver:forKeyPath:context
方法,在里面对keypath
判空或者直接在原方法外包装一次try catch
就可以解决第三点
try catch:一种常见的防护崩溃而被广泛使用的错误处理机制
防止崩溃的方法
有很多中方法去预防KVO崩溃,这里记录我学习的预防崩溃的方法:
- 为
NSObject
创建一个分类,使用方法交换,实现自定义的BMP_addObserver:forKeyPath:options:context:
、BMP_removeObserver:forKeyPath:
、BMP_removeObserver:forKeyPath:context:
、BMPKVO_dealloc
方法,用来替换系统原生的添加移除观察者方法的实现。 - 使用一个自定义的对象,在该对象中使用
{keypath : [observer1, observer2 , ...](NSHashTable)}
结构的关系哈希表对keyPath
、observer
之间进行一个维护 - 在分类中自定义
dealloc
的实现,移除了多余的观察者
参考博客:iOS 开发:『Crash 防护系统』(二)KVO 防护
KVO遇到的问题
直接修改成员变量的值,会不会触发KVO?
不会触发KVO,首先,本质上*KVO是替换掉setter
*方法的实现,所以只有通过set方法修改才会触发KVO
KVC修改属性是否会触发KVO呢
是会触发的,虽然我们知道KVC使用setValue:firKey
不一定触发instance实例对象的setter方法,但是该方法在修改成员变量的值的时候会调用willchangevalueforkey
和didchangevalueforKey
两个方法,这会触发监听器的回调方法
[!IMPORTANT]
willchangevalueforKey
和didchangevalueforKey
是KVO的两个核心方法
KVO的具体实现流程
KVO的实现是依赖于OC运行时的动态特性,通过使用isa-swizzling技术动态的创建一个当前类的派生类NSKVONotifying_XXX
,动态的修改当前实例对象的isa指针,使其指向派生类,并重写了setXXX
、class
、dealloc
这几个方法,并且添加了一个叫_isKVOA
的方法,来区分是不是系统通过KVO
自动生成的。在OC发送消息的时候,通过isa指针找到当前对象所属的类对象。类对象中保存着当前对象的实例方法,在向该对象发送消息的时候,实际上是发送到了派生类对象的方法。
这里要注意一点,当移除观察者之后,isa指针会指向原来的类,但是并不销毁中间类
子类继承父类的属性,属性改变时KVO是否可以监听?
由于继承关系Father <- Son <- KVOSOn
,当监听一个父类属性keyPath
的时候,Son实例同样也可以通过继承链找到父类的Setter方法,将其加入到KVOSon中去。