3

前言

AnyView 是一种类型擦除的视图,对于 SwiftUI 容器中包含的异构视图非常方便。在这些情况下,你不需要指定视图层次结构中所有视图的具体类型。通过这种方式,你可以避免使用泛型,从而简化你的代码。

然而,这可能会带来性能损失。如果是 AnyView(基本上是一个包装类型),SwiftUI 将很难确定视图的身份和结构,并且它将重新绘制整个视图,这并不是真正高效的。你可以在这个出色的 WWDC 演讲中找到有关 SwiftUI 差异机制的更多细节。

Apple 也多次提到,我们应该避免在 ForEach 中使用 AnyView,称其可能会导致性能问题。一个可能发生的情况是无尽的不同视图列表,呈现不同类型的数据(例如聊天、活动动态等)。在本文中,我将使用 Stream 的 SwiftUI 聊天 SDK 进行一些测量,使用其默认的基于泛型的实现,并将其与使用 AnyView 的修改后的实现进行比较。

测试设置

关于测试设置的几点说明:

  • 所有测试和测量都在 iPhone 11 Pro Max 上进行。
  • 为保持一致性,在所有测试中都使用相同的数据集和用户。
  • 测试会执行多次。
  • 正在测试的列表具有不同类型的数据(例如图像、视频、GIF、文本等)。
  • 在测试不同实现时执行相同的操作(例如,在内容上滚动三次)。
  • 数据以每页 25 个项目的形式获取。
  • 我们将使用动画卡顿仪器配置文件以及这个开源 FPS 计数器。

动画卡顿仪器配置文件

动画卡顿

苹果建议使用动画卡顿作为衡量应用性能的指标。卡顿基本上是指在屏幕上显示的帧比预期晚的帧。卡顿时间越长,出现的故障和挂起就越明显,从而造成用户体验不佳。例如,如果你有 100 毫秒的卡顿,这意味着此帧显示晚于预期的 100 毫秒,从而使用户可以看到挂起。卡顿可以出现在提交阶段或渲染阶段。

为了提高我们应用的性能,我们需要将这些动画卡顿降到最低(或者更好地摆脱它们)。

我还将展示与 FPS(每秒帧数)的比较,因为它通常是开发人员更熟悉的度量标准之一。当使用 FPS 作为度量标准时,重要的是指定最大帧速率(在这种情况下为 60),并在应用程序没有活动时丢弃值。

浏览数据

首先,让我们看看在浏览内容时不同的实现会表现如何。在这个测试中,我们将通过整个消息列表三次滚动。

没有 AnyView

下面是没有泛型实现的动画卡顿记录。

如你所见,有几个动画卡顿,其中 2 个是橙色的,这意味着卡顿持续时间超过了可接受的延迟时间 33 毫秒。因此,在这 2 种情况下,将会丢失一帧。这 2 个卡顿发生在加载新消息并将其附加到消息列表时。在加载消息时进行任何后续滚动,不会影响性能。

在此测试期间,FPS 值的平均值约为每秒 59 帧。滚动是流畅且响应迅速的。

有 AnyView

接下来,让我们做同样的测试,同时使用 AnyView 包装器。以下是动画卡顿仪器配置文件中的结果。

你可以在此示例中看到一些更多的橙色。有更多的动画卡顿超过了可接受的延迟时间 33 毫秒。这导致在执行测试时在仪器和视觉上都出现一些可见的卡顿。

此外,当你再次浏览列表时,性能不会改善(甚至变得更糟)。这是有道理的,因为 SwiftUI 不知道它已经显示过此视图一次(因为它隐藏在 AnyView 下)。因此,它会再次绘制它,同时还可能缓存(但不使用)该视图的旧版本。

此测试中的平均 FPS 约为每秒 55 帧,你可能会注意到在滚动时出现一些可见的故障,尽管情况并不那么糟糕。

在浏览数据时修改

我们可以进行的另一个测试是性能测试 - 向列表发送大量内容并强制更新视图(例如,响应消息),同时我们也浏览数据。这将在较短的时间间隔内触发视图的多次重绘。

没有 AnyView

在没有 AnyView 包装器的情况下进行测试产生了与常规滚动测试相似的结果(58-59 FPS)。这也是预期的,因为 SwiftUI 知道视图的标识和结构。当需要更新视图时,仅对其进行更改(例如,向视图添加另一个反应)。

有 AnyView

当我们在这种情况下使用 AnyView 时,事情就变得有趣了 - 在短时间内对屏幕上的视图进行频繁更新。

在此场景中,有几个可见的卡顿和挂起,当我们频繁响应消息时,FPS 降至 50 以下。由于在几秒钟内强制重绘视图多次,帧丢失在这里更加明显。由于 SwiftUI 不知道这个视图是什么,我假设它每次都会从头开始重绘。其中一些视图相当昂贵(例如 GIF),因此重新绘制可能是一项相当昂贵的操作。

通过使用 AnyView,效果类似于将 id 修饰符的值设置为 UUID() - 这将在发生更改时始终更新视图项目。

分析结果

测试/实现没有 AnyView(FPS)有 AnyView(FPS)性能退化
浏览数据595510%
在浏览数据时修改595016.5%

这些数字相当依赖于设置,因此不应该被视为铁板钉钉的结果,而只是一个指示。

仅浏览数据时,如果你将视图包装在 AnyView 中,则会比不包装时慢大约 10%。如果你在浏览数据时更改数据,则此差异将增加到约 17%,而且这些故障在这里更加明显。

为了更好地理解结果,我们需要深入了解 SwiftUI 的工作原理。在这个关于 SwiftUI 性能的 WWDC 会话中,来自 SwiftUI 团队的 Raj 讨论了列表或表需要提前知道所有标识符。只有在内容解析为恒定数量的行时,才能高效地收集它们而无需访问所有内容。如果使用条件检查或 AnyView,将无法确定行数,并且必须提前创建所有视图,这会影响性能。

因此,请尽量避免这样的代码:

ForEach(someData) { someElement in
  if someCondition {
    SomeView(data: someElement)
  }
}

以及像这样的代码:

ForEach(someData) { someElement in
    AnyView(SomeView(data: someElement))
}

最后一段代码类似于我们使用 AnyView 进行测试的方式。这意味着,当列表发生更改时,我们实际上重新创建了整个列表。这也解释了为什么 AnyView 实现随着时间的推移变慢 - 每次重绘时都需要从头开始创建更多内容。

总结

总而言之,在这些情景中(包含异构视图的可滚动列表),最好为容器中的不同视图使用具体类型。这可能听起来更复杂一些,但实际上你可以使其更简单,而不必过多地处理泛型。

然而,这并不意味着使用 AnyView 总是会以这种方式影响性能。例如,如果你有一个菜单,作为几个异构元素的列表,在点击时显示不同的导航目标,并且决定将这些视图包装为 AnyView,我的测量结果表明与使用其他方法相比,性能没有区别。

在这篇文章中,使用 AnyView 与使用 if-else 语句的不同类型的测试显示出没有显着差异。使用 if-else 导致视图标识丢失,就像 AnyView 一样,因此在这里没有性能差异是可以预期的。

这也取决于实现的方式 - 你的数据模型,将状态传递到哪里,哪些更新可能会导致视图重绘等等。


Swift社区
16.3k 声望4.4k 粉丝

我们希望做一个最专业最权威的 Swift 中文社区,我们希望更多的人学习和使用Swift。我们会分享以 Swift 实战、SwiftUI、Swift 基础为核心的技术干货,欢迎您的关注与支持。