KVOController详解

KVO在MVC架构的项目中是一种特别有用的技术。KVOController建立在Cocoa经受时间考验的KVO实现上。它提供简单、现代的API,并且是线程安全的。优点如下:

  • 通知是通过block、action或者NSKeyValueObserving回调(即-observeValueForKeyPath:ofObject:change:context)来实现;
  • 不会出现移除observer的异常;
  • 在controller销毁时隐式地移除observer;
  • 保证了线程安全,避免出现这样的异常
    简单地说,KVOController让我们更优雅、简单、安全地使用KVO。

源码分析

KVOController是面向观察者设计的,而不是跟直接使用Cocoa的KVO时一样面向被观察者。这是一个很轻的开源库,只由一个FBKVOController类和一个NSObject+FBKVOController分类构成。

FBKVOController.h

FBKVOController类是用于管理整个KVO流程,它持有了观察者对象,又提供了添加观察行为的API,头文件内容如下:
初始化方法

/**
    @param observer 观察者对象
    @param retainObserved 是否强引用被观察对象
*/
- (instancetype)initWithObserver:(nullable id)observer retainObserved:(BOOL)retainObserved;
/**
    简便初始化方法。
    retainObserved默认为YES。
*/
- (instancetype)initWithObserver:(nullable id)observer;

公开属性与API

/** 弱引用的方式持有观察者对象 */
@property (nullable, nonatomic, weak, readonly) id observer;
/** 
    对指定对象的指定keyPath添加观察,通过Block进行回调
    @param object 被观察对象
    @param keyPath 被观察对象的keyPath
    @param options NSKeyValueObservingOptions
*/
- (void)observe:(nullable id)object keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(FBKVONotificationBlock)block;
/**
    以SEL的方式回调
*/
- (void)observe:(nullable id)object keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options action:(SEL)action;
/**
    不注册block和sel则回调观察者类的-observeValueForKeyPath:ofObject:change:context:方法
*/
- (void)observe:(nullable id)object keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
/** 一次性监听多个keyPath */
- (void)observe:(nullable id)object keyPaths:(NSArray<NSString *> *)keyPaths options:(NSKeyValueObservingOptions)options block:(FBKVONotificationBlock)block;
- (void)observe:(nullable id)object keyPaths:(NSArray<NSString *> *)keyPaths options:(NSKeyValueObservingOptions)options action:(SEL)action;
- (void)observe:(nullable id)object keyPaths:(NSArray<NSString *> *)keyPaths options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

/** 注销观察对象对应的keyPath */
- (void)unobserve:(nullable id)object keyPath:(NSString *)keyPath;
- (void)unobserve:(nullable id)object;
- (void)unobserveAll;

KVOController的API提供了三种回调的方式,也提供了一次性添加多个观察的keyPaths的方法。此外,头文件中还定义了两个有意思的宏,用于判断编译时属性是否存在(防止手滑写错),代码如下:

