巧妙利用KVO实现精准的VC耗时检测

本文主要想分享一下我在根据这篇博客 《一种基于KVO的页面加载,渲染耗时监控方法》 实现 VC 耗时检测的过程中,产生并解决的疑惑,以及在该博客中发现的问题。本文最终输出了一个用于检测 VC 加载耗时的小工具:VCProfiler

本文的实现基于 @盗版五子棋博客,并在其基础之上进行了一点改进。最终的源代码发布在 Github 上:VCProfiler

背景

View Controller 的加载耗时优化是 App 性能优化中的一个重要环节,也是用户对于 App 流畅度的一个直观感知。

公司内部的性能监测工具中,也一直有对 VC 的加载耗时进行检测,但是之前的方法是在 load 方法中,用 Method Swizzle 的方式替换 UIViewControllerinitloadViewViewDidLoad 等方法,并在其中记录对应的时间戳,以此来计算 VC 加载过程中各个阶段的耗时。

这样的检测方式不是很精准,因为我们 hook 的是 UIViewController 的方法,如果我们在自己的 VC 中重写了对应的方法,并执行了一些耗时的操作,那么这些操作的时间就没有被计算进去。因此我们需要改进现有的检测方式,以便更加精准地测量 VC 加载过程中的耗时。

发现新大陆

在研究新方法的过程中,我发现了@盗版五子棋 写的一篇博客《一种基于KVO的页面加载,渲染耗时监控方法》,这篇博客中描述了一种很巧妙的检测 VC 耗时的思路。

这个思路利用了 KVO 在实现上的一些特性,简单来说,就是在 VC 创建的时候,故意触发 KVO,让 runtime 在我们自定义的 VC 对象上,再动态地包装一层 KVO 的子类,然后像之前一样 hook 这个子类 VC 的各个关键的方法,在其中进行时间的记录。这样一来,我们不再仅仅是检测父类的耗时,而是直接能够测量出我们自己 VC 本身的方法耗时。

我的疑惑

具体的一些原理和实现方式 @盗版五子棋 的博客中已经写的非常详细,我在此就不再赘述。但在读那篇博客的过程中,我心里一直有个疑问:

既然 hook 了父类,也就是 UIViewControllerinit 方法,那为什么不直接在父类的 init 方法中,获取子类 VC 的类型,然后替换子类 VC 的各个关键方法?

底下的评论中也有类似的疑问,但当时没有看到作者的明确回复,因此我就分别按照两种方法实现了一下 VC 的耗时检测,在实现的过程中,突然意识到了使用 KVO 的优势。

为何选择 KVO

直接 hook 各个 VC 类的方法,在一般的情况下也是能实现相应的耗时检测的,但是在各个 VC 之间存在继承关系的时候,会导致子类和父类重复 hook,甚至一不小心就会在 Method Swizzle 的时候把 IMP 换错掉。

假设我们有两个 VC,一个是 A,一个是 B,B 继承于 A。如果直接 hook 各个 VC 的方法,那么 A 和 B 都会被 hook 一次,当我们在加载 B 的时候,A、B 两个类的耗时检测都会被触发。而对于 KVO 的方法而言,由于每次 hook 的都是系统动态生成的 KVO 类,完全不会影响到原有的 A 和 B,所以 B 继承下来的 A 依然是纯净的 A,不会受到干扰。

这是我觉得 KVO 的方法带来的最大的好处。

发现的问题

问题1、不需要 hook init 方法

原文在提到 hook UIViewController 的 init 方法的时候,同时 hook 了三个 init 方法:

  • init
  • initWithCoder:
  • initWithNibName:bundle:

但其实 -init 方法是没有必要 hook 的,因为 UIViewController-init 方法最终也是调用 initWithNibName:bundle: 来进行初始化。如果同时 hook 了 -init 方法,会导致一次多余的操作。

因此,我们只需要 hook 两个初始化方法就够了:

  • initWithCoder:
  • initWithNibName:bundle:

问题2、KVORemover 对 VC 的引用要用 unsafe_unretained

另一个问题是,原文中提到,使用一个 KVO remover 在 dealloc 的时候移除 VC 的 KVO:

然后我们构建一个移除器,这个移除器弱引用保存了vc的实例和对应的keypath……
……
而在对应的移除器的dealloc方法里,我们把kvo监听给移除就可以了。

1
2
3
4
5
6
7
8
9
- (void)dealloc
{
#ifdef DEBUG
NSLog(@"WZQKVORemover called");
#endif
if (_obj) {
[_obj removeObserver:[WZQKVOObserverStub stub] forKeyPath:_keyPath];
}
}

实际实现中,使用 associate object 来移除 KVO 的正确性确实是有保障的,但是,如果 remover 保存的 VC 实例,也就是obj属性,使用了 weak 来修饰,那么在 remover 进入 -dealloc 方法的时候,上述代码中的 if (_obj) 判断将永远为 false。因为在 dealloc 的时候,weak 修饰的属性已经被置为 nil(但是在此处断点的话,Xcode 中仍然看得到 obj 原先的值),也就无法正常移除 KVO。

因此 remover 中引用的 VC 实例需要使用 unsafe_unretained 来修饰。

总结

利用 KVO 动态创建子类的方式来 hook VC,并计算耗时,这确实是一个很有意思的想法,同时也非常的简单有效。此外,由于所有的工作都可以在 UIViewController 的 Category 中实现,也使得这种方式基本没有侵入性。鉴于 @盗版五子棋 文章中的 Github 链接是无效的,我已经将实现好的代码发布在了 Github 上,有需要的同学可以自取:VCProfiler