本文参考链接:
Foundation: NSKeyValueObserving(KVO)
概述
KVO是基于观察者模式来实现的。
观察者模式:一个目标对象管理所有依赖于它的观察者对象,并在它自身的状态改变时主动通知观察者对象。这个主动通知通常是通过调用各个观察者对象所提供的接口方法来实现的。观察者模式较完美地将目标对象与观察者对象解耦****。
KVO全称为Key-Value Observing,是Foundation框架提供的一种机制,使用KVO,可以方便地对指定对象的某个属性进行观察,当属性发生变化时,进行通知****。
使用KVO只需要两个步骤:
注册Observer;
接收通知。
1、注册Observer
使用下面方法注册Observer:
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
即:
addObserver:forKeyPath:options:context:
参数含义:
- observer:观察者,需要响应属性变化的对象。该对象必须实现 observeValueForKeyPath:ofObject:change:context: 方法。
- keyPath:要观察的属性名称。要和属性声明的名称一致。
- options:对KVO机制进行配置,修改KVO通知的时机以及通知的内容。
- context:context是一个c指针,可以传入任意类型的对象,在观察者接收通知回调的方法 observeValueForKeyPath:ofObject:change:context: 中可以接收到这个对象,是KVO中的一种传值方式。这个参数可以用来区分同一对象对同一个属性的多个不同的监听。
注意:分清观察者对象和目标对象,调用 addObserver:forKeyPath:options:context: 方法的对象是目标对象,observer是观察者对象,keyPath是目标对象的属性。
注意,在注册了Observer后,一定要在合适时机移除注册,否则会crash。移除注册的两种方法:
- (void)removeObserver:(NSObject *)anObserver
forKeyPath:(NSString *)keyPath
- (void)removeObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
context:(void *)context
苹果官方推荐的方式是,在init的时候进行addObserver,在dealloc时removeObserver,这样可以保证add和remove是成对出现的,是一种比较理想的使用方式。
第二种方法带有context属性,主要是用来区分不同的观察者Observer的。
如果observer没有监听keyPath属性,则调用这两个方法会抛出异常并崩溃。所以,必须确保先注册了观察者,才能调用移除方法。。实际上,在添加观察者的时候,观察者对象与被观察属性所属的对象都不会被retain,然而在这些对象被释放后,相关的监听信息却还存在,KVO做的处理是直接让程序崩溃。
- options参数是一个枚举类型,共有四种取值方式:
enum {
NSKeyValueObservingOptionNew = 0x01, //新值
NSKeyValueObservingOptionOld = 0x02, //旧值
NSKeyValueObservingOptionInitial = 0x04, //
NSKeyValueObservingOptionPrior = 0x08
};
NSKeyValueObservingOptionNew:接收方法中使用change参数传入变化后的新值,键为:NSKeyValueChangeNewKey;
NSKeyValueObservingOptionOld:接收方法中使用change参数传入变化前的旧值,键为:NSKeyValueChangeOldKey;
NSKeyValueObservingOptionInitial:注册之后立即调用一次接收方法。如果还如果配置了NSKeyValueObservingOptionNew,change参数内容会包含新值,键为:NSKeyValueChangeNewKey。
NSKeyValueObservingOptionPrior:如果加入这个参数,接收方法会在变化前后分别调用一次,共两次,变化前的通知change参数包含notificationIsPrior = 1。其他内容根据NSKeyValueObservingOptionNew和NSKeyValueObservingOptionOld的配置确定。
注意:options参数可以配置多个,如:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld,使用 | 或运算符连接。
调用addObserver:forKeyPath:options:context:方法时,观察者对象与被观察属性所属的对象都不会被retain,也就是说,引用计数不会加1。
可以重复添加监听:可以多次调用addObserver:..方法,将同一对象注册为同一属性的的观察者(参数可以完全相同,可以使用context参数进行区分)。这些观察者会并存。
2、接收通知
当被监听的属性的值发生变化时,KVO会自动通知注册了的观察者。
上文提到,观察者必须实现以下方法,这个方法就是观察者接收通知的方法:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
参数:
object:目标对象,即所监听的对象,也就是所监听的属性所属的对象。
change:是传入的变化量,通过在注册时用options参数进行的配置,会包含不同的内容。
- change参数
除了根据options参数控制的change参数内容,默认change参数会包含一个NSKeyValueChangeKindKey键值对,传递被监听属性的变化类型:
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};
NSKeyValueChangeSetting:属性的值被重新设置;
NSKeyValueChangeInsertion、NSKeyValueChangeRemoval、NSKeyValueChangeReplacement:表示更改的是集合属性,分别代表插入、删除、替换操作。
如果NSKeyValueChangeKindKey参数是针对集合属性的三个之一,change参数还会包含一个NSKeyValueChangeIndexesKey键值对,表示变化的index。
- chang字典里,新值的key为“new”,旧值的key为“old”,变化类型的key为“kind”。
3、示例
注意,KVO的运行是通过重写setter方法来触发通知机制的,也就是说,如果你直接赋值给实例变量而不是使用属性赋值的话,是不会触发KVO的。也就是说,下面的self.str如果换成了_str是无效的,因为self.str赋值时调用了setter方法。但是使用KVC来给实例变量赋值,会触发KVO。这点下面会详细说明。
#import "ViewController.h"
@interface ViewController ()
@property (copy, nonatomic) NSString *str;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.str = @"1111111";
[self addObserver:self forKeyPath:@"str" options:NSKeyValueObservingOptionInitial|NSKeyValueObservingOptionNew context:nil];
self.str = @"2222222";
self.str = @"3333333";
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
for (NSString *key in change) {
NSLog(@"%@",change[key]);
}
}
- (void)dealloc{
[self removeObserver:self forKeyPath:@"str"];
}
@end
4、自动通知和手动通知
上面提到,KVO默认会自动通知观察者。取消自动通知的方法是实现下面的类方法,通过返回NO来取消自动通知。
+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key
系统也会单独针对这个属性自动生成相关的类方法,是否自动通知这个属性被改变,也可以单独重写这个类方法:
//假如有一个属性
@property (copy, nonatomic) NSString *str;
//则系统会自动生成关于这个属性的类方法,是否自动通知
+ (BOOL)automaticallyNotifiesObserversOfTest;
针对非自动通知的属性,可以分别在变化之前和之后手动调用如下方法(will在前,did在后)来手动通知观察者:
- (will/did)ChangeValueForKey:
- (will/did)ChangeValueForKey:withSetMutation:usingObjects:
- (will/did)Change:valuesAtIndexes:forKey:
手动通知的好处就是,可以灵活加上自己想要的判断条件,事实上自动通知也是框架通过调用这些方法实现的。
需要注意的是,对于对象中其它没有处理的属性,我们需要调用[super automaticallyNotifiesObserversForKey:key],以避免无意中修改了父类的属性的处理方式。
下面的代码是在setter方法中使用 -(will/did)ChangeValueForKey: 方法加上了KVO的通知,如果是在 setter 方法之外改变了实例变量,且希望这种修改被观察者监听到,则需要像在setter方法里面做一样的处理。
@interface ViewController ()
@property (copy, nonatomic) NSString *str;
@end
@implementation ViewController
- (void)setStr:(NSString *)str{
[self willChangeValueForKey:@"str"];
_str = [str copy];
[self didChangeValueForKey:@"str"];
}
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key{
if ([key isEqualToString:@"str"]) {
return NO;
}
return [super automaticallyNotifiesObserversForKey:key];
}
//也可以是以下方法:
//+ (BOOL)automaticallyNotifiesObserversOfStr{
//
// return NO;
//
//}
- (void)viewDidLoad {
[super viewDidLoad];
self.str = @"1111111";
[self addObserver:self forKeyPath:@"str" options:NSKeyValueObservingOptionInitial|NSKeyValueObservingOptionNew context:nil];
self.str = @"2222222";
self.str = @"3333333";
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
for (NSString *key in change) {
NSLog(@"%@",key);
NSLog(@"%@",change[key]);
}
}
- (void)dealloc{
[self removeObserver:self forKeyPath:@"str"];
}
@end
5、KVO实现原理
KVO的实现是基于runtime运行时机制的,下面就来详细介绍一下原理,如下图:
- 当某个类的对象第一次被观察时,系统就会在运行期动态地创建该类的一个派生类,在这个派生类中重写****基类中任何**被观察属性的 **setter 方法****。
- 派生类在被重写的 setter 方法中实现真正的通知机制,就如前面手动实现键值观察那样。这么做是基于设置属性会调用 setter 方法,而通过重写就获得了 KVO 需要的通知机制。当然前提是要通过遵循 KVO 的属性设置方式来变更属性值,如果仅是直接修改属性对应的成员变量,是无法实现 KVO 的。
- 同时派生类还重写了 class 方法以“欺骗”外部调用者它就是起初的那个类。然后系统将这个对象的 isa 指针指向这个新诞生的派生类,因此这个对象就成为该派生类的对象了,因而在该对象上对 setter 的调用就会调用重写的 setter,从而激活键值通知机制。此外,派生类还重写了 dealloc 方法来释放资源。
概括总结一下:KVO的实现原理,KVO是基于Runtime机制的,当某个类的对象第一次被观察时,在运行时会动态地生成一个派生类,派生类会重写setter方法实现通知机制,并重写class方法,使对象的isa指针指向该派生类,以及重写delloc方法来释放资源。
6. KVO 和线程
一个需要注意的地方是,KVO行为是同步的,并且是在与观察的属性发生变化同样的线程上。没有队列或者 Run-loop 的处理。手动或者自动调用 -didChange... 会触发 KVO 通知。
所以,当我们试图从其他线程改变属性值的时候我们应当十分小心,除非能确定所有的观察者都用线程安全的方法处理 KVO 通知。通常来说,我们不推荐把 KVO 和多线程混起来。如果我们要用多个队列和线程,我们不应该在它们互相之间用 KVO。
KVO 是同步运行的这个特性非常强大,只要我们在单一线程上面运行(比如主队列 main queue),KVO 会保证下列两种情况的发生:
首先,如果我们调用一个支持 KVO 的 setter 方法,如下所示:
self.exchangeRate = 2.345;
KVO 能保证所有 exchangeRate 的观察者在 setter 方法返回前被通知到。
其次,如果某个键被观察的时候附上了 NSKeyValueObservingOptionPrior (改变前后各调用接受方法一次) 选项,直到 -observeValueForKeyPath... 被调用之前, exchangeRate 的 存取方法都会返回同样的值。
7、重要注意点
可以通过 KVO 在 Model 和 Controller 之间进行通信。
在类的内部,要区分 self.str 和 _str,赋值给成员变量是不会触发 KVO 回调的,因为赋值给成员变量是不会调用 setter 方法的。KVO 是通过 重写的setter方法来触发的。点语法的调用是通过存取方法来访问的,例如:student.name = @"xds";
但是可以通过 KVC 来触发 KVO 的回调函数,也就是说对成员变量可以使用 KVC 来触发 KVO。对一个实例变量调用KVC,KVC内部主动调用了对象的willChangeValueForKey:和didChangeValueForKey: 这两个方法,所以会触发KVO操作。
调用KVO时需要传入一个keyPath,由于keyPath是字符串的形式,所以其对应的属性发生改变后,字符串没有改变容易导致Crash。我们可以利用系统的反射机制将keyPath反射出来,这样编译器可以在@selector()中进行合法性检查。
NSStringFromSelector(@selector(isFinished))
-
触发 KVO 的三种方式:
- 如果使用了 KVC 访问属性或成员变量,如果有访问器方法,则运行时会在访问器方法中调用 will/didChangeValueForKey: 方法;没有访问器方法,运行时 会在 setValue:forKey: 方法中调用 will/didChangeValueForKey: 方法。
- 直接使用了访问器方法(点语法),会在运行时重写 setter 方法,调用 will/didChangeValueForKey: 方法;
- 显示调用 will/didChangeValueForKey: 方法。
8、计算属性(注册依赖键)
有时候,我们监听的某个属性可能会依赖于其它多个属性的变化(类似于swift,可以称之为计算属性),不管依赖的哪个属性发生了变化,都会导致计算属性的变化。
我们首先要确定计算属性与所依赖属性的关系。
假如有一个Student类,其resume属性依赖于name、age属性。
//Student.h
@interface Student : NSObject
@property (copy, nonatomic) NSString *name;
@property (assign, nonatomic) NSInteger age;
@property (copy, nonatomic) NSString *resume;//简历信息
@end
//Student.m
@implementation Student
- (NSString *)resume{
return [NSString stringWithFormat:@"name = %@,age = %ld",self.name,self.age];
}
@end
定义了这种依赖关系后,我们就需要以某种方式告诉KVO,当我们的被依赖属性name和age修改时,要发送resume属性被修改的通知。此时,我们需要重写NSKeyValueObserving协议的方法:
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key
这个方法返回的是一个集合对象,包含了依赖属性的名称对应的字符串。
另外,我们也可以实现一个命名为 keyPathsForValuesAffecting<Key> 的类方法来达到同样的目的,其中<Key>是我们计算属性的名称。
注意:
- 需要注意的就是当我们重写+keyPathsForValuesAffectingValueForKey:时,需要去调用super的对应方法,并返回一个包含父类中可能会对key指定属性产生影响的属性集合。
- 重写方法是在目标对象类里,而不是观察者对象的类里,这里要注意。
实现如下:
@implementation Student
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
if ([key isEqualToString:@"resume"]) {
return [NSSet setWithObjects:@"name",@"age", nil];
}
return [super keyPathsForValuesAffectingValueForKey:key];
}
//或者重写下面的方法
//+ (NSSet<NSString *> *)keyPathsForValuesAffectingResume{
//
// return [NSSet setWithObjects:@"name",@"age", nil];
//
//}
@end
//ViewController.h
- (void)viewDidLoad {
[super viewDidLoad];
student = [[Student alloc] init];
student.name = @"xds";
student.age = 18;
[student addObserver:self forKeyPath:@"resume" options:NSKeyValueObservingOptionNew context:nil];
student.name = @"xdsxxxx";
student.age = 22;
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"%@",change);
}
输出如下,发现当name和age改变时,会通知resume属性也被改变了。
2018-08-29 11:26:56.125679+0800 KVO[1170:91206] {
kind = 1;
new = "name = xdsxxxx,age = 18";
}
2018-08-29 11:26:56.125984+0800 KVO[1170:91206] {
kind = 1;
new = "name = xdsxxxx,age = 22";
}
9、集合属性的监听
- 对于集合属性(这里指NSArray和NSSet,不包括NSDictionary)的KVO,我们需要知道:对于集合属性,只有在赋值时会触发KVO,改变集合属性里的元素是不会触发KVO的(比如添加、删除、修改元素)。
当给集合对象赋值时,是可以触发KVO的。
//Student.h
@interface Student : NSObject
@property (copy, nonatomic) NSArray *classmates;
@end
- (void)viewDidLoad {
[super viewDidLoad];
student = [[Student alloc] init];
student.name = @"xds";
student.age = 18;
[student addObserver:self forKeyPath:@"classmates" options:NSKeyValueObservingOptionNew context:nil];
student.classmates = [NSArray array];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
NSLog(@"%@",change);
}
@end
//输出
2018-08-29 11:57:31.909262+0800 KVO[1230:110669] {
kind = 1;
new = (
);
}
- 我们可以通过使用KVC的集合代理对象(collection proxy object)来处理集合相关的操作,使集合对象内部元素改变时也能触发KVO。
直接操作:
有序集合对应方法如下:
-countOf<Key>
//必须实现,对应于NSArray的基本方法count:
-objectIn<Key>AtIndex:
-<key>AtIndexes:
//这两个必须实现一个,对应于 NSArray 的方法 objectAtIndex: 和 objectsAtIndexes:
-get<Key>:range:
//不是必须实现的,但实现后可以提高性能,其对应于 NSArray 方法 getObjects:range:
-insertObject:in<Key>AtIndex:
-insert<Key>:atIndexes:
//两个必须实现一个,类似于 NSMutableArray 的方法 insertObject:atIndex: 和 insertObjects:atIndexes:
-removeObjectFrom<Key>AtIndex:
-remove<Key>AtIndexes:
//两个必须实现一个,类似于 NSMutableArray 的方法 removeObjectAtIndex: 和 removeObjectsAtIndexes:
-replaceObjectIn<Key>AtIndex:withObject:
-replace<Key>AtIndexes:with<Key>:
//可选的,如果在此类操作上有性能问题,就需要考虑实现之
无序集合对应方法如下:
-countOf<Key>
//必须实现,对应于NSArray的基本方法count:
-objectIn<Key>AtIndex:
-<key>AtIndexes:
//这两个必须实现一个,对应于 NSArray 的方法 objectAtIndex: 和 objectsAtIndexes:
-get<Key>:range:
//不是必须实现的,但实现后可以提高性能,其对应于 NSArray 方法 getObjects:range:
-insertObject:in<Key>AtIndex:
-insert<Key>:atIndexes:
//两个必须实现一个,类似于 NSMutableArray 的方法 insertObject:atIndex: 和 insertObjects:atIndexes:
-removeObjectFrom<Key>AtIndex:
-remove<Key>AtIndexes:
//两个必须实现一个,类似于 NSMutableArray 的方法 removeObjectAtIndex: 和 removeObjectsAtIndexes:
-replaceObjectIn<Key>AtIndex:withObject:
-replace<Key>AtIndexes:with<Key>:
//这两个都是可选的,如果在此类操作上有性能问题,就需要考虑实现之
- 在进行可变集合对象操作时,先调用下面方法通过key或者keyPath获取集合对象,然后再对集合对象进行add或remove等操作时,就会触发KVO的消息通知了。这种方式属于间接操作,是实际开发中最常用到的。
key方法:
- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
- (NSMutableOrderedSet *)mutableOrderedSetValueForKey:(NSString *)key;
- (NSMutableSet *)mutableSetValueForKey:(NSString *)key;
keyPath方法:
- (NSMutableArray *)mutableArrayValueForKeyPath:(NSString *)keyPath;
- (NSMutableOrderedSet *)mutableOrderedSetValueForKeyPath:(NSString *)keyPath;
- (NSMutableSet *)mutableSetValueForKeyPath:(NSString *)keyPath;
例子:
student = [[Student alloc] init];
student.name = @"xds";
student.age = 18;
[student addObserver:self forKeyPath:@"classmates" options:NSKeyValueObservingOptionNew context:nil];
student.classmates = [NSMutableArray array];
NSMutableArray *array = [student mutableArrayValueForKey:@"classmates"];
[array addObject:@"4"];
输出:
2018-08-29 12:54:51.889282+0800 KVO[1404:146332] {
kind = 1;
new = (
);
}
2018-08-29 12:54:51.889866+0800 KVO[1404:146332] {
indexes = "<_NSCachedIndexSet: 0x604000033f80>[number of indexes: 1 (in 1 ranges), indexes: (0)]";
kind = 2;
new = (
4
);
}
**通过 - (NSMutableArray )mutableArrayValueForKey:(NSString )key; 这个方法,我们便可以将可变数组与强大的KVO结合在一起。KVO机制能在集合改变的时候把详细的变化放进change字典中。
- 如果我们想到手动控制集合属性消息的发送,则可以使用下面几个方法,即:
-willChange:valuesAtIndexes:forKey:
-didChange:valuesAtIndexes:forKey:
或
-willChangeValueForKey:withSetMutation:usingObjects:
-didChangeValueForKey:withSetMutation:usingObjects:
10、监听信息
如果我们想获取一个对象上有哪些观察者正在监听其属性的修改,则可以查看对象的observationInfo属性,其声明如下:
@property void *observationInfo
可以看到它是一个void类型指针(就是id类型),指向一个包含所有观察者的一个标识信息对象,这些信息包含了每个监听的观察者,注册时设定的选项等等。我们还是用示例来看看。
使用如下:
id info = student.observationInfo;
NSLog(@"%@", [info description]);
11、小结
KVO作为Objective-C中两个对象间通信机制中的一种,提供了一种非常强大的机制。在经典的MVC架构中,控制器需要确保视图与模型的同步,当model对象改变时,视图应该随之改变以反映模型的变化;当用户和控制器交互的时候,模型也应该做出相应的改变。而KVO便为我们提供了这样一种同步机制:我们让控制器去监听一个model对象属性的改变,并根据这种改变来更新我们的视图。所有,有效地使用KVO,对我们应用的开发意义重大。