主页 TCA - SwiftUI 的救星?(四)
Post
Cancel

TCA - SwiftUI 的救星?(四)

这是一系列关于 TCA 文章的最后一篇。在系列中前面的几篇里,我们简述了 TCA 的最小 Feature 核心思想,并研究了绑定和环境值的处理,以及 Effect 角色和 Feature 组合的方式等话题。作为贯穿整个系列的示例 app,现在应该已经拥有一个可用的猜数字游戏了。这篇文章会综合运用之前的内容,来看看和 UI 以及日常操作更贴近的一些话题,比如如何用 TCA 的方式展示 List 并让结果可以被删除,如何处理导航以及 alert 弹窗等。

如果你想要跟做,可以直接使用上一篇文章完成练习后最后的状态,或者从这里获取到起始代码。

展示结果 List

在前一篇文章最后的练习中,我们使用了 var results: [GameResult] 来存放结果并显示已完成的状态数字。现在我们的 app 还只有一个单一页面,我们打算为 app 添加一个展示所有已猜测结果,并且可以对结果进行删除的新页面。

使用 IdentifiedArray 进行改造

在实际开始之前,来对 results 数组进行一些改造:使用 TCA 中定义的 IdentifiedArray 来代替简单的 Swift Array

1
2
3
4
5
6
7
8
struct GameState: Equatable {
  var counter: Counter = .init()
  var timer: TimerState = .init()
  
- var results: [GameResult] = []
+ var results = IdentifiedArrayOf<GameResult>()
  var lastTimestamp = 0.0
}

这会导致编译无法通过,我们先把错误放一放,来看看相比起 Array 来说,IdentifiedArray 的优势:

  • 和普通 Array 一样,IdentifiedArray 也尊重元素顺序,并支持基于 index 的 O(1) 随机存取。而且它提供了和 Array 兼容的 API。
  • 但是和 Array 不同,IdentifiedArray 要求其中元素遵守 Identifiable 协议:也就是只有包含 id 属性的实例能被放入其中,而且需要确保唯一,不能有同样 id 的元素被放入。
  • 有了 Identifiable 和唯一性的保证,IdentifiedArray 就可以利用类似字典的方式通过 id 快速地查找元素。

使用 Array 对一组数据建模,是最容易和最简单的想法。但当 app 更复杂时,处理 Array 很容易造成性能退化或者出错:

  • 要根据相等 (也就是 Array.firstIndex(of:)) 来查找其中的某个元素会需要 O(n) 的复杂度。
  • 使用 index 来获取元素虽然是 O(1),但是如果处理异步的情况,异步操作开始时的 index 有可能和之后的 index 不一致,导致错误 (试想在异步期间,以同步的方式删除了某些元素的情况:异步操作之前保存的 index 将会失效,访问这个 index 可能获取到不同的元素,甚至引起崩溃)。

IdentifiedArray 通过提供基于 Identifiable 的访问方式,可以同时解决上面两个问题。虽然在我们的这个简单例子中使用 Array 也无伤大雅,但是在 TCA 的世界,甚至在普通的其他 Swift 开发时,如果被上面的问题困扰,我们都可以用 IdentifiedArray 来处理。

回到 app,为了让 IdentifiedArrayOf<GameResult> 能成立 (它是 IdentifiedArray<Element.ID, Element> 的类型别名),我们需要 GameResult 满足 Identifiable。因为 Counter 已经满足 Identifiable 了,所以一个简单的方法就是重构一下 GameResult,让它直接包含 Counter

