KVO 的一些基础用法

1,243 阅读9分钟

KVO 是实际开发中经常使用的系统 API,这里就对一些用法整理一下。(大部分都来源于苹果文档,其实苹果文档和系统 API 中的注释说明了好多问题,建议没事就看看。)

1、KVO 介绍

KVO 是一种机制,它允许将其他对象的指定属性的更改通知给对象。可以观察属性,包括简单属性,一对一关系和一对多关系。一对多关系的观察者被告知所做更改的类型,以及更改涉及哪些对象。

2、注册/移除监听

看先系统API NSObject(NSKeyValueObserverRegistration)

/* Register or deregister as an observer of the value at a key path relative to the receiver. The options determine what is included in observer notifications and when they're sent, as described above, and the context is passed in observer notifications as described above. You should use -removeObserver:forKeyPath:context: instead of -removeObserver:forKeyPath: whenever possible because it allows you to more precisely specify your intent. When the same observer is registered for the same key path multiple times, but with different context pointers each time, -removeObserver:forKeyPath: has to guess at the context pointer when deciding what exactly to remove, and it can guess wrong.
*/
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

API 的上方有一段注释:

注册或注销一个相对于接收方的键路径上的值的观察者。 这些选项决定了观察者通知中包含什么内容,以及它们何时被发送(如上所述),上下文在观察者通知中传递(如上所述)。

你应该尽可能使用 -removeObserver:forKeyPath:context: 而不是 -removeObserver:forKeyPath: 因为它允许你更精确地指定你的意图。

当同一个观察者多次为同一个键路径注册,但每次都使用不同的上下文指针时,-removeObserver:forKeyPath: 不得不猜测上下文指针来决定到底要删除什么,但是它可能会猜错。

1、context 的作用

addObserver:forKeyPath:options:context: 方法装中的 context 指针包含将在相应的更改通知中传递回观察者的任意数据。

可以指定 contextNULL,并完全依赖于 keyPath 字符串来确定更改通知的来源,但是这种方法可能会导致对象的问题,该对象的父类由于不同的原因也在观察相同的密钥路径。

一种更安全、更可扩展的方法是使用 context

类中唯一命名的静态变量的地址是一个很好的 context,可以为每个需要观察的 keyPath 创建一个不同的 context,这完全绕过了字符串比较的需要,从而导致更有效的通知解析。

例如:

static void *PersonAccountContext = &PersonAccountContext;

- (void)registerAsObserverForAccount {
    [self.person addObserver:self forKeyPath:@"account" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:PersonAccountContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    if (context == PersonAccountContext) {
        /// 观察的是 account
    }
}

2、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
};
  • NSKeyValueObservingOptionNew:获取新值
  • NSKeyValueObservingOptionOld:获取旧值
  • NSKeyValueObservingOptionInitial:注册后立即调用一次,但是不会返回 Old 的值
  • NSKeyValueObservingOptionPrior:需要监听每次变更之前和之后分别向观察者发送通知

3、移除监听

从上方系统 API 的介绍,Apple 希望开发者使用 - (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context 这样可以通过 context 移除相应的监听, 而不是通过 keyPath 再由系统判定移除,如果父类和子类同时监听了,可能 keyPath 判定不了是需要移除子类的还是父类的,导致移除错误后,发生程序执行错误或者代码 Crash

在移除监听的时候需要注意多次移除同一个 keyPath 或者 contextCrash 问题。可以通过使用 @try { } @catch (NSException *exception) { } @finally {} 来避免此问题的发生。

3、监听回调

1、observe 的回调参数说明

@interface NSObject(NSKeyValueObserving)

- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;

@end

监听回调总共 4 个参数:

  • keyPath:监听的 key 的路径

  • object:key 所在的对象

  • changechange 字典中始终会包含一个 NSKeyValueChangeKindKey 的条目,其值是包装NSKeyValueChangeNSNumberNSKeyValueChange 的含义取决于键路径标识的属性类型:

    • 对于任何类型的属性 ,NSKeyValueChangeSetting 表明观察对象已收到 setvalue: forKey: 消息,或者 KVC 设置方法已被调用,或者 -willChangeValueForKey:-didChangeValueForKey: 一对方法被调用;
    • 对于有序(数组)的一对多关系,NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement表明一个对象中的可变数组调用了 -mutableArrayValueForKey: 方法,或者调用 -mutableOrderedSetValueForKey: 方法,或者 -willChange:valuesAtIndexes:forKey:-didChange:valuesAtIndexes:forKey: 的一对方法被调用;
    • 对于无序(集合)的一对多关系,NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement表明一个对象中的可变数组调用了 -mutableSetValueForKey: 方法,或者 KVC 设置方法已被调用,或者 -willChangeValueForKey:withSetMutation:usingObjects:-didChangeValueForKey:withSetMutation:usingObjects: 的一对方法被调用。
  • context:当前监听的唯一标识,可以为 NULL

2、KindKey 枚举

对于 NSKeyValueChangeKindKey 的枚举:

typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1,
    NSKeyValueChangeInsertion = 2,
    NSKeyValueChangeRemoval = 3,
    NSKeyValueChangeReplacement = 4,
};
  • NSKeyValueChangeSetting:键值更改设置
  • NSKeyValueChangeInsertion:键值更改插入
  • NSKeyValueChangeRemoval:键值更改删除
  • NSKeyValueChangeReplacement:键值更改替换

