这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战
KVO(NSKeyValueObserving)
对象采用的一种非正式协议,用于通知其他对象的指定属性的更改。
1、非正式协议(informal protocol):所谓的非正式协就是类别,即凡是NSObject或子类的类别,都是非正式协议。
2、正式协议(protocal):指的是一个以@protocol方式命名的方法列表,与非正式协议不同的是,它要求显示的采用协议。你可以使用@required或者optional关键字指定方法是否必须实现。子类继承父类采用的协议。正式协议也可以遵守其他协议。
Introduction
键值监听提供了允许对象监听别的对象属性变化的机制,这种机制在MVC的模式中的model和controller之间的通信很有用的,controller可以观察model的属性变化,或者view通过controller观察model的属性变化,另外model也可以观察其他model的变化(常用于有依赖关系的值改变)。
监听的属性可以是单个属性值,一对一关系的或者一对多关系的(注册从属关系键值)。一对多关系的观察者被告知所做更改的类型,以及更改涉及哪些对象。
举例说明,Person类需要监听Account中的balance和interestRate,该两个属性变化时通知Person。
通常,如果您的对象继承自 NSObject 并且以通常的方式创建属性,那么对象及其属性将自动符合 KVO。 也可以手动实现合规性。 KVO 合规性描述了自动和手动键值观察之间的区别,以及如何实现两者。
1.创建观察的实例对象 person,和被观察的实例account,Person发送addObserver:forKeyPath:options:context:消息,并对应keypath。
2.Person 实现 observeValueForKeyPath:ofObject:change:context: method
3.当不再使用或者delloc时,Person需要 removeObserver:forKeyPath: to the Account.
备注:与使用 NSNotificationCenter 的通知不同,没有中央对象为所有观察者提供更改通知。 相反,当发生更改时,通知会直接发送到观察对象。 NSObject 提供了这个键值观察的基本实现,应该很少需要覆盖这些方法。
代码实现
1.添加监听
static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;
//观察者
OCPerson *person = [[OCPerson alloc]init];
//被观察者
OCAccount *acc = [[OCAccount alloc]init];
acc.interestRate = 0.2;
acc.balance = 10;
//添加观察
[acc addObserver:person forKeyPath:@"balance" options:NSKeyValueObservingOptionOld context:PersonAccountBalanceContext];
[acc addObserver:person forKeyPath:@"interestRate" options:NSKeyValueObservingOptionInitial context:PersonAccountInterestRateContext];
//属性变化
acc.balance = 20;
acc.interestRate = 0.4;
options 官方说明
NSKeyValueObservingOptionNew: 表明通知中的更改字典应该提供新的属性值,如果有的话。
NSKeyValueObservingOptionOld: 表明通知中的更改字典应该包含旧的属性值,如果有的话。
NSKeyValueObservingOptionInitial: 在属性发生变化后立即通知观察者,这个过程甚至早于观察者注册是时候。如果在注册的时候配置了 `NSKeyValueObservingOptionNew`,那么在通知的更改字典中也会包含 `NSKeyValueChangeNewKey`,但是不会包括 `NSKeyValueChangeOldKey`。(在初始通知中,观察到的属性值可能是旧的,但是对于观察者来说是新的)其实简单来说就是这个枚举值会在属性变化前先触发一次 `observeValueForKeyPath` 回调。
NSKeyValueObservingOptionPrior: 这个会先后连续出发两次 `observeValueForKeyPath` 回调。同时在回调中的可变字典中会有一个布尔值的 `key - notificationIsPrior` 来标识属性值是变化前还是变化后的。如果是变化后的回调,那么可变字典中就只有 `new` 的值了,如果同时制定了 `NSKeyValueObservingOptionNew` 的话。如果你需要启动手动 `KVO` 的话,你可以指定这个枚举值然后通过 `willChange` 实例方法来观察属性值。在出发 `observeValueForKeyPath` 回调后再去调用 `willChange` 可能就太晚了。
context 传值使用,可用作监听回调中判断不同变化的属性
static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;\
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;
2.实现监听回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == PersonAccountBalanceContext) {
// Do something with the balance…
} else if (context == PersonAccountInterestRateContext) {
// Do something with the interest rate…
}else{
// Any unrecognized context must belong to super
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
change包含5个key
| key | value | 描述 |
|---|---|---|
| NSKeyValueChangeKindKey | NSNumber类型 | 1:Setting,2:Insertion,3:Removal,4:Replacement |
| NSKeyValueChangeNewKey | id | 变化后的新值 |
| NSKeyValueChangeOldKey | id | 变化后的旧值 |
| NSKeyValueChangeIndexesKey | NSIndexSet | 插入、删除或替换的对象的索引 |
| NSKeyValueChangeNotificationIsPriorKey | NSNumber boolValue | Option为Prior时标识属性值是变化前和还是变化后的 |
3.移除监听
- (void)unregisterAsObserverForAccount:(Account*)account {
[account removeObserver:self forKeyPath:@"balance" context:PersonAccountBalanceContext];
[account removeObserver:self forKeyPath:@"interestRate" context:PersonAccountInterestRateContext];
}
KVO合规性
- 类必须符合属性的键值编码
- KVO 支持与 KVC 相同的数据类型,包括 Objective-C 对象以及标量和结构体中列出的标量和结构。
- 监听的属性需完成KeyPath注册
KVO类型
自动类型,支持由 NSObject 提供,默认情况下可用于符合键值编码的类的所有属性。通常,如果您遵循标准的 Cocoa 编码和命名约定,您可以使用自动更改通知——您无需编写任何额外的代码。
//触发KVO方式
[account setName:@"Savings"];
// Use setValue:forKey:.
[account setValue:@"Savings" forKey:@"name"];
// Use a key path, where 'account' is a kvc-compliant property of 'document'.
[document setValue:@"Savings" forKeyPath:@"account.name"];
// Use mutableArrayValueForKey: to retrieve a relationship proxy object.
Transaction *newTransaction = <#Create a new transaction for the account#>;
NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
[transactions addObject:newTransaction];
(<> "Manual Change Notification")
手动类型,更改通知提供了对何时发出通知的额外控制,并且需要额外的编码。您可以通过实现类方法 automaticallyNotifiesObserversForKey: 来控制子类属性的自动通知。
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
BOOL automatic = NO;
if ([theKey isEqualToString:@"balance"]) {
automatic = NO;
}
else {
automatic = [super automaticallyNotifiesObserversForKey:theKey];
}
return automatic;
}
//多属性情况
- (void)setBalance:(double)theBalance {
[self willChangeValueForKey:@"balance"];
[self willChangeValueForKey:@"itemChanged"];
_balance = theBalance;
_itemChanged = _itemChanged+1;
[self didChangeValueForKey:@"itemChanged"];
[self didChangeValueForKey:@"balance"];
}
//数组
- (void)removeTransactionsAtIndexes:(NSIndexSet *)indexes {
[self willChange:NSKeyValueChangeRemoval valuesAtIndexes:indexes forKey:@"transactions"];
// Remove the transaction objects at the specified indexes.
[self didChange:NSKeyValueChangeRemoval valuesAtIndexes:indexes forKey:@"transactions"];
注册从属关系的键值
上文中提到KVO可以监听的可以是单个属性,还支持监听to-One Relationships(一对一),to-Many Relationships(一对多)的属性。 在许多情况下,一个属性的值取决于另一对象中一个或多个其他属性的值。如果一个属性的值发生更改,则派生属性的值也应标记为更改。如何确保为这些从属属性发布键值观察通知取决于关系的基数。 这个在上面已经有所提到,这里在通过举例进行详细的说明。
一对一关系
要自动触发一对一关系的通知,您应该重写 keyPathsForValuesAffectingValueForKey:或实现遵循其定义的用于注册从属键的模式的合适方法。
例如,一个人的全名取决于名字和姓氏。返回全名的方法可以编写如下:
- (NSString *)fullName {
return [NSString stringWithFormat:@"%@ %@",firstName, lastName];
}
fullName当firstName或lastName属性更改时,必须通知观察该属性的应用程序,因为它们会影响属性的值。
第一种方法是我们通过重写keyPathsForValuesAffectingValueForKey:指定fullName的属性取决于lastName和firstName属性。通常我们应该调用super并返回一个集合,该集合包括这样做所导致的集合中的其他任何成员免受干扰。
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"fullName"]) {
NSArray *affectingKeys = @[@"lastName", @"firstName"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
第二种方法是通过实现遵循命名约定的类方法keyPathsForValuesAffecting<Key>来实现相同的结果,其中<Key>是依赖值的属性名称(首字母大写)。实现代码如下:
+ (NSSet *)keyPathsForValuesAffectingFullName {
return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}
对于分类我们只能以第二种方法进行实现因为我们不能再分类中覆盖keyPathsForValuesAffectingValueForKey:的实现。
一对多关系
keyPathsForValuesAffectingValueForKey:方法不支持包含多对多关系的键路径。那么对于这种关系的键值路径我们该如何处理呢?
例如我们有个Department(部门),他又一个employees(员工数组)对象,部门跟员工有很多关系,但是Employee(员工)具有salary(薪资)属性,这时我们希望部门有个totalSalary(总工资)属性,那么这个属性取决于员工数组中所有员工的薪资,我们也不能使用keyPathsForValuesAffectingTotalSalary和employees.salary作为键返回。
此时我们可以使用键值观察将父项(在此示例中为Department)注册为所有子项(在此示例中为employees)的相关属性的观察者。您必须作为观察者添加和删除父对象,因为要在关系中添加或删除子对象。在该observeValueForKeyPath:ofObject:change:context:方法中,您将响应更改来更新从属值,如以下代码片段所示:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == totalSalaryContext) {
[self updateTotalSalary];
}
else
// deal with other observations and/or invoke super...
}
- (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;
}
另外如果您使用的是Core Data,则可以将父项注册到应用程序的通知中心,作为其托管对象上下文的观察者。父母应以类似于观察键值的方式响应孩子发布的相关变更通知。
KVO实现详情
官方说法
Automatic key-value observing is implemented using a technique called isa-swizzling.
The
isapointer, 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
isapointer to determine class membership. Instead, you should use theclassmethod to determine the class of an object instance.
说明
- kvo(默认自动模式)采用的
isa-swizzling技术。 - isa在没有添加KVO监听的情况下会指向该类的类对象,类对象维护了一张哈希表,这个哈希表的本质是包含指向该类实现的方法的指针以及其他数据。
- 当添加KVO监听后,将修改观察对象的
isa指针,指向中间类而不是真实的类,因为isa的值不一定反映的是实例的实际的类。 - 所以我们永远不要依靠
isa指针来确定类成员,所以我们应该使用class方法确定对象实例的类.言外之意,苹果不想开发者知道并使用这个中间类。
代码探究
通过官方文档可知,添加监听后的实例isa的指向发生了变化,产生了一个中间类,通过代码验证如下
//观察者
OCPerson *person = [[OCPerson alloc]init];
NSLog(@"监听之前的类对象 = %@",object_getClass(person));
[person addObserver:**self** forKeyPath:@"fullName" options:NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew context:**NULL**];
NSLog(@"监听之后的类对象 = %@",object_getClass(person));
打印可知,添加监听后的实例对象指向了中间类,形如(NSKVONotifying_OBJC)
监听之前的类对象 = OCPerson
监听之后的类对象 = NSKVONotifying_OCPerson
接着通过如下代码
Class newCls = object_getClass(person);
Class supercls = [newCls superclass];
NSLog(@"中间类 %@ 的父类是%@",newCls,supercls);
打印结果
中间类 NSKVONotifying_OCPerson 的父类是OCPerson
可看出这个中间类和原类对象的关系为继承关系,这个设计的意图很明显,这个苹果不想让开发者感知的类在实现KVO的基础上会集成原类的能力.那么该类和原类又有什么区别呢
//通过runtime,获取中间类 类对象的相关属性
//实例对象列表
unsigned ivarCount;
Ivar *ivarList = class_copyIvarList(newCls, &ivarCount);
//属性列表
unsigned propertyCount;
objc_property_t *proList = class_copyPropertyList(newCls, &propertyCount);
//方法列表
unsigned methodCount;
Method *method = class_copyMethodList(newCls, &methodCount);
NSLog(@"\n实例方法个数:%d\n属性个数:%d\n方法个数:%d",ivarCount,propertyCount,methodCount);
得到结果如下
实例方法个数:0
属性个数:0
方法个数:6
也就是说属性和成员变量在改中间类中都没有重写,重写了的是方法列表.打印该类的方法列表 如下
for(int i = 0;i<methodCount;i++){
Method mt = method[i];
SEL sel = method_getName(mt);
NSLog(@"中间类中的方法 = %@",NSStringFromSelector(sel));
}
得到结果,
中间类中的方法 = setLastName:
中间类中的方法 = setFirstName:
中间类中的方法 = setFullName:
中间类中的方法 = class
中间类中的方法 = dealloc
中间类中的方法 = _isKVOA
- 对于重写
set方法,本身监听的是fullName,所以会重写setFullName,另外,添加了依赖兼职关系(上文,一对一关系),所以会重写fullName相关依赖的set方法. - 对于重写
class,是为了掩饰该中间类的存在.比如,通过[person class]得到的还是原来类的对象. - 对于重写
dealloc应该是移除监听时需要处理一些逻辑 - 对于重写
_isKVOA方法应该是返回是否是KVO的值
接着查看中间类方法的实现
IMP imp = method_getImplementation(mt);
打印
(IMP) imp = 0x000000010f544583 (Foundation`_NSSetObjectValueAndNotify)
(IMP) imp = 0x000000010f544583 (Foundation`_NSSetObjectValueAndNotify)
(IMP) imp = 0x000000010f544583 (Foundation`_NSSetObjectValueAndNotify)
(IMP) imp = 0x000000010f54308e (Foundation`NSKVOClass)
(IMP) imp = 0x000000010f542e37 (Foundation`NSKVODeallocate)
(IMP) imp = 0x000000010f542e2f (Foundation`NSKVOIsAutonotifying)
通过imp打印我们可以发现, setter方法被重写_NSSetObjectValueAndNotify.
移除KVO监听
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
NSLog(@"移除监听之前的类对象 = %@",object_getClass(self.person));
[self.person removeObserver:**self** forKeyPath:@"fullName"];
NSLog(@"移除监听之后的类对象 = %@",object_getClass(self.person));
Class cls = NSClassFromString(@"NSKVONotifying_OCPerson");
NSLog(@"移除监听后中间类是否依然存在 = %@",cls);
}
打印结果
移除监听之前的类对象 = NSKVONotifying_OCPerson
移除监听之后的类对象 = OCPerson
移除监听后中间类是否依然存在 = NSKVONotifying_OCPerson
当移除KVO监听后我们发现isa重新指向了原类,且中间类对象并没有被移除.
至此,KVO相关的探究就完结了.
自定义KVO
网上也提供了大神自定义实现的KVO,本人项目中暂未有遇到此需求,固没细研.
- FaceBook 的 KVOController
- 根据原生的
KVC和KVO反汇编而编写的 DIS_KVC_KVO - 开源的
GNUStep的libs-base(最接近APPLE源码的)gnustep/libs-base