正确实现 KVO(第二版)

Mike Ash Friday Q&A 中文译文:正确实现 KVO(第二版)

作者 TommyWu
封面圖片: 正确实现 KVO(第二版)

译文 · 原文: Friday Q&A 2012-03-02: Key-Value Observing Done Right: Take 2 · 作者 Mike Ash

原文:https://www.mikeash.com/pyblog/friday-qa-2012-03-02-key-value-observing-done-right-take-2.html 发布:2012-03-02 作者:Mike Ash 译者:MiMo(mimo-v2.5-pro);代码块保留英文原样


我又回来了继续 Friday Q & A 系列,本周我打算跟进 Mike 在 2008 年发布的文章《Key-Value Observing Done Right》,其中首次推出了 KVO(键值观察)的替代方案 MAKVONotificationCenter。距那时已过去许久,这个实用代码库是时候更新了 —— 我完成了这项工作。在 Mike 和 Tony Xiao 的协助下,该库经历全面重构,现已成为具备趣味特性的现代代码库。本文将详解新增功能及其实现过程。

关于苹果努力的思考

键值观察自 Panther(10.3 系统)版本起就已存在,此后经历了三次公开更新:

  • Tiger 版本中新增了无序集合(sets)的可变类型。

  • Leopard 版本增加了 “初始观察” 与 “先前观察” 功能,并大幅改进了依赖键(dependent keys)的注册方式。

  • Lion 版本(及 iOS 5)新增了基于传入上下文(context)移除已注册观察的方法。(译注:此处提及的系统版本与 API 可能已过时,现代系统可能已变化)

在 Mike Ash 2008 年 Leopard 时代的文章中,他描述了三个主要问题。其中只有一个后来得到解决(观察移除时缺少上下文参数)。另外两个问题 —— 缺乏自定义选择子(selector)和上下文指针(context pointer)的无效性 —— 至今未解决,且此后又出现了更多问题:

  • 随 Snow Leopard 我们获得了代码块(blocks),但 KVO 完全忽略了它们。
  • KVO 无法处理从未注销观察的对象这一问题,甚至从未被质疑过。
  • NSNotificationCenter 获得了代码块和定向观察移除功能(从添加调用返回一个对象,可用于稍后移除同一观察)。KVO 也错过了后者。
  • KVO 从未具备 “移除该对象上所有观察者” 或 “移除此对象注册的所有观察” 的语义,这使得子类化和扩展更加痛苦。
  • KVO 没有语法支持一次性为多个键路径(key path)注册观察,也不支持同时观察多个对象。

Mike 的解决方案(MAKVONotificationCenter)对观察者和被观察者对象都施加了额外的 retain(保留),导致容易产生循环引用(retain cycles),并且无法在 dealloc 方法中移除观察。

以上所有问题都是苹果本应解决的,针对这些限制提交的缺陷报告(bugs)在两大操作系统版本中仅换来了一项改变。

在我看来,KVO(Key-Value Observing,键值观察)之所以受到如此少的关注,是因为它最初仅仅是作为 Cocoa 绑定(Cocoa bindings)底层机制的一部分而实现的。许多开发者(包括我自己)都认为,Cocoa 绑定在其预期目标 —— 简化用户界面与代码的连接 —— 上是一个彻底的失败。它在苹果内部使用的场景中仍然有效,这对他们来说这就足够了。

但对我来说却远远不够。

设计更好的 KVO
很长一段时间里,我一直使用 Jerry Krinock 改进的 MAKVONotificationCenter(参见 Mike 原文的评论),这个实现加入了基础的 block 支持,并增加了更具体和更宽松的观察者注销功能。但该实现有些不够优雅,且仍存在 retain cycle(引用循环)问题。最终我决定坐下来,认真打造一个更正式的解决方案。

在我看来,这个 “全新改进” 的 KVO 需要具备以下特性:

  • 必须解决 Mike 提出的三个问题,因此至少需基于他的原始实现进行改进。
  • 必须支持 block 块作为观察者回调。
  • 必须支持 “自动注销”,即在对象释放时移除其注册或被注册的所有观察。
  • 需要更便捷地注册多个键路径(key paths),以及在多个对象上注册键路径。
  • 不能保留(retain)观察者或被观察对象。

