KVO的全称是Key-value observing(键值观察),它提供了一种机制,允许对象在其他对象的特定属性发生变化时收到通知。下面我们先从API中去分析他的用法,然后分析他的底层原理。
API分析
我们经常使用KVO方式如下:
// Wushuang.h
@interface Wushuang : NSObject
@property (nonatomic, strong) NSString *name;
@property (nonatomic, strong) NSString *nickName;
@end
// VC
@interface SecondViewController ()
@property (nonatomic, strong) Wushuang *ws;
@end
self.ws = [Wushuang alloc];
self.ws.name = @"Dianji";
self.ws.nickName = @"Iron Man";
// 添加观察
[self.ws addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
// 观察回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@" 🎈 change : %@ 🎈 ", change);
}
addObserver时,末尾的context参数往往被忽视了,那么这个参数有什么作用呢?下面我们去分析
1. context的作用
-
在官方文档 Key-Value Observing Programming Guide 中,是这样描述
context的:- 大致意思是
context可以用来判断观察属性的路径,这样更安全,可拓展行也更强。 - 如果观察一个对象中的两个不同属性,我们可以用
keyPath来判断,但观察多个不同对象的属性时,就需要先判断object再判断keyPath,这样就比较繁琐,此时context的优势就体现出来了,用法如下:
static void *wushuangNameContext = &wushuangNameContext; static void *teacherNameContext = &teacherNameContext; [self.ws addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:wushuangNameContext]; [self.teacher addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:teacherNameContext]; - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context { if (context == wushuangNameContext) { NSLog(@"1. keyPath == %@ object = %@", keyPath, object); } else if (context == teacherNameContext) { NSLog(@"2. keyPath == %@ object = %@", keyPath, object); } NSLog(@" 🎈 change : %@ 🎈 ", change); }context和属性一一对应,这样就更简洁也更安全,也提高了判断的效率 - 大致意思是
2. 移除观察者
-
我们知道在
iOS9之后KVO就不需要手动移除,所以往往不需要手动处理移除。但在官方文档中有这样一句话介绍KVO移除:-
观察者在释放时不会自动移除自己,如果
继续发送消息就会产生空指针异常。平时不移除观察者,是因为一般观察者在销毁时,被观察者也不存在了,所以不会出现异常 -
也就是说如果被观察者是一个
单例,在观察者释放后,再重新添加该类的对象为观察者时就会产生异常。如图所示: -
所以在使用时还是尽量手动移除观察者。
-
3. 手动触发KVO
-
我们常用的
KVO是自动触发回调通知,也就是观察的属性值改变后,就会收到回调通知。在 KVO Compliance 中有介绍,触发回调是由automaticallyNotifiesObserversForKey方法确定,因为不去实现默认值是YES,也就是自动触发回调通知,如果设置成NO则就需要手动触发,需要在赋值前后设置相应的willChangeValueForKey:和didChangeValueForKey:方法。 -
下面进行案例验证案例验证:
// Wushuang.m + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { if ([key isEqualToString:@"name"]) { return YES; } else if ([key isEqualToString:@"nickName"]) { return NO; } return YES; } - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { self.ws.name = [NSString stringWithFormat:@"%@ +", self.ws.name]; self.ws.nickName = [NSString stringWithFormat:@"%@ !", self.ws.nickName]; }-
vc中观察了Wushuang的name和nickName属性,其中name设置成自动通知,nickName设置成手动通知,点击赋值结果如下:
结果只有name值的改变,回调中收到了通知。 -
在
Wushuang.m中添加一些代码再尝试
- (void)setNickName:(NSString *)nickName { [self willChangeValueForKey:@"nickName"]; _nickName = nickName; [self didChangeValueForKey:@"nickName"]; }再次修改属性的值,就会收到
nickName值改变的通知了: -
4. 依赖多个Keys
-
在很多情况下,一个属性的值取决于另一个对象中的一个或多个其他属性的值。这个时候就需要用
keyPathsForValuesAffectingValueForKey来设置依赖关系:// Wushuang.h @interface Wushuang : NSObject @property (nonatomic, strong) NSString *food; @property (nonatomic, strong) NSString *meat; @property (nonatomic, strong) NSString *vegetable; @end // Wushuang.m + (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key { NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key]; if ([key isEqualToString:@"food"]) { NSArray *affectingKeys = @[@"meat", @"vegetable"]; keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys]; } return keyPaths; } - (NSString *)food { return [NSString stringWithFormat:@"%@ and %@", self.meat, self.vegetable]; } // VC代码 [self.ws addObserver:self forKeyPath:@"food" options:NSKeyValueObservingOptionNew context:NULL]; - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { self.ws.meat = @"beef"; self.ws.vegetable = @"radish"; }改变值输出结果如下:
5. 集合类型
-
对于集合类型的属性的观察就有些不一样了,在
KVC的 Accessing Collection Properties 中有如下介绍:
对于集合对象的访问需要使用代理的相关方法,这样会相应的修改底层属性,进而触发KVO的回调。 -
以数组为例子,具体实现如下:
// Wushuang.m @property (nonatomic, strong) NSMutableArray *dataArray; // 添加监听 [self.ws addObserver:self forKeyPath:@"dataArray" options:NSKeyValueObservingOptionNew context:NULL]; - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { // [self.ws.dataArray addObject:@"Tony Stark"]; // 没有作用 [[self.ws mutableArrayValueForKey:@"dataArray"] addObject:@"Steve Rogers"]; }- 在想数组里添加
object时,以常规的方式访问数组是不会触发KVO通知的。需要使用mutableArrayValueForKey协议方法的方式访问到dataArray,再进行添加object。
- 在想数组里添加
原理分析
-
在
KVO的文档最后对它的原理有一些介绍:- 我们知道对象
isa指向类,但当注册观察者时,被观察对象的isa指向了中间类,我们可以打印被观察对象在观察前后的isa来对比差异
- 我们知道对象
产生中间类
-
打印在观察前后
ws对象的内容:- 在观察前对象的
isa指向Wushuang,但观察后isa就指向了NSKVONotifying_Wushuang,这个中间类名字上也带有Wushuang,那么它和原来的类有什么关系呢?中间类又有哪些内容呢?对象的值改变后为什么会影响原类呢?下面我们去挨个分析
- 在观察前对象的
原类和中间类的关系
-
我们可以使用
Runtime获取所以注册类的信息,然后筛选出类Wushuang的子类:// 遍历类及其子类 - (void)printClasses:(Class)class { // 获取所以注册类数量 int count = objc_getClassList(NULL, 0); // 创建一个数组, 其中包含给定对象 NSMutableArray *mutArray = [NSMutableArray arrayWithObject:class]; // 获取所有已注册的类 Class *classes = (Class *)malloc(sizeof(class) * count); objc_getClassList(classes, count); for (int i = 0; i < count; i++) { if (class == class_getSuperclass(classes[i])) { [mutArray addObject:classes[i]]; } } free(classes); NSLog(@" classes = %@ ", mutArray); }然后在添加观察前后查看与
Wushuang类相匹配的信息:- 其中的
WSTeacher是Wushuang的子类,所以可以得出结论:-
中间类是被观察类的子类
-
中间类没有其他子类
-
- 其中的
中间类有什么
-
现在知道了中间类的名字,那么我们可以使用
Runtime方法打印出它的方法列表- (void)printMethodesInClass: (Class)class { unsigned int count = 0; Method *methodList = class_copyMethodList(class, &count); for (int i = 0; i < count; i++) { Method method = methodList[i]; SEL sel = method_getName(method); IMP imp = method_getImplementation(method); NSLog(@" 🎉 : %@ --- %p ", NSStringFromSelector(sel), imp); } free(methodList); }分别打印原类和中间类,输出如下:
- 原类里打印的是一些属性对应的
setter和getter方法 - 中间类打印的是观察属性的
setter方法,class,dealloc以及_isKVOA这四个:_isKVOA:是一个辨识码,来判断这个类是不是因为KVO产生的动态子类dealloc:判断它是否进行释放class:是类的信息setName:是要变化的属性的setter方法
- 实际上这四个方法都是重写父类的方法。
- 原类里打印的是一些属性对应的
isa什么时候指回来
-
中间类中重写了
dealloc,可以在移除观察者前后来观察isa信息:- 从打印结果可以发现,在
dealloc中移除观察者后,对象的isa就指回来了。
- 从打印结果可以发现,在
中间类什么时候销毁
-
那么当前页面销毁时,这个中间类也会销毁吗?下面来验证下:
- 通过几次打印对比发现:
-
vc对象出栈后,因监听而产生的中间类依然在注册类中,并不会因为页面销毁而销毁
-
- 再次监听相同类时,并不会产生新的中间类
-
- 通过几次打印对比发现:
setter方法做了什么
-
我们知道中间类中有
setter方法,那么它做了什么?监听的属性还是成员变量?可我们可以在类中定义一个成员变量来查看改变值后是有回调信息:// Wushuang.h @interface Wushuang : NSObject { @public NSString *hobby; } @property (nonatomic, strong) NSString *name; @end // VC [self.ws addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL]; [self.ws addObserver:self forKeyPath:@"hobby" options:NSKeyValueObservingOptionNew context:NULL]; - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { self.ws.name = [NSString stringWithFormat:@"%@ +", self.ws.name]; self.ws->hobby = @"Swimming"; }- 打印结果如下:
- 结果改变成员变量并不会触发监听通知,所以
KVO在底层是通过setter方法来监听属性,要分析这个过程就需要借助LLDB的指令watchpoint set variable +变量,在添加观察前来设置内存断点,当变量的值发生改变就会触发断点,如下图:
堆栈信息反应了在属性值变化后,底层调用了下面几个函数:
-
Foundation _NSSetObjectValueAndNotify
-
结合堆栈和汇编可以得出该方法里会调用
willChangeValueForKey和didChangeValueForKey方法:
-
Foundation -[NSObject(NSKeyValueObservingPrivate) _changeValueForKey:key:key:usingBlock:]
-
Foundation -[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:maybeNewValuesDict:usingBlock:]
这三个函数执行完后最终就会执行
-(void)observeValueForKeyPath:ofObject:change:context:方法
流程总结
-
在添加观察时,
runtime会产生一个中间类:-
- 中间类
继承于原类
- 中间类
-
- 中间类会
重写被观察key的setter方法,
- 中间类会
-
- 对象的
isa从指向元类,变成指向中间类
- 对象的
-
-
当对属性赋值时,对象会根据
isa找到中间类对应的setter方法,然后在willChangeValueForKey和didChangeValueForKey方法之间进行赋值,进而触发-(void)observeValueForKeyPath:ofObject:change:context:方法。 -
当在
dealloc中移除通知后,isa会重新指向原来的类,相关实例变量的值不变。dealloc后中间类并不会释放,依然在注册类中。
观察前后,以及赋值流程图如下: