前回書いた 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 の便利なテスト機能について紹介されています。
また気力があれば、そちらの紹介もできたらと思います!