实现全新改进的 KVO
第一步是构建这个新版 KVO 的接口。我基于 MAKVONotificationCenter 做了如下扩展:

  • 一个用于关闭自动注销行为的开关。由于这里必然涉及一点运行时的技巧,我认为应该能随时按需将其关闭。

  • 一个 “观察” 协议。这将是一个通用对象,由观察注册方法返回,可用于移除特定的注册。可以查询该注册是否仍然有效。

  • 一个 “键路径集合” 协议。任何对象都可以实现一个方法,该方法返回一个可快速枚举(fast-enumerable)的对象,从而被用作 “键路径”。NSStringNSSetNSArrayNSOrderedSet 自动获得了此支持。

  • 一个表示单次 KVO 通知信息的对象,即 MAKVONotification。此对象将被传递给一个观察者块,并提供便捷的访问方式,以获取通常隐藏在变更字典(change dictionary)中的所有信息。

关于 NSObject 的分类,提供了基于选择子(selector)和块(block)的注册方法,以及配套的注销方法。我还添加了 “相反语序” 的方法,因为我不喜欢 KVO 的处理顺序。KVO 的原始方法采用[target addObserver:observer ...]的形式。对我来说,让被观察的目标去添加观察者是本末倒置的。因此我添加了[observer observeTarget:target ...]的方法,这些方法承担着艰巨而困难的任务 —— 需要将参数以相反的顺序传递给MAKVONotificationCenter的方法。

  • 在同一类别中,包含了基于观察者、目标、键路径、选择子(selector)任意组合(包括无参数)注销观察的方法。

基于 Mike 的实现,为其实现添加块(block)支持相当简单:

#if NS_BLOCKS_AVAILABLE
if (_selector)
#endif
((void (*)(id, SEL, NSString *, id, NSDictionary *, id))objc_msgSend)(_observer, _selector, keyPath, object, change, _userInfo);
#if NS_BLOCKS_AVAILABLE
else
{
MAKVONotification *notification = nil;
// Pass object instead of _target as the notification object so that
// array observations will work as expected.
notification = [[MAKVONotification alloc] initWithObserver:_observer object:object keyPath:keyPath change:change];
((void (^)(MAKVONotification *))_userInfo)(notification);
}
#endif

“注册观察” 的相关例程只是将一个 NULL 选择子(selector)和用户信息块(block)传递给辅助对象。MAKVONotification 的实现非常简单,只需向其传递适当数据并添加几个访问器从字典中提取数据即可。

处理 “键路径(key path)设置” 的代码也同样简单:

NSMutableSet *keyPaths = [NSMutableSet set];
for (NSString *path in [keyPath ma_keyPathsAsSetOfStrings])
[keyPaths addObject:path];
_MAKVONotificationHelper *helper = [[_MAKVONotificationHelper alloc] initWithObserver:observer object:target keyPaths:keyPaths selector:selector userInfo:userInfo options:options];

将键路径(key paths)复制到一个集合中,是为了避免要求原始键路径对象(或其返回的对象)具有持久且不可变的生命周期。例如,我为 NSArray 和 NSSet 提供的默认实现返回 self 作为” 字符串集合”,它们很容易是可变的(mutable),这会在辅助对象(helper object)需要注销观察时带来麻烦。

支持同时观察多个对象,是通过检测数组目标来实现的:

for (NSString *keyPath in _keyPaths)
{
if ([target isKindOfClass:[NSArray class]])
{
[target addObserver:self toObjectsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [target count])]
forKeyPath:keyPath options:options context:&MAKVONotificationHelperMagicContext];
}
else
[target addObserver:self forKeyPath:keyPath options:options context:&MAKVONotificationHelperMagicContext];
}

我改用关联对象(associated object)机制,将每个辅助对象同时添加到观察者和目标对象上,而不是使用一个中心字典来管理辅助对象 —— 这种做法既需要昂贵的字符串构建过程,对于 block 的支持也不可靠。