#define FBKVOKeyPath(KEYPATH) \
@(((void)(NO && ((void)KEYPATH, NO)), \
({ const char *fbkvokeypath = strchr(#KEYPATH, '.'); NSCAssert(fbkvokeypath, @"Provided key path is invalid."); fbkvokeypath + 1; })))

#define FBKVOClassKeyPath(CLASS, KEYPATH) \
@(((void)(NO && ((void)((CLASS *)(nil)).KEYPATH, NO)), #KEYPATH))

因为我们平时直接在写入keyPath时,都是以字符串的方式写入,如果字符串拼写错误的话可能会造成无法监听相应属性的问题。例如:

Person *person = [[Person alloc] init];
Observer *observer = [[Observer alloc] init];
[observer.KVOController observe:person
                        keyPath:@"fristNmae"
                        options:NSKeyValueObservingOptionNew
                          block:block];
person.firstName = @"西瓜冰";

把firstName写错成了fristNmae,因为属性不存在,所以当属性改变时没有发生通知。使用宏FBKVOKeyPath后,我们可以跟调用对象属性那样将需要监听的属性传入,例如:

[observer.KVOController observe:person
                        keyPath:FBKVOKeyPath(person.firstName)
                        options:NSKeyValueObservingOptionNew
                          block:block];

因为有自动补全功能,所以一般不会写错,即使写错了,也会在编译时报错。这个宏的校验步骤拆解后如下:

    // 1 校验传入的KeyPath是否有编译错误
    ((void)(NO && ((void)KEYPATH, NO))
    // NO && ... 是为了运行时直接返回NO减少操作, 因为有(void)KEYPATH的存在,所以编译时校验了object.property
    
    // 2 将传入的object.property转换为"property"
    { const char *fbkvokeypath = strchr(#KEYPATH, '.'); NSCAssert(fbkvokeypath, @"Provided key path is invalid."); fbkvokeypath + 1; }
    // 2.1 #KEYPATH将object.property转为字符串"object.property"
    // 2.2 strchr截取".property"
    // 2.3 NSCAssert保证点语法的存在
    // 2.4 ".property"+1="property"
    
    // 3 使用@()语法糖将char *转换为NSString类型
    @(((void)NO, "property"))
    // 因为','操作符是返回后面的值,即string = (@"a", @"b");string的值为@"b"

宏FBKVOClassKeyPath也以差不多的形式实现,就不重复了。

FBKVOController.m

FBKVOController的实现文件里面包含了两个重要的私有类_FBKVOInfo_FBKVOSharedController。KVOController的全部功能就由这三个类来共同完成,这三个类的职责分别是:
_FBKVOInfo: 用来对每个被观察的keyPath及对应的options和回调(block或者SEL)进行了存储。
FBKVOController: 将每个被观察对象作为key值,将保存着该对象被观察的keyPath及其对应的回调的_FBKVOInfo的Set集合作为value值,通过一个NSMap​Table进行存储。在添加观察和移除观察操作时,操作这个NSMap​Table,并且交付_FBKVOSharedController进行真正的KVO操作。
_FBKVOSharedController: 一个单例。所有的Cocoa的KVO事件都发生在这个单例对象里,这个对象是真正的观察者。每次被监听对象的相关keyPath发生改变时,将会通知这个单例对象,再由这个单例对象通过_FBKVOInfo保存的信息来进行回调。
这三个对象的关系大概如上所述,接下来看看具体的代码实现,首先是最基础的_FBKVOInfo:
属性

@implementation _FBKVOInfo
{
@public
  __weak FBKVOController *_controller;
  NSString *_keyPath;
  NSKeyValueObservingOptions _options;
  SEL _action;
  void *_context;
  FBKVONotificationBlock _block;
  /** 标志的keyPath状态,分别为_FBKVOInfoStateInitial、_FBKVOInfoStateObserving、_FBKVOInfoStateNotObserving */
  _FBKVOInfoState _state;
}

_FBKVOInfo类的内容大概就是由上面这些属性,以及一系列初始化这些属性的初始化方法构成。此外还重写了hash方法和isEqual,如下:

- (NSUInteger)hash
{
  return [_keyPath hash];
}

- (BOOL)isEqual:(id)object
{
  if (nil == object) {
    return NO;
  }
  if (self == object) {
    return YES;
  }
  if (![object isKindOfClass:[self class]]) {
    return NO;
  }
  return [_keyPath isEqualToString:((_FBKVOInfo *)object)->_keyPath];
}

因为一个被观察对象的keyPath具有唯一性,为了防止对同一个对象重复添加了监听,所以_FBKVOInfo的唯一性由keyPath决定。
接下来是FBKVOController类,除了公开的属性外还有以下私有属性:
私有属性

/** 以被观察者对象为key,以_FBKVOInfo的Set作为value,来对其进行关联和保存 */
NSMapTable<id, NSMutableSet<_FBKVOInfo *> *> *_objectInfosMap;
/** 用于保证NSMapTable线程安全的锁 */
pthread_mutex_t _lock;

这里使用NSMutableSet来保存_FBKVOInfo是为了防止重复监听同一个被观察对象的同一个keyPath。NSMutableSet的唯一性是通过调用_FBKVOInfohash方法和isEqual方法来确定的,就如上面所述,KVOController已经重写了_FBKVOInfohash方法和isEqual方法来保证keyPath的唯一性。
使用NSMapTable而不是使用NSMutableDictionary则是因为NSMapTable能控制对key和value的内存管理方式
初始化方法

- (instancetype)initWithObserver:(nullable id)observer retainObserved:(BOOL)retainObserved
{
  self = [super init];
  if (nil != self) {
    _observer = observer;
    NSPointerFunctionsOptions keyOptions = retainObserved ? NSPointerFunctionsStrongMemory|NSPointerFunctionsObjectPointerPersonality : NSPointerFunctionsWeakMemory|NSPointerFunctionsObjectPointerPersonality;
    _objectInfosMap = [[NSMapTable alloc] initWithKeyOptions:keyOptions valueOptions:NSPointerFunctionsStrongMemory|NSPointerFunctionsObjectPersonality capacity:0];
    pthread_mutex_init(&_lock, NULL);
  }
  return self;
}

初始化方法里,保存了观察者对象,根据传入的retainObserved设置NSMapTable管理内存的方式,初始化了锁。
接下来,以最方便的block方式回调为例,看一下KVOController的完整通知流程。
FBKVOController的方法

// 外部API接口,添加观察的方法
- (void)observe:(nullable id)object keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(FBKVONotificationBlock)block
{
  NSAssert(0 != keyPath.length && NULL != block, @"missing required parameters observe:%@ keyPath:%@ block:%p", object, keyPath, block);
  if (nil == object || 0 == keyPath.length || NULL == block) {
    return;
  }
  
  _FBKVOInfo *info = [[_FBKVOInfo alloc] initWithController:self keyPath:keyPath options:options block:block];
  
  [self _observe:object info:info];
}

// 尝试从NSMapTable中取出已保存的_FBKVOInfo对象,有则返回,无则新增,并用锁保证了存取过程的安全。
- (void)_observe:(id)object info:(_FBKVOInfo *)info
{
  pthread_mutex_lock(&_lock);
  
  NSMutableSet *infos = [_objectInfosMap objectForKey:object];
    
  _FBKVOInfo *existingInfo = [infos member:info];

  if (nil != existingInfo) {
    pthread_mutex_unlock(&_lock);
    return;
  }

  if (nil == infos) {
    infos = [NSMutableSet set];
    [_objectInfosMap setObject:infos forKey:object];
  }

  [infos addObject:info];

  pthread_mutex_unlock(&_lock);

  [[_FBKVOSharedController sharedController] observe:object info:info];
}

该方法最后通过_FBKVOSharedController类的方法来添加真正的KVO监听。因为_FBKVOSharedController是个单例,所以第一次调用+sharedController会进行初始化,所以先看下_FBKVOSharedController的属性和初始化,如下:
属性

/** 使用NSHashTable来以弱引用的方式持有_FBKVOInfo */
NSHashTable<_FBKVOInfo *> *_infos;
/** 用于保证NSHashTable线程安全的锁 */
pthread_mutex_t _mutex;

初始化方法

- (instancetype)init
{
  self = [super init];
  if (nil != self) {
    NSHashTable *infos = [NSHashTable alloc];
#ifdef __IPHONE_OS_VERSION_MIN_REQUIRED
    _infos = [infos initWithOptions:NSPointerFunctionsWeakMemory|NSPointerFunctionsObjectPointerPersonality capacity:0];
#elif defined(__MAC_OS_X_VERSION_MIN_REQUIRED)
    if ([NSHashTable respondsToSelector:@selector(weakObjectsHashTable)]) {
      _infos = [infos initWithOptions:NSPointerFunctionsWeakMemory|NSPointerFunctionsObjectPointerPersonality capacity:0];
    } else {
      // silence deprecated warnings
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
      _infos = [infos initWithOptions:NSPointerFunctionsZeroingWeakMemory|NSPointerFunctionsObjectPointerPersonality capacity:0];
#pragma clang diagnostic pop
    }

#endif
    pthread_mutex_init(&_mutex, NULL);
  }
  return self;
}

FBKVOController差不多的初始化方法,没啥好说的。
_FBKVOSharedController的添加观察方法

- (void)observe:(id)object info:(nullable _FBKVOInfo *)info
{
  if (nil == info) {
    return;
  }

  pthread_mutex_lock(&_mutex);
  [_infos addObject:info];
  pthread_mutex_unlock(&_mutex);

  // 真正使用Cocoa的KVO添加观察,以info作为context参数
  [object addObserver:self forKeyPath:info->_keyPath options:info->_options context:(void *)info];

  if (info->_state == _FBKVOInfoStateInitial) {
    info->_state = _FBKVOInfoStateObserving;
  } else if (info->_state == _FBKVOInfoStateNotObserving) {
    // 当NSKeyValueObservingOptions属性中包含NSKeyValueObservingOptionInitial,
    // 并且在回调中取消了监听(调用unobserve方法)可能因为没有移除监听导致出现安全问题。
    [object removeObserver:self forKeyPath:info->_keyPath context:(void *)info];
  }
}

_FBKVOSharedController的KVO监听方法

// KVO监听方法
- (void)observeValueForKeyPath:(nullable NSString *)keyPath
                      ofObject:(nullable id)object
                        change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change
                       context:(nullable void *)context
{
  NSAssert(context, @"missing context keyPath:%@ object:%@ change:%@", keyPath, object, change);

  _FBKVOInfo *info;

  {
    // lookup context in registered infos, taking out a strong reference only if it exists
    pthread_mutex_lock(&_mutex);
    info = [_infos member:(__bridge id)context];
    pthread_mutex_unlock(&_mutex);
  }

  if (nil != info) {

    // take strong reference to controller
    FBKVOController *controller = info->_controller;
    if (nil != controller) {

      // take strong reference to observer
      id observer = controller.observer;
      if (nil != observer) {

        // dispatch custom block or action, fall back to default action
        if (info->_block) {
          NSDictionary<NSKeyValueChangeKey, id> *changeWithKeyPath = change;
          if (keyPath) {
            NSMutableDictionary<NSString *, id> *mChange = [NSMutableDictionary dictionaryWithObject:keyPath forKey:FBKVONotificationKeyPathKey];
            [mChange addEntriesFromDictionary:change];
            changeWithKeyPath = [mChange copy];
          }
          info->_block(observer, object, changeWithKeyPath);
        } else if (info->_action) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
          [observer performSelector:info->_action withObject:change withObject:object];
#pragma clang diagnostic pop
        } else {
          [observer observeValueForKeyPath:keyPath ofObject:object change:change context:info->_context];
        }
      }
    }
  }
}

在这个监听方法中,_FBKVOSharedController将接收到的更改信息重新封装后转发给_FBKVOInfo保存的观察者的对象。
从上面整个流程中,我们可以看到,观察者对象并没有真正地对被观察者对象进行任何监听,而是通过一个专门负责观察监听和转发信息的单例类来完成监听和发送通知。这样做的好处是为防止发送通知时观察者对象未移除监听并且已经不存在而导致应用Crash的情况提供了双层保障。因为该单例的在APP整个生命周期内都存在,所以最多是接收到信息并不进行其他操作。最后,我们再看下KVOController怎么取消观察:
FBKVOController取消观察方法

- (void)unobserve:(nullable id)object keyPath:(NSString *)keyPath
{
  _FBKVOInfo *info = [[_FBKVOInfo alloc] initWithController:self keyPath:keyPath];

  [self _unobserve:object info:info];
}

- (void)_unobserve:(id)object info:(_FBKVOInfo *)info
{
  pthread_mutex_lock(&_lock);

  NSMutableSet *infos = [_objectInfosMap objectForKey:object];

  _FBKVOInfo *registeredInfo = [infos member:info];

  if (nil != registeredInfo) {
    [infos removeObject:registeredInfo];

    if (0 == infos.count) {
      [_objectInfosMap removeObjectForKey:object];
    }
  }

  pthread_mutex_unlock(&_lock);

  [[_FBKVOSharedController sharedController] unobserve:object info:registeredInfo];
}

差不多就是添加观察的逆过程。
_FBKVOSharedController取消观察方法

- (void)unobserve:(id)object info:(nullable _FBKVOInfo *)info
{
  if (nil == info) {
    return;
  }

  pthread_mutex_lock(&_mutex);
  [_infos removeObject:info];
  pthread_mutex_unlock(&_mutex);

  if (info->_state == _FBKVOInfoStateObserving) {
    [object removeObserver:self forKeyPath:info->_keyPath context:(void *)info];
  }
  info->_state = _FBKVOInfoStateNotObserving;
}

- (void)unobserve:(id)object infos:(nullable NSSet<_FBKVOInfo *> *)infos
{
  if (0 == infos.count) {
    return;
  }

  pthread_mutex_lock(&_mutex);
  for (_FBKVOInfo *info in infos) {
    [_infos removeObject:info];
  }
  pthread_mutex_unlock(&_mutex);

  for (_FBKVOInfo *info in infos) {
    if (info->_state == _FBKVOInfoStateObserving) {
      [object removeObserver:self forKeyPath:info->_keyPath context:(void *)info];
    }
    info->_state = _FBKVOInfoStateNotObserving;
  }
}

这里取消对多个_FBKVOInfo的观察的-unobserve:infos:方法,不是遍历着调用-unobserve:info:,而是使用以上代码实现是为了减少互斥锁切换消耗的时间。
FBKVOController的dealloc方法

- (void)dealloc
{
  [self unobserveAll];
  pthread_mutex_destroy(&_lock);
}

FBKVOController对象销毁时会移除取消所有观察。

NSObject+FBKVOController

分类NSObject+FBKVOController进一步简化了我们的使用,这个分类提供了两个属性:

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

这两属性都是懒加载的形式创建,区别在于是否强引用被观察对象。

- (FBKVOController *)KVOController
{
  id controller = objc_getAssociatedObject(self, NSObjectKVOControllerKey);
  
  // lazily create the KVOController
  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);
}

通过动态绑定的方式保存这两属性。

Reference

https://github.com/facebook/KVOController

本文作者:西瓜冰soso
本文链接:https://www.jianshu.com/p/8deccb9c8398
温馨提示:
由于本文是原创文章,可能会有更新以及修正一些错误,因此转载请保留原出处,方便溯源,避免陈旧错误知识的误导。另外文章如有错误,请不吝指教,谢谢。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270