一发入魂:极简解决 SwiftUI 复杂视图未能正确刷新的问题(中)

0 阅读5分钟

在这里插入图片描述

概述

各位似秃非秃小码农们都知道,在 SwiftUI 中视图是状态的函数,这意味着状态的改变会导致界面被刷新。

但是,对于有些复杂布局的 SwiftUI 视图来说,它们的界面并不能直接映射到对应的状态上去。这就会造成一个问题:状态的改变并没有及时的引起 UI 的变化。

在这里插入图片描述

如上图所示:无论英雄挑战关卡的结果是成功还是失败,在视图的显示中都没有体现出来。这该如何是好呢?

在本篇博文中,您将学到如下内容:

  1. 始末原由:不刷新的根本原因
  2. 不尽如人意的解决方案

相信学完本课后,大家都会掌握只需寥寥几行代码就让 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 的实现就可以发现,它们与上面简单场景中的代码有如下几点不同之处:

  1. 视图界面并不与状态直接对应;
  2. 视图界面中创建了多个临时局部变量,它们只是从状态“派生”出来,其本身并不是状态。这意味着,背后状态的改变并不能实际引起这些局部变量发生改变;
  3. 某些实际发生改变的状态只是视图状态中的一个“子状态”,即用户在关卡视图只会造成 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 代码都与状态对应起来。这样做当状态改变时,必定引起界面的刷新。但是,实际情况是:如果这样做的话,会导致代码变得很复杂;若是改写大量现有代码,更会秃头小码农们苦不堪言、无事生非。

除此以外,我们还有另外一种思路,那就是在实际状态发生改变时,手动去刷新视图的指定部分。

通过强制刷新视图可以解决问题,不过这也会带来几个问题:

  1. 迫使视图重建,会造成其自身状态丢失,可能会导致原本丝滑连贯的动画变得僵硬或干脆消失不见;
  2. 视图重建过于频繁,也会降低渲染性能;
  3. 迫使视图重建不是毫无代价的,这需要添加新的辅助状态,并适时的驱动它们刷新视图,这无论如何都会让代码布局和显示逻辑变得臃肿不堪;

如下代码所示,我们通过在 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-)