如何优雅地使用 KVO

KVO 作为 iOS 中一种强大并且有效的机制,为 iOS 开发者们提供了很多的便利;我们可以使用 KVO 来检测对象属性的变化、快速做出响应,这能够为我们在开发强交互、响应式应用以及实现视图和模型的双向绑定时提供大量的帮助。

但是在大多数情况下,除非遇到不用 KVO 无法解决的问题,笔者都会尽量避免它的使用,这并不是因为 KVO 有性能问题或者使用场景不多,总重要的原因是 KVO 的使用是在是太 ** 麻烦了。


使用 KVO 时,既需要进行注册成为某个对象属性的观察者,还要在合适的时间点将自己移除,再加上需要覆写一个又臭又长的方法,并在方法里判断这次是不是自己要观测的属性发生了变化,每次想用 KVO 解决一些问题的时候,作者的第一反应就是头疼,这篇文章会为各位为 KVO 所苦的开发者提供一种更优雅的解决方案。

使用 KVO

不过在介绍如何优雅地使用 KVO 之前,我们先来回忆一下,在通常情况下,我们是如何使用 KVO 进行键值观测的。

首先,我们有一个 Fizz 类,其中包含一个 number 属性,它在初始化时会自动被赋值为 @0:

// Fizz.h
@interface Fizz : NSObject

@property (nonatomic, strong) NSNumber *number;

@end

// Fizz.m
@implementation Fizz

- (instancetype)init {
    if (self = [super init]) {
        _number = @0;
    }
    return self;
}

@end

我们想在 Fizz 对象中的 number 对象发生改变时获得通知得到新的和旧的值,这时我们就要祭出 -addObserver:forKeyPath:options:context方法来监控 number 属性的变化:

Fizz *fizz = [[Fizz alloc] init];
[fizz addObserver:self
       forKeyPath:@"number"
          options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
          context:nil];
fizz.number = @2;

在将当前对象 self 注册成为 fizz 的观察者之后,我们需要在当前对象中覆写 -observeValueForKeyPath:ofObject:change:context: 方法:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"number"]) {
        NSLog(@"%@", change);
    }
}

在大多数情况下我们只需要对比 keyPath 的值,就可以知道我们到底监控的是哪个对象,但是在更复杂的业务场景下,使用 context 上下文以及其它辅助手段才能够帮助我们更加精准地确定被观测的对象。

但是当上述代码运行时,虽然可以成功打印出 change 字典,但是却会发生崩溃,你会在控制台中看到下面的内容:

2017-02-26 23:44:19.666 KVOTest[15888:513229] {
    kind = 1;
    new = 2;
    old = 0;
}
2017-02-26 23:44:19.720 KVOTest[15888:513229] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'An instance 0x60800001dd20 of class Fizz was deallocated while key value observers were still registered with it. Current observation info: <NSKeyValueObservationInfo 0x60800003d320> (
<NSKeyValueObservance 0x608000057310: Observer: 0x7fa098f07590, Key path: number, Options: <New: YES, Old: YES, Prior: NO> Context: 0x0, Property: 0x608000057400>
)'

这是因为 fizz 对象没有被其它对象引用,在脱离 viewDidLoad 作用于之后就被回收了,然而在 -dealloc 时,并没有移除观察者,所以会造成崩溃。

我们可以使用下面的代码来验证上面的结论是否正确:

// Fizz.h
@interface Fizz : NSObject

@property (nonatomic, strong) NSNumber *number;
@property (nonatomic, weak) NSObject *observer;

@end

// Fizz.m
@implementation Fizz

- (instancetype)init {
    if (self = [super init]) {
        _number = @0;
    }
    return self;
}

- (void)dealloc {
    [self removeObserver:self.observer forKeyPath:@"number"];
}

@end

Fizz 类的接口中添加一个 observer 弱引用来持有对象的观察者,并在对象 -dealloc 时将它移除,重新运行这段代码,就不会发生崩溃了。

由于没有移除观察者导致崩溃使用 KVO 时经常会遇到的问题之一,解决办法其实有很多,我们在这里简单介绍一个,使用当前对象持有被观测的对象,并在当前对象 -dealloc 时,移除观察者:

- (void)viewDidLoad {
    [super viewDidLoad];
    self.fizz = [[Fizz alloc] init];
    [self.fizz addObserver:self
                forKeyPath:@"number"
                   options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                   context:nil];
    self.fizz.number = @2;
}

- (void)dealloc {
    [self.fizz removeObserver:self forKeyPath:@"number"];
}

这也是我们经常使用来避免崩溃的办法,但是在笔者看来也是非常的不优雅,除了上述的崩溃问题,使用 KVO 的过程也非常的别扭和痛苦:

  • 1、需要手动移除观察者,且移除观察者的时机必须合适;
  • 2、注册观察者的代码和事件发生处的代码上下文不同,传递上下文是通过 void * 指针;
  • 3、需要覆写 -observeValueForKeyPath:ofObject:change:context: 方法,比较麻烦;
  • 4、在复杂的业务逻辑中,准确判断被观察者相对比较麻烦,有多个被观测的对象和属性时,需要在方法中写大量的 if 进行判断;

虽然上述几个问题并不影响 KVO 的使用,不过这也足够成为笔者尽量不使用 KVO 的理由了。

优雅地使用 KVO

如何优雅地解决上一节提出的几个问题呢?我们在这里只需要使用 Facebook 开源的 KVOController 框架就可以优雅地解决这些问题了。

如果想要实现同样的业务需求,当使用 KVOController 解决上述问题时,只需要以下代码就可以达到与上一节中完全相同的效果:

[self.KVOController observe:self.fizz
                    keyPath:@"number"
                    options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                      block:^(id  _Nullable observer, id  _Nonnull object, NSDictionary<NSString    *,id> * _Nonnull change) {
                          NSLog(@"%@", change);
                      }];

我们可以在任意对象上获得 KVOController 对象,然后调用它的实例方法 -observer:keyPath:options:block: 就可以检测某个对象对应的属性了,该方法传入的参数还是非常容易理解的,在 block 中也可以获得所有与 KVO 有关的参数。

使用 KVOController 进行键值观测可以说完美地解决了在使用原生 KVO 时遇到的各种问题。

  • 1、不需要手动移除观察者;
  • 2、实现 KVO 与事件发生处的代码上下文相同,不需要跨方法传参数;
  • 3、使用 block 来替代方法能够减少使用的复杂度,提升使用 KVO 的体验;
  • 4、每一个 keyPath 会对应一个属性,不需要在 block 中使用 if 判断 keyPath;

KVOController 的实现

KVOController 其实是对 Cocoa 中 KVO 的封装,它的实现其实也很简单,整个框架中只有两个实现文件,先来简要看一下 KVOController 如何为所有的 NSObject 对象都提供 -KVOController 属性的吧。

分类和 KVOController 的初始化

KVOController 不止为 Cocoa Touch 中所有的对象提供了 -KVOController 属性还提供了另一个 KVOControllerNonRetaining 属性,实现方法就是分类和 ObjC Runtime。

@interface NSObject (FBKVOController)

@property (nonatomic, strong) FBKVOController *KVOController;
@property (nonatomic, strong) FBKVOController *KVOControllerNonRetaining;

@end

从名字可以看出 KVOControllerNonRetaining 在使用时并不会持有被观察的对象,与它相比 KVOController 就会持有该对象了。

对于 KVOController 和 KVOControllerNonRetaining 属性来说,其实现都非常简单,对运行时非常熟悉的读者都应该知道使用关联对象就可以轻松实现这一需求。

- (FBKVOController *)KVOController {
  id controller = objc_getAssociatedObject(self, NSObjectKVOControllerKey);
  if (nil == controller) {
    controller = [FBKVOController controllerWithObserver:self];
    self.KVOController = controller;
  }
  return controller;
}

- (void)setKVOController:(FBKVOController *)KVOController {
  objc_setAssociatedObject(self, NSObjectKVOControllerKey, KVOController, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (FBKVOController *)KVOControllerNonRetaining {
  id controller = objc_getAssociatedObject(self, NSObjectKVOControllerNonRetainingKey);
  if (nil == controller) {
    controller = [[FBKVOController alloc] initWithObserver:self retainObserved:NO];
    self.KVOControllerNonRetaining = controller;
  }
  return controller;
}

- (void)setKVOControllerNonRetaining:(FBKVOController *)KVOControllerNonRetaining {
  objc_setAssociatedObject(self, NSObjectKVOControllerNonRetainingKey, KVOControllerNonRetaining, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

两者的 setter 方法都只是使用 objc_setAssociatedObject 按照键值简单地存一下,而 getter 中不同的其实也就是对于 FBKVOController 的初始化了。

到这里这个整个 FBKVOController 框架中的两个实现文件中的一个就介绍完了,接下来要看一下其中的另一个文件中的类 KVOController。

KVOController 的初始化

KVOController 是整个框架中提供 KVO 接口的类,作为 KVO 的管理者,其必须持有当前对象所有与 KVO 有关的信息,而在 KVOController 中,用于存储这个信息的数据结构就是 NSMapTable。

top Created with Sketch.