1
2
3
4
5
6
7
8
9
10
11
- struct GameResult: Equatable {
+ struct GameResult: Equatable, Identifiable {
-   let secret: Int
-   let guess: Int
+   let counter: Counter
    let timeSpent: TimeInterval

-   var correct: Bool { secret == guess }
+   var correct: Bool { counter.secret == counter.count }
+   var id: UUID { counter.id }
}

然后,更新 reducerbody 的部分,让编译通过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let gameReducer = Reducer<GameState, GameAction, GameEnvironment>.combine(
  .init { state, action, environment in
    switch action {
    case .counter(.playNext):
      let result = GameResult(
-       secret: state.counter.secret,
-       guess: state.counter.count,
+       counter: state.counter,
        timeSpent: state.timer.duration - state.lastTimestamp
      )
      // ...
  },
  // ...
)

struct GameView: View {
  var body: some View {
    // ...
-      resultLabel(viewStore.state)
+      resultLabel(viewStore.state.elements)
  }
  
  // ...
}

编译并运行,app 的行为应该没有改变,不过在底层我们已经转为使用 IdentifiedArray 来存储结果数据了。

这个重构在我们的 app 中不是必要的,但是 TCA 里大量使用了 IdentifiedArray。有意识地从一开始就使用 IdentifiedArray 很多时候可以节省不必要的麻烦。

使用独立 feature 的方式进行构建

和之前的各个 feature 一样,result list 的画面也是由 state,reducer,environment 和 action 等要素组成的。需要再次强调,这就是 TCA 最优秀的一点:我们只需要着眼创建简单的小组件,然后通过组合的方式把它们添加到大组件中

对于 State 角色,暂时只需要一个数组,我们可以简单地用 IdentifiedArrayOf<GameResult> 来表示。创建一个新的 GameResultListView.swift 文件,添加如下内容:

1
2
3
import ComposableArchitecture

typealias GameResultListState = IdentifiedArrayOf<GameResult>

除了展示外,我们还希望能够删除结果,所以 action 需要能反应这个操作。

1
2
3
enum GameResultListAction {
  case remove(offset: IndexSet)
}

GameResultListView 不需要特殊的 Environment,reducer 也非常简单:

1
2
3
4
5
6
7
8
9
10
struct GameResultListEnvironment {}

let gameResultListReducer = Reducer<GameResultListState, GameResultListAction, GameResultListEnvironment> { 
  state, action, environment in
  switch action {
  case .remove(let offset):
    state.remove(atOffsets: offset)
    return .none
  }
}

相信你已经对这些部分非常熟悉了。最后,创建 GameResultListView 并把这些东西组合起来就好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct GameResultListView: View {
  let store: Store<GameResultListState, GameResultListAction>
  var body: some View {
    WithViewStore(store) { viewStore in
      List {
        ForEach(viewStore.state) { result in
          HStack {
            Image(systemName: result.correct ? "checkmark.circle" : "x.circle")
            Text("Secret: \(result.counter.secret)")
            Text("Answer: \(result.counter.count)")
          }.foregroundColor(result.correct ? .green : .red)
        }
      }
    }
  }
}

我们还没有把 GameResultListView 添加到 app 里,想要先验证它,可以添加 preview:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct GameResultListView_Previews: PreviewProvider {
  static var previews: some View {
    GameResultListView(
      store: .init(
        initialState: .init(rows: [
          GameResult(
            counter: .init(
              count: 20, secret: 20, id: .init()
            ),
            timeSpent: 100),
          GameResult(
            counter: .init(),
            timeSpent: 100)
        ]),
        reducer: gameResultListReducer,
        environment: .init()
      )
    )
  }
}

支持删除

在 SwiftUI 中添加默认的删除操作非常简单,只需要为 cell 添加 onDelete 就行了。作为通用 UI,我们也添加一个 EditButton

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct GameResultListView: View {
  let store: Store<GameResultListState, GameResultListAction>
  var body: some View {
    WithViewStore(store) { viewStore in
      List {
        ForEach(viewStore.rows) { result in
          // ...
        }
+       .onDelete { viewStore.send(.remove(offset: $0)) }
      }
+     .toolbar {
+       EditButton()
+     }
    }
  }
}

onDelete 中,向 viewStore 发送 .remove action,从而触发 reducer 并更新状态即可。如果在 Preview 中选择运行,我们就可以在预览画布中直接删除显示的项目了。

基本导航

接下来通过导航的方式显示这个新创建的 GameResultListView。在 app 主页面中,我们已经看到过如何将小组件使用 pullback 的方式进行组合了。将 list feature 和 app 其他部分的 feature 进行组合的方式并没有什么不同:也就是把子组件的 state,action,reducer 和 view 都集成到父组件去。

在这里,我们计划在导航栏上添加一个 “Detail” 按钮,通过 NavigationLink 的方式显示结果列表。首先,在 CounterDemoApp.swift 中添加一个 NavigationView,作为整个 app 的容器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct CounterDemoApp: App {
  var body: some Scene {
    WindowGroup {
+     NavigationView {
        GameView(
          store: Store(
            initialState: GameState(),
            reducer: gameReducer,
            environment: .live)
        )
+     }
    }
  }
}
State

GameState 中,已经存在 var results: IdentifiedArrayOf<GameResult> 数据源了,我们可以直接将它作为列表画面的数据源。

Action

GameResultListView 操作结果数组的同时,我们希望把结果拉回到 GameState.results 里,为此,我们需要一个能处理 GameResultListAction 的 action。在 GameAction 中新加入一个成员:

1
2
3
4
5
enum GameAction {
  case counter(CounterAction)
  case timer(TimerAction)
+ case listResult(GameResultListAction) 
}
Reducer

更新 gameReducer,让 gameResultListReducer 根据 .listResult 的行为把操作的结果拉回到 results。在 gameReducercombine 的最后,添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let gameReducer = Reducer<GameState, GameAction, GameEnvironment>.combine(
  .init { state, action, environment in
    // ...
  },
  // ...
  timerReducer.pullback(
    state: \.timer,
    action: /GameAction.timer,
    environment: { .init(date: $0.date, mainQueue: $0.mainQueue) }
+  ),
+ gameResultListReducer.pullback(
+   state: \.results,
+   action: /GameAction.listResult,
+   environment: { _ in .init() }
  )
)

这样,接收到 .listResult action 时 gameResultListReducer 造成的结果 (新的 result list state,也就是 IdentifiedArrayOf<GameResult>) 将通过 \.results 这个 WritableKeyPath 写回到 GameState.results 属性中,以完成 state 的更新。

View

最后,在 body 中创建 NavigationLink,用 scoperesults 切割出来,把新的 store 传递给 GameResultListView 作为目标 view,导航就完成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct GameView: View {
  let store: Store<GameState, GameAction>
  var body: some View {
    WithViewStore(store.scope(state: \.results)) { viewStore in
      VStack {
        // ...
      }.onAppear {
        viewStore.send(.timer(.start))
      }
+   }.toolbar {
+     ToolbarItem(placement: .navigationBarTrailing) {
+       NavigationLink("Detail") {
+         GameResultListView(store: store.scope(state: \.results, action: GameAction.listResult))
+       }
+     }
    }
  }
结果

运行 app,现在主页面 GameView 处于 NavigationView 环境中。当进行几个猜数字后,点击 “Detail” 按钮,app 可以导航到 GameResultListView 中,你也可以在详细页面里删除几个结果,然后返回到主页面:注意主页面上的计数将随着详细页面的修改而变动,它们共享单一的数据源,这避免了数据在两个页面中的不同步,一般来说是非常好的实践:

如果你对 pullback 的行为还不清楚,推荐对照前一篇文章中的这张流程图再次确认:

存在的问题

TCA 这类类似 Elm 的架构形式,一大特点是 State 完全决定 UI,这也是在进行 UI 测试时很重要的手段:只要我们能构建出合适的 State (model 层),我们就能期待固定的 UI,这让整个 app 的界面成为一个“纯函数”:UI = F(State)

但不幸的是,上面这种简单的导航形式破坏了这个公式:显示主页面时的 State 和显示列表页面时的 State 是无法区分的,同一种状态可能会对应不同的 UI。这是因为管理导航的状态存在于 SwiftUI 内部,它在我们的 State 中没有体现出来。

如果不是很计较 app 的严肃性,那么这种简单的导航关系也不是不能接受。不过为了满足纯函数的要求,我们来看看 SwiftUI 提供的另一种导航方式,也就是基于 Binding 值控制的导航,要如何与 TCA 协同工作。

基于 Binding 的导航

除了上面用到的最简单的 init(_:destination:) 以外,NavigationLink 还有一些带有 Binding 的变种版本,比如:

1
2
3
4
5
6
7
8
9
10
11
12
init(
  _ titleKey: LocalizedStringKey, 
  isActive: Binding<Bool>, 
  @ViewBuilder destination: () -> Destination
)

init<V>(
  _ titleKey: LocalizedStringKey, 
  tag: V, 
  selection: Binding<V?>, 
  @ViewBuilder destination: () -> Destination
) where V : Hashable

前者接受 Binding<Bool>,这个 Binding 可以通过两种方式控制导航状态:

  • 当用户通过 UI 触发导航时,SwiftUI 负责将这个值设为 true。在使用回退按钮返回时,SwiftUI 负责将这个值设为 false
  • 我们也可以通过代码把这个 Binding 值设置为 truefalse 来触发相应的导航和回退行为。

相比起前者的 Bool,后者接受 V? 的绑定值和一个代表当前 NavigationLinktag 值:当 selectionVtagV 相同时,导航生效并展示 destination 的内容。为了判断这个相同,SwiftUI 要求 V 满足 Hashable

这两个变体为 TCA 提供了机会,可以通过 State 来控制导航状态:只要我们在 GameState 中添加一个代表的导航状态的变量,就可以通过把这个变量转换为 Binding 并设置它,来让状态和 UI 一一对应:即 state 为 true 或者 non-nil 值时,显示详细页面;否则为 falsenil 时,显示主页面。

Identified

在这个例子中,我们选用 Binding<V?> 的方法来控制。在 GameState 中添加一个属性:

1
2
3
4
struct GameState: Equatable {
  // ...
+ var resultListState: Identified<UUID, GameResultListState>?
}

Binding<V?> 中需要 V 满足 Hashable,这里我们原本的目标是让 GameResultListState (也就是 IdentifiedArrayOf<GameResult>) 满足 Hashable。这是一个相对困难的任务:我们可以为 IdentifiedArray 添加 Hashable 实现,但是这并不是一个好选择:这两个类型定义都不属于我们,我们无法控制将来 TCA 是否会为 IdentifiedArray 引入 Hashable 实现。TCA 中将一个任意值转为 Hashable 更简单的方式就是用 Identified 包装它,手动为它赋予一个 id 值,用它作为 V 的类型。在我们的例子中,导航只有一个单一的状态,所以我们完全可以定义一个通用的 UUID 作为 NavigationLinktag,在 GameView.swift 的顶层 scope 添加下面的定义:

1
let resultListStateTag = UUID()

使用 Binding<V?>tag 的版本,更多是为了区分多个可能的导航情况 (比如一个列表中的各个选项都可能导航至下一个页面)。

实际上,对于我们这里的例子,因为只有一个可能的触发导航的情况它,所以并没有必要使用 tag 的方式控制,只需要使用 Binding<Bool> 就可以了。不过我们还是选择 Binding 的版本作为例子,因为它更具一般性。

Binding 和导航 Action 处理

如果你还记得 TCA 中绑定值的处理方式,通过 viewStore.binding 操作绑定值时,可以在这个值发生变化时让 TCA 发送一个 action。我们需要在 reducer 中捕获这个 action 并为 resultListState 设置合适的值。在 GameAction 里添加控制导航的 action 成员:

1
2
3
4
5
6
enum GameAction {
  case counter(CounterAction)
  case listResult(GameResultListAction)
  case timer(TimerAction)
+ case setNavigation(UUID?)
}

然后将 bodyNavigationLink 的部分替换为基于 Binding 的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct GameView: View {
  let store: Store<GameState, GameAction>
  var body: some View {
    WithViewStore(store.scope(state: \.results)) { viewStore in
      // ...
    }.toolbar {
      ToolbarItem(placement: .navigationBarTrailing) {
-       NavigationLink("Detail") {
-          GameResultListView(store: store.scope(state: \.results, action: GameAction.listResult))
-       }
+       WithViewStore(store) { viewStore in
+         NavigationLink(
+           "Detail",
+           tag: resultListStateTag,
+           selection: viewStore.binding(get: \.resultListState?.id, send: GameAction.setNavigation),
+           destination: {
+             Text("Sample")
+           }
+         )
+       }
      }
    }
  }

NavigationLink 的 selection 被触发时,.setNavigation(resultListStateTag) 被发送,在 gameReducer 中,捕获这个 action 并进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let gameReducer = Reducer<GameState, GameAction, GameEnvironment>.combine(
  .init { state, action, environment in
    switch action {
    // ...
+   case .setNavigation(.some(let id)):
+     state.resultListState = .init(state.results, id: id)
+     return .none
+   case .setNavigation(.none):
+     state.results = state.resultListState?.value ?? []
+     state.resultListState = nil
+     return .none
    }
  },
  // ...
)

接收到带有 id.setNavigation action 时,我们手动设置 resultListState,这会触发导航。在用户退出导航时,接收到 .setNavigation(.none),这时我们把 resultListState.value 设置回 result,然后把整个 resultListState 置为 nil,从导航中返回。

现在,gameReducergameResultListReducer 进行 pullback 时,将结果拉回 results。但是现在我们想要传递给 GameResultListView 的值已经是 resultListState.value,而非原来的 results。我们需要修改 gameResultListReducer.pullback 的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let gameReducer = 
  // ...
  gameResultListReducer
-   .pullback(
-     state: \.results,
-     action: /GameAction.listResult,
-     environment: { _ in .init() }
-   )
+   .pullback(
+     state: \Identified.value,
+     action: .self,
+     environment: { $0 }
+   )
+   .optional()
+   .pullback(
+     state: \.resultListState,
+     action: /GameAction.listResult,
+     environment: { _ in .init() }
+   )
) 

如果你还记得 pullback 的初衷,你应该记得,它的目的是把原本作用在本地域上的 reducer 转换为能够作用在全局域的 reducer。在这里,我们想要做的是把 gameResultListReducerGameResultListState 造成的变更,拉回到 GameState.resultListState.value 中。因为 resultListState 是一个可选值,因此原本在 pullback 中我们应该把 state 写为 \.resultListState?.value。不过这种写法只能给我们不可写的 KeyPath,而非 pullback 要求的 WritableKeyPath。为了处理可选值,TCA 提供了 optional() 操作,来处理可选值的 WritableKeyPath。这里我们可以理解为,先把 GameResultListState 的结果写到某个 Identifiedvalue 里,然后把这个 Identified 包裹在一个可选值里,最后再通过 \.resultListState 写到 GameState 里。

IfLetStore

整个过程的最后一步,是在 NavigationLinkdestination 里创建正确的 GameResultListView。和上面 pullback 的情况类似,我们不再选择使用 results,而是使用 \.resultListState?.value 来切分 store:

1
2
3
4
5
// 注意,无法编译
store.scope(
  state: \.resultListState?.value, 
  action: GameAction.listResult
)

但这样做得到的是一个可选值 state 的类型 Store<GameResultListState?, GameResultListAction>,它并不能满足 GameResultListView 所需要的 Store<GameResultListState, GameResultListAction>。TCA 在处理 store 中可选值属性的切割时,使用 IfLetStore 来进行包装,它会根据其中状态可选值是否为 nil 来构建不同的 view:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var body: some View {
  // ...
  NavigationLink(
    "Detail",
    tag: resultListStateTag,
    selection: viewStore.binding(get: \.resultListState?.id, send: GameAction.setNavigation),
    destination: {
-     Text("Sample")
+     IfLetStore(
+       store.scope(state: \.resultListState?.value, action: GameAction.listResult),
+       then: { GameResultListView(store: $0) }
+     )
    }
  )
}

至此,我们完成了最完整的使用 Binding 进行导航的方式。运行 app,你会发现看起来整个 app 的行为和简单导航时并没有什么区别。但是我们现在可以通过构建合适的 GameState,来直接显示结果详细页面。这在追踪和调试 app 中带来巨大便利,也正是 TCA 的强大之处。比如,在 CounterDemoApp 中,我们可以添加一些 sample:

1
2
3
4
5
6
7
8
9
10
11
12
let sample: GameResultListState = [
  .init(counter: .init(count: 10, secret: 10, id: .init()), timeSpent: 100),
  .init(counter: .init(), timeSpent: 100),
]

let testState = GameState(
  counter: .init(), 
  timer: .init(),
  resultListState: .init(sample, id: resultListStateTag),
  results: sample,
  lastTimestamp: 100
)

然后将它直接设置给 app:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct CounterDemoApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationView {
        GameView(
          store: Store(
-           initialState: GameState(),
+           initialState: testState
            reducer: gameReducer,
            environment: .live)
        )
      }
    }
  }
}

