🍎

[SwiftUI][TCA] pullback/combine/scope

2022/05/13に公開

概要

この記事ではTCA初心者の筆者が理解を深めていくために、
pointfreeco公式のサンプルアプリを基に理解しやすく整理していきます。
今回はpullback・combine・scopeといったメソッドの使用例を整理していきます。

前回のTCAの基礎についてはこちら

今回扱うファイル

今回は公式サンプルのこちらのファイルです。
https://github.com/pointfreeco/swift-composable-architecture/blob/0.16.0/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-Composition-TwoCounters.swift

Composition-TwoCounters

ここでは主にpullbackcombineというメソッドでReducerを合成したり、
scopeを使用しグローバルなstoreをローカルなstoreに変換しています。

State,Action

struct TwoCountersState: Equatable {
  var counter1 = CounterState()
  var counter2 = CounterState()
}

enum TwoCountersAction {
  case counter1(CounterAction)
  case counter2(CounterAction)
}

Reducer

今回Reducerでは前回のCounterアプリのcounterReducerを使用していて、
その2つのReducerを1つにまとめる事が出来る、
以下のpullbackcombineいうメソッドについて説明します。

let twoCountersReducer = Reducer<TwoCountersState, TwoCountersAction, TwoCountersEnvironment>
  .combine(
    counterReducer.pullback(
      state: \TwoCountersState.counter1,
      action: /TwoCountersAction.counter1,
      environment: { _ in CounterEnvironment() }
    ),
    counterReducer.pullback(
      state: \TwoCountersState.counter2,
      action: /TwoCountersAction.counter2,
      environment: { _ in CounterEnvironment() }
    )
  )

pullback

pullbackについて公式の詳細なドキュメントです。
https://pointfreeco.github.io/swift-composable-architecture/Reducer/#reducer.pullback(state:action:environment:)
ドキュメントの説明には以下のように記載があります。

ローカルの状態、アクション、環境に対して動作するリデューサを、グローバルな状態、アクション、環境に対して動作するリデューサに変換します。これは、メソッドに3つの変換を提供することで実現されます。


グローバル状態からローカル状態の一部を取得/設定することができる書き込み可能なキーパス。


ローカルなアクションをグローバルなアクションに抽出/埋め込むことができるケースパス。


グローバル環境をローカル環境に変換する関数。


この操作は、大きなリデューサを小さなリデューサに分解するために重要である。combine(_:)-1ern2演算子と一緒に使うと、ドメインの小さな断片に対して動作する多くのリデューサを定義し、それらを引き戻して大きなドメインに対して動作する一つの大きなリデューサに結合することができます。

今回の実装で整理すると、
現状のまま参照すればもちろんcounterReducerの型は、
Reducer<CounterState, CounterAction, CounterEnvironment>なので、
Reducer<TwoCountersState, TwoCountersAction, TwoCountersEnvironment>に変換する必要があります。
counterReducerに対してpullbackメソッドを使用することで上記のReducerに変換出来ます。
pullbackという命名の通り、
既存生成されているReducerを1回元に戻す(init)ことで、
変換したいReducerに必要な引数である、
StateActionEnvironmentを新たに指定することで変換するイメージです。

combine

combineについて公式の詳細なドキュメントです。
https://pointfreeco.github.io/swift-composable-architecture/Reducer/#reducer.combine(_:)
ドキュメントの説明には以下のように記載があります。

多くのリデューサを1つにまとめる。


ここで重要なのは、リデューサーの組み合わせの順番が重要であるということです。reducerA と reducerB を組み合わせることは、reducerB と reducerA を組み合わせることと同じとは限りません。


これは、ドメインが重複しているリデューサを扱うときに問題になることがあります。たとえば、reducerAがreducerBのドメインを埋め込んで、そのアクションに反応したり、その状態を変更したりする場合、reducerAがreducerBの実行前にreducerBの状態を変更することを選択するか、reducerBの実行後に変更するかで違いが生じることがあるのです。


これは、おそらく optional(file:line:) のリデューサを扱うときに最もわかりやすいと思います。この場合、親ドメインは子ドメインをリスニングしてその状態をnil outすることができます。親リデューサが子リデューサより先に実行されると、 子リデューサは自分自身のアクションに反応することができません。


親リデューサを子リデューサより先に実行すると、アプリケーションのロジックエラーと見なされ、アサーションに失敗する可能性があります。そのため、ほとんどの場合、子ドメインから親ドメインへ順にreducerを組み合わせる必要があります。

Reducer<TwoCountersState, TwoCountersAction, TwoCountersEnvironment>
このReducerへの変換に成功しました。
次はこの2つのReducerを統合し、
1つのReducerにするために必要なメソッドがcombineになります。
今回は2つでしたが引数にReducerの配列を指定することも可能です。

View,Store

ここのポイントは、self.store.scopeの処理です。
共通のCounterViewというコンポーネントを使いますが、
TwoCountersStateTwoCountersActionには今回複数のケースがあります。
それらを区別し使用したいStateActionを引数に指定します。
今回は変数counter1counter2も同じCounterStateですが、
グローバルなTwoCountersStateに別のstateが定義されている場合などは、
使用したいローカルなstateを指定するのによく使えると思います。

struct TwoCountersView: View {
  let store: Store<TwoCountersState, TwoCountersAction>

  var body: some View {
    Form {
      Section(header: Text(template: readMe, .caption)) {
        HStack {
          Text("Counter 1")

          CounterView(
            store: self.store.scope(state: \.counter1, action: TwoCountersAction.counter1)
          )
          .buttonStyle(.borderless)
          .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
        }
        HStack {
          Text("Counter 2")

          CounterView(
            store: self.store.scope(state: \.counter2, action: TwoCountersAction.counter2)
          )
          .buttonStyle(.borderless)
          .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
        }
      }
    }
    .navigationBarTitle("Two counter demo")
  }
}

次回

次回はbindingの使用を理解していきます。
記事はこちら

Discussion