KVO (Key-Value Observing),俗称“键值监听”,能够用来监听对象属性的变化,也是 Objective-C 中的“观察者模式”最典型的实现。
KVO
基本使用
有如下 Valenti 定义:
@interface Valenti : NSObject
@property(nonatomic, assign) NSInteger age;
@end
注册通知
外部:
声明全局静态变量 Context:
static void * ValentiObserverContext = &ValentiObserverContext;
_v = [[Valenti alloc] init];
_v.age = 23;
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[_v addObserver:self forKeyPath:@"age" options:options context: ValentiObserverContext];
其中 - (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context
为注册方法,也就是对 _v 注册监听。
keyPath: 目标观察属性
options: 监听事件的类型,可用位运算进行多个类型的监听
context: 上下文,可对多个监听器对象监听相同 keyPath 进行区分
在这里需要注意的是很多人都喜欢将 context 参数传 nil,这并不是恰当的做法,有时我们通过 keyPath 来判断是无法得知 Valenti 的父类是否也在监听该对象,通过 context 判断就不会有这个问题,并且苹果推荐的 context 应该是独一无二且每个属性都应该有一个 context 的,字符串不能保证独一无二,那么最正确的做法就是用一个静态变量的地址作为 context 传值。形如:
static void * ValentiObeserverContext = &ValentiObserverContext;
options 最常见的传值便是:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
,表示对新旧值都监听,options 总共有四个:
typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
NSKeyValueObservingOptionNew = 0x01,
NSKeyValueObservingOptionOld = 0x02,
NSKeyValueObservingOptionInitial API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x04,
NSKeyValueObservingOptionPrior API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x08
};
NSKeyValueObservingOptionInitial:不会监听属性任何值,但会在注册监听的那一刻立即发送通知
NSKeyValueObservingOptionPrior:会在值改变前发送一次通知,,值改变后发送一次通知,并且,值改变前的通知中change
字典里会包含一个键值对notificationIsPrior = 1;
实现方法
注册通知后需要实现 - (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context
方法以便观察对结果的监听:
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary<NSKeyValueChangeKey,id> *)change
context:(void *)context {
NSLog(@"属性 %@", keyPath);
NSLog(@"改变后的值 %@", change);
NSLog(@"上下文 %p", context);
}
将年龄修改为 26 的时候触发方法调用。
打印结果:
属性 age
改变后的值 {
kind = 1;
new = 26;
old = 23;
}
上下文 0x103a00098
{}
内就是 change 的内容。
移除通知
一般情况下是在 dealloc
方法中移除监听,方法为 - (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
:
- (void)dealloc {
[_v removeObserver:self forKeyPath:@"age"];
}
探究本质
上述代码稍做改变:
初始化增加 v2 对象。
_v = [[Valenti alloc] init];
_v.age = 23;
_v2 = [[Valenti alloc] init];
_v2.age = 1;
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[_v addObserver:self forKeyPath:@"age" options:options context: ValentiObserverContext];
修改属性同时增加对 v2 的 age 属性的修改。
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
_v.age = 26;
_v2.age = 100;
}
运行结果:
属性 age
改变后的值 {
kind = 1;
new = 26;
old = 23;
}
上下文 0x10b88c098
看到仅仅是监听到了 v 的变化,毕竟我仅仅对 v 进行了监听而并没有对 v2 注册监听。
但是问题来了,xx.age = xxx;
相当于调用属性的 setter
方法,即相当于:
[_v setAge: 26];
[_v2 setAge: 100];
并且手动实现 age 的 setter 方法:
- (void)setAge:(NSInteger)age {
_age = age;
}
运行后发现只有在 v 的 age 属性赋值后才出发了监听的方法,而同样对 age 赋值的 v2 却没有触发通知,那么问题不出现在 setter 方法上,所以可推测问题只出现在对象本身身上。
借助 Xcode 调试
我们在 [_v setAge: 26]
加断点调试,运行进入 LLDB 调试环境打印 self.v.isa
指针,如图:
发现 v 的 isa 指针指向 NSKVONotifying_Valenti
,同理打印 v2 的 isa 指针,结果为:
v2 的 isa 指针指向的是
Valenti
。
我们在对象 v 添加监听之前和之后分别打印 object_getClass()
返回结果:
运行结果为:
Valenti
NSKVONotifying_Valenti
再次验证,在 NSObject 对象本质中得知实例对象的 isa 指针指向其类对象,那么此处 v 的 isa 指针指向的 NSKVONotifying_Valenti 就是其类对象,这和 v2 的差异就显示出来了。
这个“神奇”的 v 对象被系统“动了手脚”——改了其类对象!
此时再来借助 methodForSelector()
打印 v 和 v2 在添加监听之前和添加监听之后的 setAge: 实现的地址:
NSLog(@"v---->%p, v2---->%p", [self.v methodForSelector: @selector(setAge:)], [self.v2 methodForSelector: @selector(setAge:)]);
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;[_v addObserver:self forKeyPath:@"age" options:options context: ValentiObserverContext];
NSLog(@"v---->%p, v2---->%p", [self.v methodForSelector: @selector(setAge:)], [self.v2 methodForSelector: @selector(setAge:)]);
运行结果为:
v---->0x1003ea944, v2---->0x1003ea944
v---->0x1f72e90f8, v2---->0x1003ea944
methodForSelector()
返回的是一个实现,也就是 IMP 类型。
发现 v 在添加监听之后的实现已然和另外三个地址不同,那这个不同的地址的实现又是如何的,再进入 LLDB 调试环境下打印其实现,借助命令:
po IMP(地址)
打印 0x1003ea944 和 0x1f72e90f8 两个实现,结果为:
发现未添加监听的 v2 的 setAge: 方法和添加监听前 v 的 setAge: 都是 Valenti 的 setAge: 方法的实现,而添加了监听后的 v 的 setAge: 的实现却是 Foundation 框架下的 _NSSetLongLongValueAndNotify
方法。
进而得知,KVO 是运用 Runtime 机制换了 setter 方法的实现。那么 v 和 Valenti 以及 NSKVONotifying_Valenti 的关系是什么?通过 Objetive-C 对象的分类以及 isa、superclass 指针的方法可以打印 NSKVONotifying_Valenti 的父类,结果如下:
原来 NSKVONotifying_Valenti 是 Valenti 的子类。
那么,动态生成的 NSKVONotifying_Valenti 中有什么实现方法?运行下段:
unsigned int count = 0;
Method *methodArray = class_copyMethodList(object_getClass(_v), &count);
unsigned int i;
for(i = 0; i < count; i++) {
NSLog(@"----->%@", NSStringFromSelector(method_getName(methodArray[i])));
}
free(methodArray);
得到打印:
----->setAge:
----->class
----->dealloc
----->_isKVOA
可推测在对 age 属性进行赋值的时候,调用流程为:通过 isa 指针找到 NSKVONotifying_Valenti 类对象,在该类对象中找有关 setAge: 的实现然后进行调用,并不是直接找到 Valenti 的类对象中的 setAge: 进行调用。接着,NSKVONotifying_Valenti 类对象的 setAge: 又调用了 Foundation 框架的 _NSSetIntValueAndNotify
C 语言函数,通过逆向技术可得到在 _NSSetIntValueAndNotify 函数中还有 willChangeValueForKey:
方法和 didChangeValueForKey:
,这几个函数的调用关系为:
最后在 didChangeValueForKey:
函数中调用监听器方法,通知 age 属性发生了改变。
对 Foundation 框架使用
nm -a
命令(逆向技术得到 Foundation 框架)可以获得其私有方法列表,如下:
0013df80 t __NSSetBoolValueAndNotify
000a0480 t __NSSetCharValueAndNotify
0013e120 t __NSSetDoubleValueAndNotify
0013e1f0 t __NSSetFloatValueAndNotify
000e3550 t __NSSetIntValueAndNotify
0013e390 t __NSSetLongLongValueAndNotify
0013e2c0 t __NSSetLongValueAndNotify
00089df0 t __NSSetObjectValueAndNotify
0013e6f0 t __NSSetPointValueAndNotify
0013e7d0 t __NSSetRangeValueAndNotify
0013e8b0 t __NSSetRectValueAndNotify
0013e550 t __NSSetShortValueAndNotify
0008ab20 t __NSSetSizeValueAndNotify
0013e050 t __NSSetUnsignedCharValueAndNotify
0009fcd0 t __NSSetUnsignedIntValueAndNotify
0013e470 t __NSSetUnsignedLongLongValueAndNotify
0009fc00 t __NSSetUnsignedLongValueAndNotify
0013e620 t __NSSetUnsignedShortValueAndNotify
其他 3 个方法
在上文中得知,v 添加注册监听后会动态添加新类 NSKVONotifying_Valenti,并且这个类除了实现 setter 方法外,还有 class 方法
、dealloc 方法
以及 _isKVOA 方法
。
dealloc
dealloc 的作用就是移除自身的监听或者其他代理相关。
class
已经知道,class
方法和 object_getClass:
方法都能获得其类对象:
NSLog(@"%@ %@", object_getClass(_v), [_v class]);
理论上得到的结果都应该是 Valenti 或者 NSKVONotifying_Valenti,但实际却不相同:
NSKVONotifying_Valenti Valenti
Runtime 函数打印的是动态生成的类,也就是 v 的 isa 指针真正指向的类对象,而 class 方法返回的却是 Valenti 类,那么可猜想,在 NSKVONotifying_Valenti 中的 class 方法返回的很有可能是 [Valenti class]
以至于“骗”过我们让我们以为没有 NSKVONotifying_Valenti 的存在!
_isKVOA
从命名来看该方法应该是标识该对象是否被添加了监听的标志。
结论
到这里已经明白,KVO 的本质便是借助 Runtime 修改对象 isa 原本指向的类为新的派生类,并重写期 setter 方法。换而言之,只有调用了对象的 setter 方法,才会触发监听的方法。若 age 属性声明如下:
{
@public
NSInteger age;
}
并通过:
_v->age = 26;
这种形式赋值是不会触发监听通知的。
并且我们可以在不调用 setter 方法的时候手动触发监听通知,那就是主动调用 willChangeValueForKey:
和 didChangeValueForKey:
两个方法。
KVC
KVC (Key-Value Coding),俗称“键值编码”,可通过 key 访问某个属性。
基本使用
赋值
- (void)setValue:(nullable id)value forKeyPath:(NSString *)keyPath;
- (void)setValue:(nullable id)value forKey:(NSString *)key;
keyPath 表示可对对象进行深层赋值:
现定义 Album 类,描述专辑信息:
@interface Album : NSObject
@property(nonatomic, copy) NSString* genre;
@property(nonatomic, copy) NSString* artist;
@property(nonatomic, assign) NSInteger disc;
@end
在 Valenti 类中添加成员变量:
@property (nonatomic, strong) Album* album;
那么我们可通过深层路径对 Album 的属性进行监听:
[_v setValue:@"Pop" forKeyPath:@"album.genre"];
取值
- (nullable id)valueForKeyPath:(NSString*)keyPath;
- (nullable id)valueForKey:(NSString*)key;
NSKeyValueCoding 类别的其他方法
+ (BOOL)accessInstanceVariablesDirectly;
该方法默认返回 YES,如没找到属性的 setter 方法,会按照 _key,_iskey,key,iskey 的顺序搜索成员,若开发者重写该方法,则系统不会遵循这个顺序搜索。- (void)setNilValueForKey:(NSString *)key;
若 value 设置为 nil 会调用该方法。- (NSDictionary<NSString *, id> *)dictionaryWithValuesForKeys:(NSArray<NSString *> *)keys;
给一组 key 得到与 key 对应的 value 的 [key : value] 形式的字典(和集合相关的 KVC API 都会导致性能的消耗)。...
更多的其他方法在文章末尾有外链。
原理
由上节得知赋值流程为:
我们将四种形式的 key 全都暴露出来验证四个属性的查找顺序:
{
@public
NSInteger _age;
NSInteger _isAge;
NSInteger age;
NSInteger isAge;
}
在 setValue: forKey: 方法后打断点看到 LLDB 环境下:
优先给 _key 赋值。
注释 _age 后打印:
优先给 _isKey 赋值。
注释 _isAge 后打印:
优先给 key 赋值, 得到验证。
通过 KVO 章节阐述的过程中知道,这四个属性都是无法触发通知的,但是通过 KVC 对这种形式的属性赋值,是能够触发通知的:
改变后的值 {
kind = 1;
new = 26;
old = 23;
}
这说明 KVC 的内部实现能够触发 key 改变的通知。
触发 KVO 的先决条件是 willChangeValueForKey: 以及 didChangeValueForKey: 那么我们在 Valenti 类重写这两个方法验证:
@implementation Valenti
+ (BOOL)accessInstanceVariablesDirectly {
return YES;
}
- (void)willChangeValueForKey:(NSString *)key {
[super willChangeValueForKey:key];
NSLog(@"willChangeValueForKey");
}
- (void)didChangeValueForKey:(NSString *)key {
[super didChangeValueForKey:key];
NSLog(@"didChangeValueForKey");
}
@end
运行:
willChangeValueForKey
改变后的值 {
kind = 1;
new = 26;
old = 23;
}
didChangeValueForKey
得到验证。
同理,KVC 取值的过程为:
若重写了四个 getter 方法,调用顺序同样和 setter 方法一样。