现在运行 app,我们会被直接导航到结果页面。确保唯一的 state 对应的唯一 UI,可以让开发快速定位问题:只需要提供 app 出现问题时的 state,理论上就可以稳定重现和立即开始调试。

更多讨论

SwiftUI 导航最佳实践

虽然 Apple 在 SwiftUI 导航上做了不少努力,但是传统的几种导航方式有一定缺失:不论是 navigation 还是 sheet,对于基于 Binding 的导航,控制导航状态的 Binding 值并不会被传递到 NavigationLinkdestination 或者 View.sheetcontent 中,这导致后续页面无法有效修改前置页面的数据,从而造成事实上的数据源不统一。

在 TCA 中因为不能直接修改 state,我们选择通过在 Binding 变化时发送 action 的方式更新 state。这种方法在 TCA 里非常合适,但在普通的 SwiftUI app 里虽然也可行,却显得有点儿格格不入。TCA 的维护者对此专门开源了一套工具,来补充原生 SwiftUI 架构在导航上的不足,其中也包含了对于这个话题的更深入的讨论。

ViewStore 的各种形式

在上面的例子中,我们看到了在 View 中使用 IfLetStore 来切分 state 中的可选值的方法;对于可选值,在组合 reducer 时,我们在 pullback 之前相应地使用了 optional() 方法将非可选的本地状态转换为可选值的全局状态,从而完成状态回拉。

