一、KVO简介
KVO全称是 key-value Observing. 就是我们日常中经常用到的观察者模式.
在之前的KVC探索里, 官方文档中有提到过这个机制. 它建立在理解KVC的基础上, 在键值编码后才能到达键值观察.
二、KVO初探
首先来建立一个类用于观察:
@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@end
@interface Student : NSObject
@property (nonatomic, copy) NSString *name;
@end
@interface ViewController ()
@property (nonatomic, strong) Person *person;
@property (nonatomic, strong) Student *student;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [Person new];
self.student = [Student new];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
[self.student addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"ViewController - %@",change);
}
@end
添加观察着的前面几个参数很好理解, 这里来看一下context. 官方文档中说明, 如果使用了相同的keyPath, 不便于区分, 这个时候使用context会更直接一些.
再来到观察者实现. 一般我们会在观察者实现中来写自己想要的代码逻辑. 但是有一个问题: 当有两个对象有着同样的一个属性, 同时观察这个属性, 那么在观察着实现中我们为了区分是哪一个对象带来的观察着实现, 就会写下很多的ifelse判断, 这对于我们的开发来说, 是极大的不便的. 这个时候我们应该使用context会更加便捷.
2.1、context的使用
例如我们需要观察person的name属性, 那么我们应该对它用字符串匹配的方式来进行标记.
写入一个标记用来记录此时观察的是这个属性:
static void *PersonNameContext = &PersonNameContext ;
用一个指针变量来当作context. 那么观察着就应该改为:
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];
此刻我们就可以直接通过context来判断观察着实现观察的是哪个对象的哪个属性, 这样会更加便利更加安全.
2.2、移除观察者
使用观察者有三个步骤:
- 添加观察者
- 观察者回调
- 在合适的地方移除观察者
假如我们在dealloc里面进行观察者销毁
- (void)dealloc{
[self.person removeObserver:self forKeyPath:@"name"];
}
假如Person是一个单例, 我们在当前的控制器以及第二个控制器中添加观察者, 此时push到下一个控制器中, 在下一个控制器中, 上一个控制器还没有释放掉. 同样的操作观察者, 观察者就会执行两次.
假如第二个控制器没有移除操作,进入这个控制器后返回到第一个控制器, 会造成观察者没有移除. 此时第二个控制器被释放, 但是person还记录第二个控制器的观察者, 此时修改person的name属性, 会造成野指针.
2.3、观察者的启动开关
我们在开发中, 会碰到某些需求导致需要对某一个属性停止观察.此时我们一般都会去注释掉添加观察者移除观察者这两行代码. 这种办法是可行的, 但是还是会感觉到不是很便利.
在观察者中, 有一观察者的自动开关, 当我们把这个自动开关关闭后, 就可以手动调节是否对这个属性进行观察.
// 自动开关
+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
return NO;
}
- (void)setName:(NSString *)name{
[self willChangeValueForKey:@"name"];
_name = name;
[self didChangeValueForKey:@"name"];
}
此时若要开启观察, 我们直接将自动开关返回YES就可以.
2.4、观察者依赖
在某些情况下, 造成一个属性的变化会是因为另外某个属性的变化. 例如: 下载进度百分比的显示会因为 已下载大小与总大小的因素所影响.
给Person新加一些属性
@property (nonatomic, copy) NSString *downloadProgress;//进度
@property (nonatomic, assign) double writtenData;//写入
@property (nonatomic, assign) double totalData;//总量
- (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];
}
重写属性依赖:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"downloadProgress"]) {
NSArray *affectingKeys = @[@"totalData", @"writtenData"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
这样当我们对downloadProgress进行添加观察者后, 改变writtenData与totalData, 此时仍会观察和到downloadProgress变化.、
2.4、可变数组的KVO
当我们对一个可变数组进行观察的时候, 这个时候对数组进行addObject. 此时并不会走观察者. 因为可变数组添加成员并没有走set方法.
KVO对于可变数组有特殊的处理. KVO是建立在KVC的基础上的. 所以我们需要用另一种方法来对数组进行操作:
[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"1"];
用这种方法来访问可变数组. 此时就会观察到可变数组的观察回调. 并且回调的change中的NSKeyValueChangeKey为2.
NSKeyValueChangeKey进去后可以看到代表的是 NSKeyValueChangeInsertion, 也就是插入变化. 此时它走的是可变数组的- (void)insertObject:(id)object inDateArrayAtIndex:(NSUInteger)index;
方法
三、KVO的底层
在官方文档中, 有一段写的是KVO的实现细节, 大致意思是:
自动键值观察是使用称为isa-swizzling的技术实现的。
该isa指针,顾名思义,指向对象的类,它保持一个调度表。该分派表实质上包含指向该类实现的方法的指针以及其他数据。
在为对象的属性注册观察者时,将修改观察对象的isa指针,指向中间类而不是真实类。结果,isa指针的值不一定反映实例的实际类。
您永远不要依靠isa指针来确定类成员。相反,您应该使用该class方法确定对象实例的类。
也就是创建观察的时候, 底层创建来一个处于中间的动态类, 用来处理观察者的实现. 修改的是原来的类生成的对象的isa.
改动前: person -> Person -> 元类
改动后: person -> NSKVONotifying_Person -> 根元类
来调试看一下它生成的中间动态类:
推测是生成了一个 NSKVONotifying_类名 的中间类.
那么这个中间类与原来的类是什么关系呢? 来写一个方法遍历所有的子类:
- (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);
}
调用结果:
表明新生成的中间动态类就是原来的类的子类.
3.1、NSKVONotifying_xxx
我们知道类的结构是 isa superclass cache_t bit,观察者主要是通过属性的setter的方法来实现的, 所以我们研究这个中间类也是从方法上入手.
写一个方法来遍历当前类中的所有方法:
- (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);
}
结果如下:
新生成的中间类重写了当前所观察的属性的setter方法. class是重写了class方法. dealloc释放. _isKVOA观察者相关.
由此想起来无论在观察者添加的前后, 通过 object_getClassName([Person class]) 得到的类都是Person. 所以中间类中所包含的class方法应该就是为中间类做的一层伪装, 返回的依旧是Person()
再来看一下移除观察者之后, isa是变化成什么:
当移除观察者后, 此时对象当isa会重新指向之前的类.
那么移除后中间类是否被销毁类呢? 答案是否定的.
依旧存在, 方便再一次的调用.
四、总结:
KVO原理:
- 动态生成一个子类: NSKVONotifying_xxx, 对象的isa指向这个类
- 观察setter
- NSKVONotifying_xxx 重写了 set class dealloc _isKVOA方法
- 移除KVO后, 对象的isa重新指向原来的类
- 移除后NSKVONotifying_xxx依旧存放, 方便下一次调用.