4、具体使用

1、对于普通属性(除去数组/集合)

建议使用 context 进行监听,尽量不要使用 keyPath 判断你的业务逻辑,这样不精准,而且容易出现继承链中的 BUG

普通的就没有什么好说的了,直接上代码:

//ViewContoller.m

static void *PersonAccountContext = &PersonAccountContext;
static void *PersonNameContext = &PersonNameContext;
static void *PersonFullNameContext = &PersonFullNameContext;

@interface ViewController ()

@property (nonatomic, strong) Person *person;

@end

- (void)viewDidLoad {
    [super viewDidLoad];
    
    [self.person addObserver:self forKeyPath:@"account" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:PersonAccountContext];
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:PersonNameContext];
    [self.person addObserver:self forKeyPath:@"fullName" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:PersonFullNameContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{   
    if (context == PersonAccountContext) {
        /// 观察的是 account
        NSLog(@"%@",change);
    }
    
    if (context == PersonNameContext) {
        /// 观察的是 name
        NSLog(@"%@",change);
    }
    
    if (context == PersonFullNameContext) {
        /// 观察的是 fullName
        NSLog(@"%@",change);
    }
}

2、对于数组/集合

//ViewContoller.m

static void *PersonMutArrayContext = &PersonMutArrayContext;

@interface ViewController ()

@property (nonatomic, strong) Person *person;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person.mutArray = @[@"1"].mutableCopy;
    
    [self.person addObserver:self forKeyPath:@"mutArray" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:PersonMutArrayContext];
    
    // 数组变化
    [self.person.mutArray addObject:@"1"];/// 执行不了
    
    /// 需要使用下面这个方法
    // 并且在 person 对象中实现 - (void)insertObject:(id)object in<key>AtIndex:(NSUInteger)index 方法
    [[self.person mutableArrayValueForKey:@"mutArray"] addObject:@"2"];
    
    
    ///同理下方代码一样,具体下方 person.m
    [self.person mutableArrayValueForKey:@"mutArray"] removeObject:@"1"];

    self.person.mutArray = [NSMutableArray array];

    [[self.person mutableArrayValueForKey:@"mutArray"] addObject:@"2"];

    [[self.person mutableArrayValueForKey:@"mutArray"] replaceObjectAtIndex:0 withObject:@"3"];
    
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{   
    if (context == PersonAccountContext) {
        /// 观察的是 account
        NSLog(@"%@",change);
    }
    
    if (context == PersonNameContext) {
        /// 观察的是 name
        NSLog(@"%@",change);
    }
    
    if (context == PersonFullNameContext) {
        /// 观察的是 fullName
        NSLog(@"%@",change);
    }
}

@end 


//Person.m
#import "Person.h"

@implementation Person

- (void)insertObject:(id)object inMutArrayAtIndex:(NSUInteger)index
{
    [self.mutArray insertObject:object atIndex:index];
}

- (void)removeObjectFromMutArrayAtIndex:(NSUInteger)index
{
    [self.mutArray removeObjectAtIndex:index];
}

- (void)replaceObjectInMutArrayAtIndex:(NSUInteger)index withObject:(id)object
{
    [self.mutArray replaceObjectAtIndex:index withObject:object];
}

@end

打印结果如下:

2021-05-24 17:05:23.576432+0800 自定义KVO[19814:279800] {
    indexes = "<_NSCachedIndexSet: 0x60000156d020>[number of indexes: 1 (in 1 ranges), indexes: (0)]";
    kind = 3;
    old =     (
        1
    );
}
2021-05-24 17:05:23.576741+0800 自定义KVO[19814:279800] {
    kind = 1;
    new =     (
    );
    old =     (
    );
}
2021-05-24 17:05:23.576963+0800 自定义KVO[19814:279800] {
    indexes = "<_NSCachedIndexSet: 0x60000156d020>[number of indexes: 1 (in 1 ranges), indexes: (0)]";
    kind = 2;
    new =     (
        2
    );
}
2021-05-24 17:05:23.577142+0800 自定义KVO[19814:279800] {
    indexes = "<_NSCachedIndexSet: 0x60000156d020>[number of indexes: 1 (in 1 ranges), indexes: (0)]";
    kind = 4;
    new =     (
        3
    );
    old =     (
        2
    );
}

为什么 [self.person.mutArray addObject:@"1"]; 对集合类型无效,具体可以看官方文档对 KVC的解释。

About Key-Value Coding

3、自动观察/手动观察

默认情况下,只要我们注册了监听,系统就会自动观察,如果属性值改变系统就会回调。

但是在某些情况下,比如上线前需要进行对某个字段取消监听,如果查找再删除相应的代码可能会引发意想不到的问题,此时就需要手动控制通知流程,手动更改通知提供了这样做的方法。

手动通知和自动通知不是相互排斥的。除了已经存在的自动通知外,您还可以自由地发出手动通知。更典型的情况是,您可能希望完全控制特定属性的通知。在这种情况下,重载 NSObjectautomaticnotifiesobserverforkey: 方法进行 key 判断返回 NO或者重载 automaticallyNotifiesObserversOfxxx(xxx为属性名,这里就是 automaticallyNotifiesObserversOfName) 返回 NO

代码实现如下:

//ViewContoller.m

/// ViewContoller 中对 person 对象的 account 属性和 name 属性进行了监听,
/// 上线前需要关闭对 name 的监听
- (void)viewDidLoad {
    [super viewDidLoad];
    
    [self.person addObserver:self forKeyPath:@"account" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:PersonAccountContext];
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:PersonNameContext];
}

//Person.m
@implementation Person

// 方法一:
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    if ([key isEqualToString:@"name"]) {
        return NO;
    }
    return [super automaticallyNotifiesObserversForKey:key];;
}

// 方法二:
+ (BOOL)automaticallyNotifiesObserversOfName {
    return NO;
}

@end

如果你要求更多,比如,当时监听了一个对象的 10 个属性,但是上线是只需要监听 1 个,那么可以直接把 automaticallyNotifiesObserversForKey 返回 NO,再使用willChangeValueForKey/didChangeValueForKey这一对方法手动修改对象。

//Person.m
@implementation Person

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    return NO;
}

- (void)setName:(NSString *)name {
    [self willChangeValueForKey:@"name"];
    _name = name;
    [self didChangeValueForKey:@"name"];
}

4、多个键值联动

在有些情况下,一个属性的值取决于当前或者其他对象一个或多个其他属性的值。如果一个属性的值发生更改,则派生属性的值也应标记为更改。

比如,一个人的名字(fullName)需要由姓(firstName)和名(lastName)组成。也就是说,只要 firstName 或者 lastName 其中一个发生变化,就需要通知 fullName 更改。

代码如下:

//Person.m
@implementation Person

/// 这个一定需要实现
- (NSString *)fullName
{
    return [NSString stringWithFormat:@"%@ %@",_firstName,_lastName];
}

/// 方法一:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];

    if ([key isEqualToString:@"fullName"]) {
        NSArray *affectingKeys = @[@"lastName", @"firstName"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];;
    }
    return keyPaths;
}

/// 方法二:
+ (NSSet<NSString *> *)keyPathsForValuesAffectingFullName
{
    return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}

@end

5、 多对多的关系

keyPathsForValuesAffectingValueForKey: 方法不支持包含多关系的 key-path

例如,假设有一个 Department 对象,它与 Employee 之间有一个对多关系(employees),而 Employee 有一个 salary 属性。你可能希望 Department 对象具有一个 totalSalary属性,该属性依赖于关系中所有 Employees 的工资。

你不能这样做,例如,keyPathsForValuesAffectingTotalSalaryemployees.salary 作为键返回。

您可以使用键值观察来注册父对象(本例中为Department)作为所有子对象(本例中为 Employees)的相关属性的观察者。

你必须作为一个观察者添加和删除父对象,因为子对象被添加和从关系中删除(参见注册Key-Value Observing)。

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;
}

6、结语

关于 自定义 KVO 之前写了一个简单的 Demo,虽然没有实现完全,但基本可以知道 KVO 的主要思想了。