另一种特殊的 Store 形式是 ForEachStore,它针对 State 中的 IdentifiedArray,将其中每一个元素切为一个新的 Store。如果 List 中的每个 cell 自成一套 feature 的话 (比如示例的猜数字 app 中,允许结果列表页面的每个结果 cell 再点击进去,并显示一个 CounterView 来修改内容的话),这种方式将让我们很容易把 List 和 TCA 进行结合。与 IfLetStoreoptional() 的关系类似,在组合 reducer 时,TCA 也为 IdentifiedArray 的属性准备了 forEach 方法来把数组中的各个元素变更拉回到全局状态的对应元素中。我们将把关于数组切分和拉回的课题作为练习留给读者。

另外,对于 enum 形式的 State,TCA 也准备了相应的 SwitchStoreCaseLet,可以让我们以相似的语法根据不同 State 属性创建 view。关于这些内容,在理解了 TCA 的工作原理后,就都是一些类似语法糖的存在,可以在实际用到时再加以确认。

Alert 和结果存储

可能有细心的同学会问,在上面 Binding 导航的时候,为什么不直接选择在 .setNavigation(.some(let id)) 的时候单独只设置一个 UUID,而保持将结果直接 pullback 到 results 呢?resultListState 存在的意义是什么?或者甚至,为什么不直接使用 Binding<Bool>NavigationLink 版本呢?

