LoginSignup
15
6

More than 3 years have passed since last update.

Composable Architecture を利用した Todo アプリの紹介(Part2)

Last updated at Posted at 2020-10-17

前回書いた Composable Architecture を利用した Todo アプリの紹介(Part1)の続きで、 Part2 の部分について書きました。

今回出来上がるもの

Composable Architecture を利用した Todo アプリ(Part2)

Part1 の最後まで終えた後の ActionReducer は以下のようになっていると思います。

ContentView.swift
enum AppAction {
  case todoCheckboxTapped(index: Int)
  case todoTextFieldChanged(index: Int, text: String)
}

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, _ in
  switch action {
  case let .todoCheckboxTapped(index: index):
    state.todos[index].isComplete.toggle()
    return .none

  case let .todoTextFieldChanged(index: index, text: text):
    state.todos[index].description = text
    return .none
  }
}

↑のままだと、Action を追加する度に index を必要とする Action が増えていき、View からの index のバインドも繰り返すことになってしまい非効率です。今は全ての Todo を対象にしてしまっているため、 index が必要となっていますが、一つの Todo のみを対象にした Reducer を作って、それを Todo のコレクションで動作する Reducer に変換できたら効率化できそうです。(Todo のコレクション一つ一つに対して、一つの Todo のみを対象にした Reducer を適用するイメージです)
Composable Architecture では、このような ユースケースをサポートするために forEach というオペレータがあります。これについては後で説明します。

単一の Todo のための Action, Environment, Reducer の定義

まずは、一つの Todo のみを対象とした Reducer を作る準備を進めるために、新しく以下のような Action を定義します。

ContentView.swift
enum TodoAction {
  case checkboxTapped
  case textFieldChanged(String)
}

AppAction にあったものから接頭辞である todo が切り取られ、index もなくなりました。

次に単一の Todo のための依存関係である Environment も定義します。特に今は依存関係は存在しないので、空で良いです。

ContentView.swift
struct TodoEnvironment {}

次に単一の TodoTodoAction のみで動作する Reducer を定義します。

ContentView.swift
let todoReducer = Reducer<Todo, TodoAction, TodoEnvironment> { state, action, _ in
  switch action {
  case .checkboxTapped:
    state.isComplete.toggle()
    return .none

  case .textFieldChanged(let text):
    state.description = text
    return .none
  }
}

AppAction, appReducer をそれぞれ TodoAction, todoReducer と組み合わせて使用する

次に、AppActionを先ほど定義した特定の TodoAction で動作するように書き換えます。

ContentView.swift
enum AppAction {
  case todo(index: Int, action: TodoAction)
}

次に、一つの Todo に対して動作する todoReducer を、Todo のコレクション全体に対して動作する Reducer に変換するために、forEachを使用します。

ContentView.swift
let appReducer = todoReducer.forEach(
  state: <#T##WritableKeyPath<GlobalState, MutableCollection>#>,
  action: <#T##CasePath<GlobalAction, (Comparable, TodoAction)>#>,
  environment: <#T##(GlobalEnvironment) -> TodoEnvironment#>
)

だいぶ複雑なので、forEach が何をしようとしているかを説明していきます。
forEach には、stateactionenvironment の三つの引数があり、いくつかの Generics や KeyPath, CasePath などがあることがわかると思います。だいぶ複雑ではあるのですが、Composable Architecture では何度も使用することになるので、いずれ慣れるらしいです。(自分はまだ慣れていないですが...)

元の記事では forEach の中心的な考え方は、「ドメインの一部(今で言うと単一の Todo のみしか知らない todoReducer )を、より複雑なドメイン(今で言うと todo のコレクションを知っている appReducer )に変換したい」ということだと述べられています。
実際にそれを行っていくためには、単一の Todostateactionenvironment それぞれを適切に変換していく必要があります。

一つ一つの引数の埋め方について見ていきましょう。

まず、state です。

