The Composable Architecture(TCA)のサンプルからComposableなArchitectureを学ぶ

f:id:bamboohero:20210529013258p:plain

前回はTCAの一番単純なサンプルである01-GettingStarted-Counterについて説明しました。

bamboo-hero.com


今回は01-GettingStarted-Composition-TwoCountersについて説明してみます。



小さいモジュールを組み合わせてアプリを構成する

本サンプルの目的は、小さいモジュールを組み合わせて一つのアプリを構成する方法について説明することです。

これはまさしく、アーキテクチャの名前となっているComposableなArchitectureの一番小さい実践例です。

本サンプルでは、前回の記事で説明したカウンターサンプルのコンポーネントであるCounterViewCounterStateCounterActionを使っています。そして、これらを組み合わせることでより大きい単位の機能を作っています。

具体的には、2つのカウンター機能を持った画面です。

f:id:bamboohero:20210528001344g:plain


Stateのスコープを絞る

こちらがサンプルの実装の一部です。カウンター機能部分はCounterViewを使っています。

// [1]
struct TwoCountersState: Equatable {
  var counter1 = CounterState()
  var counter2 = CounterState()
}

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

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

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

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

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

[1]
TwoCountersStateは本サンプル画面のStateです。子ビュー(CounterView)のStateであるCounterState型のプロパティを2つ持っています。

[2]
CounterViewのイニシャライザはStoreインスタンスを要求しますが、ここで渡すStoreが扱うStateをCounterStateに変換します。
Store.scope(state:action:)は指定されたState、Actionの型に対応したStoreインスタンスを新たに生成して返すメソッドです。

CounterViewが扱うStateをTwoCountersStateではなくCounterStateにすることで、CounterViewが上位の画面の状態を変更してしまう可能性を排除しています。例えばTwoCountersStateがtotalCountなるプロパティを持っていたとして、CounterViewがそれを変更できないようにしています。
状態変化を起こせる範囲を限定することで、予期せぬバグを生まないように工夫されているんですね。

また、CounterViewがCounterStateのみに依存していることで、他の画面でも再利用することが可能です。これによってComposableなArchitectureが作れるわけですね。


小さいReducerを組み合わせて大きなReducerを作る

本サンプルでもう一つ学べるのはReducerの実装ですね。以下のように、2つのcounterReducerを組み合わせています。

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

[1]
counterReducerはCounterActionを受け取ってCounterStateを変化させるReducerです。CounterViewで使われているものですね。

ここではpullbackメソッドを使い、counterReducerをTwoCountersState、TwoCountersActionに対応させています。pullbackはローカルStateで動作するReducerを上位のStateで動作するReducerに変換するためのメソッドです。
CounterState、CounterActionで動作するReducerを、TwoCountersState、TwoCountersActionで動作するReducerに変換したんですね。

[2]
2つのcounterReducercombineで一つのReducerに統合しています。
ここで、combineメソッドに渡すReducerの順番は意識する必要があります。本サンプルでは特に問題になりませんが、各Reducerが同じ状態を変更する可能性があるとき、Reducerはcombineに渡した順番で実行されるので、ReducerA→ReducerBとReducerB→ReducerAでは状態変化の結果が変わる可能性があります。
具体例がないとわかりづらく、私もまだそういう例に触れていないので詳しい説明はできませんが、頭の片隅に入れておこうと思います。


まとめ

本サンプルでは小さいモジュールを組み合わせて一つのアプリを構成するというThe Composable Architectureのコンセプトの具体に触れることができました。

学んだことの一つは、各モジュールが扱うStateのスコープを小さくすることで、そのモジュールの再利用性を高めることができるということ。

もう一つは、Reducerの一つ一つは小さく実装した上で、上位レイヤで複数のReducerを組み合わせて一つの大きなReducerを作ることができるということ。その際、Reducerを組み合わせる順番を意識する必要があること。

まだまだ2つのサンプルを見ただけですが、TCAを理解する上で重要な内容が含まれていましたね。引き続きサンプルの研究を続けていきます。