Objective-C 之 KVO 底层原理

231 阅读13分钟

1. KVO 简介

KVOKey-Value Observing,是一种机制,该机制允许将一个对象的特定属性的更改,通知其他对象。对于应用程序中模型层和控制器层之间的通信特别有用。可以观察到包括简单属性,一对一关系和一对多关系的属性。一对多关系的观察者被告知所做更改的类型,以及更改涉及哪些对象。

观察者模式

一个目标对象管理所有依赖于它的观察者对象,并在它自身的状态改变时主动通知观察者对象。这个主动通知通常是通过调用各观察者对象所提供的接口方法来实现的。观察者模式较完美地将目标对象与观察者对象解耦。

简单来说KVO可以通过监听key,来获得value的变化,用来在对象之间监听状态变化。KVO的定义都是对NSObject的扩展来实现的,Objective-C中有个显式的NSKeyValueObserving分类,所以对于所有继承了NSObject的类型,都能使用KVO(一些纯Swift类和结构体是不支持KVC的,因为没有继承NSObject)。

KVO 与通知

KVONSNotificationCenter都是 iOS 中观察者模式的一种实现。区别在于,相对于被观察者和观察者之间的关系,KVO是一对一的,而通知一对多的。KVO对被监听对象无侵入性,不需要修改其内部代码即可实现监听。

2. KVO 基本使用

假设一个Person对象与一个Account对象交互,表示该人在银行的储蓄帐户。实例Person可能需要知道该Account实例的某些方面(例如余额或利率)何时发生变化。

如果这些属性是的公共属性Account,则Person可以定期轮询Account来发现更改,但效率低下,并且通常不切实际。更好的方法是使用KVO

使用KVO,首先必须确保被观察的对象(Account)符合KVO。通常,对象继承NSObject并以通常的方式创建属性,则对象及其属性将自动符合KVO标准。

使用KVO分为3个步骤:注册观察者对象 > 处理变更通知 > 移除观察者对象

注册观察者对象

将观察者对象Person注册到被观察的对象AccountPerson会针对每个观察到的关键路径向Account发送一条addObserver:forKeyPath:options:context:消息。

/// 注册观察者对象,接收被观察对象(该方法调用者)的属性发生改变的 KVO 通知。
/// observer 观察者, 要注册 KVO 通知的对象。观测者必须实现 KVO 方法:observeValueForKeyPath:ofObject:change:context:
/// keyPath 被观察的(该方法调用者)属性,此值不能为零.
/// options 指定观察通知中包含的内容 如:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
/// context 在方法observeValueForKeyPath:ofObject:change:context:中传递的任意数据,该数据将在相应的更改通知中传递观察者
- (void)addObserver:(NSObject *)observer 											
         forKeyPath:(NSString *)keyPath 								
            options:(NSKeyValueObservingOptions)options	
            context:(void *)context;										

observer

观察者对象, 要注册KVO通知的对象。

keyPath

被观察对象的指定关键路径(该方法调用者与关键路径的属性), 此值不能为 nil。

options

按位或枚举值,可以在更改字典中返回的值。它既影响通知中提供的更改字典的内容,也影响生成通知的方式。

  • NSKeyValueObservingOptionNewchange字典包括改变后的值
  • NSKeyValueObservingOptionOld:change字典包括改变前的值
  • NSKeyValueObservingOptionInitial:注册后立刻触发KVO通知
  • NSKeyValueObservingOptionPrior:值改变前是否也要通知

通过指定选项NSKeyValueObservingOptionOld,可以选择从更改之前接收观察到的属性的值。可以使用NSKeyValueObservingOptionNew选项请求属性的新值。使用这些选项的位“或”可以接收旧值和新值。

context

