1、KVO简介
KVO
即Key-Value Observing
,翻译成是中文键值观察
,是一种非正式的协议,它定义了对象之间观察和通知状态改变的机制,是观察者模式的一种衍生。KVO通过对对象的某个属性添加注册观察,当该属性的值发送变化时,会触发观察者对象实现的KVO接口方法,自动通知观察者。简单来说KVO就是通过监听key
来获取所对应的的value
的变化,从而达到对象状态变化的监听。和KVC
一样KVO
的定义也是对NSObject
的扩展来实现的,Objective-C中有个显式的NSKeyValueObserving
类别名,所以对于所有派生于NSObject的类的对象,都能使用KVO。
2、KVO的基础使用
2.1、注册观察者
根据KVO的定义,KVO是对对象的属性状态变化的监听,那么首先要对该对象(被观察者)进行注册观察者。
注册观察者的方法如下:
- (void)addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context
这个方法中的四个参数解释如下:
observer
:注册KVO通知的对象,也就是观察者。观察者必须实现
observeValueForKeyPath:ofObject:change:context:
。keyPath
:观察者的属性的keypath
,相对于接受者,值不能是nil
。options
:KVO的一些属性配置;有四个选项。context
: 上下文,这个会传递到订阅着的函数中,用来区分消息,所以应当是不同的。
其实前两个参数比较好理解,需要特别说明的是后面两个参数。
2.1.1、options参数
options
参数是NSKeyValueObservingOptions
类型,是一个枚举类型,其定义如下:
typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
NSKeyValueObservingOptionNew = 0x01,
NSKeyValueObservingOptionOld = 0x02,
NSKeyValueObservingOptionInitial = 0x04,
NSKeyValueObservingOptionPrior = 0x08
};
这个四个枚举变量的含义如下:
NSKeyValueObservingOptionNew
: 表明变化的change字典应该提供新的属性值。NSKeyValueObservingOptionOld
: 表明变化的字典应该包含旧的属性值。NSKeyValueObservingOptionInitial
:是否应在观察者注册方法返回之前立即将通知发送给观察者。如果NSKeyValueObservingOptionNew
也被指定,则通知中的change字典将始终包含一个NSKeyValueChangeNewKey
,但绝不会包含一个NSKeyValueChangeOldKey
。(在初始通知中,观察到的属性的当前值可能是旧的,但是对于观察者来说是新的。)。NSKeyValueObservingOptionPrior
:是否应该在每次更改之前和之后向观察者发送单独的通知,而不是在更改之后发送单个通知。在更改之前发送的通知中的change字典中会包含有一个notificationIsPrior
项,用以区分是在更改前发送的通知,但不会包含有NSKeyValueChangeNewKey
,即使是NSKeyValueObservingOptionNew
被指定。
看如下例子:
在注册观察者的时候options的参数传入的是NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial
,这个时候在进入到页面的时,打印的结果中有一个new
项,其实这个时候的name的只是旧的属性值。在点击屏幕触发修改name属性后打印的结果中的new的结果是新的属性值。
正如上图所示,如果options参数传入的是
NSKeyValueObservingOptionPrior
,则会在更改前后各发一次通知,不管是否有传入NSKeyValueObservingOptionNew
,在更改前的通知中的change字典中都不会包含有NSKeyValueChangeNewKey
项。
2.1.1、context参数
context
指针在addObserver:forKeyPath:options:context: message
中包含任意的数据,这些数据将在相应的变更通知中被传递回观察者。您可以指定NULL
并完全依赖于keyPath
来确定更改通知的来源,但是这种方法可能会对一个对象造成问题,因为该对象的超类由于不同的原因也在观察相同的keyPath
。一种更安全、更可扩展的方法是使用context
来确保接收到的通知是针对观察者的,而不是超类。类中唯一命名的静态变量的地址是一个很好的context。在超类或子类中以类似方式选择的context不太可能重叠。可以为整个类选择一个context,并依赖于通知消息中的keyPath来确定更改了什么。或者,也可以为每个观察到的keyPath创建不同的上下文,从而完全绕过字符串比较的需要,从而提高通知解析的效率。
正如下面所示的那样是为Person类和Student类的name属性创建的context。这样只需要在接收通知的时候判断context边可以分辨出是哪个对象的属性发生了改变。
static void * PersonNameContext = &PersonNameContext;
static void * StudentNameContext = &StudentNameContext;
2.2、观察者接收消息
观察者接收消息的方法如下:
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context
在这个方法的里面除了change外的其他几个参数都是在添加注册观察者的时候传入的参数原样带回,在这里可以通过keyPath来匹配确认改变的属性,也可以通过object和keyPath相结合的方式来区分确认是哪个对象的那个属性发生了修改,但是这样不免在代码的合理性和优雅上大打折扣了,所以最好的方式是通过context来区分。change这个字典保存了变更的信息,其内容和你在添加注册的时候传入的options参数有关。
2.3、手动观察
按照上面章节所讲可以实现对对象属性的监听,那是因为属性值的变化由系统控制的,开发者只需要告诉系统监听什么属性便可以了,但是在实际的开发中我们有可能属性的值的变化并不需要受系统的支配。实际上除了系统自动监听属性值的变化外,还有一种方式便是可以由开发者支配属性的值变化后是否发送通知。只需要修改类方法 automaticallyNotifiesObserversForKey:
的返回值,如果返回 YES
就是自动,返回 NO
就是手动。
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key{
return NO;
}
一旦automaticallyNotifiesObserversForKey
方法的返回的NO,系统就不在自动监控属性的值变化,如果想要还能监控到属性的值变化,那么还需要调用两个方法:
- (void)willChangeValueForKey:(NSString *)key
- (void)didChangeValueForKey:(NSString *)key
只需要在属性值修改的前后分别调用这两个方法便可。
- (void)setName:(NSString *)name{
[self willChangeValueForKey:@"name"];
_name = name;
[self didChangeValueForKey:@"name"];
}
需要注意的是对于一个容器类的属性,不仅必须指定已更改的key
,还必须指定更改的类型和所涉及对象的索引。 更改的类型是 NSKeyValueChange
,它指定 NSKeyValueChangeInsertion,NSKeyValueChangeRemoval 或 NSKeyValueChangeReplacement
,受影响的对象的索引作为 NSIndexSet
对象传递:
- (void)removeObjectFromMArrayAtIndex:(NSUInteger)index {
[self willChange:NSKeyValueChangeRemoval valuesAtIndexes:[NSIndexSet indexSetWithIndex:index] forKey:@"mArray"];
[self.mArray removeObjectAtIndex:index];
[self didChange:NSKeyValueChangeRemoval valuesAtIndexes:[NSIndexSet indexSetWithIndex:index] forKey:@"mArray"];
}
2.4、依赖键
有时候一个属性的值依赖于另一对象中的一个或多个属性,如果这些属性中任一属性的值发生变更,被依赖的属性值也应当为其变更进行标记。因此,object 引入了依赖键。
2.4.1、一对一关系
一对一的这种依赖关系实现自动的KVO有两种方式,一种是重写keyPathsForValuesAffectingValueForKey
方法,一种是实现一个合适的方法。
比如说fullName
这个属性依赖于firstName
和lastName
,只要是两者中任一修改都会影响到fullName,那么就可以在每次监听到两者中任一变化后对fullName进行值修改便可以达到目的。
- (NSString *)fullName {
return [NSString stringWithFormat:@"%@ %@",firstName, lastName];
}
但是这样的实现不免有些麻烦,重写keyPathsForValuesAffectingValueForKey
方法,使得fullNam的监听和firstName、lastName相关联,这样的方式会更加简单。
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"fullName"]) {
NSArray *affectingKeys = @[@"lastName", @"firstName"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
如上代码所示,通过实现类方法 keyPathsForValuesAffectingValueForKey
来返回一个集合,这样就实现了fullName和firstName、lastName的联动。
实际上还有一个便利的方法,就是 keyPathsForValuesAffecting
,Key
是属性的名称(需要首字母大写)。这个方法的效果和 keyPathsForValuesAffectingValueForKey
是一样的,但针对的某个具体属性。
+ (NSSet *)keyPathsForValuesAffectingFullName {
return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}
2.4.2、一对多关系
keyPathsForValuesAffectingValueForKey
方法不支持包含一对多关系的Key Path
。例如,假设你有一个Department对象,该对象与Employee 有一对多关系(即 employees 属性),而 Employee 具有salary 属性。 如果需要在Department 对象上增加totalSalary 属性,而该属性取决于关系中所有Employees的薪水。例如,您不能使用keyPathsForValuesAffectingTotalSalary 和返回employees.salary 作为键来执行此操作。
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == totalSalaryContext) {
[self updateTotalSalary];
} else{
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)updateTotalSalary {
[self setTotalSalary:[self valueForKeyPath:@"[email protected]"]];
}
- (void)setTotalSalary:(NSNumber *)newTotalSalary {
if (totalSalary != newTotalSalary) {
[self willChangeValueForKey:@"totalSalary"];
_totalSalary = newTotalSalary;
[self didChangeValueForKey:@"totalSalary"];
}
}
- (NSNumber *)totalSalary {
return _totalSalary;
}
2.5、取消注册
在合适的地方取消注册是一个必要的过程,否则会造成不可预估的错误,建议是取消注册和添加注册是一对一关系。取消注册的两个方法如下:
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
2.6、KVO和线程
一个需要注意的地方是,KVO 行为是同步的,并且与所观察的值发生变化的同样的线程上。没有队列或者 Run-loop 的处理。所以,当我们试图从其他线程改变属性值的时候我们应当十分小心,除非能确定所有的观察者都用线程安全的方法处理 KVO 通知。通常来说,我们不推荐把 KVO 和多线程混起来。如果我们要用多个队列和线程,我们不应该在它们互相之间用 KVO。
3、KVO的实现原理
在前面的章节中介绍了KVO的基本是否,但是对于KVO的实现原理还没有一个清晰的概念,好在KVO的官方文档有对于KVO的实现原理有一个明确的说明。
Automatic key-value observing is implemented using a technique called isa-swizzling.
【译】使用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.
【译】顾名思义,isa
指针指向对象的类,这个类维护了一个调度表。这个调度表本质上包含指向类实现的方法和其他数据的指针。
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.
【译】当一个观察者为一个对象的属性注册时,被观察对象的isa
指针被修改,指向一个中间类
而不是真正的类。因此,isa指针的值不一定反映实例的实际类。
根据这段话可以得知一个信息,那就是KVO实际上是生成了一个中间类,并且将被观察对象的isa指向这个中间类,但是这个中间类是什么?这个中间类和真是的类之间是什么关系?KVO是怎样观察被观察对象的属性变化的?这个中间类会不会随着取消注册而消亡?带着这些疑问,我们进入到下一步的探索。
3.1、所谓中间类
在上面有提到被观察对象的isa
是指向这个中间类的,那么我们便可以通过LLDB指令的方式来查探这个中间类。
如上图所示在为注册观察者之前person对象的isa指向的Person类,在注册观察者之后person对象的isa指向的是一个NSKVONotifying_Person
类,那么我们便可以确定这个生成的中间类是NSKVONotifying_XX
的类。
在这里需要注意的是如果在外部创建了一个
NSKVONotifying_XX
的类,KVO将无法正常工作。
3.2、NSKVONotifying_Person和Person的关系
我们已经知道了生成的中间类是NSKVONotifying_Person
,而且这个和Person类之间一定存在这某种关系,那么我们需要知道NSKVONotifying_Person
类的继承链关系。
如上图所示,我们尝试着去获取NSKVONotifying_Person
类的父类,发现NSKVONotifying_Person类的父类是Person类,也就是说NSKVONotifying_Person从Person继承而来。
3.3、如何观察属性变化
KVO观察的是属性值的变化,那么对于一个属性来说其值的修改其实是调用的setter
方法或者通过KVC的方式。如果对于属性和实例变量同时监听其变化会怎么样呢?
如上图所示同时对实例变量nickName和属性name进行了注册观察者,发现只有属性name能够接受到变化的通知,而实例变量监控不到变化。但是如果使用KVC的方式来访问实例变量便可以监控到其值的变化。
那么属性而言属性值变化的监听是通过其setter方法来实现的。如果用户注册了对某个对象的某一个属性的观察,那么此派生类会重写这个方法,并在其中添加进行通知的代码。Objective-C 在发送消息的时候,会通过 isa 指针找到当前对象所属的类对象。而类对象中保存着当前对象的实例方法,因此在向此对象发送消息时候,实际上是发送到了派生类(NSKVONotifying_Person)对象的方法。由于编译器对派生类的方法进行了复写,并添加了通知代码,因此会向注册的对象发送通知。注意派生类只重写注册了观察者的属性方法。
NSKVONotifying_Person类除了重写Person类属性的setter方法,还会重写class
、dealloc
、_isKVOA
等方法。之所以要重写class
方法其目的就是为了隐藏NSKVONotifying_Person
这个类。而重写setter方法是为了在其中调用- (void)willChangeValueForKey:(NSString *)key;
方法和- (void)didChangeValueForKey:(NSString *)key;
方法,然后再didChangeValueForKey
中调用observeValueForKeyPath
方法用以通知外界属性值发生了变化。
3.3、NSKVONotifying_Person类的消亡
NSKVONotifying_Person类是在注册观察者后生成的,那么会不会在取消注册后会消亡呢?在取消注册之后代用打印类的方法名称集的方法,如果有打印结果显示则说明NSKVONotifying_Person类并不会随着取消注册而消亡。
如上图所示,在取消注册观察者之后打印出了NSKVONotifying_Person类的所有方法,说明NSKVONotifying_Person这个类在取消注册观察者之后依然存在。在取消注册观察者后person对象的isa又指向了Person类。
4、 KVO的优缺点
4.1、KVO的优点
- KVO提供了一种简单的方法实现两个对象间的同步。例如:model和view之间同步;
- 能够对非我们创建的对象,即内部对象的状态改变作出响应,而且不需要改变内部对象(SKD对象)的实现;
- 能够提供观察的属性的最新值以及先前值;
- 用key paths来观察属性,因此也可以观察嵌套对象;
- 完成了对观察对象的抽象,因为不需要额外的代码来允许观察值能够被观察
4.2、KVO的缺点
- 我们观察的属性必须使用strings来定义。因此在编译器不会出现警告以及检查;
- 对属性重构将导致我们的观察代码不再可用;
- 观察多个属性时,需要些复杂的if判断条件语句;
- 当释放观察者时不需要移除观察者。
5、自定义KVO
在上边的章节中详细分析了KVO的使用和实现原理,知道了KVO的原理其实是生成了一个
NSKVONotifying_XX
(XX表示被观察对象的所属类)的中间类
,这个中间类继承自被观察对象的所属类,并且重写了这个类的属性的setter
方法和class
方法,同时,观察者对象的isa
指针的指向不在是指向对象的所属类,而是指向这个中间类。KVO观察属性的变化其实是观察属性的setter方法的调用,在中间了重写父类的setter方法中,会调用willChangeValueForKey
方法和didChangeValueForKey
方法,而在didChangeValueForKey方法内部会调用observeValueForKeyPath
方法将属性的变化通知给观察者。当取消注册观察者的时候,被观察对象的isa指针会重写指向所属类。
5.1、基本思路
根据KVO的实现原理设计自定义KVO的基本思路如下:
- 首先检查被观察对象的属性的setter方法是有实现;
- 动态生一个中间类继承自被观察对象的所属类;
- 为中间类添加setter方法、class方法、dealloc方法;
- 修改被观察对象的isa指向中间类;
- 在中间类的setter方法里面进行消息发送给父类,通过block的方式将属性的新值和旧值传回;
- 在dealloc方法里面进行isa重写指回。
5.2、注册观察者
按照上面的思路,首先是要对被观察对象的属性的setter
方法进行验证。验证的关键点就是对于参入的keyPath
进行setter的方法的拼接,因为这里的keyPath实际上就是被观察对象的被观察属性。
- (void)judgeSetterMethodFromKeyPath:(NSString *)keyPath {
Class superClass = object_getClass(self);
SEL setterSeletor = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod(superClass, setterSeletor);
if (!setterMethod) {
@throw [NSException exceptionWithName:NSInvalidArgumentException reason:[NSString stringWithFormat:@"没有当前%@的setter,请检查keyPath参数的正确性", keyPath] userInfo:nil];
}
}
然后需要动态的创建一个中间类,继承自被观察对象的所属类,并且要重写setter和class方法。
static NSString *const kDSKVOPrefix = @"DSKVONotifying_";
- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
NSString *oldClassName = NSStringFromClass([self class]);
NSString *newClassName = [NSString stringWithFormat:@"%@%@", kDSKVOPrefix, oldClassName];
Class newClass = NSClassFromString(newClassName);
// 防止重复创建生成新类
if (newClass) return newClass;
// 申请类
newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
// 注册类
objc_registerClassPair(newClass);
// 添加class : class的指向是父类
SEL classSEL = NSSelectorFromString(@"class");
Method classMethod = class_getInstanceMethod([self class], classSEL);
const char *classTypes = method_getTypeEncoding(classMethod);
class_addMethod(newClass, classSEL, (IMP)ds_class, classTypes);
// 添加setter
SEL setterSEL = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod([self class], setterSEL);
const char *setterTypes = method_getTypeEncoding(setterMethod);
class_addMethod(newClass, setterSEL, (IMP)ds_setter, setterTypes);
// 2.3.3 : 添加dealloc
SEL deallocSEL = NSSelectorFromString(@"dealloc");
Method deallocMethod = class_getInstanceMethod([self class], deallocSEL);
const char *deallocTypes = method_getTypeEncoding(deallocMethod);
class_addMethod(newClass, deallocSEL, (IMP)ds_dealloc, deallocTypes);
return newClass;
}
Class ds_class(id self, SEL _cmd)
{
return class_getSuperclass(object_getClass(self));
}
然后需要修改被观察对象的isa
指向中间类,并且需要将KVO的信息进行对象化保存。完整的注册观察者的方法代码如下:
static NSString *const kDSKVOAssiociateKey = @"kDSKVO_AssiociateKey";
- (void)ds_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(DSKVOBlock)block {
// 1: 验证是否存在setter方法 : 不让实例进来
[self judgeSetterMethodFromKeyPath:keyPath];
// 2: 动态生成子类
Class newClass = [self createChildClassWithKeyPath:keyPath];
// 3: 修改isa的指向
object_setClass(self, newClass);
// 4: 保存信息
DSKVOInfo *info = [[DSKVOInfo alloc] initWitObserver:observer forKeyPath:keyPath handleBlock:block];
NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void *_Nonnull)(kDSKVOAssiociateKey));
if (!mArray) {
mArray = [NSMutableArray arrayWithCapacity:1];
objc_setAssociatedObject(self, (__bridge const void *_Nonnull)(kDSKVOAssiociateKey), mArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
[mArray addObject:info];
}
在这里定义了一个类来对象化KVO的信息,并且用关联对象的形式进行信息的保存和取出。
typedef void(^DSKVOBlock)(id observer,NSString *keyPath,id oldValue,id newValue);
@interface DSKVOInfo : NSObject
@property (nonatomic, weak) NSObject *observer;//观察者,这里用weak修饰,避免出现循环引用
@property (nonatomic, copy) NSString *keyPath;
@property (nonatomic, copy) DSKVOBlock handleBlock;
@end
@implementation DSKVOInfo
- (instancetype)initWitObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath handleBlock:(DSKVOBlock)block {
if (self = [super init]) {
_observer = observer;
_keyPath = keyPath;
_handleBlock = block;
}
return self;
}
@end
5.3、通知观察者
根据KVO的原理,观察者观察到被观察者对象状态的变化其实是观察的属性的setter方法,那么我们就可以定义自己的setter方法,只需要发送出状态变化后的值的消息就可以了。
/// 自定义setter方法,属于中间类,父类发送消息
/// @param _cmd 方法编号,被观察属性的setter方法
/// @param newValue 属性的新的值
static void ds_setter(id self, SEL _cmd, id newValue)
{
NSLog(@"来了:%@", newValue);
NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
id oldValue = [self valueForKey:keyPath];
// 消息转发 : 转发给父类
// 改变父类的值 --- 可以强制类型转换
struct objc_super superStruct = {
.receiver = self,
.super_class = class_getSuperclass(object_getClass(self)),
};
//objc_msgSendSuper(&superStruct,_cmd,newValue);
void (*ds_msgSendSuper)(void *, SEL, id) = (void *)objc_msgSendSuper;
ds_msgSendSuper(&superStruct, _cmd, newValue);
//信息数据回调
NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void *_Nonnull)(kDSKVOAssiociateKey));
for (DSKVOInfo *info in mArray) {
if ([info.keyPath isEqualToString:keyPath] && info.handleBlock) {
info.handleBlock(info.observer, keyPath, oldValue, newValue);
}
}
}
在这里之所以要想父类的setter方法发送消息是为了修改调用父类的setter方法修改属性值。
5.4、自动移除观察者
移除观察者其实就是讲被观察者对象的isa指针指回去。
static void ds_dealloc(id self, SEL _cmd)
{
NSLog(@"ds_dealloc");
Class superClass = [self class];
object_setClass(self, superClass);
}
这里的自定义KVO代码比较简单,其目的只是为了更好的理解KVO的实现原理。实际上这里的代码还存在很多的问题,比如说Options参数处理,context参数处理,观察嵌套对象的处理,多线程问题等等一系列的问题,所以这里的代码不具备代码设计的完整性。如果想要更加优雅的使用KVO,建议大家去阅读KVOController的源码,如果对于KVO的实现原理没有那么清晰的认识,可以去GNU下载阅读KVO的相关代码。
6、总结
- KVO调用
addObserver:
方法注册观察者,observer是观察者对象,keyPath是被观察者的属性名称,不能为nil,options参数的传入关系到接收通知的change字典的值,context上下问为区分对象属性变化提供有效途径。 - 观察者实现
observeValueForKeyPath
方法接收属性变化的通知。 - 调用
removeObserver
方法实现取消注册观察者,这是一个必要的过程。 - KVO的原理在于生成了一个继承于被观察者对象的类的中间类,这个中间类重写了父类的属性的
setter
方法,并且修改了被观察者对象的isa
指针指向,重写的setter方法里面调用了willChangeValueForKey
和didChangeValueForKey
方法,而在didChangeValueForKey方法内部会调用observeValueForKeyPath
方法,从而达到了属性修改后通知观察者的目的。 - 在取消注册观察者后生成的中间类并不会消亡,并且被观察者对象的isa指针会重新指向原来的类。