DEV Community

Ting
Ting

Posted on

探究是否用 @ViewBuilder 替换 AnyView

起因

App landing page

我们的开源示例程序 ArcGIS Maps SDK for Swift Samples 里面,使用了分栏式的展示逻辑,即点击列表中的一个示例,在详细页面 detail view 里展示一个相关地图功能。每一个功能都有很丰富的交互,因此每个详细功能页面少则占用10M+,多则100M+的内存才能加载。

由于每个页面都算很“重”的视图,因此避免 SwiftUI 因视图 identity 改变而重新计算,变得尤为重要。

在这个 app 最初设计时,遇到了这样的问题:我有一个 protocol Sample 用来表示一个示例。每一个示例的主页面都是一个不同的类型,如何在点击一个示例时,即时创建这个页面呢?在代码生成里,我使用了如下的方法

func makeBody() -> AnyView { .init(\(sample.viewName)()) }
Enter fullscreen mode Exit fullscreen mode

这样,从每一个示例名称,就可以生成出对应的示例页面了。

这样做的好处在于,没有任何复杂的语法,就能直接从字符串创建一个页面;坏处在于,编译时无法提前知道创建的页面的具体类型是什么,因此只能用 type-erased AnyView 作为返回类型。

然而,实际上,我是可以“提前”知道页面视图的类型的。根据每个示例的类型,通过 case-is 语法来推断出对应的视图的类型。但是,这个推断的过程,无法避免地产生多种结果类型。如果使用 existential any View (func makeBody() -> any View) 来表示结果类型,编译器在 SwiftUI 会报错,提示无法进行类型检查:

The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions

网上有很多教程都反对使用 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())
}
Enter fullscreen mode Exit fullscreen mode


@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))
}
Enter fullscreen mode Exit fullscreen mode

两个方法时 @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
        >
    >
>,
// …
Enter fullscreen mode Exit fullscreen mode

但是经过这次测试得到意想不到的结果,也能明白为什么 @ViewBuilderAnyView 要慢了。因为最终创建的都是 AnyView 的前提下,@ViewBuilder 多了花在 switch-case 判断的时间,比起直接从示例类型生成视图,相当于额外的时间。

测试3:大量 AnyView

用类似如下视图来测试 AnyView 在列表这种动态计算的视图中的性能影响。这个示例创建 50000 个 HStackAnyView 包装的文本视图。

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") }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

明显可以看到,当打开 AnyView 的列表时,有很长一段卡死的时间。但是当 AnyView较少,比如一千个以下时,造成的性能影响并不大,不足以拖慢 app 的运行速度。

结论

  1. AnyView 个数不多,对性能影响不太大
  2. 使用 @ViewBuilder 替代 AnyView,在一些情况下并不能提升性能

参考链接

Top comments (0)