addObserver:forKeyPath:options:context:消息中的上下文指针包含任意数据,这些数据将在相应的更改通知中传递回观察者。可以指定NULL并完全依赖键路径字符串来确定更改通知的来源,但是这种方法可能会导致对象的父类由于不同的原因而观察到相同的键路径,从而导致问题。更安全,更可扩展的方法是使用上下文确保您收到的通知是发给观察者的,而不是父类的。

类中唯一命名的静态变量的地址提供了良好的上下文。在父类或子类中以类似方式选择的上下文不太可能重叠。可以为整个类选择一个上下文,然后依靠通知消息中的关键路径字符串来确定更改的内容。另外,可以为每个观察到的键路径创建一个不同的上下文,从而完全不需要进行字符串比较,从而可以更有效地进行通知解析。balanceinterestRate属性的示例上下文:

static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;

Persond对象使用给定的上下文指针将自己注册为Account实例balanceinterestRate属性的观察者。

//注册观察者对象, 接收被观察 account 对象 balance 属性值的改变的 KVO 通知。
[account addObserver:self
          forKeyPath:@"balance"
             options:(NSKeyValueObservingOptionNew |  NSKeyValueObservingOptionOld)
             context:PersonAccountBalanceContext];
 
//注册观察者对象, 接收被观察 account 对象 interestRate 属性值的改变的 KVO 通知。
 [account addObserver:self
       		 forKeyPath:@"interestRate"
              options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld)
              context:PersonAccountInterestRateContext];

接收变更通知

当观察到的对象属性值发生变化时,观察者接收到一条observeValueForKeyPath:ofObject:change:context:消息。所有观察者都必须实现这个方法。

/// 被观察对象的指定关键路径处的值发生更改时,通知观察对象。观察对象必须该方法:
/// keyPath 被观察已更改的属性
/// object  被观察对象
/// change  描述被观察对象的属性值所做的更改的字典。
/// context 注册观察者以接收 KVO 通知时提供的值。
- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object 
                        change:(NSDictionary<NSKeyValueChangeKey, id> *)change 
                       context:(void *)context;

当被观察对象Account指定关键路径处的值发生更改时,被观察对象Account将向察者对象Person发送此消息。然后,察者对象Person可以根据变更通知采取适当的措施。

观察者的observeValueForKeyPath:ofObject:change:context:方法实现,记录了balanceinterestRate属性的新旧值:

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
 
    if (context == PersonAccountBalanceContext) {
 
    } else if (context == PersonAccountInterestRateContext) {

    } else {
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                               context:context];
    }
}

如果在注册观察者时指定上下文为 NULL,则将通知的键路径与要观察的键路径进行比较(如: keyPath == balance),以确定已更改的内容。如果对所有观察到的关键路径使用了同一个上下文,则首先针对通知的上下文进行测试,然后找到匹配项,使用关键路径字符串比较来确定具体更改的内容。如果您为每个键路径提供了唯一的上下文,如上所示,则一系列简单的指针比较会同时告诉您通知是否针对此观察者,如果是,则更改了哪个键路径。

在任何情况下,观察者都应始终observeValueForKeyPath:ofObject:change:context:在其无法识别上下文(或在简单情况下,是任何关键路径)时调用父类的实现,因为这意味着父类也已注册了通知。

注意: 如果通知传播到类层次结构的顶部,则NSObject抛出,NSInternalInconsistencyException因为这是编程错误:子类无法使用为其注册的通知。

移除观察者对象

通过向被观察对象发送一条removeObserver:forKeyPath:context:消息,指定观察对象,键路径和上下文,可以删除键值观察者。

/// 停止观察者对象接收被观察对象(该方法调用者)的属性发生改变的 KVO 通知。
/// observer (要移除的)观察者对象。
/// keyPath  被观察对象的指定关键路径
/// context  注册观察者以接收 KVO 通知时提供的值。
- (void)removeObserver:(NSObject *)observer 
            forKeyPath:(NSString *)keyPath
               context:(void *)context;	