NSMutableSet *observerHelpers = nil;
if (_observer) {
@synchronized (_observer)
{
if (!(observerHelpers = objc_getAssociatedObject(_observer, &MAKVONotificationCenter_HelpersKey)))
objc_setAssociatedObject(_observer, &MAKVONotificationCenter_HelpersKey, observerHelpers = [NSMutableSet set], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
@synchronized (observerHelpers) { [observerHelpers addObject:self]; }
}

有些人可能反对这里使用的精简赋值语法,但它是有效的。@synchronized块使操作变得线程安全。现在,根据 target(目标对象)、observer(观察者对象)、key path(键路径)和 selector(选择子)的某种组合来移除特定的观察变得相对简单:

- (void)removeObserver:(id)observer object:(id)target keyPath:(id<MAKVOKeyPathSet>)keyPath selector:(SEL)selector
{
NSParameterAssert(observer || target); // at least one of observer or target must be non-nil
@autoreleasepool
{
NSMutableSet *observerHelpers = objc_getAssociatedObject(observer, &MAKVONotificationCenter_HelpersKey) ?: [NSMutableSet set],
*targetHelpers = objc_getAssociatedObject(target, &MAKVONotificationCenter_HelpersKey) ?: [NSMutableSet set],
*allHelpers = [NSMutableSet set],
*keyPaths = [NSMutableSet set];
for (NSString *path in [keyPath ma_keyPathsAsSetOfStrings])
[keyPaths addObject:path];
@synchronized (observerHelpers) { [allHelpers unionSet:observerHelpers]; }
@synchronized (targetHelpers) { [allHelpers unionSet:targetHelpers]; }
for (_MAKVONotificationHelper *helper in allHelpers)
{
if ((!observer || helper->_observer == observer) &&
(!target || helper->_target == target) &&
(!keyPath || [helper->_keyPaths isEqualToSet:keyPaths]) &&
(!selector || helper->_selector == selector))
{
[helper deregister];
}
}
}
}

首先,获取目标(target)和观察者(observer)各自的助手(helper)列表。我大量利用了向 nil 发送消息是安全的且始终返回 nil 这一特性,以避免额外的检查,代价是一两次小的内存分配。然后将这些键路径(key paths)列表构建为一个集合,并将目标和观察者的助手列表以线程安全的方式合并为一个集合。由于集合是唯一性集合,这个合并列表中不会有重复的助手。

现在循环遍历该列表中的所有助手,并检查每一个助手是否符合指定的标准 —— 它是否拥有正确的观察者、正确的目标、正确的键路径集合以及正确的选择子(selector)?(注意:代码检查的是键路径集合的精确相等,而不是被检查的助手是否观察了传入路径中的” 任意” 路径而非” 所有” 路径。我并没有觉得增加额外的检查值得花费额外的精力。)如果匹配,则注销(deregister)该助手,这会自行处理其线程安全问题。

对象释放时自动移除 KVO 通知

改进后的 KVO 最复杂的新特性是:即使在 -dealloc 方法中,也无需再特意寻找地方调用 [self removeAllObservations]。这在精神上与 ARC 无需调用 [super dealloc] 是一致的;我几乎不需要在观察者或目标对象释放前手动移除观察,而追踪何时该这样做可能变得相当繁琐。

最直接的在 dealloc 时总是注销观察的方法,是在 -dealloc 方法中执行。这意味着要么采用动态子类化,要么采用方法混淆(method swizzling)。由于 KVO 本身已经使用了动态子类化,那将是一层我尚未准备好深入探究的复杂性。因此我选择了方法混淆。

我的 MAKVONotificationCenter 在观察者和目标对象上都调用了 -_swizzleObjectClassIfNeeded:。该方法如下所示:(译注:此处的方法混淆(method swizzling)实现在现代 Objective-C Runtime 或系统版本中可能已受到限制或不再推荐使用)

- (void)_swizzleObjectClassIfNeeded:(id)object
{
if (!object)
return;
@synchronized (MAKVONotificationCenter_swizzledClasses)
{
Class class = [object class];//object_getClass(object);
if ([MAKVONotificationCenter_swizzledClasses containsObject:class])
return;
SEL deallocSel = NSSelectorFromString(@"dealloc");/*@selector(dealloc)*/
Method dealloc = class_getInstanceMethod(class, deallocSel);
IMP origImpl = method_getImplementation(dealloc),
newImpl = imp_implementationWithBlock(/* ... snip ... */
class_replaceMethod(class, deallocSel, newImpl, method_getTypeEncoding(dealloc));
[MAKVONotificationCenter_swizzledClasses addObject:class];
}
}

首先要做的是检查这个特定的类是否已经被交换过。我们需要的是对象在这里自认为的类,而不是它实际所属的类 —— 在 KVO(键值观察)下,这两者是不同的,而对 KVO 的动态子类进行方法交换(method swizzling)曾导致非常奇怪的行为。如果它已被交换过,则什么都不做。我不会检查给定类的超类或子类是否已被交换过,因为在类层次结构中多次交换是无害的,最坏的情况也就是导致一些多余的无用工作。

接下来,检索 -dealloc 的 SEL;在 ARC 模式下无法使用 @selector(dealloc),所以我不得不用 NSSelectorFromString() 来绕过编译器。我获取 dealloc 的实例方法及其原始实现。然后,我使用 Lion 系统中令人愉悦的 imp_implementationWithBlock() API 从一个 block 创建一个新的实现 —— 我将在下面描述该实现本身。最后,我用新实现替换了类上的原始 dealloc,并将该类添加到已交换类的列表中。

这里是新的 dealloc 实现本身:

(__bridge void *)^ (void *obj)
{
@autoreleasepool
{
for (_MAKVONotificationHelper *observation in [objc_getAssociatedObject((__bridge id)obj, &MAKVONotificationCenter_HelpersKey) copy])
{
// It's necessary to check the option here, as a particular
// observation may want manual deregistration while others
// on objects of the same class (or even the same object)
// don't.
if (!(observation->_options & MAKeyValueObservingOptionUnregisterManually))
[observation deregister];
}
}
((void (*)(void *, SEL))origImpl)(obj, deallocSel);
};

这里需要注意几个要点。首先,请注意我将 block 本身强制转换为 (__bridge void *) —— 这是为了让 ARC(自动引用计数)不对将 block(本质上是对象)转换为指针形式的操作发出警告,因为这是 C API 所要求的。

其次,该 block 的参数类型是 void * 而非 id。这看起来确实有悖直觉;毕竟传递给实现 block 的对象正是 self!答案再次与 ARC 相关。在 ARC 环境下,函数(block 也是一种函数)的所有参数在函数入口处都会被自动发送 retain(保留)消息。但由于此处是 dealloc(释放)方法的实现,这会导致尝试复活已释放对象(译注:即重新 retain 本应释放的对象),从而造成内存损坏,引发严重问题。将对象强制转换为普通指针可以避开 ARC 的自动管理。这比将其标记为 __unsafe_unretained(不安全无引用)更好,因为语义更清晰。

方法的整个实现体被包裹在 autorelease pool(自动释放池)中,因为这里可能涉及大量操作,最好不要让所有临时对象一直存留到下一个事件循环。

接下来,遍历为此对象注册的帮助器(helper)集合。请记住,这个集合包含两种帮助器:既包含将该对象作为观察者(observer)注册的帮助器,也包含将其作为目标(target)注册的帮助器 —— 因为一个帮助器总是会同时添加到它的观察者和目标中。我们会检查手动解注册选项,如果该选项未设置,就解注册该帮助器。此处无需考虑线程安全;正在被释放的对象不可能被多个线程同时使用,否则程序无论如何都会崩溃。

最后,我们调用通过 block 从其外围上下文捕获的 dealloc 原始实现。这正是使用 block 实现交换(swizzle)的妙处所在;无需在任何地方额外存储原始的 dealloc 实现,block 会为我们完成这件事。这也是交换子类实现是安全的原因。如果某个类确实被交换了两次(例如,一个实现了 dealloc 的类及其未实现 dealloc 的子类),最终只会是两个交换用的 block 被依次调用,然后才会执行正确的原始 dealloc

这个助手对象使用了 __unsafe_unretained 引用!为什么不用 __weak 的?你在打什么主意?!

如果你在阅读时对照代码,可能已注意到负责主要魔法的 _MAKVONotificationHelper 对象,对其 observer(观察者)和 target(被观察目标)持有 __unsafe_unretained 引用(ARC 修饰符,表示不持有对象、不自动置零)。使用更安全的、ARC 提供的零弱引用(Zeroing Weak References,简称 ZWRs)不是更合理吗?

嗯,并不合理。原因如下:

  • 在 OS X 10.6 / iOS 4.x 上,__weak 修饰符不可用。虽然代码中已经使用了其他破坏向后兼容性的 API(特别是 imp_implementationWithBlock()),但如果确实需要,绕开它们并不太难,而且还有其他原因让我们不使用零弱引用。

  • 在 OS X 上,你无法对一整部分类建立零弱引用,其中就包括 NSWindow。这意味着你无法将这类对象用作观察者,也无法将它们作为被观察的目标。(译注:此限制在现代系统中可能已部分移除或缓解)

  • __unsafe_unretained 在存在被交换的 -dealloc 方法时并不安全!无论是观察者还是目标对象,总会在其被销毁前移除辅助对象,此时另一个对象中的辅助对象也会一并移除。若调用方恰好传入了” 我想要手动取消注册” 的标记,则会回归原始 KVO 的语义:在对象仍注册有观察者的情况下销毁它本身就是非法 / 会导致崩溃的错误。若改用 __weak 引用,只会暂时掩盖问题,而无法真正解决。

谁还需要观察者?
在所有这些改动的开发过程中,Mike Ash 和 Tony Xiao 一直在紧密跟进。Tony 尤其在 NSObject 的分类中提出了一个特别巧妙的方法:

- (id<MAKVOObservation>)addObservationKeyPath:(id<MAKVOKeyPathSet>)keyPath options:(NSKeyValueObservingOptions)options block:(void (^)(MAKVONotification *notification))block;

这个方法从根本上质疑了 KVO(键值观察)是否真的需要一个” 观察者” 对象才能工作。采用基于块的回调方式时,唯一重要的对象就是被观察的对象。观察者是谁根本无关紧要,而 KVO 自身的内部要求通过辅助对象作为观察者就能满足。虽然在实际使用中观察者仍然很重要,因为代码块几乎必然会引用它,但从概念上并没有清晰的理由要求它必须存在。

Tony 还促成了用__unsafe_unretained替代__weak来修饰观察者和目标引用的讨论。他提供了很大帮助,在此特别感谢他,“谢谢你,Tony!”

总结

文件中的其余代码大多是模板代码且相当直观,因此我的讨论到此结束。但还需要提一下,我编写的单元测试对于确保代码正常工作起到了不可估量的作用。单元测试是好东西,各位!

本周的内容就到这里,三周后我将回归,届时 Mike 会有两篇关于从头重建 Cocoa 集合类的文章。一如既往,感谢阅读!


#Original (English)

Source: https://www.mikeash.com/pyblog/friday-qa-2012-03-02-key-value-observing-done-right-take-2.html

I’m back again for Friday Q&A, and this week I’m going to follow up on Mike’s 2008 article, Key-Value Observing Done Right, where he debuted a replacement for KVO, MAKVONotificationCenter. It’s been a long time since then, and it was high time such a useful piece of code got an update, which I gave it. With the help of Mike and Tony Xiao, it’s gotten a full overhaul and is now a modern code library with some fun features. In this article, I’m going to go through the new stuff and how it was done.

Ruminations on Apple’s effortsKey-Value Observing has been around since Panther (10.3), and since then it has received three publicly-visible updates:

  • In Tiger, mutation types for unordered collections (sets) was added.

  • In Leopard, “initial” and “prior” observations, along with a much-improved means for registering dependent keys, were added.

  • In Lion (and iOS 5), methods were added for removing registered observations based on a passed context.

In Mike Ash’s article in 2008, circa Leopard, he described three major issues. Of those, only one has been solved since (the missing context parameter to observation removal). The other two, a lack of custom selectors and the uselessness of the context pointer, remain unsolved, and more issues have arisen since:

  • With Snow Leopard, we got blocks, but KVO has completely ignored them.

  • KVO’s inability to deal with objects whose observations are never unregistered has never even been challenged.

  • NSNotificationCenter got blocks and targeted observation removal (returning an object from the add call that could be used to remove that same observation later). KVO also missed that latter.

  • KVO never had a “remove all observers on this object” or “remove all observations registered by this object” semantic, making subclassing and extension that much more painful.

  • KVO has no syntax for registering more than one key path at a time for observation. Or for observing more than one object at a time.

  • Mike’s solution (MAKVONotificationCenter) imposed an extra retain on both the observing and observed objects, leading to a tendency towards retain cycles and an inability to remove observations in a dealloc method.

All of these are things Apple should have dealt with, and bugs filed against all of these limitations resulted in only one change in two major OS releases.

It is my personal opinion that KVO received so little attention because it was originally implemented as nothing more than a piece of the puzzle behind Cooca bindings. Cocoa bindings have been, in the opinion of many (including myself), a dismal failure in their intended purpose of making UI easy to wire up to code. It still works for the things Apple uses it for, and that’s good enough for them.

That wasn’t good enough for me, though.

Designing a better KVOFor a long time, I’d used MAKVONotificationCenter with some extra tweaks by the inspired Jerry Krinock (see the comments on Mike’s original article and myself which added basic blocks support as well as both less and more specific observer unregistration. But that implementation was a bit inelegant and still suffered from the retain cycle issue. I finally decided to sit down and hack out something a little more formal.

The “new and improved” KVO needed the following features, in my eyes:

  • It had to solve all three of Mike’s listed issues, and therefore had to be based on his original implementation to at least some extent.

  • It needed to support blocks as observer callbacks.

  • It needed to support “automatic deregistration”, i.e. removing any observations an object had registered, or had registered on it, during deallocation.

  • It needed to be much easier to register multiple key paths, and key paths on multiple objects.

  • It needed not to retain observer or observee.

Implementing the new and improved KVOThe first step was to build the interface for this new version of KVO. Starting from MAKVONotificationCenter, I added;

  • A flag to turn off the automatic deregistration behavior. Since a tiny bit of runtime trickery was definitely going to be involved in that, I figured it’d be best to be able to shut it off on demand.

  • A protocol for an “observation”. This would be a generic object returned by observation registration methods that could be used to remove that specific registration. It could be queried as to whether the registration was still valid.

  • A protocol for a “key path set”. Any object could implement a method that returned a fast-enumerable object and thus be used as a “key path”. NSString, NSSet, NSArray, and NSOrderedSet got this support for free.

  • An object that represented the information for a single KVO notification, an MAKVONotification. This object would be passed to an observation block and provide convenient access to everything normally hidden in a change dictionary.

  • The category on NSObject to provide the selector- and block-based registration methods, as well as the complementary unregistration methods. I also put in “opposite sentiment” methods, since I don’t like KVO’s order of doing things. KVO’s original methods go “[target addObserver

    …]”. To me, telling the target of the observation to add an observer is backwards. So I added “[observer observeTarget
    …]” methods, which had the daunting and difficult task of sending the parameters to MAKVONoticationCenter’s methods in the opposite order.

The category on NSObject to provide the selector- and block-based registration methods, as well as the complementary unregistration methods. I also put in “opposite sentiment” methods, since I don’t like KVO’s order of doing things.

KVO’s original methods go “[target addObserver

…]”. To me, telling the target of the observation to add an observer is backwards. So I added “[observer observeTarget
…]” methods, which had the daunting and difficult task of sending the parameters to MAKVONoticationCenter’s methods in the opposite order.

  • In that same category, methods for unregistering observations based on any combination of observer, target, key path, selector, including none.

Starting from Mike’s implementation, adding blocks support was quite trivial:

#if NS_BLOCKS_AVAILABLE
if (_selector)
#endif
((void (*)(id, SEL, NSString *, id, NSDictionary *, id))objc_msgSend)(_observer, _selector, keyPath, object, change, _userInfo);
#if NS_BLOCKS_AVAILABLE
else
{
MAKVONotification *notification = nil;
// Pass object instead of _target as the notification object so that
// array observations will work as expected.
notification = [[MAKVONotification alloc] initWithObserver:_observer object:object keyPath:keyPath change:change];
((void (^)(MAKVONotification *))_userInfo)(notification);
}
#endif

The appropriate “register an observervation” routines simply passed the helper object a NULL selector and the block as the user info. MAKVONotification’s implementation was very trivial, just passing it the appropriate data and adding a few accessors to grab the data from the dictionary.

The code for handling “key path sets” was also pretty trivial:

NSMutableSet *keyPaths = [NSMutableSet set];
for (NSString *path in [keyPath ma_keyPathsAsSetOfStrings])
[keyPaths addObject:path];
_MAKVONotificationHelper *helper = [[_MAKVONotificationHelper alloc] initWithObserver:observer object:target keyPaths:keyPaths selector:selector userInfo:userInfo options:options];

The key paths are copied into a set to avoid the requirement that the original key paths object (or the object it returns) have a persistent and immutable lifetime. The default implementations I provided for NSArray and NSSet, for example, return self as the “set of strings”, and they could easily be mutable, which would be trouble later when the helper object needed to unregister its observation.

Making it possible to observe more than one object at a time was done by detecting an array target:

for (NSString *keyPath in _keyPaths)
{
if ([target isKindOfClass:[NSArray class]])
{
[target addObserver:self toObjectsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, [target count])]
forKeyPath:keyPath options:options context:&MAKVONotificationHelperMagicContext];
}
else
[target addObserver:self forKeyPath:keyPath options:options context:&MAKVONotificationHelperMagicContext];
}

Rather than using a central dictionary of helpers to track observations, which required expensive string building and was unreliable in any case for blocks, I added each helper object to both the observer and target as an associated object:

NSMutableSet *observerHelpers = nil;
if (_observer) {
@synchronized (_observer)
{
if (!(observerHelpers = objc_getAssociatedObject(_observer, &MAKVONotificationCenter_HelpersKey)))
objc_setAssociatedObject(_observer, &MAKVONotificationCenter_HelpersKey, observerHelpers = [NSMutableSet set], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
@synchronized (observerHelpers) { [observerHelpers addObject:self]; }
}

Some may object to the condensed value-of-assignment syntax I used here, but it works. The @synchronized blocks make the operations thread-safe. Now removing a particular observation based on some combination of target, observer, key path, and selector becomes relatively simple:

- (void)removeObserver:(id)observer object:(id)target keyPath:(id<MAKVOKeyPathSet>)keyPath selector:(SEL)selector
{
NSParameterAssert(observer || target); // at least one of observer or target must be non-nil
@autoreleasepool
{
NSMutableSet *observerHelpers = objc_getAssociatedObject(observer, &MAKVONotificationCenter_HelpersKey) ?: [NSMutableSet set],
*targetHelpers = objc_getAssociatedObject(target, &MAKVONotificationCenter_HelpersKey) ?: [NSMutableSet set],
*allHelpers = [NSMutableSet set],
*keyPaths = [NSMutableSet set];
for (NSString *path in [keyPath ma_keyPathsAsSetOfStrings])
[keyPaths addObject:path];
@synchronized (observerHelpers) { [allHelpers unionSet:observerHelpers]; }
@synchronized (targetHelpers) { [allHelpers unionSet:targetHelpers]; }
for (_MAKVONotificationHelper *helper in allHelpers)
{
if ((!observer || helper->_observer == observer) &&
(!target || helper->_target == target) &&
(!keyPath || [helper->_keyPaths isEqualToSet:keyPaths]) &&
(!selector || helper->_selector == selector))
{
[helper deregister];
}
}
}
}

First, get the list of helpers on both target and observer. I make heavy use of the fact that sending messages to nil is harmless and always returns nil to avoid extra checks, at the cost of a small allocation or two. Then build the list of key paths into a set, and combine the target and observer’s helper lists as a single set, thread-safely. Because sets are unique collections, there will be no duplicate helpers in this combined list.

Now loop over all the helpers in that list and check whether each one matches the criteria specified - does it have the right observer, the right target, the right set of key paths, and the right selector? (Note: The code checks for exact equality of key path sets, rather than whether the helper being checked observes “any” of the paths passed instead of “all” of them. It didn’t strike me that it’d be worth the extra effort to add the extra checks.) If so, deregister that helper, which will handle its own thread safety.

Automatically removing KVO notifications at object deallocationThe most complicated new feature of this improved KVO was no longer having to find a place to call [self removeAllObservations], even if that place was -dealloc. This was in spirit with ARC’s lack of need to call [super dealloc]; I almost never need to remove an observation before the observer or target is deallocated, and keeping track of when to do so can get pretty arduous.

The most obvious way to always unregister observations at dealloc is to do it from the -dealloc method. That meant either dynamic subclassing or swizzling. Since KVO already does dynamic subclassing, that was a layer of complexity I wasn’t prepared to delve into. I chose method swizzling.

My MAKVONotificationCenter calls -_swizzleObjectClassIfNeeded: on both the observer and target objects. It looks like this:

- (void)_swizzleObjectClassIfNeeded:(id)object
{
if (!object)
return;
@synchronized (MAKVONotificationCenter_swizzledClasses)
{
Class class = [object class];//object_getClass(object);
if ([MAKVONotificationCenter_swizzledClasses containsObject:class])
return;
SEL deallocSel = NSSelectorFromString(@"dealloc");/*@selector(dealloc)*/
Method dealloc = class_getInstanceMethod(class, deallocSel);
IMP origImpl = method_getImplementation(dealloc),
newImpl = imp_implementationWithBlock(/* ... snip ... */
class_replaceMethod(class, deallocSel, newImpl, method_getTypeEncoding(dealloc));
[MAKVONotificationCenter_swizzledClasses addObject:class];
}
}

The first thing to do is check whether this particular class has been swizzled before. We want the class the object thinks it is here, not the class it actually is - with KVO, these differ, and swizzling KVO’s dynamic subclasses turned out to cause very strange behaviors. If it has been swizzled, do nothing. I don’t check whether a superclass or subclass of the given class has been swizzled before, because doing it multiple times in the hierarchy is harmless, resulting in a little extra useless work at worst.

Next, retrieve the SEL for -dealloc; it’s not possible to use @selector(dealloc) in ARC mode, so I have to cheat the compiler with NSSelectorFromString(). I get the instance method for dealloc, and its original implementation. Then I create a new implementation from a block using Lion’s delightful imp_implementationWithBlock() API - I’ll describe the implementation itself below. Finally, I replace the the original dealloc on the class with the new one and add the class to the list of swizzled classes.

Here’s the new dealloc implementation itself:

(__bridge void *)^ (void *obj)
{
@autoreleasepool
{
for (_MAKVONotificationHelper *observation in [objc_getAssociatedObject((__bridge id)obj, &MAKVONotificationCenter_HelpersKey) copy])
{
// It's necessary to check the option here, as a particular
// observation may want manual deregistration while others
// on objects of the same class (or even the same object)
// don't.
if (!(observation->_options & MAKeyValueObservingOptionUnregisterManually))
[observation deregister];
}
}
((void (*)(void *, SEL))origImpl)(obj, deallocSel);
};

There’s quite a bit to notice here. First, notice I cast the block itself to a (__bridge void *) - this is to keep ARC quiet about casting a block (which is an object) to its pointer form, as required by the C API.

Next, the block takes as its parameter a void pointer, rather than an id. This certainly seems counterintuitive; after all, the object passed to an implementation block is self! Once again, ARC has the answer. Under ARC, all parameters to a function (which a block is) are automatically sent a retain message when the function is entered. But since this is the implementation of a dealloc method, the result is attempted object resurrection, which corrupts memory. Much hilarity ensues. Forcing the object to be a plain pointer hides it from ARC. This was better than marking it as __unsafe_unretained because the semantics are clearer.

The entire body of the method is wrapped in an autorelease pool, as there’s the potential for quite a lot of work to happen here and it’d be nice not to have all that sitting around until the next event loop.

Next, the set of helpers registered for this object is looped over. Remember, this set will include both helpers that have the object as an observer and those that have it as a target, since a helper is always added to both its observer and target. We check for the manual unregistration option, and if it’s unset, unregister the helper. No thread safety is needed here; an object that is being deallocated can not be being used from multiple threads, or the program is already going to crash no matter what we do.

Finally, we call through to the original implementation of dealloc, which the block captured from its surrounding context. This is the beauty of using a block implementation; there’s no need to stash the original dealloc anywhere at all; the block will do it for us. This is also why it’s safe to swizzle subclasses. If somehow a class does get swizzled twice (say, a class that has a dealloc and then a subclass of it that doesn’t), all that will happen is that two swizzled blocks will be called, followed by the correct original dealloc.

The helper is using __unsafe_unretained references! Why not __weak ones? What are you trying to pull here!?If you’ve been looking at the code while reading this, you may have noticed that the _MAKVONotificationHelper object that does most of the magic holds __unsafe_unretained references to its observer and target. Wouldn’t it make more sense to use the safer zeroing weak references ARC so helpfully provides?

Well, no. Here’s why:

  • __weak isn’t available on OS X 10.6 / iOS 4.x. While there are already other APIs in use (particularly imp_implementationWithBlock()) that break backwards compatibility, they’re not that hard to work around if you want to, and there are other reasons not to use ZWRs.

  • You can’t take ZWRs to a whole list of classes on OS X, including NSWindow. That would mean you couldn’t use such objects as observers or make them the target of observations.

  • __unsafe_unretained is not unsafe in the presence of a swizzled -dealloc method! Either observer or target will always remove the helper before it goes away, at which point it’s gone from the other as well. And if the caller happened to pass the “I want to unregister manually” flag, the semantics of original KVO return: It was already illegal/a crashing bug to deallocate an object with registered observers. Making these proper __weak references would only mask the problem temporarily, not solve it.

Who needs an observer at all?During the development of all these changes, Mike Ash and Tony Xiao were following along fairly closely. Tony in particular came up with a particularly clever method in the NSObject category:

- (id<MAKVOObservation>)addObservationKeyPath:(id<MAKVOKeyPathSet>)keyPath options:(NSKeyValueObservingOptions)options block:(void (^)(MAKVONotification *notification))block;

This method questions the basic assumption that there needs to be an “observer” object at all for KVO to work! With a block-based callback, the only important object is the one being observed. Who the observer is doesn’t matter at all, and KVO’s own internal requirements are satisfied with the helper object as the observer. While in practice the observer is still important, since the block will almost certainly reference it, there’s no conceptually clear reason to have it there.

Tony was also responsible for the discussion that led to __weak observer and target references being replaced with __unsafe_unretained ones. He was a big help, and here’s my shout out to him saying, “Thanks, Tony!”

ConclusionThe rest of the code in the file is pretty much boilerplate stuff and is fairly straightforward, so that wraps up my discussion, other than to mention that the unit tests I wrote were invaluable in making sure the code worked. Unit tests are a Good Thing, people!

That’s all for this week for me, I’ll be back in three weeks after Mike’s two-parter on rebuilding Cocoa collection classes from scratch. As always, thanks for reading!