iOS 9升级:为什么我的单元测试停顿了?

关于为什么从iOS 8升级到9 SDK导致测试缓慢的调查。

艾伦•芬伯格 Alan Fineberg)撰写

抬起头,我们已经搬家了! 如果您想继续了解Square的最新技术内容,请访问我们的新家https://developer.squareup.com/blog

将Square Register从iOS 8升级到iOS 9时,我们发现单元测试行为发生了难以诊断的变化。 我们调查了此问题,并追溯了我们的步骤以找到根本原因。 最终,我们发现潜在的问题不仅是Square特有的,而且可能会影响任何iOS开发人员。

现在测试缓慢,不可靠

iOS版Square Register已有将近7年的历史,它是一个庞大的应用程序,其中包含超过一百万行代码。 从iOS 8或iOS 9迁移基本SDK似乎很简单,但这种变化的规模却带来了许多挑战。

这些挑战中最主要的是应用程序的测试套件。 实施更改后不久,我们注意到我们的单元测试套件(每个构建都运行了数千个测试)已经大大减慢了速度,最终将失败。 这些测试失败在单独运行时从未发生,但通常在较大的测试套件中失败,并且并非总是以相同的方式失败。 由于没有一个单一的测试有缺陷,所以一个合理的解释表明共享测试环境受到污染。

这是主队列!

下一步是减少草垛的大小。 我们开始注释测试以缩小范围。 在文件变得乏味之后逐一注释掉-我们提供了使生活更轻松的类别技巧(下)。 不幸的是,不存在一种更好的方式来运行与iOS中的命名模式匹配的测试子集:

令人沮丧的是,此Xcode功能仅启动了Instruments的惰性实例,而不是附加到进程。

相反,要描述测试运行,我们必须:

  • 在代码中设置一个断点,
  • 运行测试,
  • 发射仪器,
  • 通过进程ID小心地附加到正确的进程(下拉列表中可能有重复项)
  • 然后点击“记录”。

泄漏工具不认为NSManagedObjectContext(下面的屏幕快照中为SDManagedObjectContext)是泄漏,因为它最终被拆除了。 但是它确实报告了保留/释放对的列表。 因此,我们查看了单个对象超过6000个保留/发布的列表。 当时,我们没有发现问题的根源,因此它又回到了通过lldb进行调试的过程。

经过大量调试并打印出[context keepCount]后,我们发现上下文的save:方法添加了一个保留,并且,如果我们注释掉了这一行,则按预期方式清理了上下文(!),并且泄漏为堵塞。

我回到了Instruments,用此新信息对它的报告进行了交叉检查,并且肯定的是:

罗,未配对的保留。

导致泄漏的保持架的详细视图:

Instruments的详细信息视图:堆栈跟踪包括save:,CFRetain和_registerAyncReferenceCallback(sic)

诊断

为了调试,我覆盖了[NSManagedObjectContext(_NSCoreDataSPI)performWithOptions:andBlock]并设置了一个符号断点(基于上面的Instruments输出)。 我的汇编语言不太流利,但是我可以通过阅读以下注释进行跟进:

在第91行,我们增加了保留人数。 在第132行,我们将异步工作添加到队列中。

我们推断出,在调用save:时,上下文正在排队一些工作并同时保留其自身(逐步执行并调用p(int)[$ esi keepCount]确认了这一点)。 这项工作还减少了保留计数,但是由于它保留在队列中,因此自保留上下文永远存在。 我们还使用sqlite3和.dump逐步进行了验证,以确保如果排队的工作从未执行过,则不会丢失任何数据。

考虑到我们最终了解了正在发生的情况,我们退出了创建一个小型测试项目,以隔离我们的怀疑并确认该问题存在于一个更简单的示例中。 已确认! 该示例项目也出现了相同的问题(尝试一下!)。

使用符号断点确定Xcode 6 v.7中的行为差异

我们使用此测试项目比较了iOS 8和iOS 9。 很明显:保存:在iOS8中不是异步的!**

**让我限定一下:(据我从程序集得知)iOS 8保存:在其“标准代码路径”中是同步的,并且没有使dispatch_async入队。 从iOS 9开始,它现在是异步的,并立即调用dispatch_async作为其“标准代码路径”的一部分。我鼓励您检查示例项目并将其在Xcode 6与Xcode 7中进行比较(将主分支与xcode-6进行比较)。

治疗

不幸的是,使上下文的异步工作脱离mainQueue的唯一方法是旋转运行循环。 如果放任不管,操作将在后台建立,并且当runloop最终运行时,上下文将使操作队列饿死。

因此,我们的解决方法是将类似以下的代码添加到tearDown中:

  [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.001]]; 

此后,某些上下文仍然泄漏,但是这些是来自服务的合法内存泄漏,并未完全拆除。

确保我们所有的t都被点缀,然后在上下文的dealloc断点处添加条件断点,并在有2个以上的实例徘徊时让其播放并停止播放:

播放声音并使用条件意味着我们可以让测试运行并在堆积过多实例时得到提醒。

尽管整个测试套件需要花费一些时间,但是由于我们执行了实例跟踪,因此这是一个机械过程,可以跟踪剩余的泄漏并验证没有泄漏。

成功!

跟踪剩余的泄漏并在tearDown处添加一些runloop旋转后,runloop变得清晰,测试速度提高了28%。 尽管调试花费了大量时间,但希望以后可以通过更短,更稳定的测试运行来弥补这段时间的损失,更不用说我们得到了一篇博客文章。

感谢 Kyle Van Essen Eric Muller Justin Martin 的帮助!