起因
我们的开源示例程序 ArcGIS Maps SDK for Swift Samples 里面,使用了分栏式的展示逻辑,即点击列表中的一个示例,在详细页面 detail view 里展示一个相关地图功能。每一个功能都有很丰富的交互,因此每个详细功能页面少则占用10M+,多则100M+的内存才能加载。
由于每个页面都算很“重”的视图,因此避免 SwiftUI 因视图 identity 改变而重新计算,变得尤为重要。
在这个 app 最初设计时,遇到了这样的问题:我有一个 protocol Sample
用来表示一个示例。每一个示例的主页面都是一个不同的类型,如何在点击一个示例时,即时创建这个页面呢?在代码生成里,我使用了如下的方法
func makeBody() -> AnyView { .init(\(sample.viewName)()) }
这样,从每一个示例名称,就可以生成出对应的示例页面了。
这样做的好处在于,没有任何复杂的语法,就能直接从字符串创建一个页面;坏处在于,编译时无法提前知道创建的页面的具体类型是什么,因此只能用 type-erased AnyView
作为返回类型。
网上有很多教程都反对使用 AnyView
,主要缺点就是 SwiftUI 无法确定它的真实 identity,因而在更新子视图树时,没法高效地决定这个视图是否需要更新,从而延长计算的时间。实际上,在视图树很矮、子视图关系不复杂的情况下,其带来的影响很小。具体到我们的 app 上来,我决定测试一番。
测试1:AnyView
的创建频率
既然网上说法最关注 AnyView
频繁重新计算所导致的性能损失,不妨先来看看我们的 app 究竟多频繁更新视图。剧透:其实根本没几次。
经过测试我发现,由于 AnyView
处于每个示例视图树的最顶端,即整个示例的所有视图都是 AnyView
的子节点,因此有且仅有整个示例的视图发生变化时,AnyView
才会更新。即,当我在列表中,从一个示例切换到另一个示例,makeBody()
方法才会被调用,从而创建一个新的 AnyView
。对于这个 app 而言,切换不同的示例属于低频的用户操作,根本达不到影响渲染性能的级别。
测试2:使用 @ViewBuilder
是否真正节省时间?
剧透:不仅不节省,反而多花时间。
首先,我们假设,当每个示例的根视图被创建时,无论它是被 AnyView
所包装,还是被 @ViewBuilder
计算所得的 _ConditionalContent
所包装,一旦其被创建,后面渲染的时间是一样的。这样,我们可以只关注 AnyView
和 @ViewBuilder
所产生的包装层的时间差别。
依据我的测试,当我遍历创建整个 app 里的200个左右示例页面,使用
func makeBody() -> AnyView { .init(\(sample.viewName)()) }
// 遍历所有示例并创建视图
var bodies: [AnyView] = []
for sample in SamplesApp.samples {
bodies.append(sample.makeBody())
}
和
@ViewBuilder
func view(for sample: Sample) -> some View {
switch sample {
case is AddPointCloudLayerFromFile:
AddPointCloudLayerFromFileView()
case is SelectFeaturesInFeatureLayer:
SelectFeaturesInFeatureLayerView()
// ...
default:
fatalError("Unknown \(sample.name) sample view generated.")
}
}
// 遍历所有示例并创建视图
var bodies: [any View] = []
for sample in SamplesApp.samples {
bodies.append(view(for: sample))
}
两个方法时 @ViewBuilder
的方法平均时间比 AnyView
要慢5%左右(50ms vs 55ms)。
这样的结果有些出乎我的意料。毕竟,如果理论上创建 AnyView
需要花更多时间来确定其运行时的类型,难道不应该花更多时间吗?
更令人意想不到的是接下来的发现。当我试图分析 @ViewBuilder func view(for sample: Sample) -> some View
这个方法返回的视图类型时,发现其并非如老版本中使用 _ConditionalContent
来包装视图,而是直接使用了 AnyView
!即在调试器中,print(type(of: view(for: sample)))
的结果是 AnyView
。在调试器变量区显示的类型则是 <<opaque return type of ArcGIS_Maps_SDK_Samples.ContentView.view(for: ArcGIS_Maps_SDK_Samples.Sample) -> some>>.0
。
作为对比,在我第一次测试 @ViewBuilder
的结果时,它的类型是类似于如下的二叉树状结构的。当时我觉得这很合理——相当于用二叉搜索快速确定一个 result builder 的结果类型,应该性能上很不错。
_ConditionalContent<
_ConditionalContent<
_ConditionalContent<
AddRasterFromFileView,
AddSceneLayerFromServiceView
>,
_ConditionalContent<
BrowseBuildingFloorsView,
ClipGeometryView
>
>,
_ConditionalContent<
_ConditionalContent<
CreatePlanarAndGeodeticBuffersView,
CutGeometryView
>,
_ConditionalContent<
DisplayFeatureLayersView,
DisplayMapView
>
>
>,
// …
但是经过这次测试得到意想不到的结果,也能明白为什么 @ViewBuilder
比 AnyView
要慢了。因为最终创建的都是 AnyView
的前提下,@ViewBuilder
多了花在 switch-case
判断的时间,比起直接从示例类型生成视图,相当于额外的时间。
测试3:大量 AnyView
用类似如下视图来测试 AnyView
在列表这种动态计算的视图中的性能影响。这个示例创建 50000 个 HStack
和 AnyView
包装的文本视图。
import SwiftUI
class Model {
static let items50K = (0 ..< 50_000).map { Item(id: $0) }
}
struct Item: Identifiable {
let id: Int
var text: String { String(id) }
}
struct NormalItemView: View {
let item: Item
var body: some View {
HStack { Text(item.text) }
}
}
struct AnyItemView: View {
let item: Item
var body: some View {
AnyView(Text(item.text))
}
}
struct NormalListView: View {
var items: [Item]
var body: some View {
List(items) { item in
NormalItemView(item: item)
}
.listStyle(.plain)
}
}
struct AnyListView: View {
var items: [Item]
var body: some View {
List(items) { item in
AnyItemView(item: item)
}
.listStyle(.plain)
}
}
struct ContentView: View {
var body: some View {
NavigationView {
VStack(spacing: 20) {
NavigationLink {
NormalListView(items: Model.items50K)
} label: { Text("NormalView 50K") }
NavigationLink {
AnyListView(items: Model.items50K)
} label: { Text("AnyView 50K") }
}
}
}
}
明显可以看到,当打开 AnyView
的列表时,有很长一段卡死的时间。但是当 AnyView
较少,比如一千个以下时,造成的性能影响并不大,不足以拖慢 app 的运行速度。
结论
-
AnyView
个数不多,对性能影响不太大 - 使用
@ViewBuilder
替代AnyView
,在一些情况下并不能提升性能
Top comments (0)