前言
在日常的开发过程中,我们经常会用到KVO来进行一些开发,让我们的开发更加简便、直接。那么KVO到底是什么?他在底层的原理是什么呢?我们今天就来探索一下它
概念
KVO的概念描述
KVO 是 Objective-C 对 观察者模式(Observer Pattern)的实现。当被观察对象的某个属性发生更改时,观察者对象会获得通知。一般继承自NSObject的对象都默认支持KVO。KVO是响应式编程的代表。
KVO的作用
- 监听带有状态的基础控件,如开关、按钮等;
- 监听字符串的改变,当监听的字符串改变时,来做一些自定义的操作;
- 当数据模型的数据发生改变时,视图组件能动态的更新,及时显示数据模型更新后的数据,比如
tableview中数据发生变化进行刷新列表操作,监听scrollView的contentOffset属性监听页面的滑动.
简单的使用KVO
我们来看个例子,这是最简单的KVO的使用方法
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [DMPerson new];
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.person.nick = [NSString stringWithFormat:@"%@+",self.person.nick];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"%@",change);
}
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"nick"];
}
运行代码并简单点击屏幕点击结果如下
我们创建了一个
DMPerson的实例对象,对它的nick属性进行了观察,当每次nick赋予新值时,就会调用observeValueForKeyPath方法告诉我们。同时我们在touchesBegan方法中给nick赋值,每次我们触摸屏幕,就会进行一次赋值。
KVO中的context
我们在看上面的例子的时候,可以看到addObserver方法需要传递几个参数
- 第一个是观察者是谁,也就是我们调用谁的
observeValueForKeyPath方法 - 第二个是被观察的属性是什么,一般来说是被观察者的属性
- 第三个是
options也就是我们可以设置的参数,通常有四种
NSKeyValueObservingOptionNew把更改之前的值提供给处理方法NSKeyValueObservingOptionOld把更改之后的值提供给处理方法NSKeyValueObservingOptionInitial把初始化的值提供给处理方法,一旦注册,立马就会调用一次。通常它会带有新值,而不会带有旧值。NSKeyValueObservingOptionPrior分2次调用。在值改变之前和值改变之后
- 第四个是
context上下文
其他的我们都很好理解,但是这个context我们平时使用的时候,一般都是直接传NULL,那么他有什么用呢?我相信苹果肯定不会写一些没用的东西放在这里。打开苹果的KVO文档,我们可以看到下面这样一段话
这里的意思大致就是如果我们传了这个参数,会使性能安全,更加直接,这又是为什么呢?
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [DMPerson new];
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
self.person.nick = [NSString stringWithFormat:@"%@+",self.person.nick];
self.person.name = [NSString stringWithFormat:@"%@*",self.person.name];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if (object == self.person) {
if ([keyPath isEqualToString:@"nick"]) {
NSLog(@"nick 改变了\n%@",change);
} else if ([keyPath isEqualToString:@"name"]) {
NSLog(@"name 改变了\n%@",change);
}
}
}
我们把上面的例子修改一下,我现在需要同时观察DMPerson对象的两个属性name和nick,并且根据不同的属性变化进行不同的操作,在observeValueForKeyPath就会写的比较复杂,我们需要先判断object是不是DMPerson,再判断keyPath是nick还是name。
这个时候,我们就可以使用
context这个参数
更改下注册观察者的代码
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:PersonNickContext];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:PersonNameContext];
再把回调中的代码判断改下
if (context == PersonNickContext) {
NSLog(@"通过context nick 改变了\n%@",change);
} else if (context == PersonNameContext) {
NSLog(@"通过context name 改变了\n%@",change);
}
看看结果
没错,context实际上就是一个标志,让我们能够更加简单,直接的判断是哪个属性的改变回调的。
移除观察者
我们在使用KVO的时候,一般会需要在不需要使用的时候,移除观察者,而这一般都是放在dealloc方法中。那么我们不移除,是不是可以的呢?会有什么问题呢?实践才是唯一真理,我们来试试就知道
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [DMPerson new];
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.person.nick = @"mantou";
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"DetailViewController :%@",change);
}
我们在一个二级页面写下这段代码,然后每次每次进入点击一下屏幕后就退出该页面,重复几次。结果如下
似乎并没有什么问题,接下来我们修改下代码,观察
DMWorker对象的属性改变,DMWorker是一个单例
self.worker = [DMWorker shareInstance];
[self.worker addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL];
同样的操作,来看看结果
在第二次进入并点击触摸后,应用发生了异常崩溃。为什么呢?我们去苹果官方文档中找到了一段答案
An observer does not automatically remove itself when deallocated. The observed object continues to send notifications, oblivious to the state of the observer. However, a change notification, like any other message, sent to a released object, triggers a memory access exception. You therefore ensure that observers remove themselves before disappearing from memory.
大意是当
dealloced时,观察者不会自动删除自己,当被观察者继续发送通知的时候,可能会给已经释放掉的观察者发送消息,最终造成了内存的访问异常。因此,需要保证当观察者被销毁时,将观察者移除。
- 当我们第一次进入
DetailViewController的时候,我们注册了观察者(用D1来作为称呼),然后触摸触发了回调,没有任何问题,接着我们推出页面 - 当我们第二次进入
DetailViewController的时候,我们第一次进入注册的观察者D1已经被销毁,但是由于DMWorker是个单例,所以依旧会像D1发送回调消息,最终导致内存访问异常,应用崩溃。
结论:当我们的观察者dealloc时,一定要移除观察者
自动/手动监听KVO
当时我们使用最基本的方法来使用KVO时,默认是自动监听模式,而当我们想改变成手动监听模式的时候,我们需要在被监听的对象中实现automaticallyNotifiesObserversForKey方法
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
//可以根据不同的key值,来区分使用自动还是手动监听
if ([key isEqualToString:@"nick"]) {
return YES;
}
return NO;
}
如果直接return NO则表示全部使用手动监听,这是我们在上面的案例中直接去触摸屏幕就没有任何响应了,如果想要响应则需要实现下面的方法
[self willChangeValueForKey:@"nick"];
_nick = nick;
[self didChangeValueForKey:@"nick"];
在willChangeValueForKey和didChangeValueForKey中间进行赋值,则会进行手动监听到。
观察多个因素影响的属性
我们有时候需要观察一个属性,但是这个属性是由多个其他的因素共同影响而变化的。举个例子,例如我们的下载文件的过程,下载进度 = 已下载 / 总数。如果已下载和总数都是在不断变化的,那么我们该怎么做才能对下载进度进行观察呢?请看下面的例子
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [DMPerson new];
[self.person addObserver:self forKeyPath:@"downloadProgress" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.person.writtenData += 10;
self.person.totalData += 30;
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"DetailViewController :%@",change);
}
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"downloadProgress"];
}
我们一共触摸了三次,接下来看看打印结果
我们可以看到,每次触摸都打印了多条数据,为什么呢?我们来看看
DMPerson中的实现
+ (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];
}
其实很简单,其实只要在keyPathsForValuesAffectingValueForKey方法中,将downloadProgress关联的两个因素totalData和writtenData通过setByAddingObjectsFromArray关联起来,那么每次totalData或者writtenData改变时,系统会自动通知我们downloadProgress改变了。而第一次打印了三次的原因只是因为我们当totalData为0时,设置他为100,多调用了一次而已。
可变数组的观察
如果我们需要观察可变数组,我们该怎么办呢?继续开始试验
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [DMPerson new];
self.person.dateArray = [NSMutableArray array];
[self.person addObserver:self forKeyPath:@"dateArray" options:NSKeyValueObservingOptionNew context:NULL];
[self.person.dateArray addObject:@"mantou"];
}
在viewDidLoad中实现这些代码,理论上进入页面后就会观察到并回调,而实际上并没有任何响应。我们在苹果的文档中,又找到了这样一行
In order to understand key-value observing, you must first understand key-value coding
这句话的意思,要想理解KVO就先要理解KVC,也就是说KVO是建立在KVC上的。
而在KVC的官方文档中,有这么一段话
意思就是,如果你需要使用KVO去观察集合类型的数据变化,那么就需要使用对应的api来获取这个集合,这样在你进行设置值的时候,系统就能够通知到你。那么我们修改下我们的代码
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [DMPerson new];
[self.person addObserver:self forKeyPath:@"dateArray" options:NSKeyValueObservingOptionNew context:NULL];
self.person.dateArray = [NSMutableArray array];
[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"mantou"];
}
结果打印出来了,第一次是dateArray初始化的打印,第二次是addObjcet的打印
细心的你肯定已经发现,两次打印的
kind值并不一样,那么他们代表什么呢?
// *NSKeyValueChange*
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};
这个枚举,告诉了我们答案。
KVO底层原理探索
在上面我们举例了很多KVO的应用,那么他的在底层的原理是什么样的呢?我们现在就来探索一下他
修改isa指向
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[DMPerson alloc] init];
[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
}
首先我们实现一个简单的KVO,然后再addObserver方法处打上断点,我们想要探究一下,addObserver方法调用以后,系统进行了什么操作呢,先来看看他的isa指向
我们发现,当调用
addObserver之后,self.person的isa指向已经变成了NSKVONotifying_DMPerson。在之前的探索中,我们知道,实例对象的和类的关系实际上就是实例对象的isa指向了类对象。所以这里我们可以简单粗暴的认为,self.person在调用addObserver方法后,已经从DMPerson类的实例对象,变成了NSKVONotifying_DMPerson的实例对象。
NSKVONotifying_DMPerson
那么这个NSKVONotifying_DMPerson是什么东西?他是不是一开始就直接存在的呢?他跟DMPerson又有什么关系呢?我们接下来继续探索。依旧是刚刚的代码,我们首先来看看NSKVONotifying_DMPerson是不是一开始就存在的
从这个结果我们可以很明显的看到
NSKVONotifying_DMPerson这个类,是在调用addObserver方法后,系统动态添加生成的一个类。接下来我们发现这两个类名字这么相似,而系统动态生成的那个类,有没有可能是DMPerson的子类呢?我们打印一下NSKVONotifying_DMPerson的父类
很惊奇的发现
NSKVONotifying_DMPerson是继承自DMPerson的。那么这个中间类,有没有可能存在自己的子类呢?我们通过下面这段代码来看看
#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(@"classes = %@", mArray);
}
结果比较明显,也从另外一个方面印证了
NSKVONotifying_DMPerson是DMPerson的子类。
既然如此,那么NSKVONotifying_DMPerson里面到底有什么东西呢?我们知道类里面无非就存储了成员变量、方法、协议等等,我们就通过下面这段代码来探索一下他里面的方法都有什么
#pragma mark **- 遍历方法-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、class、dealloc这几个方法,并且添加了一个叫_isKVOA的方法,来区分是不是系统通过KVO自动生成的类。
移除观察者修改回isa指向
我们之前强调过,在不使用观察者时,需要移除观察者,否则可能会造成内存访问异常。既然调用addObserver方法会修改isa指向,系统新生成的中间类,那么移除观察者后系统会怎么做呢?我们在dealloc方法中移除观察者这里打上断点,然后继续观察self.person的isa指向
我们看到移除观察者之后,
self.person的isa又指回了DMPerson类。那么之前生成的中间类,是否会释放呢?我们继续看看
通过lldb我们发现,他还存在,没有进行销毁。原因是如果下次继续进行观察者添加,系统就不会再生成新的中间类,而是直接使用这个类了,防止资源的浪费。这一步实际上也是我们再刚刚看到的
NSKVONotifying_DMPerson中的dealloc方法重写后里面所做的。
重写的class方法
我们研究过了重写的dealloc方法,那么重写的这个class方法有什么意义呢?
通过对
addObserver方法调用前后的class方法的打印结果,我们可以看到,虽然self.person的isa已经指向NSKVONotifying_DMPerson了,但是,由于NSKVONotifying_DMPerson重写了class方法,最后打印输出的还是DMPerson,目的是为了隐藏系统在背后做的一系列动作,让开发者更少的关注底层。
重写的setter方法
NSKVONotifying_DMPerson重写的其他几个方法我们都分析过了,只剩下了最后的也是最重要的setter方法的重写了。在这之前,我们先思考一个问题,KVO针对的是某个对象的属性的观察,那么成员变量能不能观察呢?我们实验一下就知道了
@interface DMPerson : NSObject {
@public
NSString *name;
}
@property (nonatomic, copy) NSString *nickName;
@end
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[DMPerson alloc] init];
[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
NSLog(@"实际情况:%@ - %@",self.person.nickName, self.person->name);
self.person.nickName = @"mantou";
self.person->name = @"tong";
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
NSLog(@"%@",change);
}
这里我们在DMPerson中声明了一个属性nickName和一个成员变量name,并且分别设置了KVO观察,我们来看看运行结果
很明显
nickName和name的赋值都生效了,但是只有nickName的赋值触发了KVO的回调,所以我们可以得到一个结论
KVO的观察只针对属性,对于成员变量没有效果
我们知道设置KVO以后,self.person实际上已经是NSKVONotifying_DMPerson的实例了,那么DMPerson类里面nickName的属性,在我们调用self.person.nickName = @"mantou"时会不会发生改变呢?我们在dealloc中移除KVO以后的地方打上断点
我们发现,移除观察者以后
isa指向了DMPerson,所以DMPerson中的nickName属性实际上也已经改变了,这就很神奇了,我们调用的NSKVONotifying_DMPerson的setNickName方法,但是最后DMPerson中的属性值改变了,这是为什么呢?我们在调用setNickName的地方打上断点
然后看看堆栈信息
这里可以看出,在我们调用
NSKVONotifying_DMPerson的setNickName方法后,系统通过Foundation框架中的一些底层处理,最终调用了DMPerson的setNickName方法。这里就真相大白了。
KVO流程总结
我们来总结一下整个KVO的流程
- 只针对属性观察,实际上是观察
setter方法 - 设置观察者后,会自动生成一个中间类,一般命名为
NSKVONotifying_xxxx,并将实例对象的isa指向中间类,中间类是被观察对象的子类。 NSKVONotifying_xxxx中重写了class方法、dealloc方法和被观察的属性的setter方法,并且生成了一个_isKOVA的标识方法- 在调用中间类
NSKVONotifying_xxxx的setter方法后,实际上会在其中调用被观察者的属性的setter方法 - 最后当观察者释放时,需要移除观察者,这时
isa会重新指回被观察者并且中间类并不会移除,如果下次继续添加观察,则直接使用这个中间类不用重新生成