初探
Key-value observing is a mechanism that allows objects to be notified of changes to specified properties of other objects.
KVO
, 即Key-value observing
,也就是我们常说的键值观察,它是是一种机制,允许将其他对象的指定属性的更改通知给指定对象。
KVO
机制对应用程序中模型层和控制器层之间的通信特别有用,控制器对象通常观察模型对象的属性,而视图对象通过控制器观察模型对象的属性,模型对象可能会观察其他模型对象(通常是确定从属值何时更改),甚至是自身(再次确定从属值何时更改)。
比如说,我们的视图控制器viewController
上有一个label
用来显示模型对象person
的一个属性name
,我们需要的实时显示,当name
发生变化的时候,label
的显示也会及时变化,那么我们就可以给person
添加一个观察者viewController
,用以观察name
属性,当name
发生变化的时候,viewController
就可以及时刷新label
,这样就做到了模型层和控制器层的通信。
KVO
的使用
使用步骤
- 使用
addObserver:forKeyPath:options:context:
方法将观察者注册到观察对象 -
observeValueForKeyPath:ofObject:change:context:
在观察者内部实现以接受更改通知消息 - 当观察者
removeObserver:forKeyPath:
不再应该接收消息时,使用该方法注销观察者。该方法至少在从内存释放观察者之前调用。
下面我们配合一个例子进行探究:
@interface TPerson : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSMutableArray *hobbies;
@property (nonatomic, copy) NSString *downloadProgress;
@property (nonatomic, assign) double writtenData;
@property (nonatomic, assign) double totalData;
@end
@implementation TPerson
@end
然后在ViewController
里实现如下代码:
@interface ViewController ()
@property (nonatomic, strong) TPerson *person;
@property (nonatomic, assign) int pNumber;
@end
1. 注册键值观察
被观察对象首先通过调用下面方法向观察者注册自己,并且传入要观察的属性的keyPath
,另外还指定了一个options
参数和一个上下文指针context
来管理通知的各个方面。
- (void)addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(nullable void *)context;
该方法的参数如下:
-
(NSObject *)observer
: 观察者 -
(NSString *)keyPath
: 要观察的属性 -
(NSKeyValueObservingOptions)options
: 设置回调的内容和时机 -
(nullable void *)context
: 上下文指针用以区分被观察者和观察属性
我们再来看看options
的取值有什么含义:
-
NSKeyValueObservingOptionNew = 0x01
: 变化之后的新值 -
NSKeyValueObservingOptionOld = 0x02
: 变化之前的旧值 -
NSKeyValueObservingOptionInitial = 0x04
: 变化之前通知 -
NSKeyValueObservingOptionPrior = 0x08
: 变化之后通知
注意,options
可以填写多个。
我们可以使用context
指针用来区分每一个监听,这样在通知回调的时候可以直接根据context
进行区分处理,这样更简洁一些。使用方式如下:
static void *PersonNickNameContext = &PersonNickNameContext;
当不使用context
指针的时候,需要传入NULL
,而不能传入nil
,因为不是id
类型。
2. 接收变化回调
观察者必须实现下面方法,才能接收到观察对象的变化。
- (void)observeValueForKeyPath:(nullable NSString *)keyPath
ofObject:(nullable id)object
change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change
context:(nullable void *)context;
-
(nullable NSString *)keyPath
: 观察的属性 -
(nullable id)object
: 观察对象 -
(nullable NSDictionary<NSKeyValueChangeKey, id> *)change
: 返回的观察信息 -
(nullable void *)context
: 上下文指针
-
NSKeyValueChangeKindKey
: 返回的更改信息的时机,1是变化之前,2是变化之后 -
NSKeyValueChangeNewKey
: 返回的值是新值 -
NSKeyValueChangeOldKey
: 返回值是旧值 -
NSKeyValueChangeIndexesKey
: 观察的属性是数组或者NSSet
的下标变化 -
NSKeyValueChangeNotificationIsPriorKey
: 观察通知的时机是否是Prior
-
NSKeyValueChangeSetting = 1
: 数据的设置 -
NSKeyValueChangeInsertion = 2
: 集合的插入 -
NSKeyValueChangeRemoval = 3
: 集合的移除 -
NSKeyValueChangeReplacement = 4
: 集合的替换
当被观察对象是集合对象,在NSKeyValueChangeKindKey
字段中会包含NSKeyValueChangeInsertion
、NSKeyValueChangeRemoval
、NSKeyValueChangeReplacement
的信息,表示集合对象的操作方式。
3. 移除观察
当我们不需要观察者继续观察对象了,就需要移除观察者。在观察者被释放之前必须移除观察者,否则会出现BUG
。移除之前必须注册,否则就会崩溃。
一般,我们在观察者初始化期间(例如在init
中或viewDidLoad
中)注册为观察者,在释放过程中(通常在中dealloc
)注销,确保在观察者从内存中释放之前将其注销。
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
接着上面的例子,我们在viewDidLoad
进行注册KVO
,回调通知的策略给name
属性使用改变之前的,hobbies
使用改变之后的。:
static void *PersonNameContext = &PersonNameContext;
static void *PersonHobbyContext = &PersonHobbyContext;
- (void)viewDidLoad {
self.person = [[TPerson alloc] init];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld | NSKeyValueObservingOptionInitial context:PersonNameContext];
self.person.hobbies = [NSMutableArray array];
[self.person addObserver:self forKeyPath:@"hobbies" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld | NSKeyValueObservingOptionPrior context:PersonHobbyContext];
[self.person addObserver:self forKeyPath:@"downloadProgress" options: NSKeyValueObservingOptionNew context:NULL];
}
监听回调:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == PersonNameContext) {
NSLog(@"===name===change==%@=====", change);
} else if (context == PersonHobbyContext) {
NSLog(@"===hobbies===change==%@=====", change);
} else {
NSLog(@"===downloadProgress===change==%@=====", change);
}
}
然后在下面方法中改变观察的属性的值:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.pNumber++;
self.person.name = [NSString stringWithFormat:@"%@-%d-%@", @"Number", self.pNumber, @"Person"];
switch (self.pNumber % 4) {
case 1:
case 3:
{
[[self.person mutableArrayValueForKeyPath:@"hobbies"] addObject:@"add"];
}
break;
case 2:
{
[[self.person mutableArrayValueForKeyPath:@"hobbies"] removeObject:@"add"];
}
break;
case 0:
{
[[self.person mutableArrayValueForKeyPath:@"hobbies"] replaceObjectAtIndex:0 withObject:@"replace"];
}
break;
default:
break;
}
NSLog(@"===%@===", self.person.hobbies);
self.person.writtenData += 10;
self.person.totalData += 20;
}
然后在dealloc
方法中移除观察:
- (void)dealloc {
[self.person removeObserver:self forKeyPath:@"name"];
[self.person removeObserver:self forKeyPath:@"hobbies"];
[self.person removeObserver:self forKeyPath:@"downloadProgress"];
}
运行程序,控制台输出:
===name===change=={
kind = 1;
new = "<null>";
}=====
因为name
属性的监听策略使用了NSKeyValueObservingOptionInitial
,所以进入页面就会响应。点击一下页面:
===name===change=={
kind = 1; // NSKeyValueChangeSetting
new = "Number-1-Person"; // NSKeyValueObservingOptionNew
old = "<null>"; // NSKeyValueObservingOptionOld
}=====
// NSKeyValueObservingOptionPrior
===hobbies===change=={
indexes = "<_NSCachedIndexSet: 0x600002949ac0>[number of indexes: 1 (in 1 ranges), indexes: (0)]"; // NSKeyValueChangeIndexesKey
kind = 2; // NSKeyValueChangeInsertion
notificationIsPrior = 1; // NSKeyValueObservingOptionPrior
}=====
===hobbies===change=={
indexes = "<_NSCachedIndexSet: 0x600002949ac0>[number of indexes: 1 (in 1 ranges), indexes: (0)]";
kind = 2;
new = (
add
);
}=====
继续点击页面,控制台会输出hobbies
的removeObject
和replaceObjectAtIndex
的监听,kind
也会有相应的变化,此处就不一一演示了。
注意,观察的属性是数组的时候不能直接是add
等方法:
[self.person.hobbies addObject:@"addd"]
由于KVO
是基于KVC
,而直接使用add
等方法是不会触发KVC
的,所以我们要使用
[[self.person mutableArrayValueForKeyPath:@"hobbies"] addObject:@"add"]
符合KVO
的使用标准
当我们要使用KVO
观察某个对象的时候,必须得确保该对象符合KVO
的使用标准才可以。
- 该类和属性必须要符合
KVC
,因为KVO
的实现依托于KVC
。KVO
支持的数据类型与KVC
相同,包括Objective-C
对象以及基本数据类型和结构体。 - 该类能为该属性发出
KVO
更改的通知。 - 当有依赖关系的时候,注册合适的依赖
key
。
发出KVO
通知
发出KVO
通知有两种方式,一种是自动通知,另外一种是手动通知。
自动通知
自动通知需要该类和属性必须要符合KVC
,使用KVC
的方式对属性进行赋值。如下面方法:
- (void)setValue:(nullable id)value forKey:(NSString *)key
手动通知
在某些情况下,我们希望控制通知过程。例如,最大程度地减少出于应用程序特定原因而不必要的触发通知,或将多个更改分组为一个通知。这是就可以使用手动更改通知。
手动和自动通知不是互斥的。当我们使用手动通知的时候,第一步需要重写automaticallyNotifiesObserversForKey:
,并且返回NO
;第二步在在更改值之前调用willChangeValueForKey:
、在更改值之后调用didChangeValueForKey:
。当一个类里既存在需要手动通知的属性,又存在需要自动通知的属性,在这个方法里就需要进行判断,分别处理。
- (void)setName:(NSString *)name {
if (_name != name) {
[self willChangeValueForKey:@"name"];
_name = name;
[self didChangeValueForKey:@"name"];
}
}
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
return NO;
}
如果操作的属性是集合类型,需要使用以下方法:
- (void)willChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;
- (void)didChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key;
注册合适的依赖key
在许多情况下,一个属性的值取决于另一个或多个其他属性的值。如果一个属性的值发生更改,则派生属性的值也应标记为更改。我们需要确保当一个附属的属性发生变化时,其父属性也能收到改变通知。
一对一关系
要自动触发一对一关系的通知,需要重写 keyPathsForValuesAffectingValueForKey:
或实现遵循其定义的用于注册从属key
的式的方法。
- 使用
keyPathsForValuesAffectingValueForKey
方法
+ (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];
}
运行程序,控制台输出:
===downloadProgress===change=={
kind = 1;
new = "0.100000";
}=====
===downloadProgress===change=={
kind = 1;
new = "0.083333";
}=====
- 使用
keyPathsForValuesAffectingDownloadProgress
代替keyPathsForValuesAffectingValueForKey
方法也可以达到同样的效果。
+ (NSSet<NSString *> *)keyPathsForValuesAffectingDownloadProgress {
return [NSSet setWithObjects:@"totalData", @"writtenData", nil];
}
一对多关系
假设有一个Department
对象,Department
又包含多个employees
,所以该对象与Employee
形成了一对多关系,而Employee
具有薪金属性。当Department
对象具有一个totalSalary
属性,该属性取决于所有雇员的薪水,可以在observeValueForKeyPath
进行处理。
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (context == totalSalaryContext) {
[self updateTotalSalary];
}
}
- (void)updateTotalSalary {
[self setTotalSalary:[self valueForKeyPath: @"employees.@ sum.salary"]];
}
- (void)setTotalSalary:(NSNumber *)newTotalSalary {
if (totalSalary!= newTotalSalary) {
[self willChangeValueForKey:@“totalSalary”];
_totalSalary = newTotalSalary;
[self didChangeValueForKey:@“totalSalary”];
}
}
-(NSNumber *)totalSalary {
return _totalSalary;
}
KVO
原理
看完KVO
的使用,我们来看一下KVO
的实现原理,官方文档这么说:
Automatic key-value observing is implemented using a technique called isa-swizzling.
The isa pointer, as the name suggests, points to the object's class which maintains a dispatch table. This dispatch table essentially contains pointers to the methods the class implements, among other data.
When an observer is registered for an attribute of an object the isa pointer of the observed object is modified, pointing to an intermediate class rather than at the true class. As a result the value of the isa pointer does not necessarily reflect the actual class of the instance.
You should never rely on the isa pointer to determine class membership. Instead, you should use the class method to determine the class of an object instance.
KVO
是使用isa-swizzling
技术实现的。这个isa
指针指向对象的类,它维护一个派发表。该派发表实质上包含该类实现的方法的指针以及其他数据。
在为对象的属性注册观察者时,将修改观察对象的isa
指针,让其指向一个中间类而不是原来的类。表现出来的现象就是,isa
指针的值不一定指向正确类,因为它可能指向一个中间类。所以不要依靠isa
指针来确定类,相反,应该使用类方法确定实例对象的类。
我们可以使用上述demo
来验证一下,在注册观察者的前后设置log
,运行程序:
从控制台我们可以看出,此时生成了一个中间类NSKVONotifying_TPerson
。
当观察一个对象A
时,KVO
机制动态创建一个对象A
当前类的中间类,其类名为NSKVONotifying_A
,该类继承自对象A
的本类,并为这个新的类重写了被观察属性keyPath
的setter
方法。setter
方法会负责在调用原setter
方法之前和之后,通知所有观察对象属性值的更改情况。
被观察对象A
的isa
指针从指向原来的A
类,被KVO
机制修改为指向NSKVONotifying_A
类,来实现当前类属性值改变的监听。
需要注意的是,此处生成的是一个类,而不是对象,修改的是原对象的isa
指向,让其指向中间类
下面我们来验证一下NSKVONotifying_A
和A
类的关系到底是不是继承?我们在注册前后使用下面打印一下TPerson
类及其子类的名称:
- (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);
}
控制台输出:
可以看出,生成的中间类是被观察对象的类的子类
接着我们给TPerson
添加一个实例变量,看看属性和实例变量由于setter
方法的差异会不会有不同的结果。
@public
NSString *ivarName;
在touchesBegan
事件,给ivarName
赋值:
self.person->ivarName = @"ivarName";
运行程序,控制台输出:
==change=={
kind = 1;
new = "Number-1-Person";
old = "<null>";
}==
可以发现,并没有ivarName
的改变通知,这也说明了ivarName
没有setter
方法,所以并不能发出改变通知。
同样,我们可以在注册前后打印类的方法来分析前后的差异:
- (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(@"--sel--%@--imp--%p",NSStringFromSelector(sel),imp);
}
free(methodList);
}
- (void)viewDidLoad {
[self printClassAllMethod:[TPerson class]];
NSLog(@"--注册之前--");
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld | NSKeyValueObservingOptionInitial context:PersonNameContext];
NSLog(@"--注册之后--");
[self printClassAllMethod:NSClassFromString(@"NSKVONotifying_TPerson")];
}
由于注册之后就生成了中间类NSKVONotifying_TPerson
,所以注册后我们直接打印中间类的方法。控制台输出结果如下:
--sel--setHobbies:--imp--0x10f10ef70
--sel--hobbies--imp--0x10f10ef50
--sel--writtenData--imp--0x10f10eff0
--sel--setWrittenData:--imp--0x10f10f010
--sel--totalData--imp--0x10f10f040
--sel--setTotalData:--imp--0x10f10f060
--sel--.cxx_destruct--imp--0x10f10f090
--sel--name--imp--0x10f10ef20
--sel--setName:--imp--0x10f10eb90
--sel--downloadProgress--imp--0x10f10ee10
--sel--setDownloadProgress:--imp--0x10f10efb0
--注册之前--
--注册之后--
--sel--setName:--imp--0x7fff25721c7a
--sel--class--imp--0x7fff2572073d
--sel--dealloc--imp--0x7fff257204a2
--sel--_isKVOA--imp--0x7fff2572049a
从以上结果可以看出,在注册之前,属性name
和实例变量ivarName
的区别就是没有setter/getter
。另外,在注册之后,又打印出来了setName
方法,NSKVONotifying_TPerson
作为TPerson
的子类,如果还有setName
方法的话,说明NSKVONotifying_TPerson
,重写了父类的setName
的方法,也就是中间类重写了原来类的setter。
既然中间类NSKVONotifying_TPerson
重写了dealloc
方法,那么它肯定是在该方法里做了某些处理,我们在观察者的dealloc
方法里,移除观察者的之后,加入以下方法:
NSLog(@"--移除之后--%s--", object_getClassName(self.person));
运行程序,控制台输出:
--移除之后--TPerson--
可以看出,移除观察者之后,被观察对象的isa
指针会指回到原来的A
类。
总结
KVO
是基于KVC
的一种机制,通过观察回调,可用于不同层面的通信。其使用步骤如下:
- 注册观察者
- 接受更改回调
- 移除观察者
KVO
原理
- 动态生成了一个中间类,该类是被观察对象的类的子类,类名是
NSKVONotifying_A
,被观察对象A
的isa
指针从指向原来的A
类,被KVO
机制修改为指向NSKVONotifying_A
类 - 观察的是中间类
NSKVONotifying_A
重写的setter
方法 - 中间类
NSKVONotifying_A
重写了很多方法:-
setter
: 发送改变通知 -
class
: 返回被观察对象的类 -
dealloc
: 释放相关方法 -
_isKVOA
: 标志是生成的中间类
-
- 移除观察的之后, 被观察对象的
isa
指针会指回到原来的A
类 - 移除观察之后中间类
NSKVONotifying_A
不会销毁
参考文献: Aplle官方文档