前言

KVO对于每一名iOS开发者而言,想必再熟悉不过了。你一定能熟练的写出KVO的日常三连:addObserverobserveValueForKeyPathremoveObserver。可是,你真的了解KVO吗?例如:KVO的底层是如何实现的?使用KVO有哪些风险?KVOController又是什么?KVOController解决了原生KVO的哪些问题,又带来了怎样的风险?

接下来,我们不妨代入到具体的场景来看问题:

场景一:Person使用KVO观察Stock的属性price。(Stock的实例对象由Person初始化,并被Person对象强持有)

下面这些问题,你能快速准确的得出答案吗?

  1. 如果使用KVC修改price属性的值,Person可以观察到price的变化吗?

  2. 如果price属性是在Stock的分类Stock+Balance中声明的,Person可以观察到price的变化吗?

  3. 如果price不是Stock的一个属性,只是Stock中一个被声明成Public的变量,Person可以观察到price的变化吗?

  4. 添加观察后,对象stock的类还是Stock吗?

  5. 当price发生变化时,消息是如何通知给Person的?

另外:

  1. KVO在iOS10及以下会出现哪些崩溃?分别是如何触发的?

  2. KVO在iOS11以以上还会出现上述6中的这些崩溃吗?

  3. KVOController会出现上述的崩溃吗?它都做了哪些优化?

  4. KVOController又有哪些坑?

如果你能快速准确的回答出上面的9个问题,那么恭喜你,你已经对KVO了如指掌,这篇文章并不是为你准备的。但是如果你对于其中的部分问题心存疑惑,那么不妨带着问题阅读完下面的内容,相信你一定可以找到答案!

本文分别从KVO的使用、实现原理和隐患三方面来展开,并在介绍完原生KVO的基础上,从源码实现的角度,介绍开源库KVOController是如何解决原生隐患的,以及其不完美之处。最后结合日常开发中可能出现的实际情况,介绍了该如何安全的使用KVOController。
什么是KVO

KVO全称为Key-Value Observing,是一种观察键值变化的机制。

回到上述的场景一:Person类的实例对象,使用KVO观察Stock类的实例对象stock的属性price。

代码实现分为三步:

  • 添加观察