对于很多情况,在 list view 里直接操作 results 是完全可行的,不过如果我们有需要暂时保留原来数据的场景的话,在 .setNavigation(.some(let id)) 中复制一份 results (在例子中我们通过创建新的 Identified 值进行复制),在编辑过程中保持原来 results 的稳定,并在完全结束后再把更改后的 resultListState 重新赋给 results 就是必要的了。

我们通过一个例子来说明,比如现在我们希望在从列表界面返回后多加一次 alert 弹窗确认,当用户确认更改后通过网络请求向服务端“汇报”这次更改,然后在成功后再刷新 UI。如果用户选择放弃修改的话,则维持原来的结果不变。

AlertState

显示一个 alert 在 app 开发中是非常常见的,TCA 为此内置了一个专门用来管理 alert 的类型:AlertState。为了让 alert 能够工作,我们可以为它添加一组 action,描述 alert 的按钮点击行为。在 GameView.swift 中添加:

1
2
3
4
5
enum GameAlertAction: Equatable {
  case alertSaveButtonTapped
  case alertCancelButtonTapped
  case alertDismiss
}

然后在 GameState 里新增 alert 属性:

1
2
3
4
5
struct GameState: Equatable {
  // ...
  
+ var alert: AlertState<GameAlertAction>?
}

