1. KVO定义
1.1 KVO简介
KVO 全称是 Key-value Observing,翻译过来就是:键值观察。
官文中对KVO进行了详细介绍,键值观察是一种机制,它允许对象在其他对象的指定属性发生更改时收到通知。它对于应用程序中模型层和控制器层之间的通信特别有用。
要使用KVO,首先必须确保被观察对象符合KVO。通常,如果您的对象继承自NSObject并且您以通常的方式创建属性,则您的对象及其属性将自动符合KVO。
在Xcode中找到 KVO的定义,可以看到是对NSObject的扩展来实现的:
1.2 KVO提供的API
-
监听注册
使用方法
addObserver:forKeyPath:options:context:向被观察对象注册观察者。必须执行以下步骤才能使对象能够接收KVO兼容属性的键值观察通知:- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;观察者指定一个
选项参数options和一个上下文指针context来管理通知的各个方面 -
接收通知
在观察者内部实现
observeValueForKeyPath:ofObject:change:context:以接受更改通知消息- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context; -
移除监听
使用方法
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;来移除观察者- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
2. KVO使用
2.1 context参数
参考 官文,关于context相关定义:
注册方法addObserver:forKeyPath:options:context:中的context可以传入任意数据,并且可以在监听方法中接收到这个数据。
-
context作用:标签-区分,可以更精确的确定被观察对象属性,用于继承、 多监听;也可以用来传值。 说白了就是:当有多个监听时,在监听回调方法observeValueForKeyPath:ofObject:change:context:中你可以通过context直接判断你监听的属性,不用在通过object、keyPath来判断 -
苹果的推荐用法:用
context来精确的确定被观察对象属性,使用唯一命名的静态变量的地址作为context的值。可以为整个类设置一个context,然后在监听方法中通过object和keyPath来确定被观察属性,这样存在继承的情况就可以通过context来判断;也可以为每个被观察对象属性设置不同的context,这样使用context就可以精确的确定被观察对象属性。 如: -
context优点:嵌套少、性能高、更安全、扩展性强。 -
context注意点:- 如果传的是一个对象,必须在移除观察之前持有它的强引用,否则在监听方法中访问
context就可能导致Crash; - 空传
NULL而不应该传nil
- 如果传的是一个对象,必须在移除观察之前持有它的强引用,否则在监听方法中访问
2.2 移除观察者
关于KVO,网上有人说iOS9以后就不用移除了,这样说对吗,我们打开 方文 ,可以找到下面这段内容:
简单解释一下就是:当观察者被释放时它并不会主动移除自己。观察对象还是会继续发送通知,对已经释放的内存发送消息,会抛出异常导致Crash。因此观察者要在释放前移除它们自己,下面用示例来说明
创建个 YJDetailViewController, push 到这个控制器:
static void *YJPersonNameContext = &YJPersonNameContext;
@interface YJDetailViewController ()
@property (nonatomic, strong) YJPerson *person;
@end
@implementation YJDetailViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"详情页";
static int enterCount = 0;
enterCount++;
NSLog(@"第 %d 次进来 YJDetailViewController", enterCount);
// 注意,这里 self.person 是个单例
self.person = [YJPerson shareInstance];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:YJPersonNameContext];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s", __func__);
self.person.name = @"ZhangSan";
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == YJPersonNameContext) {
NSLog(@"change = %@", change);
}
}
- (void)dealloc {
// [self.person removeObserver:self forKeyPath:@"name" context:YJPersonNameContext];
}
@end
pop离开页面,再次push进入,这时touchesBegan时程序报错:
正如苹果所说:当观察者(这里是YJDetailController) 被释放时它并不会主动移除自己。观察对象(这里是self.person单例)还是会继续发送通知,对已经释放的内存发送消息,会抛出异常导致Crash。
把 dealloc 方法中的注释松开:
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"name" context:YJPersonNameContext];
}
再次运行,看效果:
这样就ok了
2.3 KVO 的自动触发
可以在被观察对象的类中重写+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key方法来控制KVO的自动触发。
假如我们只允许外界观察YJPerson的name属性,其它属性都不允许观察,可以在YJPerson类如下操作。这样外界就只能观察name 属性,即使外界注册了对YJPerson对象其它属性的监听,那么在属性发生改变时也不会触发KVO。
// 返回值代表允不允许触发 KVO
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
BOOL automatic = NO;
if ([key isEqualToString:@"name"]) {
automatic = YES;
}
return automatic;
}
测试:
-
YJPerson中,未按上面实现automaticallyNotifiesObserversForKey -
-
YJPerson中,按上面实现automaticallyNotifiesObserversForKey
2.4 KVO的手动触发
为了尽量减少不必要的触发通知操作,或者当多个更改同时具备的时候才调用属性改变的监听方法。
可以通过在赋值的前后手动调用willChangeValueForKey:和didChangeValueForKey:两个方法来手动触发KVO,示例:
// YJPerson.m
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
return NO;
}
// YJDetailViewController.m
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"%s", __func__ );
[self.person willChangeValueForKey:@"nick"];
self.person.nick = @"san san";
[self.person didChangeValueForKey:@"nick"];
}
运行代码,输出:
2.5 一对多关系
有些情况下,一个属性的改变依赖于别的一个或多个属性的改变,也就是说当别的属性改了,这个属性也会跟着改变。
比如我们在YJPerson类中添加fullName属性,该属性的改变依赖于name和nick属性的改变。如下:
// YJPerson.m
- (NSString *)fullName {
return [NSString stringWithFormat:@"%@-%@", self.name, self.nick];
}
观察者监听fullName,当name和nick属性值改变时,观察者也应该被通知。
重写keyPathsForValuesAffectingValueForKey方法,来指明fullName依赖于name和nick:
// YJPerson.m
+ (NSSet<NSString *>)keyPathsForValuesAffectingValueForKey:(NSString *)key
{
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"fullName"]) {
NSArray *affectingKeys = @[@"name", @"nick"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
在YJDetailViewController 观察 fullName属性:
- (void)viewDidLoad {
[super viewDidLoad];
[self.person addObserver:self forKeyPath:@"fullName" options:NSKeyValueObservingOptionNew context:YJPersonFullNameContext];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.person.name = @"ZhangSan";
self.person.nick = @"san san";
}
运行代码,输出:
设置 name 时,nick 还是空,所以第一次 输出new = "ZhangSan-(null)"
2.6 对集合的观察
对于集合的KVO,我们需要了解的一点是,KVO旨在观察关系(relationship)而不是集合。对于不可变集合属性,我们更多的是把它当成一个整体来监听,而无法去监听集合中的某个元素的变化;对于可变集合属性,实际上也是当成一个整体,去监听它整体的变化,如添加、删除和替换元素。
监听集合整体的变化,见下图案例:
对于数组的 增、删、替换 观察都必须通过 KVC-mutableArrayValueForKey 方法才能触发回调
细心的你可能已经发现了,上面输出结果中 kind 值不一样,直接赋值触发回调中kind = 1,添加元素触发回调中kind = 2。这是因为,KVO机制能在集合改变的时候把详细的变化放进change字典中。kind说明:
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};
补充:集合(Set)也有一套对应的方法来实现集合代理对象,包括无序集合与有序集合;而字典则没有,对于字典属性的监听,还是只能作为一个整理来处理。
3. KVO原理分析
我们先打开 官文 看一下它是怎么介绍KVO的实现的:
这里提到KVO的实现用到了isa-swizzling技术,isa会指向一个中间类。因此,isa 指针的值不一定反映实例的实际类。永远不要依赖isa指针来确定类成员关系。相反,您应该使用类方法来确定对象实例的类。
3.1 KVO 动态生成子类
在添加观察前后分别通过object_getClassName获取self.person类名:
在添加观察后self.person的类名变成了NSKVONotifying_YJPerson,那么它和YJPerson是什么关系呢?运行项目,使用lldb 调试
由调试结果可以看出中间类NSKVONotifying_YJPerson的superCls指向了 YJPerson,即 NSKVONotifying_YJPerson 是 YJPerson 的子类
3.1 动态子类中有哪些方法
使用 runtime API 来获取类的方法列表
// 遍历方法-ivar-property
- (void)printClassAllMethod:(Class)cls {
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);
}
运行代码,输出:
-
setNickName是重写的父类YJPerson的方法 -
class重写class返回父类YJPerson,这样在外部看来,是没有变化的 -
_isKVOA就是判断当前的类是不是KVO创建的。 -
dealloc中实例变量的isa重新指回到原来的YJPerson类
总结
Objective-C依托于强大的runtime机制来实现KVO。- 当我们第一次观察某个对象的属性时,
runtime会创建一个中间类继承自观察对象所属类 - 在这个
中间类中,它会重写所有被观察的key的setter,然后将观察对象的isa指针指向中间类(这个指针告诉Objective-C运行时某个对象到底是什么类型的)。所以观察对象神奇地变成了中间类的实例。 - 在
中间类的setKey方法中,调用父类 的setKey方法进行赋值,同时发出改变回调(即kvo的监听回调) - 在进行监听移除后,
观察对象的isa恢复到原来的类上