state: <#T##WritableKeyPath<GlobalState, MutableCollection>#>

state ではグローバルな State である AppState からコレクション(Todos)を取り出し、そのコレクション内の任意の要素を変更した上で、取り出したコレクション全体を AppState に戻すことができるようにする必要があります。KeyPath については詳しく説明しませんが(こちらの記事が参考になりました🙇‍♂️)、WritableKeyPath でこのようなことを実現することができます。

そのため、WritableKeyPath を利用して state の部分は以下のように書くことができます。

todoReducer.forEach(
  state: \AppState.todos,
  action: <#T##CasePath<GlobalAction, (Comparable, TodoAction)>#>,
  environment: <#T##(GlobalEnvironment) -> TodoEnvironment#>
)

次に、action です。

action: <#T##CasePath<GlobalAction, (Comparable, TodoAction)>#>

action では、グローバルな Action である AppActioncase を関心のある単一の index (コード上では Comparable )と一緒になった TodoAction に分離する必要があります。ここではそのために、CasePath というものが使われています。CasePath は Composable Architecture の作者である Point-Free さんが作られているもので、struct に対して KeyPath を使うと struct のプロパティを分離できるのと似たように、enum に対して CasePath を使うと enum の case を分離することができます(参照)。
以上を踏まえて、action は以下のように書くことができます。

todoReducer.forEach(
  state: \AppState.todos,
  action: /AppAction.todo(index:action:),
  environment: <#T##(GlobalEnvironment) -> TodoEnvironment#>
)

action の部分は、自分で頑張って書くこともできますが、Composable Architecture には変換値を生成できる機能があるため、/AppAction.todo(index:action:) のように書くことができています。

最後に、environment です。

environment: <#T##(GlobalEnvironment) -> TodoEnvironment#>

environmentstateaction に比べると単純で、グローバルな Environment をローカルな TodoEnvironment に変換するだけです。今回はグローバルな Environment は特に必要ないため、省略することができ以下のように書くことができます(ついでに state の書き方も省略しています)。

ContentView.swift
let appReducer: Reducer<AppState, AppAction, AppEnvironment> = todoReducer.forEach(
  state: \.todos,
  action: /AppAction.todo(index:action:),
  environment: { _ in TodoEnvironment() }
)

もちろん、todoReducer がもっと複雑になれば、ここでグローバルな Envrionment を使うことも出てきますが今回は省略で OK です。

forEach 大変ですね...
実際大変だと思うのですが、forEach 内で面倒な index に関する作業を全て一箇所で処理してくれるため、Reducer 内では index について全く考える必要がなくなる利点があると作者は述べています。今はシンプルな Todo アプリであるため、その恩恵は感じることがあまりできませんが、Action の数が増えたり、Effect を実行する必要が出てきたりして、todoReducer がより複雑になるとこの恩恵を感じることができるようです。
実際、複雑には見えますが、やっていることは appReducertodoReducer を繋ぐだけになっています。アーキテクチャによってはこのような部分を隠蔽するものもありますが、Composable Architecture でこのコードをコンパイルすることによって、正しくコードが動いていることを非常に強力に保証してくれるとのことです。また、(後の章にはなりますが)テストを書くとこの部分のコードは嫌でも実行されることになるため、テストカバレッジも得ることができるという仕組みになっているみたいです。

View への適用

ここまでで、新たな appReducer の準備は整ったので、いよいよ View に適用していきます。
まず、Button の部分は ↓ のように書き換えることができます。

