はじめに
こちらの記事を拝見して、SwiftUI なら Composable Architecture で書くのが良さそうと思い、SwiftUI・Composable Architecture の勉強がてら書くことにしました。
基本的には Composable Architecture の作者さんが公開されている A Tour of the Composable Architecture の Part 1 - 4 を実際に追ってみたので、それをまとめようかと思います。A Tour of the Composable Architecture は作者である POINT-FREE さんの100個目の動画にあたるらしく、それより前の動画で Composable Architecture に関わる内容についても言及されているようです。(まだ見ることができていませんが...)
追記
Part4 まで一応まとめてみよう(ほぼ翻訳ですが...)としているので、随時リンクを追記します。
Composable Architecture とは?
Composable Architecture 自体の説明については、 ↑の yimajo さんの記事にまとまっているので、本記事では省略しようと思います。触ってみた感想としては、Redux のような感じで、SwiftUI とも相性が良さそうで、実際に使ってアプリを作ってみたいという印象でした。また、Composable Architecture が提供してくれるテストをサポートする機能によって、テストも非常に楽に書けそうで、今後このライブラリを利用して実践的なアプリも作ってみたいと思えました。
自分も最初は Composable Architecture ってなんなんだ?という感じでしたが、以下のような流れでキャッチアップすることによって、ある程度理解できるようにはなってきた気がします。
- A Tour of the Composable Architecture の Part1 - Part4 を見つつコードを書いてみる
- これで TCA の概要や、TCA を使ったテストの書き方まである程度理解できる気がします
- もし英語がきついという方は、自分の記事はほぼ翻訳のような形になってしまっているので、よければ参考にして頂ければと思います🙏
- Examples のコードを動かしてみながら漁ってみる
- 実際に TCA を利用して簡単なアプリを作ってみる
- Point-Free の他の動画を見たり、Functional Architecture 情報共有会で知見を得る
- Point-Free の動画はどれも面白いので見てみることをお勧めしたいです。また今城さん主催の情報共有会はクローズドなものですが、非常に楽しく勉強になることが多いです
Composable Architecture を利用した Todo アプリ (Part 1)
一気に Part 1 - Part 4 の内容を書こうとすると挫けそうなので、一旦 Part 1をかいつまんで行こうと思います。
まずは、Xcode で SwiftUI プロジェクトを作成し、SPM で ComposableArchitecture を導入しましょう。SPM による導入方法などは省略します。
作成できたら、ContentView.Swiftに、空ですが以下の三つを書いていきます。それぞれ Composable Architecture で必要になってくるものです。上から順にざっくりと説明すると、 Stateは名前の通りアプリで扱うことになる状態で、基本的には構造体で定義すると思いますが、必ずしも構造体である必要はないと作者は言っています。Actionはボタンのタップ、テキストフィールドへのテキスト入力など、ユーザが実行するアクションを定義する場所になっています。こちらは基本的には列挙型を使います。最後にEnvironmentは API Client、Scheduler など外部から値を差し込んだほうが好都合なもの(テスト時などのことを考えた場合など)を定義する場所になっています。こちらは基本的には構造体を使います。
struct AppState {
}
enum AppAction {
}
struct AppEnvironment {
}
次に State、Action、Environment をまとめて使用し、アプリケーションのビジネスロジックを担当することになる Reducer を定義します。基本的には、Actionごとにロジックを分岐していくので、switchを使用してactionごとの処理を書いていくことになります。
let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
switch action {
}
}
もう一つ、Composable Architecture にはEffectという重要な要素があります。Effectは、API リクエストの実行、ディスクへのデータ書き込みなど、外部との通信を行ったり...という部分になり、Reducerの中でEffectを返却することができますが、今回はEffectを使用しないので、このくらいの説明に留めることにしようと思います。
Reducerによってビジネスロジックを表現することができますが、Reducerで変更したStateをViewから利用するためには、Storeを利用します。一旦、Storeを持った簡単なViewを定義することにしましょう。
struct ContentView: View {
let store: Store<AppState, AppAction>
var body: some View {
NavigationView {
List {
Text("Hello")
}
.navigationBarTitle("Todos")
}
}
}
Storeを初期化できる部分は、(プロジェクト作成時の)SwiftUIではSceneDelegate.swiftとContentView.swift内のContentView_Previewsになるので、両方からStoreの初期化を行います。初期化には先ほど定義したappReducer、AppState()、AppEnvironment()を使用します。
let contentView = ContentView(
store: Store(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment()
)
)
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(
store: Store(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment()
)
)
}
}
基本的な部分は整ってきたので、空で定義してしまっていた部分などを徐々に埋めていきます。Todo アプリを作るので、まずは Todo のモデルを定義します。単純な Todo の文章と、実行済みか否かのフラグを持つだけのモデルです。
struct Todo {
var description = ""
var isComplete = false
}
モデルを定義したので、当然Stateはこの Todo を保持することになります。追加していきましょう。
struct AppState {
var todos: [Todo] = []
}
ここまで出来上がったので、まだ todos の中身は空っぽですが、View から Storeにアクセスして、todo を表示するようなプログラムを書いてみます。todos は空っぽなので、Helloというテキストを表示するようにしています。
struct ContentView: View {
let store: Store<AppState, AppAction>
var body: some View {
NavigationView {
List {
ForEach(self.store.state.todos) { todo in
Text("Hello")
}
Text("Hello")
}
.navigationBarTitle("Todos")
}
}
}
これで上手くいくかなと思いきや、StoreからStateに直接アクセスすることは禁止されているので、↑のようなことはできません。アクセスするためには、ViewStoreというものを使用することになります。
ViewStoreを使用するメリットについては、公式の Part 1 の記事に詳しく載っていますが、簡単に説明するとViewの再計算のコストを抑え、パフォーマンス的にも向上し、iOS/macOS などの異なるプラットフォームで共通のロジックを使うことができるらしいです。
実際にViewStoreを用いたコードを書く前に、事前に定義していたTodoとAppStateをEquatableプロトコルに適合させます。理由としては、ViewStoreがStateを排出する際に重複を自動的に取り除けるようにするためです。(ViewStoreのコードを見てみると、Stateの重複を取り除くための仕組みが提供されていることがわかります。)
struct Todo: Equatable {
var description = ""
var isComplete = false
}
struct AppState: Equatable {
var todos: [Todo]
}
var body: some View {
NavigationView {
WithViewStore(self.store) { viewStore in
List {
ForEach(viewStore.state.todos) { todo in
Text("Hello")
}
Text("Hello")
}
.navigationBarTitle("Todos")
}
}
}
しれっと、ついでにViewStoreを用いたコードも書いてしまいました。このコードの中では、viewStore.state.todosという形でStateにアクセスしていますが、これは Swift の Dynamic Member Lookup を利用していて、あたかもViewStore上に、直接Stateプロパティが存在するかのようにアクセスすることができるようになっています。
しかし、まだアプリを動かすことはできないので、少し手を加えていきます。
ForEachメソッドで各 Todo の要素を一意に識別することができるように Todo に id を持たせて以下のように変更していきます。ForEachについては本質ではないので、細かい説明は省くことにします。
struct Todo: Equatable, Identifiable {
let id: UUID
var isComplete = false
var description = ""
}
上記のように定義することによって、ForEachから各 Todo を適切に扱うことができるようになります。アプリを動かすために、Storeの初期化時にいくつかの todo を渡すように変更します。以下のように変更することで、一旦アプリは動くようになります。
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(
store: Store(
initialState: AppState(
todos: [
Todo(
description: "Milk",
id: UUID(),
isComplete: false
),
Todo(
description: "Eggs",
id: UUID(),
isComplete: false
),
Todo(
description: "Hand Soap",
id: UUID(),
isComplete: false
),
]
),
reducer: appReducer,
environment: AppEnvironment()
)
)
}
}
徐々にアプリに機能を追加していきます。
既に View から 各 todo にアクセスすることができるようになっているので、簡単なチェックボックスと TextField を追加します。todo の isCompleteによってチェックが付くチェックボックスと todo のdescriptionを表示する TextFiled になっています。
var body: some View {
NavigationView {
WithViewStore(self.store) { viewStore in
List {
ForEach(viewStore.state.todos) { todo in
HStack {
Button(action: {}) {
Image(systemName: todo.isComplete ? "checkmark.square" : "square")
}
.buttonStyle(PlainButtonStyle())
.foregroundColor(todo.isComplete ? .gray : nil)
TextField(
"Untitled todo",
text: .constant(todo.description)
)
}
}
Text("Hello")
}
.navigationBarTitle("Todos")
}
}
}
見た目はできてきたので、Actionを定義していきます。具体的にはチェックボックスのタップイベントと TextField を編集できるようなActionを定義します。
enum AppAction {
case todoCheckboxTapped
case todoTextFieldChanged(String)
}
ただ、↑のように定義してしまうと、todos の中のどの todo に対するアクションなのかがはっきりしないので、少し手を加えます。
enum AppAction {
case todoCheckboxTapped(index: Int)
case todoTextFieldChanged(index: Int, text: String)
}
後は、Reducer で具体的なビジネスロジックを実装していくことになります。イメージだけ先に示すと↓のような感じです。
let appReducer = Reducer<AppState, AppAction, Void> { state, action, _ in
switch action {
case .todoCheckboxTapped(index: let index):
// ここに State を変更するための具体的なロジックを実装していく
case .todoTextFieldChanged(index: let index, text: let text):
// ここに State を変更するための具体的なロジックを実装していく
}
}
.todoCheckboxTapped(index: )の中では、state.todos[index].isComplete.toggle()のように単純にisCompleteを変更するだけで良さそうです。.todoTextFieldChanged(index:, text: )の方でも単純にtextをdescriptionに入れてあげるだけになります。それらを踏まえた実装は以下のようになります。
let appReducer = Reducer<AppState, AppAction, Void> { state, action, _ in
switch action {
case .todoCheckboxTapped(index: let index):
state.todos[index].isComplete.toggle()
return .none
case .todoTextFieldChanged(index: let index, text: let text):
state.todos[index].description = text
return .none
}
}
勝手に謎のreturn .noneという記述を追加してしまいました。これは前の方でほとんど説明しなかったのですが、ReducerはEffectを返すことができるので、Effectを何も返さない場合は明示的にreturn .noneとする必要があります。
一旦これで基本的なビジネスロジックは完成しました。
後はビジネスロジックを View から利用するだけになります。
View から State を変更する方法は、ViewStoreを介してActionを送ってあげれば良いです。
チェックボックスをタップした時には.todoCheckboxTappedアクションを送れば良いので、Button(action: { viewStore.send(.todoCheckboxTapped(index: index)) })のように書くことができます。
TextField の方は若干特殊で、TextField にテキストが入力された場合、.todoTextFieldChangedアクションを送るのと同時に、TextField には todo の中にあるdescriptionを表示する必要があります。
ViewStoreにはこのような場合のためにヘルパーメソッドが用意されているので、以下のように簡単に実現することができます。
TextField(
"Untitled Todo",
text: viewStore.binding(
get: { $0.todos[index].description },
send: { .todoTextFieldChanged(index: index, text: $0) }
)
)
以上を実装した全体像は以下のようになります。
var body: some View {
NavigationView {
WithViewStore(self.store) { viewStore in
List {
ForEach(viewStore.state.todos) { todo in
HStack {
Button(action: { viewStore.send(.todoCheckboxTapped(index: index)) }) {
Image(systemName: todo.isComplete ? "checkmark.square" : "square")
}
.buttonStyle(PlainButtonStyle())
.foregroundColor(todo.isComplete ? .gray : nil)
TextField(
"Untitled todo",
text: viewStore.binding(
get: { $0.todos[index].description },
send: { .todoTextFieldChanged(index: index, text: $0) }
)
)
}
}
Text("Hello")
}
.navigationBarTitle("Todos")
}
}
}
これで基本的な実装は一通り終わりです。
ただ、本当にStateが更新されているかを確かめるための手段も Composable Architecture には備わっていて、それも記事の最後で紹介されているので、説明します。
方法は簡単で、Reducerの debug() メソッドを使用するだけで良いです。例えば、
let contentView = ContentView(
store: Store(
initialState: AppState(
todos: [
Todo(id: UUID()),
Todo(id: UUID()),
]
),
reducer: appReducer.debug(),
environment: ()
)
)
こんな感じで、debug()を付ければ実現できますし、以下のように特定のReducerだけに付けることもできます。
let appReducer = Reducer<AppState, AppAction, Void> { state, action, _ in
...
}
.debug()
実際にdebug()を付けたまま実行してチェックボックスをタップすると、Debug Console に以下のような表示がされます。
received action:
AppAction.todoCheckboxTapped(
index: 0
)
AppState(
todos: [
Todo(
− isComplete: false,
+ isComplete: true,
description: "Milk",
id: 5834811A-83B4-4E5E-BCD3-8A38F6BDCA90
),
Todo(
isComplete: false,
description: "Eggs",
id: AB3C7921-8262-4412-AA93-9DC5575C1107
),
Todo(
isComplete: true,
description: "Hand Soap",
id: 06E94D88-D726-42EF-BA8B-7B4478179D19
),
]
)
チェックボックスをタップしたことによって、最初の Todo のisCompleteプロパティだけがfalseからtrueに変化していることが簡単にわかります。もちろん、descriptionも同じように調べることができますが、実際に試してみて頂けると良いと思います。
おわりに
まとめると言いながら、元の記事が良くまとめられすぎていて、ただの翻訳のようになってしまった気がします...
Composable Architecture には、あと3つの Part2 - Part4 の記事があり、Composable Architecture のパワフルなテストサポート機能を使ったテストを書いたり、あまり説明しなかった Effect を使ったり、より実践的な Composable Architecture について知ることができます。GitHub のリポジトリにもいくつかサンプルがあったので、そちらも覗いてみると参考になりそうです。
自分も Part4 まで一通り読みながら写経してみて、Composable Architecture すごい!と思い続けていたので、元の記事を参照していただけると、もっと Composable Architecture について知ることができるので、ぜひ読んでみてもらえると良いと思います!
参考文献
- GitHub / swift-composable-architecture
- POINT-FREE / A Tour of the Composable Architecture: Part 1
- Qiita(yimajo さん) / Swiftによるアプリ開発のためのComposable Architectureがすごく良いので紹介したい
※本記事は、 POINT-FREE さんが公開している A Tour of the Composable Architecure をもとに許可を得て作成しています。