调试内存不足问题:使用Runtime Magic捕捉布局反馈循环

让我们想象一下这种情况:您拥有一个成功的应用程序,该应用程序具有大量的每日用户和100%的无崩溃率。 您很高兴,您的生活令人赞叹。 但是在某个时候,您开始看到App Store出现负面评论,说它经常崩溃。 检查Fabric并没有帮助-没有出现新的崩溃。 那会是什么呢?

答案是OOM(内存不足)终止。

当您在最终用户的设备上使用RAM时,操作系统可以决定为其他进程回收该内存并终止您的应用程序。 我们将此称为“内存不足”终端。 可能有多种原因:

  • 保持周期;
  • 比赛条件;
  • 废弃的线;
  • 僵局;
  • 布局反馈循环。

Apple提供了许多解决此类问题的解决方案:

  • 解决滞留周期和其他类型泄漏的分配和泄漏工具
  • Xcode 8中已引入的Memory Debugger,它替代了Allocations和Leaks工具的某些功能
  • 线程清理程序可帮助您查找竞争条件,废弃线程或死锁

布局反馈循环

我们将研究布局反馈循环。 这不是很常见的问题,但是一旦遇到,它可能会给您带来很多麻烦。

当您的视图运行其布局代码时,会发生布局反馈循环,但是某种方式导致它们再次开始其布局传递。 这可能是由于一个视图更改其超级视图之一的大小而导致的,或者可能是因为布局不明确。 无论哪种方式,此问题都会在您的CPU耗尽和RAM使用率稳定上升的情况下显现出来,这都是因为您的视图一次又一次地运行其布局代码而从未返回。

来自HackingWithSwift的Paul Hudson

对我们来说幸运的是,在WWDC16中,Apple花了整整15分钟的时间(!)引入了“布局反馈循环调试器”,该功能可帮助您识别调试期间发生循环的时刻。 这只是一个象征性的断点,它的工作方式就变得很简单:它计算在单个运行循环迭代中每个视图上的layoutSubviews()方法被调用的次数。 一旦超过某个阈值(例如100),该应用程序将在此断点处停止并打印一个不错的日志,以帮助您找到根本原因。 这是一篇不错的文章,简要描述了如何使用此调试器。

如果您可以重现此问题,则此方法非常适用。 但是,如果您有数十个屏幕,数百个视图,但您的App Store评论只说:“此应用程序很烂,总是崩溃,永远不要使用它!”? 您希望可以将所有这些人员带到您的办公室并为他们设置布局反馈循环调试器。 尽管第一部分由于GDPR而不能完全实现,但是您可以尝试在生产代码中复制UIViewLayoutFeedbackLoopDebuggingThreshold

让我们回想一下该断点是如何工作的:它计算一次layoutSubviews()调用,并在单个runloop迭代中超过某个阈值时发送一个事件。 听起来很容易,对吧?

该代码对于您的视图非常有效。 但是现在您想在另一个视图上实现它。 您当然可以创建UIView的子类,在其中实现它,然后从中继承项目中的所有视图。 然后对UITableViewUIScrollViewUIStackView等执行相同的操作……

您希望可以将此逻辑注入所需的任何类中,而无需编写大量重复的代码。 这正是运行时编程允许您执行的操作。

我们将做同样的事情-创建一个子类,重写layoutSubviews()方法并计算其调用。 唯一的区别是所有这些将在运行时完成,而不是在项目中创建重复的类。

让我们开始简单-我们将创建我们的自定义子类,并将原始视图的类更改为该新子类:

objc_allocateClassPair()的文档告诉我们该方法何时失败:

新类,如果无法创建该类,则为Nil(例如,所需名称已在使用中)”。

这意味着您不能有两个具有相同名称的类。 我们的策略是为单个视图类提供一个在运行时创建的类。 这就是为什么我们通过在原始类名前面加上前缀来形成新类的名称。

现在让我们为子类添加一个计数器。 从理论上讲,有两种方法可以执行此操作:

  1. 添加一个保存您的计数器的属性。
  2. 为此类创建一个关联的对象。

但实际上,只有一种方法是有效的。 您可以将属性视为存储在为类分配的内存中的东西,而关联的对象将存储在完全不同的位置。 由于分配给现有对象的内存是固定的,因此自定义子类上新添加的属性将从其他资源“窃取”该属性。 它可能导致意外行为和难以调试的崩溃(请参阅此处以获取更多信息)。 但是在使用关联对象的情况下,它们将仅存储在运行时创建的哈希表中,这是完全安全的:

创建新的子类,将计数器设置为0。接下来,我们实现新的layoutSubviews()方法并将其添加到我们的类中:

要了解上面实际发生的情况,让我们从看一下此结构:

即使我们不再在Swift中直接使用此结构,它也很清楚地说明了方法实际上包括什么:

  • 实现,这是在调用方法时要执行的确切功能。 它始终将接收者和消息选择器作为其前两个参数。
  • 方法类型字符串包含您的方法的签名。 您可以在此处了解有关其格式的更多信息,但是在我们的情况下,我们需要指定的字符串是"v@:"v代表void作为我们的返回类型,而@:代表接收者和消息选择器。
  • 选择器是一个关键,通过它可以在运行时查找您的方法实现。

您可以将见证表(在其他编程语言中也称为调度表)想象成一个简单的字典数据结构。 那么,选择器将是您的关键,而实现将是您的价值。 我们在这一行中正在做什么:

只是为对应于layoutSubviews()方法的键分配一个新值。

直截了当。 我们得到计数器,将其增加一。 如果超过我们的阈值,我们将发送带有类名和所需任何有效负载的分析事件。

让我们回想一下我们如何为关联对象实现和使用键:

为什么我们将var用作计数器变量的静态键属性,并通过引用将其传递到任何地方? 答案隐藏在Swift语言的基础知识中-像所有其他值类型一样,字符串也按值传递。 因此,当您将其传递给闭包时,字符串将被复制到另一个地址,这将导致关联的对象表中的键完全不同。 与符号始终确保键参数的值被赋予相同的地址。 尝试以下代码:

通过引用传递键始终是一个好主意,因为有时即使不使用闭包,由于内存管理的原因,变量的地址仍然可以更改。 对于我们的示例,如果您将上述代码运行一定的时间,即使对于前两次调用printAddress() ,也可能会看到不同的地址。

让我们回到我们的运行时魔术。 在新的layoutSubviews()实现中,还有一件重要的事情尚未完成。 通常,每次我们重写祖先类中的方法时所做的事情–调用超类实现。 layoutSubviews()的文档说:

此方法的默认实现在iOS 5.1及更早版本上不执行任何操作。 否则,默认实现将使用您设置的任何约束来确定任何子视图的大小和位置。

为了避免任何意外的布局行为,我们必须调用超类的实现。 这不会像通常那样简单:

实际上,这里发生的是,不是通常的方法调用方法(即执行要在见证表中查找实现的选择器),而是我们自己检索所需的实现并直接从我们的方法中调用它。码。

让我们看看到目前为止的实现:

让我们通过为视图创建一个模拟布局循环并为其设置计数器来对其进行测试:

我们错过了什么吗? 让我们再次回顾一下UIViewLayoutFeedbackLoopDebuggingThreshold断点的工作方式:

定义视图在被视为反馈循环之前必须在单个运行循环中布局其子视图的次数。

我们从未考虑过“单循环”条件。 如果我们的视图在屏幕上停留了很长的时间,并且经常被一遍又一遍地布置,那么我们的计数器迟早会超过阈值。 但这不是因为内存问题。

我们该如何解决? 只需在每次运行循环迭代中重置计数器即可。 为此,我们可以创建一个DispatchWorkItem来重置计数器并将其异步传递到主队列中。 这样,下次运行循环进入主线程时,它将被调用:

最终代码:

结论

而已! 现在,您可以为所有可疑视图设置分析事件,释放应用程序并找出问题发生的确切位置。 您可以将其范围缩小到特定的视图,并在用户不知道的情况下借助用户的帮助解决问题。

最后要提到的是,强大的力量带来巨大的责任。 运行时编程非常容易出错,因此很容易在不知道的情况下为您的应用引入另一个关键问题。 这就是为什么始终建议您将应用程序中所有危险代码包装在某种killswitch中,您可以从后端触发该killswitch,并在发现问题的原因时禁用该功能。 这是一篇有关Firebase的功能标志的不错的文章。

完整的代码在此GitHub存储库中可用,也可以通过CocoaPods分发,以跟踪项目中的布局循环。

PS:我要特别感谢Aleksandr Gusev对本文的审阅和提出更多帮助。