NavigationLink
是 SwiftUI 开发者非常喜欢使用的一个组件,它巧妙地结合了 Button 和导航跳转逻辑,大大简化了代码实现。但在某些场景下不恰当地使用它可能会导致严重的性能问题,使应用响应变得迟缓。本文将尝试分析这个问题的成因,并提供一个实用但略显神秘(无奈)的解决方案——使用 equatable()
修饰器来优化性能。
NavigationLink 引发的惨案
在 SwiftUI 中,如果开发者在 List
的子实体中使用 id
修饰器会破坏 List
的优化机制。SwiftUI 会在最初构建时,将所有的子视图(使用了 id
)一并构建出来(调用全部的 init
),但只会对当前可见区域的子视图调用 body
进行渲染。
请阅读 几个在 SwiftUI 中使用惰性容器的技巧和注意事项 了解更详细的信息。
尽管 id
与 List
的组合会产生不良的后果,但在某些场景中,我们仍然无法完全避免这种用法。不过考虑到构建 SwiftUI 的视图实例本身是个十分轻量级的操作,有时候所产生的后果(增加了构建的子视图数量)在性能上是可接受的。
然而,当我们在上述场景中再添加一个 NavigationLink
后,情况就会急剧恶化。
NavigationLink
有一个令人头疼的特性,那就是在默认情况下会被预创建。这意味着视图实体被创建后,SwiftUI 会直接调用子视图的 body
进行求值。比如下面的代码,尽管所有的 NavigationLink
都在一个惰性容器(List
)中,但 SwiftUI 仍然会一次性构建所有子视图(触发 init
和 body
的调用),导致视图渲染严重卡顿。
struct DemoRootView: View {
var body: some View {
NavigationStack {
List(0 ..< 10000) { i in
LinkView(i: i)
.id(i) // cause all LinkView init called
}
}
}
}
struct LinkView: View {
let i: Int
init(i: Int) {
self.i = i
print("init \(i)")
}
var body: some View {
let _ = print("update \(i)")
NavigationLink(value: i) { // cause all LinkView body called
Text("\(i)")
}
}
}
很显然,这种情况对开发者来说是完全无法接受的。为了避免视图被预创建,通常开发者的选择是完全避开 NavigationLink
,而使用如下方案:
struct DemoRootView: View {
@State var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(0 ..< 10000) { i in
LinkView(i: i, path: $path)
.id(i) // cause all LinkView init called
}
.navigationDestination(for: Int.self) {
Text("\($0)")
}
}
}
}
struct LinkView: View {
let i: Int
@Binding var path: NavigationPath
init(i: Int, path: Binding<NavigationPath>) {
self.i = i
_path = path
print("init \(i)")
}
var body: some View {
let _ = print("update \(i)")
Button {
path.append(i) // avoid using NavigationLink
} label: {
Text("\(i)")
}
}
}
但是,使用 Button
替换 NavigationLink
会失去 SwiftUI 默认提供的跳转按钮样式和交互反馈,这并非最佳方案。有没有两全其美的解决办法呢?
在一次偶然的尝试中,我发现了一个可以继续使用 NavigationLink
而又不触发预计算(不对非可见项的 body
求值)的方法,那就是使用 equatable()
修饰器。
或许一些读者觉得凑成
List
+id
+NavigationLink
的条件十分苛刻,但在实际开发中,还有很多场景能够让 NavigationLink 产生大量的预构建。比如,在一个使用ScrollView
+LazyVGrid
+ 大量NavigationLink
的场景中(常见于日历视图),如果在视图开始便跳转到滚动容器的最底部,便会造成子视图的 init 被提前触发,进而导致大量NavigationLink
被预构建。
默认 diff 机制 vs Equatable
在探讨 equatable()
修饰器的作用之前,我们需要先了解 SwiftUI 是如何进行视图 diff
操作的。
当父视图进行更新时(计算其 body
值),为了判断子视图是否也需要进行递归更新,SwiftUI 会构建一个新的子视图实例,并将其与旧实例进行快速比对,以确定子视图的构造参数是否发生了变化。SwiftUI 采用了一种高效的字段逐一比对方式来提升比较性能。这也解释了为什么 SwiftUI 虽然频繁对视图的前后值进行比对,但并不强制要求视图符合 Equatable
协议。
想深入了解 SwiftUI 的默认 diff 机制,请阅读 理解 SwiftUI 的视图刷新机制:从 TimelineView 刷新问题谈起
但当我们将视图声明为符合 Equatable
协议后,SwiftUI 会放弃默认的 diff 策略,转而使用 Equatable
提供的自定义比对方法。下面是一个示例:
struct RootView: View {
@State var i = 0
var body: some View {
VStack {
ChildView(i: i, name: "fat")
Button("i++") {
i += 1
}
}
}
}
struct ChildView: View {
let i: Int
let name: String
init(i: Int, name: String) {
self.i = i
self.name = name
print("init \(i)")
}
var body: some View {
Text("Child View \(i)")
}
// 声明了比较方法,但没有给 ChildView 声明 Equatbale 协议
static func== (lhs: Self, rhs: Self) -> Bool {
print("compare")
return lhs.i == rhs.i
}
}
在上面的代码中,尽管我们声明了 func==
方法,但由于没有正式让 ChildView
符合 Equatable
协议,SwiftUI 在比较时仍然使用默认的 diff 方式,不会调用我们自定义的比较方法。
但如果我们明确让 ChildView
符合 Equatable
协议:
extension ChildView: Equatable {}
SwiftUI 将会转为使用我们自定义的比较方法来进行视图的前后值比对。
这表明 SwiftUI 在进行 diff 时会首先检查当前视图是否符合 Equatable
协议,然后选择适合的 diff 策略。
这里需要特别注意的是,如果 ChildView
只有一个构造参数,即使声明了 Equatable
协议,SwiftUI 也不会调用我们自定义的比较方法。这个逻辑其实很合理——当我们选择让 SwiftUI 使用基于 Equatable
的方式来替代原有的高效 diff 机制时,往往是为了减少需要比较的项目(如上例中,只比较 i
值,而忽略 name
的变化)。如果视图只有一个参数,使用 Equatable
不仅没有必要,反而可能导致性能降低。
从这个行为特征中,我们可以推断 SwiftUI 的默认比较方案绝非简单的 memcmp 方式,而是会精确提取视图中需要比对的关键数据,进行有针对性的字段逐一比对。
请阅读 远离 dismiss,拥抱状态驱动,避免 SwiftUI 视图的重复计算 中使用
Equatable
来优化视图性能的案例,了解更多优化方法。
总结来说,在 SwiftUI 中,除了只有单一构造参数的特殊情况外,如果视图被声明为符合 Equatable
协议,SwiftUI 会使用我们自定义的比较方法而非默认的 diff 机制。
用 equatable() 避免 NavigationLink 的预构建
在 SwiftUI 的早期版本中,需要对一个符合 Equatable
协议的视图显式使用 equatable()
修饰器,才能切换 diff 模式。但在最近几个版本中,SwiftUI 已能自动识别符合 Equatable
的视图并调整比较策略。这不禁让人疑惑:既然 SwiftUI 能够自动切换 diff 模式,那么 equatable()
视图修饰器是否还有实际意义?
根据 equatable() 的官方文档,它只能用于符合 Equatable
协议的视图,并会用 EquatableView
包装原视图类型。
Prevents the view from updating its child view when its new value is the same as its old value.
nonisolated func equatable() -> EquatableView<Self>
虽然官方文档中并未详细介绍 EquatableView
的具体作用机制,但在我们使用了 NavigationLink
的场景中,它确实能有效阻止针对 NavigationLink
的预构建行为。下面是一个实例:
struct DemoRootView: View {
@State var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(0 ..< 10000) { i in
LinkView(i: i).equatable() // avoid unVisible LinkView's body called
.id(i) // cause all LinkView init called
}
.navigationDestination(for: Int.self) {
Text("\($0)")
}
}
}
}
struct LinkView: View, Equatable { // Equatable
let i: Int
init(i: Int) {
self.i = i
print("init \(i)")
}
var body: some View {
let _ = print("update \(i)")
NavigationLink(value: i) {
Text("\(i)")
}
}
}
通过这种方式,我们成功地避免了非可视区域中 NavigationLink
的预构建问题,使应用性能得到显著提升。
为什么????🤔
说实话,我也没有彻底想通这个现象背后的原理,但根据目前观察到的行为,我们可以大致整理出如下的信息:
- SwiftUI 默认采用高效的字段逐一比对方式来比较视图的前后值
- 当视图符合了
Equatable
协议后,SwiftUI 将切换成使用自定义的比较方法 - SwiftUI 似乎会通过编译信息感知视图中使用的
NavigationLink
,只要包含NavigationLink
的视图被创建(调用init
),默认就会对其body
求值 - 预构建的原因尚不明确,但很可能与提前创建和外部容器的链接机制有关
equatable()
修饰器通过构建EquatableView
包装原视图,可以有效阻止 SwiftUI 感知NavigationLink
或明确阻止预构建行为
尽管本文暂未彻底揭开这一机制背后的细节,但欢迎读者们一同探讨与实践,或许未来我们能共同解开 SwiftUI 的又一个谜团。
又是一笔糊涂账
在 AI 辅助编程越来越流行的今天,SwiftUI 仍然是一片”净土”。开发者通过大模型能够获取到的帮助并不多。其中最重要的原因就是 SwiftUI 作为一个闭源框架,始终有一些无法明确表述其实现原理和工作机制的地方。开发者需要依靠更多的经验和灵感来解决问题。
对于一个讲究精确的职业,这绝对不是一个理想现象,但我们也可以从另一个角度来看,这种不确定性为 SwiftUI 的开发者构建了一道防火墙,使得这一领域不那么容易被 AI 所取代😂。
"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"