The Composable Architecture(TCA)におけるBindingの扱い方

f:id:bamboohero:20210531024930p:plain

The Composable Architecture(TCA)のサンプルからBindingの扱い方について学びます。

今回はCaseStudiesの01-GettingStarted-Bindings-Basics01-GettingStarted-Bindings-Formsを例に説明します。



単方向データフローの原則を守る

TCAは単方向データフローという設計思想を持っています。

データ(State)の変更は必ず、Action→Recucer→Storeという流れで行い、最後にViewにデータの変更が通知されるというものです。

一方、SwiftUIには双方向データバインディングを行うための仕組みが備わっていて、MVVMで実装する際はこれを利用してViewModel⇔Viewという双方向でデータのやり取りを行います。
このデータバインディングの機能は便利ですが、気をつけないとコードのあちこちでデータを変更することができてしまいます。データ変更があちこちで行われてしまうと、データの流れを追うのが難しくなり、バグを生む原因にもなります。

TCAでは単方向データフローを守るための仕組みをフレームワークが提供してくれていて、開発者がうっかり間違った実装をしてしまうリスクを排除してくれます。

設計原則を強制的に守らせることができるというのが魅力ですね。


Bindingを使った実装方法

サンプルの画面はこんな感じです。コンポーネントが4つありますが、ここでは3つ目のStepperと4つ目のSliderに注目します。

f:id:bamboohero:20210531024536g:plain


StepperとSliderの実装を抜き出したのがこちらです。

enum BindingBasicsAction {
  case sliderValueChanged(Double)
  case stepCountChanged(Int)
}

Stepper(
  // [1]
  value: viewStore.binding(
    get: \.stepCount, send: BindingBasicsAction.stepCountChanged),
  in: 0...100
) {
  Text("Max slider value: \(viewStore.stepCount)")
    .font(Font.body.monospacedDigit())
}

HStack {
  Text("Slider value: \(Int(viewStore.sliderValue))")
    .font(Font.body.monospacedDigit())
  Slider(
    value: viewStore.binding(
      // [2]
      get: \.sliderValue, send: BindingBasicsAction.sliderValueChanged),
    in: 0...Double(viewStore.stepCount)
  )
}

[1]
ViewStore.binding(get:send:)でBindingを生成します。
get:にはViewStoreが持っているStateを取り出す関数もしくはKeyPathを指定します。
send:にはActionを指定します。画面操作でStepperの値が変更されたときに、ここで指定したActionが送信されます。

[2]
[1]と同じです。Sliderの値変更時にActionが送信されます。


ViewStore.binding(get:send:)の実装を覗いてみましょう。

public func binding<LocalState>(
  get: @escaping (State) -> LocalState,
  send localStateToViewAction: @escaping (LocalState) -> Action
) -> Binding<LocalState> {
  Binding(
    get: { get(self.state) },
    set: { newLocalState, transaction in
      // [1]
      if transaction.animation != nil {
        withTransaction(transaction) {
          self.send(localStateToViewAction(newLocalState))
        }
      } else {
        self.send(localStateToViewAction(newLocalState))
      }
    }
  )
}

[1]
StepperやSliderがユーザ操作を検知したとき、Stateの値を直接書き換えるのではなく、Actionを送信するようになっています。


ビューではViewStoreが持つStateを参照することはできますが、この値を書き換えることはできませんし、Bindingにおいても値を直接書き換えるのではなくActionを送信するようにしています。先述した単方向データフローがきっちりと守られていますね

Stateの変更は最終的にReducerが行います。Recucerの実装は以下のようになっています。

let bindingBasicsReducer = Reducer<
  BindingBasicsState, BindingBasicsAction, BindingBasicsEnvironment
> {
  state, action, _ in
  switch action {
  case let .sliderValueChanged(value):
    state.sliderValue = value
    return .none

  case let .stepCountChanged(count):
    // [1]
    state.sliderValue = .minimum(state.sliderValue, Double(count))
    state.stepCount = count
    return .none

  // ...(以下省略)

[1]
本サンプルでは、SliderはstepCountより大きい値を指定することはできないという仕様になっています。このようなドメインのロジックはReducerに閉じ込めるというのがTCAにおけるルールです。


BindingActionでActionを統合する

上記の例では、StepperとSliderそれぞれに対してstepCountChangedsliderValueChangedというActionが定義されていました。

これだと全てのUIコントロールに対してActionを定義しなければなりませんが、TCAはこれらを一つに統合できるBindingActionという仕組みを提供しています。

BindingActionを使用すると、実装は以下のように変わります。

enum BindingFormAction: Equatable {
  // [1]
  case binding(BindingAction<BindingFormState>)
}

Stepper(
  // [2]
  value: viewStore.binding(keyPath: \.stepCount, send: BindingFormAction.binding),
  in: 0...100
) {
  Text("Max slider value: \(viewStore.stepCount)")
    .font(Font.body.monospacedDigit())
}

HStack {
  Text("Slider value: \(Int(viewStore.sliderValue))")
    .font(Font.body.monospacedDigit())
  Slider(
    // [2]
    value: viewStore.binding(keyPath: \.sliderValue, send: BindingFormAction.binding),
    in: 0...Double(viewStore.stepCount)
  )
}

[1]
BindingAction型のAssociatedValueを持つケースが一つだけ定義されています。

[2]
StepperとSliderのBindingではどちらも同じActionが指定されています。


Reducerの実装はこう変わります。

let bindingFormReducer = Reducer<
  BindingFormState, BindingFormAction, BindingFormEnvironment
> {
  state, action, _ in
  switch action {
  // [1]
  case .binding(\.stepCount):
    state.sliderValue = .minimum(state.sliderValue, Double(state.stepCount))
    return .none

  // [2]
  case .binding:
    return .none

  // ...(以下省略)

[1]
KeyPathで特定のActionにマッチするようにしています。ここではStepperの値変更Actionに対する実装がされています。

[2]
その他のActionについては何も実装がされていませんが、Actionを受け取ってStateに値を反映させるだけの処理はBindingActionがデフォルトで実装しているようです。例えばSliderの操作によるsliderValueの変更はデフォルト実装によって処理されます。


まとめ

  • TCAには単方向データフローを強制する仕組みが備わっている
  • Bindingにおいても単方向データフローが守られている
  • BindingActionを使用することで、UIコントロールに対するActionを一つに統合することができる