はじめに
こちらの記事を拝見して、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 をもとに許可を得て作成しています。