和处理导航关系时一样,通过在 reducer 里设置 alert 可选值,就可以控制 alert 的显示和隐藏。我们计划在从结果列表页面返回时展示这个 alert,修改 gameReducersetNavigation(.none) 分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let gameReducer = Reducer<GameState, GameAction, GameEnvironment>.combine(
  .init { state, action, environment in
    switch action {
    // ...
    case .setNavigation(.none):
-     state.results = state.resultListState?.value ?? []
-     state.resultListState = nil
+     if state.resultListState?.value != state.results {
+       state.alert = .init(
+         title: .init("Save Changes?"),
+         primaryButton: .default(.init("OK"), action: .send(.alertSaveButtonTapped)),
+         secondaryButton: .cancel(.init("Cancel"), action: .send(.alertCancelButtonTapped))
+       )
+     } else {
+       state.resultListState = nil
+     }
      return .none
    }
    // ...
)

最后,在 GameView 中合适的地方加上这个 alert 就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct GameView: View {
  // ...
  var body: some View {
    WithViewStore(store.scope(state: \.results)) { viewStore in
      // ...
    }.toolbar {
      // ...
+   }.alert(
+     store.scope(state: \.alert, action: GameAction.alertAction),
+     dismiss: .alertDismiss
+   )
  }
  // ...
}

处理 dismiss 和按钮事件

不过现在代码还无法编译,因为在 reducer 里我们还没有处理 alert 相关的 action。

.alertDismiss action 将会在 alert 被 dismiss 的时候触发,之后 TCA 再根据具体是哪个按钮被点击,向 reducer 发送对应按钮绑定的 action。因此常规做法是在 .alertDismiss 中将 state.alert 置回 nil,然后在 .alertSaveButtonTapped.alertCancelButtonTapped 进行相应的逻辑。在 gameReducer.setNavigation(.none) 之后追加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let gameReducer = Reducer<GameState, GameAction, GameEnvironment>.combine(
  .init { state, action, environment in
    switch action {
    // ...
    case .setNavigation(.none):
    // ...
+   case .alertAction(.alertDismiss):
+     state.alert = nil
+     return .none
+   case .alertAction(.alertSaveButtonTapped):
+     // Todo: 暂时直接设置 results,实际应该发送请求。
+     state.results = state.resultListState?.value ?? []
+     state.resultListState = nil
+     return .none
+   case .alertAction(.alertCancelButtonTapped):
+     state.resultListState = nil
+     return .none

一切准备就绪,现在运行 app,尝试在结果列表中删除几个项目,并返回主页面。现在结果并不会直接更新了,而是先弹出确认框,并在用户点击保存时才进行更新。

Effect 和 Loading UI

最后我们来处理上面代码中 “Todo” 的部分:发送实际请求,并在完成时再进行 results 的更新。为了简单起见,这里就只用一个 delayEffect 来模拟这个请求了。实际的网络请求的实现 (以及错误处理),就留作练习了。

GameAction 中添加一个 case 代表请求结果:

1
2
3
4
enum GameAction {
  // ...
+ case saveResult(Result<Void, URLError>)
}

为了显示网络请求正在进行,我们可以在 state 里添加一个属性,表示加载正在进行:

1
2
3
4
struct GameState: Equatable {
  // ...
+ var savingResults: Bool = false
}

然后将 gameReducer.alertSaveButtonTapped case 中的处理替换,并添加对 .saveResult 的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    case .alertAction(.alertSaveButtonTapped):
-     // Todo: 暂时直接设置 results,实际应该发送请求。
-     state.results = state.resultListState?.value ?? []
-     state.resultListState = nil
-     return .none
+     state.savingResults = true
+     return Effect(value: .saveResult(.success(())))
+       .delay(for: 2, scheduler: environment.mainQueue)
+       .eraseToEffect()
    // ...
+   case .saveResult(let result):
+     state.savingResults = false
+     state.results = state.resultListState?.value ?? []
+     state.resultListState = nil
+     return .none

最后,稍微修改 GameViewNavigationLink 的部分,在请求途中显示一个 ProgressView

1
2
3
4
5
6
7
8
9
10
NavigationLink(
  // ...
  label: {
+   if viewStore.savingResults {
+     ProgressView()
+   } else {
      Text("Detail")
+   }
  }
)

总结

到这里,我想应该可以为这一个系列的 TCA 教程画上句号了。我们看到了 TCA 的各个组件以及它们的组织方式,常见的一些用法和模式,并且对背后的思想进行了探索。虽然我们没有涉及到 TCA 框架的所有部分 (毕竟这系列文章并不是使用手册,篇幅上也不允许),但是一旦我们理解和弄清了架构的思想,那么使用顶层 API 就只是手到擒来了。

对于更大和更复杂的 app 架构,TCA 框架会面临其他一些问题,比如数据在多个 feature 间共享的方式,state 过于庞大后可能带来的性能问题,以及跨越多个层级传递数据的方式等。本文写作时,这些问题都没有特别完美和通用的解决方式。不过,TCA 并没有到达 1.0 版本,它本身也在快速发展和演进中,几乎每个月都会有全新的特性甚至破坏性的变化被引入。如果你遇到了棘手的问题,或者对最佳实践有所疑问,不妨到 TCA 的项目和 issue 页面中寻求答案或者帮助。将你的心得和体会总结,并通过某种方式回馈给社区,也将会对这个项目的建设带来好处。

想要进一步学习 TCA 的话,除了它本身带有的几个 demo 以外,Point-Free 实际上还开源了一个相当完整的项目:isowords。另外,他们主持的每周教学节目,也对包括 TCA 在内的很多 Swift 话题进行了非常深刻的讨论,如果学有余力,我个人十分推荐。

练习

如果你没有跟随本文更新代码,你可以在这里找到下面练习的起始代码。

使用 modal 进行展示

在本文中,我们使用了 NavigationLink 来展示结果页面。iOS app 里另一种常见的迁移方式是 modal present。尝试使用 sheet(item:onDismiss:content:) 来呈现结果列表页面。

实际的网络请求

在用户点击保存按钮时,我们使用了下面的 Effect 来模拟网络请求:

1
2
Effect(value: .saveResult(.success(())))
  .delay(for: 2, scheduler: environment.mainQueue)

请你尝试把这个用来模拟的 Effect 替换成实际的网络请求吧!不需要真的进行数据传递,只需要随意构建一个 dataTask 就好,比如:

1
2
3
4
5
let sampleRequest = URLSession.shared
  .dataTaskPublisher(for: URL(string: "https://example.com")!)
  .map { element -> String in
    return String(data: element.data, encoding: .utf8) ?? ""
  }

然后把结果用 catchToEffect 转换为我们需要的类型。需要注意,在 reducer 中应该要合理处理错误的情况;另外,为了能够测试,这个请求应该放在环境值中,而不是直接写在 reducer 里。如果你已经忘了如何使用 TCA 处理网络请求和进行测试,可以参考前一篇文章中关于网络请求 Effect 的部分。

Loading UI 的问题

在进行保存请求时,savingResultstrue。这种情况下,我们在 GameView 里把 “Detail” 按钮替换为了 ProgressView。但是主界面中的 “Next” 按钮依然可以点击,请求期间我们仍可把新的结果添加到 results 里。在网络请求结束后,results 里虽然可能存在新的结果,但它还是会被 resultListState 覆盖,导致请求期间的结果丢失。参见下面的重现步骤:

要解决这个问题,可以选择在请求期间禁用 “Next” 按钮 (比较简单的实现,但是很差很粗暴的用户体验),或者引入一种机制来合并结果 (比较好的体验,但需要更多代码)。或者你可以自行考虑其他的解决方案。

尝试 ForEachStore

文中没有用到 ForEachStore。请参考 TCA 的相关文档,学习 ForEachStoreReducer.forEach 的用法,在结果列表页面中添加一层导航,来增加对每个结果的“编辑”功能,让用户可以利用 CounterView 修改他们之前的猜测结果。

该博客文章由作者通过 CC BY 4.0 进行授权。

TCA - SwiftUI 的救星?(三)

-