ContentView.swift
Button(action: { viewStore.send(.todo(index: index, action: .checkboxTapped)) }) {

次に、TextField の部分は ↓ のように書き換えられます。

ContentView.swift
TextField(
  "Untitled Todo",
  text: viewStore.binding(
    get: { $0.todos[index].description },
    send: { .todo(index: index, action: .textFieldChanged($0)) }
  )
)

この状態でビルドすれば、Part1 の時と同様に動作していることを確認することができます。appReducertodoReducer を利用することによって、単一の Todo のための todoReducerTodo のコレクションにうまく適用することができました 🎉

View ヘルパー ForEachStore の利用

しかし、View の書き方はもっと改善することができます。

ForEach では直接 ForEach 内で index をいじり回す必要がありますが、Composable Architecture の View ヘルパーである ForEachStore を利用することでもっと綺麗に書くことができます。

ForEachStore には、Actionindex と ローカルアクション(今でいうと TodoAction)である Store を渡してあげる必要があります。ちょうど View で宣言されている ↓ の Store はそのような構造になっていますね。

let store: Store<AppState, AppAction>

そのため、View 内で ForEachStore には以下のように渡してあげて、利用することができます。

ForEachStore(
  self.store
) { todoStore in

)

今渡している storeAppState 全てを保持していますが、今は Todo の State だけを保持する store を渡せれば良いです。また同様に AppAction の全ても保持していますが、TodoAction だけを知っている store を渡すことができれば良いです。Composable Architecture ではこのような変換をするための ↓ のように使うことができる scope というものがあります。

ForEachStore(
  self.store.scope(
    state: <#T##(AppState) -> LocalState#>,
    action: <#T##(LocalAction) -> AppAction#>
  )
) { todoStore in

)

state の部分ではグローバルな AppState をローカルな Todo に変換する必要がありますが、それは単純に ↓ のように実現することができます。

ForEachStore(
  self.store.scope(
    state: { $0.todos },
    action: <#T##(LocalAction) -> AppAction#>
  ),
  content: <#T##(Store<Identifiable, Action>) -> _#>
)

action の部分は ↓ のようになっていますね。

action: <#T##(LocalAction) -> AppAction#>

state はグローバルなものをローカルに変換していたのでスッと納得できましたが、action はローカルな ActionTodoAction)をグローバルな AppAction に変換しているので少し奇妙に思えますが、グローバルな AppStore をローカルな TodoStore に変換するために、scope ではローカルアクションをグローバルアクションに埋め込む方法を伝える必要があります。そのため、action では、ローカルからグローバルな Action に変換するクロージャを引数にとっています(参照)。

これで目的の TodoStore を手に入れることができたので、あとは index を気にせず、 WithViewStore を用いて View を構築していくことになります。
全体像は ↓ のようになるかと思います。

ContentView.swift
struct ContentView: View {
    let store: Store<AppState, AppAction>

    var body: some View {
        NavigationView {
            WithViewStore(self.store) { viewStore in
                List {
                    ForEachStore(
                        self.store.scope(state: \.todos, action: AppAction.todo(index:action:))
                    ) { todoStore in
                        WithViewStore(todoStore) { todoViewStore in
                            HStack {
                                Button(action: { todoViewStore.send(.checkboxTapped) }) {
                                    Image(systemName: todoViewStore.isComplete ? "checkmark.square" : "square")
                                }
                                .buttonStyle(PlainButtonStyle())

                                TextField(
                                    "Untitled Todo",
                                    text: todoViewStore.binding(
                                        get: \.description,
                                        send: TodoAction.textFieldChanged
                                    )
                                )
                            }
                            .foregroundColor(todoViewStore.isComplete ? .gray : nil)
                        }
                    }
                }
            }
        }
    }
}

↑ だと View が大きすぎるので、View を分割しましょう。
todoStore を利用している WithViewStore をちょうど分割することができそうなので、↓ のように分割します。

ContentView.swift
struct TodoView: View {
  let store: Store<Todo, TodoAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      HStack {
        Button(action: { viewStore.send(.checkboxTapped) }) {
          Image(systemName: viewStore.isComplete ? "checkmark.square" : "square")
        }
        .buttonStyle(PlainButtonStyle())

        TextField(
          "Untitled Todo",
          text: viewStore.binding(
            get: { $0.description },
            send: { .textFieldChanged($0) }
          )
        )
      }
      .foregroundColor(viewStore.isComplete ? .gray : nil)
    }
  }
}