// 在 PersonAccountBalanceContext 上下文 停止观察者对象接收被观察对象(account)的属性(balance)发生改变的 KVO 通知。
[account removeObserver:self
             forKeyPath:@"balance"
                context:PersonAccountBalanceContext];
 
// 在 PersonAccountInterestRateContext 上下文 停止观察者对象接收被观察对象(account)的属性(interestRate)发生改变的 KVO 通知。
[account removeObserver:self
             forKeyPath:@"interestRate"
                context:PersonAccountInterestRateContext];

移除观察者时,请记住以下几点:

  1. 如果尚未注册为观察者,则请求将其移除观察者会导致 NSRangeException
  2. 释放时,观察者不会自动移除。被观察对象会继续发送通知,而发送消息到已释放对象时,会触发内存访问异常。因此,要确保观察者在释放之前将自己移除。
  3. 一般在观察者初始化期间(例如在initviewdiload中)注册为观察者,并在释放时(通常在dealoc中)移除,确保正确配对。

3. KVC 触发条件

观察者观察的是属性,只有遵循KVO变更属性值的方式才会执行KVO的回调方法,例如是否执行了 setter方法、或者是否使用了KVC赋值。

如果赋值没有通过setter方法或者KVC,而是直接修改属性对应的成员变量,例如:仅调用 _name = @"ZhangSan",这时是不会触发KVO机制,更加不会调用回调方法的。

如果属性是容器对象,对容器对象进行addremove操作,则不会调用KVO的方法。可以通过KVC对应的API来配合使用,使容器对象内部发生改变时也能触发KVO

所以使用KVO机制的前提是遵循KVO的属性设置方式来变更属性值。

4. KVO 触发模式

自动发出变更通知

NSObject提供自动键值更改通知的基本实现。自动键值更改通知将使用键值兼容访问器以及键值编码方法进行的更改通知给观察者。

手动发出更改通知

在某些情况下,希望控制通知过程,例如,最大程度地减少出于应用程序特定原因而不必要的触发通知,或者将多个更改分组为一个通知。手动更改通知提供了执行此操作的方法。

手动和自动通知不是互斥的。除了发布的自动通知之外,还可以发布手动通知。通常,覆盖的NSObject实现automaticallyNotifiesObserversForKey:。子类实现automaticallyNotifiesObserversForKey:并在其中设置对该key不自动发送通知(返回NO即可)。子类实现应为任何无法识别的键调用super

示例: 实现balance属性的手动通知(其他属性自动):

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
 //默认返回 YES,自动模式, NO 手动模式
    if ([theKey isEqualToString:@"balance"]) {
        return NO;//手动模式
    }
    else {//默认 自动模式
        return [super automaticallyNotifiesObserversForKey:theKey];
    }
}

无论属性的值是否发生改变,是否调用Setter方法,只要调用了willChangeValueForKey:didChangeValueForKey:就会触发回调。

实现手动观察者通知,请willChangeValueForKey:在更改值之前和didChangeValueForKey:更改值之后调用:

//实现手动通知的示例访问器方法
- (void)setBalance:(double)balance {

    [self willChangeValueForKey:@"balance"];
    _balance = balance;
    [self didChangeValueForKey:@"balance"];
}

可以通过检查值是否已更改来最大程度地减少发送不必要的通知。balance并且仅在通知已更改时才提供通知:

- (void)setBalance:(double)balance {

    if (balance != _balance) {
        [self willChangeValueForKey:@"balance"];
        _balance = balance;
        [self didChangeValueForKey:@"balance"];
    }
}

如果单个操作导致更改多个属性,则必须嵌套更改通知

- (void)setBalance:(double)balance {

    [self willChangeValueForKey:@"balance"];
    [self willChangeValueForKey:@"itemChanged"];
    _balance = balance;
    _itemChanged++;
    [self didChangeValueForKey:@"itemChanged"];
    [self didChangeValueForKey:@"balance"];
}		

