KVO介绍
KVO,全称为Key-Value observing,中文名为键值观察,KVO是一种机制,它允许将其他对象的指定属性的更改通知给对象。官方文档里说到:为了理解键值观察,必须首先了解键值编码(KVC)。
KVC是键值编码,在对象创建完成后,可以动态的给对象属性赋值,而KVO是键值观察,提供了一种监听机制,当指定的对象的属性被修改后,则对象会收到通知,所以可以看出KVO是基于KVC的基础上对属性动态变化的监听了。
KVO的主要好处是,不必每次属性更改时都实施自己的计划来发送通知。其定义明确的基础设施具有框架级支持,易于采用,通常不必向项目添加任何代码。此外,基础设施已经功能齐全,因此可以轻松支持单个属性的多个观察者以及依赖值。
与使用NSNotificationCenter
的通知不同,KVO没有为所有观察者提供更改通知的中心对象。相反,当进行更改时,通知会直接发送到观察对象。NSObject
提供了KVO的基本实现,您很少需要覆盖这些方法。
KVO与NSNotificatioCenter的区别
- 相同点
(1)两者的实现原理都是观察者模式,都是用于监听
(2)都能实现一对多的操作 - 不同点
(1)KVO只能用于监听对象属性的变化,并且属性名都是通过NSString
来查找,编译器不会自动检测对错和补全,会比较容易出错
(2)NSNotification
的发送监听(post)操作我们可以控制,KVO则是由系统控制
(3)KVO可以记录新旧值变化
KVO使用
注册观察者
观察对象首先通过发送addObserver:forKeyPath:options:context:
消息与观察到的对象一起注册,将自己作为要观察的属性的观察者和密钥路径传递给自己。观察者还指定了一个选项参数和一个上下文指针来管理通知。
[me addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];
实现回调
当对象的观察属性值发生变化时,观察者会收到observeValueForKeyPath:ofObject:change:context:
消息。所有观察者都必须实施这种方法。
观察对象提供触发通知的keyPath
,本身作为相关对象,包含更改详细信息的字典,以及观察者注册此密钥路径时提供的上下文指针。
更改字典条目NSKeyValueChangeKindKey
提供有关发生更改类型的信息。如果观察到的对象的值发生了变化,NSKeyValueChangeKindKey
条目将返回NSKeyValueChangeSetting
。根据注册观察者时指定的选项,更改字典中的NSKeyValueChangeOldKey
和NSKeyValueChangeNewKey
条目包含更改之前和之后的属性值。如果属性是对象,则直接提供该值。如果属性是标量或C结构,则该值包装在NSValue
对象中(如KVC)。
如果观察到的属性是对多关系,NSKeyValueChangeKindKey
条目还指示关系中的对象是否被插入、删除或替换为分别返回NSKeyValueChangeInsertion
、NSKeyValueChangeRemoval
或NSKeyValueChangeReplacement
。
NSKeyValueChangeIndexesKey
的更改字典条目是一个NSIndexSet
对象,指定更改关系中的索引。如果注册观察者时将NSKeyValueObservingOptionNew
或NSKeyValueObservingOptionOld
指定为选项,则更改字典中的NSKeyValueChangeOldKey
和NSKeyValueChangeNewKey
条目是包含更改之前和之后相关对象值的数组。
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"name"]) {
NSLog(@"%@", change);
}
}
移除观察者
可以通过向观察到的对象发送removeObserver:forKeyPath:context:
消息来删除键值观察者,指定观察对象、键路径和上下文。
[me removeObserver:self forKeyPath:@"name" context:nil];
收到removeObserver:forKeyPath:context:
消息后,观察对象将不再接收指定密钥路径和对象的任何observeValueForKeyPath:ofObject:change:context:
的消息。
移除观察者时,需要注意:
- 如果尚未注册为观察者,则要求删除为观察者,则会导致
NSRangeException
。您可以仅仅调用removeObserver:forKeyPath:context:
一次,与addObserver:forKeyPath:options:context:
相对应,或者如果这在您的应用程序中不可行,请在try/catch
块中放置removeObserver:forKeyPath:context:
调用以处理潜在的异常。 - 观察者在
dealloc
时不会自动删除自己。观察的物体继续发送通知,忘记了观察者的状态。然而,与发送到已发布对象的任何其他消息一样,更改通知会触发内存访问异常。因此,您确保观察者在从内存中消失之前移除自己。 - 该协议不提供询问物体是观察者还是被观察者的方法。构建您的代码,以避免与
release
相关的错误。一个典型的模式是在观察者的初始化期间(例如在init
或viewDidLoad
中)注册为观察者,并在接触分配期间(通常在dealloc
中)取消注册,确保正确配对和有序添加和删除消息,并且观察者在从内存中释放之前是未注册的状态。
总的来说,KVO注册观察者
和移除观察者
是需要成对出现的,如果只注册,不移除,会出现类似野指针的崩溃。
Context
addObserver:forKeyPath:options:context:
消息中的context
指针包含任意数据,这些数据将在相应的更改通知中传递回观察者。您可以指定NULL
,并完全依赖密钥路径字符串来确定更改通知的来源,但这种方法可能会给超类出于不同原因也观察相同密钥路径的对象带来问题。
一个更安全、更可扩展的方法是使用上下文来确保您收到的通知是针对您的观察者而不是超类的。
类中唯一命名的静态变量的地址是一个很好的context
。在超类或子类中以类似方式选择的context
不太可能重叠。您可以为整个类选择单个context
,并依赖通知消息中的键路径字符串来确定更改的内容。或者,也可以为每个观察到的键路径创建一个不同的context
,这完全绕过了字符串比较的需求,从而提高通知解析效率。
也就是说,context
主要是用于区分不同对象的同名属性,从而在KVO回调方法中可以直接使用context
进行区分,可以大大提升性能,以及代码的可读性。
官方文档给了两个代码示例:
static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;
- (void)registerAsObserverForAccount:(Account*)account {
[account addObserver:self
forKeyPath:@"balance"
options:(NSKeyValueObservingOptionNew |
NSKeyValueObservingOptionOld)
context:PersonAccountBalanceContext];
[account addObserver:self
forKeyPath:@"interestRate"
options:(NSKeyValueObservingOptionNew |
NSKeyValueObservingOptionOld)
context:PersonAccountInterestRateContext];
}
KVO的自动与手动触发
自动触发
返回NO
,就监听不到,返回YES
,表示可以监听。
// 自动
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
return YES;
}
手动触发
- (void)setName:(NSString *)name {
//手动
[self willChangeValueForKey:@"name"];
_name = name;
[self didChangeValueForKey:@"name"];
}
KVO的一对多
KVO中的一对多,意思是通过注册一个KVO观察者,可以监听多个属性的变化。
举个栗子:下载过程中不断更新总任务量和当前完成任务量,进度就是_currentData / _totalData
。
先加入监听:
[me addObserver:self forKeyPath:@"progress" options:NSKeyValueObservingOptionNew context:nil];
再去自己的类里面实现下面的方法:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"progress"]) {
NSArray *affectingKeys = @[@"totalData", @"currentData"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
或者实现一个遵循命名规则的类方法keyPathsForValuesAffecting<Key>
以达到同样的效果:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingProgress {
return [NSSet setWithObjects:@"totalData", @"currentData", nil];
}
那么当totalData
和currentData
改动的时候,该值(progress
)必须被通知。这是一种依赖方法。把progress
写成如下:
- (float)progress {
return _currentData / _totalData;
}
修改_currentData
或者_totalData
,会发现,通知了progress
,就可以自动修改啦。
KVO观察可变数组
KVO是基于KVC基础之上的,所以可变数组如果直接添加数据,是不会调用setter
方法的,所有对可变数组的KVO观察下面这种方式不生效的,即直接通过[me.dateArray addObject:@"1"];
向数组添加元素,是不会触发KVO通知回调的。
将代码修改成如下方式:
[[me mutableArrayValueForKey:@"dataArray"] addObject:@"1"];
或者启动手动触发,就可以监听可变数组了,下面是监听结果
其中的kind
表示键值变化的类型,是一个枚举,有以下4种:
/* Possible values in the NSKeyValueChangeKindKey entry in change dictionaries. See the comments for -observeValueForKeyPath:ofObject:change:context: for more information.
*/
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,//设值
NSKeyValueChangeInsertion = 2,//插入
NSKeyValueChangeRemoval = 3,//移除
NSKeyValueChangeReplacement = 4,//替换
};
可以看到,这里我们可变数组的类型就是插入了。
KVO底层原理探索
在官方文档里面,可以看到如下说明:
解释(oh,不,翻译)一下:
- KVO是使用一种称为
isa-swizzling
的技术实现的。 - 顾名思义,
isa
指针指向维护调度表的对象的类。此调度表实质上包含指向类实现的方法的指针以及其他数据。 - 为对象的属性注册观察者时,将修改被观察对象的 isa 指针,其指向中间类而不是实际的类。因此,
isa
指针的值不一定反映实例的实际类。 - 永远不应该依赖
isa
指针来确定类的成员身份。相反,应该使用class
方法来确定对象实例的类。
代码调试探索
属性与成员变量
我们为类创建属性和成员变量,分别注册KVO观察,看看有什么神奇效果。
[me addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];
[me addObserver:self forKeyPath:@"memberVariable" options:NSKeyValueObservingOptionNew context:nil];
me.name = @"Billy";
me->memberVariable = @"Billy Member";
结果:
结论:KVO对成员变量不观察,只对属性观察,属性和成员变量的区别在于属性多一个 setter
方法,而KVO恰好观察的是setter
方法。
中间类
前面我们也看到了,官方文档里的描述,注册KVO观察者后,观察对象的isa
指针指向会发生改变。
我们在控制台调试,注册观察者之前:
实例对象me
的isa
指针指向Person
。
注册观察者之后:
实例对象me
的isa
指针指向NSKVONotifying_Person
。在注册观察者后,实例对象的isa
指针指向由Person
类变为NSKVONotifying_Person
中间类,即实例对象的isa
指针指向发生了变化。
中间类是子类
接下来判断关系,使用下面方法查看子类:
#pragma mark - 遍历类以及子类
- (void)printClasses:(Class)cls {
int count = objc_getClassList(NULL, 0);
NSMutableArray *array = [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])) {
[array addObject:classes[i]];
}
}
free(classes);
NSLog(@"%@'s classes = %@", cls, array);
}
我们观察注册观察者前后子类,可以发现NSKVONotifying_Person
中间类是Person
类的子类。
中间类的方法
我们通过下面的方法查看中间类的方法列表:
#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);
}
结果如下:
继续探究,这四个方法是继承还是重写呢?
在Student
中重写setName:
方法,获取Student
类的所有方法:
我们不难发现,获取到是重写过的方法。所以,可以说明上面的中间类的setName:
方法是重写Person
类的,继承的不会在子类遍历出来。
移除观察者中对中间类的思索
我们尝试移除观察者。先探究移除观察者后的isa
指针变化:
可以看出,移除观察者以后实例对象的isa
指向更改为Person
类。接下来探究那么中间类被创建了之后,并且在后续的dealloc
方法中被移除观察者之后,是否还存在?在dealloc
以后,我们查看Person
类的子类:
通过子类的打印结果可以看出,中间类一旦生成,没有移除,没有销毁,还在内存中,也许主要是考虑重用的想法,即中间类注册到内存中,为了考虑后续的重用问题,所以中间类一直存在,没有被销毁。也许也是害怕还有其他的观察者,所以没有注销。
中间类小结
- 实例对象
isa
的指向在注册KVO观察者之后,由原有类更改为指向中间类 - 中间类重写了观察属性的
setter
方法、class
、dealloc
、_isKVOA
方法 dealloc
方法中,移除KVO观察者之后,实例对象isa
指向由中间类更改为原有类- 中间类从创建后,就一直存在内存中,不会被销毁
自定义KVO
在系统中,注册观察者和KVO响应属于响应式编程,是分开写的,在自定义为了代码更好的协调,使用block
的形式,将注册和回调的逻辑组合在一起,即采用函数式编程。
创建NSObject
类的分类MYKVO
。
- (void)billyAddObserver:(NSObject *)observer
keyPath:(NSString *)keyPath
options:(BillyKeyValueObservingOptions)options
context:(nullable void *)context
handleBlock:(BillyKVOBlock)handleBlock;
- (void)billyRemoveObserver:(NSObject *)observer
keyPath:(NSString *)keyPath;
注册观察者
#pragma mark - 验证setter方法是否存在
- (void)judgeSetterMethodFromeKeyPath:(NSString *)keyPath {
Class superClass = object_getClass(self);
//根据keyPath获得setter方法
SEL setterSelector = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod(superClass, setterSelector);
if (!setterMethod) {//不存在则抛出异常
@throw [NSException exceptionWithName:NSInvalidArgumentException reason:[NSString stringWithFormat:@"没有找到当前%@的setter方法",keyPath] userInfo:nil];
}
}
//动态生成KVO的派生类NSObservingKVO
- (Class)billyKVONotifingObservingKVOWithKeyPath:(NSString *)keyPath {
NSString *oldClassName = NSStringFromClass(object_getClass(self));
//kSafeKVOPrefix = @"SafeKVONotifying_"
NSString *newClassName = [NSString stringWithFormat:@"%@%@", billyKVOPrefix, oldClassName];
Class newClass = NSClassFromString(newClassName);
if (newClass) {//防止重复创建
return newClass;
}
//不存在则创建
//申请class
newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
//注册类
objc_registerClassPair(newClass);
//添加class
SEL classSel = NSSelectorFromString(@"class");
Method classMethod = class_getInstanceMethod([self class], classSel);
const char *classType = method_getTypeEncoding(classMethod);
class_addMethod(newClass, classSel, (IMP)billy_class, classType);
//添加重写的setter
SEL setterSel = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod([self class], setterSel);
const char *setterType = method_getTypeEncoding(setterMethod);
class_addMethod(newClass, setterSel, (IMP)billy_setter, setterType);
//派生类dealloc,主要是为了不移除KVO监听时自动处理的
SEL dealSel = NSSelectorFromString(@"dealloc");
Method dealMethod = class_getInstanceMethod([self class], dealSel);
const char *dealType = method_getTypeEncoding(dealMethod);
class_addMethod(newClass, dealSel, (IMP)billy_dealloc, dealType);
return newClass;
}
- (void)billyAddObserver:(NSObject *)observer
keyPath:(NSString *)keyPath
options:(BillyKeyValueObservingOptions)options
context:(nullable void *)context
handleBlock:(BillyKVOBlock)handleBlock {
[self judgeSetterMethodFromeKeyPath:keyPath];
//获得派生类
Class newClass = [self billyKVONotifingObservingKVOWithKeyPath:keyPath];
//修改isa指针指向派生类
object_setClass(self, newClass);
//保存信息
BillyKVOManager *info = [[BillyKVOManager alloc] initWithObserver:observer keyPath:keyPath options:options handleBlock:handleBlock];
NSMutableArray *infoArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(billyInfoArray));
if (!infoArray) {
infoArray = [NSMutableArray arrayWithCapacity:1];
objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(billyInfoArray), infoArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
[infoArray addObject:info];
}
KVO响应
#pragma mark - 重写的setter
static void billy_setter(id self, SEL _cmd, id newValue) {
NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
//获得原来的值
id oldValue = [self valueForKey:keyPath];
//消息转发,转发给父类处理(setKeyPath:)
void (*billy_msgSendSuper)(void *, SEL, id) = (void *)objc_msgSendSuper;
struct objc_super superStruct = {
.receiver = self,
.super_class = class_getSuperclass(object_getClass(self)),
};
billy_msgSendSuper(&superStruct, _cmd, newValue);
//获得信息数据
NSMutableArray *infoArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(billyInfoArray));
for (BillyKVOManager *info in infoArray) {
if ([info.keyPath isEqualToString:keyPath]) {
if (info.handleBlock) {//有block回调
info.handleBlock(info.observer, info.keyPath, oldValue, newValue);
}
//实现observeValueForKeyPath:可监听值的变化
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSMutableDictionary<NSKeyValueChangeKey,id> *change = [NSMutableDictionary dictionaryWithCapacity:1];
// 对新旧值进行处理
if (info.options & BillyKeyValueObservingOptionNew) {
[change setObject:newValue forKey:NSKeyValueChangeNewKey];
}
if (info.options & BillyKeyValueObservingOptionOld) {
[change setObject:@"" forKey:NSKeyValueChangeOldKey];
if (oldValue) {
[change setObject:oldValue forKey:NSKeyValueChangeOldKey];
}
}
//消息发送给观察者
// (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
SEL observerSEL = @selector(observeValueForKeyPath:ofObject:change:context:);
// objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)
void (*billy_msgSend)(id, SEL, NSString *, id, NSDictionary<NSKeyValueChangeKey, id> *, void *) = (void *)objc_msgSend;
billy_msgSend(info.observer, observerSEL, keyPath, self, change, NULL);
});
}
}
}
有block
回调和方法响应。
移除观察者
清空数组,以及isa
指向更改。
- (void)billyRemoveObserver:(NSObject *)observer keyPath:(NSString *)keyPath {
NSMutableArray *infoArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(billyInfoArray));
if (infoArray.count <= 0) {
return;
}
for (BillyKVOManager *info in infoArray) {
if ([info.keyPath isEqualToString:keyPath]) {
[infoArray removeObject:info];
//清空数组
objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(billyInfoArray), infoArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
}
if (infoArray.count == 0) {
//isa重新指向父类
Class superClass = [self class];
object_setClass(self, superClass);
}
}
在子类中重写dealloc
方法,当子类销毁时,会自动调用dealloc
方法(在动态生成子类的方法中添加)
//派生类dealloc,主要是为了移除KVO监听时自动处理的
SEL dealSel = NSSelectorFromString(@"dealloc");
Method dealMethod = class_getInstanceMethod([self class], dealSel);
const char *dealType = method_getTypeEncoding(dealMethod);
class_addMethod(newClass, dealSel, (IMP)billy_dealloc, dealType);
#pragma mark - kvoDealloc
static void billy_dealloc(id self, SEL _cmd) {
NSLog(@"派生类移除了");
//获得父类
Class superClass = [self class];
//isa指针重新指向父类
object_setClass(self, superClass);
}
自定义KVO总结
注册观察者 & 响应
- 验证是否存在setter方法
- 保存信息
- 动态生成子类,需要重写class、setter方法
- 在子类的setter方法中向父类发消息,即自定义消息发送
- 让观察者响应
移除观察者
- 更改isa指向为原有类
- 重写子类的dealloc方法
总结
KVO的本质是什么?
利用runtime的API动态生成一个子类,并让实例对象的isa
指向这个全新的子类,当修改实例变量对象的属性时候,在新子类的setter方法中会调用Foundation的_NSSetXXXValueAndNotify
函数。willChangeValueForKey
调用原来的setter
,didChangeValueForKey:
内部会触发监听器的监听方法。