この TodoViewForEachStore で ↓ のように content の引数に取って扱うことができます。

ContentView.swift
struct ContentView: View {
    let store: Store<AppState, AppAction>

    var body: some View {
        NavigationView {
            WithViewStore(self.store) { viewStore in
                List {
                    ForEachStore(
                        self.store.scope(state: \.todos, action: AppAction.todo(index:action:)),
                        content: TodoView.init(store:)
                    )
                }
            }
        }
    }
}

これで、全く同じものを綺麗な状態のコードで作り上げることができました。

Todo を増やす機能の追加

Reducer と View が綺麗になったので、機能を追加します。
現状は元々存在している Todo が固定のものとなっており、Todo をそれ以上増やすことができないため、Todo 追加機能を実装します。

まずは NavigationBarTodo 追加用のボタンを実装します。

ContentView.swift
.navigationBarItems(trailing: Button("Add") {})

Add ボタンを押した時に Action を発火させたいので、AppAction を追加しましょう。

ContentView.swift
enum AppAction {
  case addButtonTapped
  case todo(index: Int, action: TodoAction)
}

もちろん ActionReducer で処理してあげる必要があります。
現在の Reducer は ↓ のようになっています。

ContentView.swift
let appReducer: Reducer<AppState, AppAction, AppEnvironment> = todoReducer.forEach(
  state: \.todos,
  action: /AppAction.todo(index:action:),
  environment: { _ in TodoEnvironment() }
)

このままだと、さらに AppAction を扱うことができません。
扱うためには、新しい Reducer を作成し、それを todoReducer と組み合わせるようにします。その際には combine というものを以下のように使うことができます。

ContentView.swift
let appReducer = Reducer<AppState, AppAction, AppEnvironment>.combine(
  todoReducer.forEach(
    state: \.todos,
    action: /AppAction.todo(index:action:),
    environment: { _ in TodoEnvironment() }
  ),
  Reducer { state, action, environment in
    // ...
  }
)

combine は同じドメインで動作している複数の Reducer を引数に取ります。そしてその複数の Reducer のリストを反復してそれぞれの Reducer を実行し、最終的に Reducer で返却される全ての Effect をマージして一つの Reducer に結合します。
これに関しては、言葉の説明よりも combine のコードを見た方がイメージがつきそうなので ↓ にも貼っておこうと思います。

ComposableArchitecture/Reducer.swift
  public static func combine(_ reducers: [Reducer]) -> Reducer {
    Self { value, action, environment in
      .merge(reducers.map { $0.reducer(&value, action, environment) })
    }
  }

mergeEffect の中で定義されていますが ↓ のようになっています。自分は Combine をあまり触ったことがないため、MergeMany も初めてみましたが個人的にはこちらが参考になりました。

ComposableArchitecture/Effect.swift

  public static func merge<S: Sequence>(_ effects: S) -> Effect where S.Element == Effect {
    Publishers.MergeMany(effects).eraseToEffect()
  }

話が少し逸れてしまいましたが、新しく定義した Reducer の中で Action を処理していきましょう。

ContentView.swift
Reducer { state, action, _ in
  switch action {
  case .addButtonTapped:
    state.todos.insert(Todo(id: UUID()), at: 0)
    return .none

  case .todo(index: _, action: _):
    return .none

  }
}

Todo それぞれはユニークなものにしたいため、idUUID で宣言するようにしています。

最後に Action を View から送るようにすれば Todo を追加できるようになったアプリが出来上がりです 🍵

ContentView.swift
.navigationBarItems(trailing: Button("Add") {
  viewStore.send(.addButtonTapped)
})

おわりに

前回同様ほぼ翻訳のようになってしまいましたが、ある程度理解できてすっきりしてきました。
次の Part3 は Composable Architecture の便利なテスト機能について紹介されています。
また気力があれば、そちらの紹介もできたらと思います!

15
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
6