如果需要禁用该类KVO直接automaticallyNotifiesObserversForKey:返回NO,实现属性的setter方法,不进行调用willChangeValueForKey:didChangeValueForKey方法。

5. KVO 属性依赖

有时候一个属性的值依赖于另一对象中的一个或多个属性,如果这些属性中任一属性的值发生变更,被依赖的属性值也应当为其变更进行标记。因此,object引入了依赖键。

通过给Person添加观察者,观察Account类的balance属性 和interestRate属性,只要Account类中属性有变化,就会给观察者发出更改通知,每一个属性都添加一遍观察者,很麻烦,那么可以使用属性依赖

isEqualToString@interface Account : NSObject

@property (nonatomic, assign) CGFloat balance;
@property (nonatomic, assign) CGFloat interestRate;

@end

@class Account;
@interface Person : NSObject 

@property (nonatomic, strong) Account *account;

@end

@implementation Person

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"account"]) {
        keyPaths = [[NSSet alloc] initWithObjects:@"balance", @"interestRate", nil];
    }
    return keyPaths;
}
//同时在添加观察者时,不用对 account 具体的属性添加:
 [person addObserver:self forKeyPath:@"account" options:NSKeyValueObservingOptionNew context:nil];

还可以通过实现遵循命名约定的类方法来实现相同的结果keyPathsForValuesAffecting<Key>,其中<Key>,其中<Key>是依赖于值的属性的名称(第一个字母大写)。使用此模式,如上所示的代码可以被重写:

+(NSSet *)keyPathsForValuesAffectingAccount {
   return [NSSet setWithObjects:@“ balance”,@“ interestRate”,nil];
}

6. KVO 原理

KVO是根据runtime实现的,当监听某个对象的某个属性时,KVO会创建该对象本类的子类,并重写该属性(keyPath)的setter方法。 基本的原理: 当观察对象A时,KVO机制动态创建一个对象A当前类的子类,并为这个新的子类重写了被观察属性keyPathsetter方法。setter方法随后负责通知观察对象属性的改变状况。

Apple使用了isa-swizzling来实现KVO。当观察对象A时,KVO机制动态创建一个新的名为:NSKVONotifying_A的新类,该类继承自对象A的本类,且KVONSKVONotifying_A重写观察属性的setter方法,setter方法会负责在调用原setter方法之前和之后,调用willChangeValueForKey:didChangeValueForKey:通知观察者属性值的更改情况。 NSKVONotifying_A类剖析:

在这个过程,被观察对象的isa指针从指向原来的A类,被KVO机制修改为指向系统新创建的子类NSKVONotifying_A,来实现当前类属性值改变的监听;

所以当我们从应用层面上看来,完全没有意识到有新的类出现,这是系统“隐瞒”了对KVO的底层实现过程,让我们误以为还是原来的类。但是此时如果我们创建一个新的名为NSKVONotifying_A的类,就会发现系统运行到注册KVO的那段代码时程序就崩溃,因为系统在注册监听的时候动态创建了名为NSKVONotifying_A的中间类,并指向这个中间类了。 (isa 指针的作用:每个对象都有isa指针,指向该对象的类,它告诉Runtime系统这个对象的类是什么。所以对象注册为观察者时,isa 指针指向新子类,那么这个被观察的对象就神奇地变成新子类的对象(或实例)了。) 因而在该对象上对setter的调用就会调用已重写的 setter,从而激活键值通知机制。

子类setter方法剖析:

KVO的键值观察通知依赖于 NSObject 的两个方法:willChangeValueForKey:didChangeValueForKey:,在存取数值的前后分别调用。

被观察属性发生改变之前willChangeValueForKey:被调用,通知系统该 keyPath 的属性值即将变更;当改变发生后didChangeValueForKey: 被调用,通知系统该 keyPath 的属性值已经变更;之后observeValueForKey:ofObject:change:context:也会被调用。且重写观察属性的setter方法这种继承方式的注入是在运行时而不是编译时实现的。