概述
各位似秃非秃小码农们都知道,在 SwiftUI 中视图是状态的函数,这意味着状态的改变会导致界面被刷新。
但是,对于有些复杂布局的 SwiftUI 视图来说,它们的界面并不能直接映射到对应的状态上去。这就会造成一个问题:状态的改变并没有及时的引起 UI 的变化。
如上图所示:无论英雄挑战关卡的结果是成功还是失败,在视图的显示中都没有体现出来。这该如何是好呢?
在本篇博文中,您将学到如下内容:
- 始末原由:不刷新的根本原因
- 不尽如人意的解决方案
相信学完本课后,大家都会掌握只需寥寥几行代码就让 SwiftUI 复杂视图乖乖听话的奥义!
那还等什么呢?Let‘s go!!!;)
2. 始末原由:不刷新的根本原因
为了追本穷源找到问题根源,我们不妨先来看一个简单场景下,由状态驱动 SwiftUI 视图界面改变的经典例子:
struct ContentView: View {
@State private var value = 0
var body: some View {
NavigationStack {
Form {
LabeledContent("战斗力") {
Text("\(value)")
.contentTransition(.numericText())
.font(.largeTitle.bold())
}
Button("提升战斗力") {
withAnimation(.bouncy) {
value += Int.random(in: 5...100)
}
}
.buttonStyle(.borderedProminent)
.fontWeight(.bold)
}
.navigationTitle("极简状态驱动界面示例")
}
}
}
运行可以发现,当点击按钮后,战斗力数值会得到随机地提升,即状态 value 的值会随机被递增:
在这种简单的情况下,之所以界面会根据状态的改变被痛快的刷新,是因为:它们是一种单纯的一一对应关系。
在上面示例代码中,value 状态直接与 Text 视图中的文本相“绑定”,当 value 的值发生改变时,Text 想不改变都难。
如果我们回到上一篇文章开头的代码中,通过仔细观察 StageView 和 WorldView 的实现就可以发现,它们与上面简单场景中的代码有如下几点不同之处:
- 视图界面并不与状态直接对应;
- 视图界面中创建了多个临时局部变量,它们只是从状态“派生”出来,其本身并不是状态。这意味着,背后状态的改变并不能实际引起这些局部变量发生改变;
- 某些实际发生改变的状态只是视图状态中的一个“子状态”,即用户在关卡视图只会造成 StageChallengeRecord 托管对象的改变,而 Stage 对象中包含多个 StageChallengeRecord 托管对象;而 World 对象中又包含多个 Stage 对象;
比如,在 StageView 中,我们在界面中列出的实际是 records 这个临时局部变量,但它并不是状态:
let records = stage.queryAllChallengingRecords()
类似的,在 WorldView 中实际驱动界面显示的也只是 zones 临时变量和它的计算属性 stageSortByNumberAry 而已,它们都不是状态:
let zones = world.zoneSortByNumberAry
ForEach(zone.stageSortByNumberAry) { stage in
...
}
这就是当用户挑战关卡时,无论是 StageView 或是 WorldView 视图都未能正确刷新的根本原因:因为它们都让 SwiftUI 视图依赖临时变量,而不是直接与状态绑定!
这样看来,我们产品 App 中代码的生存条件往往会比“童话世界”里残酷的多。
那么,我们又该何去何从呢?
3. 不尽如人意的解决方案
知道了问题的根本原因,解决起来就知道往哪里使劲了。
一种思路是,将所有 SwiftUI 视图的 UI 代码都与状态对应起来。这样做当状态改变时,必定引起界面的刷新。但是,实际情况是:如果这样做的话,会导致代码变得很复杂;若是改写大量现有代码,更会秃头小码农们苦不堪言、无事生非。
除此以外,我们还有另外一种思路,那就是在实际状态发生改变时,手动去刷新视图的指定部分。
通过强制刷新视图可以解决问题,不过这也会带来几个问题:
- 迫使视图重建,会造成其自身状态丢失,可能会导致原本丝滑连贯的动画变得僵硬或干脆消失不见;
- 视图重建过于频繁,也会降低渲染性能;
- 迫使视图重建不是毫无代价的,这需要添加新的辅助状态,并适时的驱动它们刷新视图,这无论如何都会让代码布局和显示逻辑变得臃肿不堪;
如下代码所示,我们通过在 StageView 中驱动 stageCellRefresher 刷新器状态,刷新了 WorldView 视图中对应的显示部分:
// WorldView:
VStack(alignment: .leading) {
...
}
.id(model.stageCellRefresher)
// StageView:
Button {
if try! hero.challengeStage() {
try! hero.moveToNextStage()
}
model.stageCellRefresher.toggle()
} label: {
Label("挑战关卡!", systemImage: "figure.fencing")
.foregroundStyle(.white)
}
因为我们需要跨视图刷新界面,所以必须将 stageCellRefresher 刷新器放到全局 Model 对象中,这也会使代码逻辑变动更复杂,毕竟全局状态太多不是什么好事。
综上所述,通过一些方法我们能够达到勉强刷新视图之目的,但这些方案貌似都不那么优雅。
所以,我们就打算牵萝补屋、削趾适屦了吗?
当然不!Never!
在下一篇博文中,我们将介绍只需 3 行代码就能让问题抽薪止沸的解决之道,敬请期待吧!
总结
在本篇博文中,我们讨论了导致 SwiftUI 复杂视图不能及时刷新的根本问题,并介绍了几种不那么优雅的解决方案。
感谢观赏,下一篇再会啦!8-)