KVC、KVO是iOS中经常会用到也是面试中经常会被问到的。今天就来探究一下KVO。
什么是KVO?
KVO:键值观察机制,它提供了观察某一属性变化的方法
KVO的全称是 KeyValueObserving ,是苹果提供的一套事件通知机制。允许对象监听另一个对象属性的变化,并在改变时接收到事件。由于KVO的实现机制,所以对 属性 才会发生作用,一般继承自NSObject 的对象都默认支持KVO。
KVO 和 NSNotificationCenter 都是iOS中 观察者模式 的一种实现。
区别在于,相对被观察者和观察者之间的关系:
- KVO是一对一的
- NSNotificationCenter 是一对多的
KVO对被监听对象无侵入性,不需要修改其内部代码即可实现监听。
KVO可以监听单个属性的变化,也可以监听集合对象的变化。通过KVC 的 mutableArrayValueForKey: 等方法获得代理对象,当代理对象的内部发生变化时,会回调KVO监听的方法。集合对象包含 NSArray 和 NSSet。
一、KVO的基本用法
使用KVO分为三步(KVO三部曲)
- 注册观察者
- 实现回调
- 移除观察者
#import "LJLKVOViewController.h"
#import <objc/runtime.h>
#import "LJLKVOPerson.h"
#import "LJLKVODownloader.h"
static void * personNameContext = &personNameContext;//观察person.name 的 context
static void * personKvoArrayContext = &personKvoArrayContext;//观察person.kvoArray 的 context
@interface LJLKVOViewController ()
@property(nonatomic, strong)LJLKVOPerson * person;
@end
1.1、注册观察者
被观察的对象调用下面的方法来注册观察者:
self.person = [[LJLKVOPerson alloc] init];
//注册观察者
[self.person addObserver:self
forKeyPath:@"name"
options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
context:personNameContext];
context 作用:快速定位观察键。
如果观察两个同名的 keyPath 的时候,不容易区分,就需要用context 来进行区分。
而且通过这样的形式,可以直接知道观察的是哪个类的哪个属性,这样做减少了在回调中嵌套多层判断,更安全也更高效。功能类似于是 tag。
如果不需要可以填NULL ,因为这个地方是nullable void * 所以填 NULL,如果是id 填nil。填nil也可以,因为xcode会帮我们把 nil 转成 NULL 编译的时候。
1.2、实现回调
注册后,观察者需要实现下面回调来接收通知:
-(void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey,id> *)change
context:(void *)context
{
//这里就可以通过注册的时候的context来快速判断出观察的哪个类的哪个属性变化触发的回调
if (context == personNameContext) {
NSLog(@"name change : %@",change);
}else{
NSLog(@"change - %@",change);
}
//change - {
// kind = 1; 这个就是观察的类型,1是set
// new = "0.100000";
/*
观察类型枚举 (被观察者变更的类型)
NSKeyValueChangeSetting = 1, 设置 例如观察的是NSString 修改的时候就是这个类型
NSKeyValueChangeInsertion = 2, 插入 例如观察的是NSMutableArray 添加数据的时候
NSKeyValueChangeRemoval = 3, 删除
NSKeyValueChangeReplacement = 4, 替换
*/
}
1.3、移除观察者
如果不再继续观察,一定要移除观察者,否则可能出现异常。养成良好习惯,以免出现一些隐藏的崩溃。
而且如果不移除的话,再次进来又重新注册。这时候会出现多次调用的情况。如果被观察者使用的是单例,不移除观察对象就还存在就不会释放,会出现野指针,就会崩溃。
[self.student removeObserver:self forKeyPath:@"name"];
较完整的三步操作如下,当然这个被观察者不必非得写成单例,根据自己需要。
#import "LGPerson.h"
{
//需要设置为公共的,否者不能在外部访问。要在外部赋值这个实例变量必须加 @public 公有.KVO不能监听实例变量
@public
NSString *nikcName;
}
@interface LGStudent : LGPerson
+ (instancetype)shareInstance;
@end
//------------------------------------------------
#import "LGStudent.h"
@implementation LGStudent
static LGStudent* _instance = nil;
+ (instancetype)shareInstance{
static dispatch_once_t onceToken ;
dispatch_once(&onceToken, ^{
_instance = [[super allocWithZone:NULL] init] ;
}) ;
return _instance ;
}
@end
//------------------------------------------------
#import "LJLKVOViewController.h"
#import "LGStudent.h"
@interface LJLKVOViewController ()
@property (nonatomic, strong) LGStudent *student;
@end
@implementation LJLKVOViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.student = [LGStudent shareInstance];
//1、注册观察者
[self.student addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
}
//触摸事件修改student.name 触发监听回调
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.student.name = @"hello word";
// 外部访问实例变量 需要设置为公共
self.student->nikcName = @"ljl";
}
#pragma mark - KVO回调
//2、实现回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"LJLKVOViewController :%@",change);
}
//3、移除观察者
- (void)dealloc{
[self.student removeObserver:self forKeyPath:@"name"];
}
@end
1.4、集合类型的属性
对集合观察的时候必须建立在KVC 的基础之上。通过KVC来访问更加便利直接。
LJLKVOPerson.h 添加一个可变数组属性
@property(nonatomic, strong) NSMutableArray * kvoArray;
注册KVO,并修改kvoArray
// 一定要初始化,否者 kvoArray为nil addObject:的时候就崩溃了
self.person.kvoArray = [NSMutableArray array];
[self.person addObserver:self
forKeyPath:@"kvoArray"
options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
context:NULL];
[self.person.kvoArray addObject:@"123"];
// 上面这样这样写是无法触发KVO 回调的,因为addObject:方法是不走setter 的。 而KVO监听是setter方法.需要将上述向数组添加元素的方法修改一下
[[self.person mutableArrayValueForKey:@"kvoArray"] addObject:@"123"];
// 这样做之后 self.person.kvoArray 指向一个新的数组对象,相当于:
// NSMutableArray * tmp = [NSMutableArray arrayWithArray:self.person.kvoArray];
// [tmp addObject:@"123"];
// self.person.kvoArray = tmp;
// 所以能触发KVO回调
1.5、多个相关属性的观察
比如有一个 LJLKVODownloader 类,用来模拟下载,有三个属性totalBytes,completedBytes,和百分比进度progress:
在UI层我们只关注 progress,但是进度是受其他两个属性影响,此时需要 LJLKVODownloader重写两个方法:
#import <Foundation/Foundation.h>
@interface LJLKVODownloader : NSObject
@property(nonatomic, assign) unsigned long long totalBytes;//总字节
@property(nonatomic, assign) unsigned long long completedBytes;//完成字节
@property(nonatomic, copy) NSString * progress;//进度
@end
#import "LJLKVODownloader.h"
@implementation LJLKVODownloader
//返回属性的一组键路径,这些属性的值会影响键控属性的值
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
NSSet * keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"progress"]) {
NSArray * dependKeys = @[@"totalBytes",@"completedBytes"];
// 通过从数组中添加对象来设置
keyPaths = [keyPaths setByAddingObjectsFromArray:dependKeys];
}
return keyPaths;
}
- (NSString *)progress{
if (self.totalBytes == 0 || self.completedBytes == 0) {
return @"0%";
}
double progress = (double)self.completedBytes/(double)self.totalBytes*100;
if (progress > 100) {
progress = 100;
}
return [NSString stringWithFormat:@"%d%%",(int)ceil(progress)];
}
@end
LJLKVODownloader * downloader = [[LJLKVODownloader alloc] init];
downloader.totalBytes = 205;
[downloader addObserver:self
forKeyPath:@"progress"
options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
context:NULL];
downloader.completedBytes = 64;
downloader.completedBytes = 168;
监听的回调结果如下
2020-03-08 00:34:53.547235+0800 filedome[31182:820584] change : {
kind = 1;
new = "32%";
old = "0%";
}
2020-03-08 00:34:53.547581+0800 filedome[31182:820584] change : {
kind = 1;
new = "82%";
old = "32%";
}
二、手动 or 自动观察开关
在理解KVO之前呢,需要先理解KVC。
KVC会在 setter 或 getter 进行调用,如果没有查找到,则调用类方法 +accessInstanceVariablesDirectly(直接访问实例变量),如果返回 YES ,再去查找成员变量
KVO 也是类似的机制,在KVO接口中有这三个接口:
- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;(自动通知观察者)
+automaticallyNotifiesObserversForKey:默认返回YES,动态创建的中间类重写了setter,猜测在修改属性前后分别调用了
-willChangeValueForKey:
和 -didChangeValueForKey:类似方法,达到通知观察者的目的。
如果子类中重载了+automaticallyNotifiesObserversForKey:并返回NO,则无法触发自动KVO通知机制,但我们可以通过手动调用-willChangeValueForKey:和-didChangeValueForKey:来触发KVO回调。
// 手动观察,不管是打开自动观察还是关闭自动观察都会回调
// 触发回调的是 didChangeValueForKey:
// 自动观察 也就是默认情况下,系统是帮我们添加了will 和 did 方法。如果在自己手动写一遍的话就会触发两次,也就是走一次did 方法就会触发一次回调
#import "LJLKVOPerson.h"
@implementation LJLKVOPerson
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key{
return YES;//自动观察
}
- (void)setName:(NSString *)name{
// 手动观察。不管是打开自动观察还是关闭自动观察都会回调
// 触发回调的是 didChangeValueForKey
// 自动观察 也就是默认情况下,系统是帮我们添加了will 和 did 方法。如果在自己手动写一遍的话就会触发两次,也就是走一次did 方法就会触发一次回调
[self willChangeValueForKey:@"name"];
_name = name;
[self didChangeValueForKey:@"name"];//这个方法里面触发回调
}
@end
三、KVO的实现原理
KVO官方文档 中说KVO是使用 isa-swizzling 技术实现了键值的自动观察
- 动态生成子类 - NSKVONotifying_xxx
- 动态添加 setter 方法
- 动态添加 class 方法
- 动态添加 dealloc 方法
- 开启手动观察
- 消息转发给我们的原类 newValue
- 消息发送 - 响应回调方法
2.2原理验证
验证一:
self.person = [[LJLKVOPerson alloc] init];
//1、下一行下断点
[self.person addObserver:self
forKeyPath:@"name"
options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
context:personNameContext];
//2、下一行下断点
self.person.name = @"liujilou";
在上边代码标注的地方打断点然后进行LLBD调试
// LLBD调试
// 1位置断点
(lldb) po object_getClassName(self.person)
"LJLKVOPerson"
// 走到2位置断点
(lldb) po object_getClassName(self.person)
"NSKVONotifying_LJLKVOPerson"
验证二:
LJLKVOPerson * person = [[LJLKVOPerson alloc] init];
[self printClasses:[LJLKVOPerson class]];
[person addObserver:self
forKeyPath:@"name"
options:(NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew)
context:NULL];
[self printClasses:[LJLKVOPerson class]];
#pragma mark - 遍历类以及子类
- (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++) {
// 判断cls 是否等于classes[i] 的父类
if (cls == class_getSuperclass(classes[i])) {
// 将cls 的所有子类添加进来
[mArray addObject:classes[i]];
}
}
free(classes);
NSLog(@"classes = %@", mArray);
}
2020-03-07 22:31:31.788378+0800 filedome[29720:765937] classes = (
LJLKVOPerson
)
2020-03-07 22:31:31.799907+0800 filedome[29720:765937] classes = (
LJLKVOPerson,
"NSKVONotifying_LJLKVOPerson"
)
通过上面的两个验证,我们可以知道:
- 确实生成了一个中间类:NSKVONotifying_LJLKVOPerson
- NSKVONotifying_LJLKVOPerson 继承与 LJLKVOPerson
- 而且也把self.person 对象的 isa 指向了这个中间类。
开始研究子类,研究动态子类: isa superclass cache_t bits - 方法 - 变量
下面继续验证后面的流程:
self.person = [[LJLKVOPerson alloc] init];
[self printClasses:[LJLKVOPerson class]];
[self printClassAllMethod:[LJLKVOPerson class]];
[self.person addObserver:self
forKeyPath:@"name"
options:(NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew)
context:NULL];
[self printClasses:[LJLKVOPerson class]];
[self printClassAllMethod:NSClassFromString(@"NSKVONotifying_LJLKVOPerson")];
#pragma mark - 遍历方法-ivar-property
- (void)printClassAllMethod:(Class)cls{
NSLog(@"*********************");
unsigned int count = 0;
// 需要导入 #import <objc/runtime.h>
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);
}
2020-03-07 22:59:18.260869+0800 filedome[30061:779679] classes = (
LJLKVOPerson
)
2020-03-07 22:59:18.261029+0800 filedome[30061:779679] *********************
2020-03-07 22:59:18.261129+0800 filedome[30061:779679] kvoArray-0x10b30e410
2020-03-07 22:59:18.261252+0800 filedome[30061:779679] setKvoArray:-0x10b30e430
2020-03-07 22:59:18.261348+0800 filedome[30061:779679] .cxx_destruct-0x10b30e470
2020-03-07 22:59:18.261437+0800 filedome[30061:779679] name-0x10b30e3e0
2020-03-07 22:59:18.261522+0800 filedome[30061:779679] setName:-0x10b30e340
2020-03-07 22:59:18.272516+0800 filedome[30061:779679] classes = (
LJLKVOPerson,
"NSKVONotifying_LJLKVOPerson"
)
2020-03-07 22:59:18.272853+0800 filedome[30061:779679] *********************
2020-03-07 22:59:18.273394+0800 filedome[30061:779679] setName:-0x10bfb0b5e
2020-03-07 22:59:18.273505+0800 filedome[30061:779679] class-0x10bfaf592
2020-03-07 22:59:18.273672+0800 filedome[30061:779679] dealloc-0x10bfaf336
2020-03-07 22:59:18.273793+0800 filedome[30061:779679] _isKVOA-0x10bfaf32e
从上面的打印可以知道NSKVONotifying_LJLKVOPerson 重写了父类 LJLKVOPerson 的
- setName (setter)
- class
- dealloc
- _isKVOA
这几个方法。这个地方能打印出来的是自己实实在在有的方法。并不是自己没有打印父类的方法,所以能证明打印出来的这些方法都是进行了重写
重写 dealloc 方法来释放资源。
重写_isKVOA 这个私有方法是用来标示该类是一个 KVO 机制声称的类。
观察的是 setter
当一个类的实例第一次注册观察者是,系统会做以下事情
1、动态生成一个继承自该类的中间类:NSKVONotifying_xxx
2、修改原对象的 isa 指向这个中间类(isa-swizzling)
3、子类中重写 -class 方法,依然返回原类,而非子类
4、重写 -dealloc 方法
5、重写 keypath 对应属性的 setter
6、添加一个 -_isKVOA 方法
NSKVONotifying_xxx(LJLKVOPerson) 的这个 class 方法还是返回的是 LJLKVOPerson()。告诉我们操作的还是 LJLKVOPerson()
dealloc 在需要释放的地方调用释放中间类
移除观察 isa 是否回来 ?
移除观察的话isa 不再指向NSKVONotifying_xxx 由指回 xxx
验证:
- (void)dealloc{
[self.person removeObserver:self forKeyPath:@"name"];
}
在移除前后打断点然后通过控制台打印可以知道:
//在dealloc 移除观察者之前打断点
po object_getClassName(self.person)
"NSKVONotifying_LJLKVOPerson"
//移除观察者之后打断点,然后 LLBD
po object_getClassName(self.person)
"LJLKVOPerson"
//可以发现isa 又指向了 LJLKVOPerson
5:移除观察之后中间动态子类 是否销毁了?
不销毁,便于下次使用。如果每次都移除销毁,注册创建太慢。
验证:
在继承 ViewController 的 LJLKVOViewController 中写注册和观察等,然后返回 ViewController 页面的时候移除观察。
在ViewController 中添加如下代码。在移除观察者之后遍历 LJLKVOPerson 类的子类,看看是否有NSKVONotifying_LJLKVOPerson。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
[self printClasses:[LJLKVOPerson class]];
}
#pragma mark - 遍历类以及子类
- (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(@"LJLKVOViewController:classes = %@", mArray);
}
2020-03-08 01:40:23.120880+0800 002---KVO原理探讨[31915:843163] LJLKVOViewController:classes = (
LJLKVOPerson,
"NSKVONotifying_LJLKVOPerson",
LGStudent
)
由此可知,在移除观察者之后,动态子类是不会被销毁的。
简单总结一下:
当一个类的实例第一次注册观察者是,系统会做以下事情
1、动态生成一个继承自原类的中间类:NSKVONotifying_xxx
2、修改原对象的 isa 指向这个中间类 NSKVONotifying_xxx(isa-swizzling)
3、子类中重写 class 方法,依然返回原类,而非子类
4、重写 dealloc 方法
5、重写 keypath 对应属性的 setter (所以能观察属性,但是不能观察实例变量)
6、添加一个 _isKVOA 方法
KVO 的原理
1: 动态生成子类 : NSKVONotifying_xxx
2: 观察的是 setter (所以能观察属性,但是不能观察实例变量)
3: 动态子类重写了很多方法 setNickName (setter) 、class 、dealloc 、_isKVOA
4: 移除观察的时候 isa 指向回来
5: 动态子类不会销毁
KVO的优缺点
优点:
- 能够提供一种简单的方法实现两个对象间的同步
- 能够对非我们创建的对象,即内部对象的状态改变做出响应,而且不需要改变内部对象的实现。能够提供观察的属性的最新值以及先前值。
- 用key path来观察属性,因此也可以观察嵌套对象
- 完成了对观察对象的抽象,因为不需要额外的代码来允许观察值能够被观察
缺点:
- 因为观察的是 setter 方法所以只能观察属性不能观察实例变量
- 对属性重构将导致我们的观察代码不再可用
- 当释放观察者时需要移除观察者,否者或出现一些隐藏错误