应用崩溃时有发生。崩溃会打断用户当前的工作流,导致数据的丢失,还会扰乱应用在后台的操作。对于开发者而言,那些最难修复的崩溃往往是那些难以重现,甚至难以检测到的崩溃。
我最近发现并修复了一个 bug ,而它正是导致 Castro 反复出现难以检测的崩溃的罪魁祸首(译者注: Castro 是原文作者开发的一款应用),我将处理这个问题的过程分享给大家并附上一些我的建议,或许能帮助你定位类似的问题。
我和 Oisin 在九月份发布了 Castro 2.1 版本,那之后不久,从 iTunes Connect 上报的 Castro 崩溃数量便急剧上升。
如果你是一名应用开发者并且登陆了开发者账号, Xcode 允许你检视 Apple 官方从你的当前帐号下的 app 用户那收集到的崩溃日志。这项功能在 Window 导航栏下的 Organizer 窗口中的 Crashes 标签中。你可以选择特定的应用版本, Xcode 会下载 Apple 从用户手上收集到的崩溃日志,前提是用户同意将信息分享给开发者。
我发现 Xcode 的这个功能也非常容易崩溃,尤其是当点击崩溃日志中线程的详情按钮进行切换的时候。一个简便的解决方案是,在列表中右键选中相应的崩溃,并选择在 Finder 中显示。如果你要研究研究包中的内容,你可以把这些崩溃日志简单地当作文本文件。
许多不同的代码路径都触发了这个崩溃,但崩溃最终都指向一个数据库查询方法。
异常码 0xdead10cc 出现意味着应用程序因为在后台操作系统资源(譬如通讯录数据库)而被 iOS 系统终止。
这时候我意识到 iOS 强制关闭我的应用是因为我违反了系统规则,而不是说我的代码出了什么小问题。但是, Castro 并没有用到通讯录数据库或是任何我能想到的类似的系统资源。我还怀疑原因是不是应用在后台长时间运行而没有取消,但我也发现日志中有一些应用仅仅运行了两秒钟就发生崩溃的记录。
经过推理,我最终将可能原因定位到我们的数据库相关的 SQLite 文件上,因为绝大部分的堆栈信息都显示崩溃是在操作数据库的时候发生的。但 2.1 版本上的哪个改动,突然就引起了这个崩溃呢?
Castro 2.1 版本引入了对 iMessage 的支持来让用户轻松地分享他们最近听过的播客。为了让 message app 能够访问数据库,我们将数据库逻辑移动到了应用共享容器中。
我猜想文件的锁机制对在共享区域的文件有更严格的要求。或许当 iOS 准备挂起一个应用的时候,系统会检查这个应用是否正在使用一些可能被其他进程使用的文件,如果有, iOS 就会直接终止这个应用。这看起来是个有理有据的解释。
如何重现正在修复的崩溃是锻炼开发者的绝佳实践。这可能涉及到临时改写一部分代码来刻意提高崩溃出现的可能性。如果我们能稳定地看到崩溃的发生,就能够逐步的验证我们的猜测,同时我们测试修复的正确性就有了参考。而与之对应的另一个方法是盲目地进行修复,发布版本,然后等着看是否会有崩溃上报。有时候,只有盲目修复一条路可走,但这条路枯燥乏味,而且到头来应用依然不断地在用户侧发生崩溃。
而这个崩溃就非常不容易重现,我觉得这里批评一下 iOS 的开发环境并不过分。操作系统粗野地执行着自己的规则,大部分时候,这样做很好,因为这样可以提高安全性,延长电池寿命和稳定性。但在这样的大环境下进行应用的测试和修复,就增加了不必要的麻烦。这些规则的变化悄无声息,而要人为地在应用周期可能出现的每一个状态下进行测试非常不方便,有时候甚至根本无法完成。
为了刻意重现崩溃dead10cc:
我在applicationDidEnterBackground方法中做了几百次数据库查询操作。
在我的 Mac 上打开 Console 应用,并过滤信息,仅显示 Castro 相关。
我从 Xcode 上运行安装应用,但以直接点击应用图标的形式打开应用。
我按 Home 键将应用退到后台,并立刻打开 Pokémon Go ,以期系统会由于内存吃紧而挂起 Castro 。
在重复了几次上述步骤之后,我发现 Console 中已经出现了我尝试重现的崩溃信息。调用堆栈看起来和真实场景的崩溃一模一样,现在我就非常自信地知道崩溃的原因何在了。
接着我发现并修复了项目中一个在后台访问数据库触发的错误:在网络状况变化时,应用会在没有创建 background task 的情况下进行数据库刷新操作。如果在刷新操作尚未完成时应用进入挂起状态, iOS 就会强制终止应用运行。
我还要再分享一件让我惊讶的事情。在 Castro 2 版本,我们在有新剧集发布后通知客户端,从而客户端会刷新用户的推送内容。当 iOS 将这条消息转发给我们的应用的时候,它会调用didReceiveRemoteNotification方法,而在这个方法中,我们有一个 completion block 的回调。官方文档中提到:
你的应用至多只有三十秒时间来处理推送消息,而后调用相应的 completion handler block 。实际开发中,一旦你处理完推送,就应该尽快地调用这个 handler block 。系统会记录下应用在后台所耗费的时间、电量、以及数据处理所消耗的流量。
令人抓狂的点在于:就像我在前文中提到的, Castro 有时候运行不到两秒就被终止了,我从调用栈信息明确看到这时候还没有调用 completion block ,所以说,尽管文档写着说应用可以安安心心的运行个 30 秒,但我的应用还是被挂起了。
正如我所怀疑的那样,dead10cc问题源于文件上锁:
“真正触发崩溃的原因是, iOS 在挂起你的应用的时候,检查到在你的应用容器中有一个被锁住的文件(本例中就是一个 SQLite 锁)。这个检查的目的在于管理和减少应用内的数据损坏。本例的问题在于,一个文件处于被锁状态,意味着它很可能正在被修改,处于一个数据不连贯的状态。也就是说,一个应用对一个文件加锁的唯一理由就是它接下来要对这个文件进行一系列的读/写操作,并且需要保证这些写操作能够顺利完成而不被其他的写操作插队。简单的说就是,一个文件还处于被锁状态意味着对应的应用还没有完成数据的写入,而处于这种状态下的文件可能会有以下的几个问题:
如果应用在挂起状态被强制终止,那些“应该却还未被写入”的数据便不会被写入,导致数据损坏。
如果这个文件在两个应用之间共享,此时第二个应用/应用扩展开始运行,那这个应用将要么被迫解除这个锁,并试图将文件恢复到一个稳定连续的状态,而让第一个应用继续处在一个不连续的状态,要么就完全忽略这个共享文件。”
至于那 30 秒的后台运行时间:
...正确的做法应该是彻底规避这个问题 - 如果你不能在 delegate 方法中完成所有的操作(译者注:这里的 delegate 方法即指didReceiveRemoteNotification方法),那么就直接另起一个 background task ,这样 iOS 在(completion block 中)挂起你的应用之前就会先通知你...
另外, Kevin 也建议应用进入后台的时候应该关闭数据库,以此来确保应用已经完成了数据刷新并能更准确的找到少见的 bug :
将关闭文件作为一项常规操作,从而将一些隐蔽而奇怪的 bug (应用在后台有时不太对劲),转化成稳定出现的问题(应用在后台无法正常运行),这时候你就可以直接去定位问题了。
这看起来是个明智的做法;我从没想过要在应用进入后台的时候关闭一部分功能,但其实这么做非常合理。在 Castro 的下一个版本更新中,我将会尝试在退后台时关闭数据库。
通过把任何会在后台持续运行的操作放到一系列 background task 中,我成功地在 beta 版本中解决了这一问题。我们会尽快发布包含这个修复的更新。
以下是我所学到的东西的小小总结:
Apple 官方会上报一些其他服务不会上报的崩溃。所以除了外部服务之外,也要查看在 iTunes Connect 和 Xcode 上面的崩溃信息。
文件的锁机制对于在共享区域的文件有着更严格的要求。
依赖于 background fetch 的 completion block 是远远不够的,不要在一个现行的 background task 之外做任何后台操作。
想要调试那些仅仅在应用生命周期的特定条件下出现的问题是非常困难的。如果你还没有尝试过新的 Sierra Console.app ,现在就开始学习吧。