@implementation Person - (void)observeNTESStock { [self.stock addObserver:self forKeyPath:@"price" options:NSKeyValueObservingOptionNew context:nil]; } @end
  • 添加回调

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { // To Do Something··· }
  • 在必要时移除观察

[self.stock removeObserver:self forKeyPath:@"price"];
各位读者可以到苹果官方文档,看到更为详细的介绍,在这里就不作过多赘述了。
KVO的实现原理

KVO相关方法的实现在Foundation框架下,由于Foundation框架是闭源的,我们无法看到最真实的源码实现。但是可以借助GNUstep窥探Foundation源码的实现。

GNUstep是GNU开源计划的项目之一,它将Cocoa的OC库,重新开源实现了一遍,虽然GNUstep不是苹果官方源码,但还是具有一定的参考价值。

我们用简化后的图例,说明KVO的实现过程:

1. stock是Stock类的实例对象,即stock对象的isa,最初指向类对象Stock;

2. 当stock对象的price属性被KVO观察时,系统会通过runtime机制,动态地生成一个继承自Stock类的子类,并让stock对象的isa指向这个子类;

3. 在生成子类的同时,系统还会通过runtime机制,动态地为子类重写父类的一些方法,比如说:setPrice:setValue:forKeyclasssuperclass等;

setPrice:的伪代码如下:

- (void) setPrice: (int)price { // 修改price前的处理 [self willChangeValueForKey: key]; // 调用原本的setPrice方法 (*imp)(self, _cmd, val); // 修改price后的处理 [self didChangeValueForKey: key]; }

简单来说,KVO会在price的setter方法被调用时,添加一些额外的操作,来将这个变化通知给观察者。

同样setValue:forKey:方法的内部,也做了类似的处理,即在原本实现的基础上,增加向观察者同步变化的代码。

重写classsuperclass的代码如下:

- (Class) class { return class_getSuperclass(object_getClass(self)); }
- (Class) superclass { return class_getSuperclass(class_getSuperclass(object_getClass(self))); }

而重写这两个方法的目的,是为了隐藏KVO的内部实现过程。我们会发现,即使被观察对象的isa此时已经指向Stock的子类,而不是Stock,但我们通过class方法打印出的stock对象的类仍然是Stock。究其原因,就是系统重写了这两个方法,屏蔽了isa-swizzling的过程。

在介绍了主要流程后,我们用UML图,直观的介绍一下整个过程:

请特别注意:图示参考GNUstep Base中KVO的实现,具体实现可能与系统不完全一致,我们仅借此了解KVO的实现思路。图中的类名前缀GS,是GNUstep的缩写,并不是真实环境下的系统生成的类名!

  1. Person类使用KVO观察Stock类的price属性时,系统会通过Runtime动态生成继承自Stock类的子类NSKVONotifying_Stock,并重写了包括setPrice:setValue:forKey:classsuperclass在内的若干个方法。然后将Stock对象的isa,指向了新生成的NSKVONotifying_Stock。这个过程叫做isa-swizzling;

  2. 每个Stock对象还对应着一个GSKVOInfo对象,在GSKVOInfo对象中,每一个被观察的keyPath,对应着一个GSKVOPathInfo对象,GSKVOPathInfo内部又维护着一个observations数组,里面存储的是GSKVOObservation对象,GSKVOObservation对象与keyPath的观察者对象一一对应,并弱引用着观察者对象;

    我们接下来用更具象的例子,重新拆解上面的逻辑。

    比如说,对象B有两个属性name和price,对象A观察B的name,对象C观察B的name和price。

    在这个例子中,B对象关联着一个KVOInfo对象,KVOInfo对象中的Map储存着两条数据,Key分别是name和price。name对应的PathInfo中存储着一个由GSKVOObservation对象组成的数组,数组中包含两个GSKVOObservation对象,它们分别弱引用A对象和C对象。pirce对应的PathInfo中的数组包含一个弱引用C对象的GSKVOObservation对象。

    再回到我们的场景一,当price发生改变时,会先从stock对象关联的KVOInfo中找到price对应的PathInfo,再找到price属性的所有观察者,遍历并调用观察者observeValueForKeyPath:ofObject:change:context:回调通知属性的改变。

至此,我们通过GUNstep了解了KVO的实现流程。当然,GUNstep中的代码细节可能与系统不尽相同,但其实现思路还是能给我们学习KVO带来一定的启发。

KVO的隐患

在我们开发过程中,常常会遇到KVO使用不当造成的程序崩溃,这些崩溃往往是系统主动抛出的异常(NSException),下面总结了几种网上常见的几种异常类型,以及iOS10以后,此类异常是否还会出现。

异常类型

iOS10以后是否仍会出现异常

没有写观察回调方法

移除观察次数多余添加观察次数

被观察者释放前没有移除监听

不会

除了上面几种常见的崩溃外,KVO还可能出现由于多线程并发导致的崩溃。所以即便我们已经很小心谨慎,但还是难免问题的时有发生。鉴于此,我们的网易新闻工程引入了Facebook著名的开源框架KVOController。

KVOController

  • 回调更具可读性。同时支持Block、Delegate以及系统原生的observeValueForKeyPath:ofObject:change:context:

  • 更安全。不会因为移除观察而抛出异常;不会发生线程安全问题;

  • 使用更加便捷。隐式移除观察;

正是因为KVOController具备的这些特点,可以较好的解决原生KVO的几点隐患,让我们在使用KVOController进行键值观察时,避免掉一些不必要的问题。
KVOController的实现原理

仍然使用场景一来说明这个过程:这次改为Person使用FBKVO(为了区分库名与分类中命名为KVOController的对象,下文简称开源库为FBKVO)观察Stock的price属性。

代码实现:

@implementation Person - (void)observeNTESStock { [self.KVOController observe:self.stock keyPaths:@"price" options:NSKeyValueObservingOptionNew block:^(id _Nullable observer, id _Nonnull object, NSDictionary * _Nonnull change) { // To Do Something··· }]; } @end

实现简示:

  1. Person类的实例对象会先生成一个名为KVOController的FBKVOController类的实例对象,KVOController内生成一个info对象(info内包含被观察的键值price、观察回调block等信息);

  2. KVOController会将info和被观察对象stock作为参数一同传入到单例FBKVOSharedController中,然后使用FBKVOSharedController观察stock对象,并以info作为context;

这个过程对应的UML图如下:

在NSObject (FBKVOController)的分类中,有两个不同的名字的FBKVOControler类的属性:KVOControllerKVOControllerNonRetaining,它们之间有什么相同之处,又有什么区别呢?

  1. 相同之处:

无论是KVOController还是KVOControllerNonRetaining都会与 观察者对象 强关联:它们setAssociate的Policy都是:OBJC_ASSOCIATION_RETAIN_NONATOMIC

  1. 区别:

  • KVOController强持有 被观察 的对象;

  • KVOControllerNonRetaining弱持有 被观察 的对象;

NSObject(FBKVOController)会将被观察对象作为Key(对应上图的Stock对象),一组包含_FBKVOInfo对象的Set集合作为Value存入NSMapTable中(但是为了便于理解,上图中省略了NSMapTable),KVOControllerKVOControllerNonRetaining两个的区别就在于,在NSMapTable中,KVOController中Key的类型是StrongMemory,KVOControllerNonRetaining中Key的类型是WeakMemory。

KVOController是如何解决原生隐患的?

我们再介绍下FBKVO的优势是如何实现的:

  • Notification using blocks, custom actions, or NSKeyValueObserving callback.

    FBKVO是通过单例_FBKVOSharedController来观察对象,KVO的回调也在_FBKVOSharedController中实现,在其中会依次判断block、action是否存在,如都不存在会执行原生的回调,其核心代码如下:

- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary *)change context:(nullable void *)context { if (info->_block) { info->_block(observer, object, changeWithKeyPath); } else if (info->_action) { [observer performSelector:info->_action withObject:change withObject:object]; } else { [observer observeValueForKeyPath:keyPath ofObject:object change:change context:info->_context]; } }
  • No exceptions on observer removal.

    如一个观察者存在多次观察同一个对象的同一个键值的情况,FBKVO会在添加观察前进行判断,保证只会在第一次添加观察时生效;同理,在移除观察时,也会判断该键值是否被添加过观察,只有添加过观察才会执行removeObserver方法,从而避免了removeObserver崩溃的问题。

  • Implicit observer removal on controller dealloc.

    FBKVO会在FBKVOController对象dealloc方法中,隐式移除观察,其核心代码如下:

@FBKVOController - (void)dealloc { _FBKVOSharedController *shareController = [_FBKVOSharedController sharedController]; for (id object in _objectInfosMap) { NSSet *infos = [objectInfoMaps objectForKey:object]; // 遍历移除所有的观察 [shareController unobserve:object infos:infos]; } } @end
  • Thread-safety with special guards against observer resurrection

    原生KVO在多线程并发下,可能存在这样的问题:

    线程一:观察者正在执行dealloc方法,并且还未执行removeObserver

    线程二:被观察对象的键值发现改变,触发了KVO的observeValueForKeyPath回调,但此时观察者已经变成野指针了

    由于在FBKVO中观察者_FBKVOSharedController是单例,不存在释放的问题,也就避免了该问题的发生。

结合上面的介绍,我们再总结下FBKVO是如何解决原生KVO的隐患的:

异常类型

KVOController处理方式

KVOController处理方式

回调写在观察者_FBKVOSharedController中

移除观察次数多余添加观察次数

根据_FBKVOInfo的keyPath,保证同一个对象的同一个键值,只会被另一个对象观察一次,并且会在移除前判断该键值是否添加过观察,只有添加过观察才会移除

被观察者释放前没有移除监听

在FBKVOController对象dealloc时,会自动移除观察

另外,由于_FBKVOSharedController是单例,永远都不会被释放,也就不会出现由于多线程并发,导致的线程安全问题。

KVOController真的完美吗?

我们的工程在使用FBKVO后,虽然避免了很多由于使用不当造成的问题,但同时也进入了一些新的问题。我们总结了两种常见的崩溃:

  1. dealloc中第一次调用KVOControllerKVOControllerNonRetaining

- (void)dealloc { [self.KVOControllerNonRetaining unobserveAll]; }

FBKVO采用了懒加载的机制,会在我们第一次调用KVOControllerKVOControllerNonRetaining时生成FBKVOController类的实例对象。一种常见的问题场景是,我们添加观察的代码是根据条件生效的,但是移除代码是不作条件区分的,在这种情况下,就可能会发生,在dealloc中第一次调用KVOController的情况,这时当运行到弱引用观察者对象这一行代码时,就会发生崩溃。

Cannot form weak reference to instance (0x600000155760) of class GCPerson. It is possible that this object was over-released, or is in the process of deallocation.

2. 在iOS10及以下系统,如果在FBKVOController释放前,被观察者对象已经被释放了,会发生崩溃;

这个问题发生的根本原因还是上文介绍的原生KVO隐患的第三条:被观察者释放前没有移除监听。但FBKVO的优点之一就是会自动移除观察,为什么还会有此类崩溃呢?要回答这个问题,我们需要重新考虑FBKVO自动移除观察的时机。

FBKVO自动移除观察的时机,是在FBKVOController的dealloc方法中!

回到场景一的例子,这次改为Person分别使用KVOController(对应下图①),以及KVOControllerNonRetaining(对应下图②)观察Stock:

如果是①这种情况,Stock对象的释放强依赖于Person对象的释放和FBKVOController对象的释放,所以能够保证在Stock对象释放前,FBKVOController对象一定执行过观察移除的操作。

但如果是②这种情况,需要考虑对象的生命周期。由于成员变量的释放是在关联对象的释放之前,在Stock对象释放时,FBKVOController的对象还未执行过移除观察的操作,就会在iOS10及以下抛出异常。

如何正确的使用KVOController

既然KVOController使用不当也会有安全隐患,那我们就该了解如何安全的使用它。

下面我们罗列了几个不同的场景,从使用KVOController还是KVOControllerNonRetaining,是否需要手动移除观察两个角度,提供一些小小的建议,仅供大家参考。

场景

(均由A观察B)

KVOController?

KVOControllerNonRetaining?

是否需要手动移除观察

注意事项

KVOController

无需手动移除

①B的生命周期> A的生命周期:KVOController

②B的生命周期< A的生命周期:KVOControllerNonRetaining

①无需手动移除

②需要手动移除

使用KVOController会导致B的释放强依赖A,假如A是单例,那么B永远不会被释放

KVOControllerNonRetaining

需要手动移除

使用KVOController会导致A与B的循环依赖

总结一下就是:

  1. 使用KVOController无需手动移除观察,使用KVOControllerNonRetaining需要在适当的时候移除观察;

  2. 一旦使用KVOController,被观察者的生命周期会受到观察者生命周期的控制。如果两者本身的生命周期互不影响,建议按照实际情况,选择使用KVOController还是KVOControllerNonRetaining;

当然,真实的业务场景往往比这复杂的多。但万变不离其宗,了解了背后的原理后不妨画一画类图,理清每个类之间的关系后,再决定使用的方式。

写在最后的话

在阅读完全篇内容后,相信你对最初的几个问题都有了答案。即使我们对KVO又爱又恨,但我们在日常开发中却总是离不开它。深入了解它,以便从最大的程度上避免问题的发生,这才是我们最应该做的。

最后预祝各位朋友,新春快乐!在新的一年里,产品都能顺利上线没有八哥。