前回書いた Composable Architecture を利用した Todo アプリの紹介(Part1)の続きで、 Part2 の部分について書きました。
今回出来上がるもの
Composable Architecture を利用した Todo アプリ(Part2)
Part1 の最後まで終えた後の Action と Reducer は以下のようになっていると思います。
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 を定義します。
enum TodoAction {
case checkboxTapped
case textFieldChanged(String)
}
AppAction にあったものから接頭辞である todo が切り取られ、index もなくなりました。
次に単一の Todo のための依存関係である Environment も定義します。特に今は依存関係は存在しないので、空で良いです。
struct TodoEnvironment {}
次に単一の Todo と TodoAction のみで動作する Reducer を定義します。
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 で動作するように書き換えます。
enum AppAction {
case todo(index: Int, action: TodoAction)
}
次に、一つの Todo に対して動作する todoReducer を、Todo のコレクション全体に対して動作する Reducer に変換するために、forEachを使用します。
let appReducer = todoReducer.forEach(
state: <#T##WritableKeyPath<GlobalState, MutableCollection>#>,
action: <#T##CasePath<GlobalAction, (Comparable, TodoAction)>#>,
environment: <#T##(GlobalEnvironment) -> TodoEnvironment#>
)
だいぶ複雑なので、forEach が何をしようとしているかを説明していきます。
forEach には、state、action、environment の三つの引数があり、いくつかの Generics や KeyPath, CasePath などがあることがわかると思います。だいぶ複雑ではあるのですが、Composable Architecture では何度も使用することになるので、いずれ慣れるらしいです。(自分はまだ慣れていないですが...)
元の記事では forEach の中心的な考え方は、「ドメインの一部(今で言うと単一の Todo のみしか知らない todoReducer )を、より複雑なドメイン(今で言うと todo のコレクションを知っている appReducer )に変換したい」ということだと述べられています。
実際にそれを行っていくためには、単一の Todo の state、action、environment それぞれを適切に変換していく必要があります。
一つ一つの引数の埋め方について見ていきましょう。
まず、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 である AppAction の case を関心のある単一の 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#>
environment は state や action に比べると単純で、グローバルな Environment をローカルな TodoEnvironment に変換するだけです。今回はグローバルな Environment は特に必要ないため、省略することができ以下のように書くことができます(ついでに state の書き方も省略しています)。
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 がより複雑になるとこの恩恵を感じることができるようです。
実際、複雑には見えますが、やっていることは appReducer と todoReducer を繋ぐだけになっています。アーキテクチャによってはこのような部分を隠蔽するものもありますが、Composable Architecture でこのコードをコンパイルすることによって、正しくコードが動いていることを非常に強力に保証してくれるとのことです。また、(後の章にはなりますが)テストを書くとこの部分のコードは嫌でも実行されることになるため、テストカバレッジも得ることができるという仕組みになっているみたいです。
View への適用
ここまでで、新たな appReducer の準備は整ったので、いよいよ View に適用していきます。
まず、Button の部分は ↓ のように書き換えることができます。
Button(action: { viewStore.send(.todo(index: index, action: .checkboxTapped)) }) {
次に、TextField の部分は ↓ のように書き換えられます。
TextField(
"Untitled Todo",
text: viewStore.binding(
get: { $0.todos[index].description },
send: { .todo(index: index, action: .textFieldChanged($0)) }
)
)
この状態でビルドすれば、Part1 の時と同様に動作していることを確認することができます。appReducer と todoReducer を利用することによって、単一の Todo のための todoReducer を Todo のコレクションにうまく適用することができました 🎉
View ヘルパー ForEachStore の利用
しかし、View の書き方はもっと改善することができます。
ForEach では直接 ForEach 内で index をいじり回す必要がありますが、Composable Architecture の View ヘルパーである ForEachStore を利用することでもっと綺麗に書くことができます。
ForEachStore には、Action が index と ローカルアクション(今でいうと TodoAction)である Store を渡してあげる必要があります。ちょうど View で宣言されている ↓ の Store はそのような構造になっていますね。
let store: Store<AppState, AppAction>
そのため、View 内で ForEachStore には以下のように渡してあげて、利用することができます。
ForEachStore(
self.store
) { todoStore in
)
今渡している store は AppState 全てを保持していますが、今は 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 はローカルな Action (TodoAction)をグローバルな AppAction に変換しているので少し奇妙に思えますが、グローバルな AppStore をローカルな TodoStore に変換するために、scope ではローカルアクションをグローバルアクションに埋め込む方法を伝える必要があります。そのため、action では、ローカルからグローバルな Action に変換するクロージャを引数にとっています(参照)。
これで目的の TodoStore を手に入れることができたので、あとは index を気にせず、 WithViewStore を用いて View を構築していくことになります。
全体像は ↓ のようになるかと思います。
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 をちょうど分割することができそうなので、↓ のように分割します。
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)
}
}
}
この TodoView は ForEachStore で ↓ のように content の引数に取って扱うことができます。
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 追加機能を実装します。
まずは NavigationBar に Todo 追加用のボタンを実装します。
.navigationBarItems(trailing: Button("Add") {})
Add ボタンを押した時に Action を発火させたいので、AppAction を追加しましょう。
enum AppAction {
case addButtonTapped
case todo(index: Int, action: TodoAction)
}
もちろん Action は Reducer で処理してあげる必要があります。
現在の Reducer は ↓ のようになっています。
let appReducer: Reducer<AppState, AppAction, AppEnvironment> = todoReducer.forEach(
state: \.todos,
action: /AppAction.todo(index:action:),
environment: { _ in TodoEnvironment() }
)
このままだと、さらに AppAction を扱うことができません。
扱うためには、新しい Reducer を作成し、それを todoReducer と組み合わせるようにします。その際には combine というものを以下のように使うことができます。
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 のコードを見た方がイメージがつきそうなので ↓ にも貼っておこうと思います。
public static func combine(_ reducers: [Reducer]) -> Reducer {
Self { value, action, environment in
.merge(reducers.map { $0.reducer(&value, action, environment) })
}
}
merge は Effect の中で定義されていますが ↓ のようになっています。自分は Combine をあまり触ったことがないため、MergeMany も初めてみましたが個人的にはこちらが参考になりました。
public static func merge<S: Sequence>(_ effects: S) -> Effect where S.Element == Effect {
Publishers.MergeMany(effects).eraseToEffect()
}
話が少し逸れてしまいましたが、新しく定義した Reducer の中で Action を処理していきましょう。
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 それぞれはユニークなものにしたいため、id は UUID で宣言するようにしています。
最後に Action を View から送るようにすれば Todo を追加できるようになったアプリが出来上がりです 🍵
.navigationBarItems(trailing: Button("Add") {
viewStore.send(.addButtonTapped)
})
おわりに
前回同様ほぼ翻訳のようになってしまいましたが、ある程度理解できてすっきりしてきました。
次の Part3 は Composable Architecture の便利なテスト機能について紹介されています。
また気力があれば、そちらの紹介もできたらと思います!