The Composable Architecture 入門録
The Composable Architecture (以下 TCA) は、Swift UIを使ったアプリを作る上で知っておきたいアーキテクチャのひとつだったので、休暇を利用して入門してみました。そのときの、自分がつまったところを記録します。
ドキュメントを読む
まずは、ライブラリのドキュメントを読みます。
- 振る舞いの定義とViewを分けて定義できる
- APIを呼ぶなどのロジックの依存注入がしやすい作り
- 振る舞いのテストを状態の監視として書ける
といった特徴があることがわかります。
SwiftUIのアプリを作る
実際にTCAを使ってアプリを構築していきます。
今回は、加賀ゆびぬき刺し模様シミュレータのアプリ内から送信できるご意見を読むためのアプリを構築しました。
アプリの画面構成は以下のとおりです。
一覧画面を表示する
はじめに一覧画面から実装をはじめました。
この画面は、以下のように振る舞います。
- 表示されたら一覧をAPIから取得する
- 一覧を取得できたら、リスト表示する
- 一覧の取得に失敗したら、エラー画面を表示する
TCAのリポジトリにある CaseStudies を参考にしながら、実装していきます。
状態の表現を何回か更新したので、その変遷をみてみましょう。
最初はサンプル通りに実装してみます。
struct State: Equatable { var rows: IdentifiedArrayOf<Row>? var isLoading: Bool = false var failedToFetch: Bool = false struct Row: Equatable, Identifiable { var opinion: Opinion let id: UUID } }
更新しなければならないフラグの種類が多く、機能が増えると間違えそうですね。
ここで、ScreenState
を導入してみます。しかし、これは冗長ですね。
struct State: Equatable { var alert: AlertState<OpinionList.Action>? var screenState: ScreenState<IdentifiedArrayOf<Row>> = .initial var isLoading: Bool { screenState.isLoading } var hasError: Bool { screenState.hasError } var data: IdentifiedArrayOf<Row>? { screenState.data } struct Row: Equatable, Identifiable { var opinion: Opinion let id: UUID } }
なにかやりようはあるだろうと思っていたところ、より最適な方法がサンプルにもあると教えてもらいました。
↓ みたいな感じでいけます🙆♂️https://t.co/jEftnLZWZA
— アイカワ (@kalupas226) January 10, 2023
それに従って書きかえたのがこちら。
enum State: Equatable { struct Row: Equatable, Identifiable { var opinion: Opinion let id: UUID } case initial case loading case loaded(IdentifiedArrayOf<Row>) case error(AlertState<OpinionList.Action>) // ... 略 ... }
このときのViewは、if 文でStateをみながら分岐しています。(まだ CaseLet
を知らなかった)
struct OpinionListView: View { let store: StoreOf<OpinionList> var body: some View { WithViewStore(self.store, observe: { $0 }) { viewStore in VStack { if viewStore.isLoading { ProgressView() } else if viewStore.hasError { OpinionListErrorView( onTapRetry: { viewStore.send(.requestFetch) } ) } else if let rows = viewStore.data, !rows.isEmpty { List(rows) { row in OpinionListRowView(opinion: row.opinion) } } else { OpinionListEmptyView() } } .alert(self.store.scope(state: \.alert), dismiss: .alertDismissed) .onAppear { viewStore.send(.didAppear) } } } }
ログイン画面を作る
これはサンプルコードhttps://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swiftがあり、それに習うのですが、ここでSwitchStore/CaseLet
が登場します。
対応するViewがわかりやすい上、sub-state に切り出すことで考えることを減らせるようになります。これは便利。
一覧画面も SwitchStore/CaseLet
を使おう
一覧画面にも適用していこうとするのですが、ここで少し詰まります。
State を以下のように定義していたので、一部にだけ sub-state があります。
enum State { case initial case loading case loaded(OpinionListSort.State) case error(OpinionListError.State) }
CaseLet
では、対象の CaseState と、そこから発生するactionを指定する必要があるため、sub-state がないときの指定に困りました。
サンプルからは以下のような書き方は見つかりますが、sub-state なしの場合のサンプルがなく CaseLet
の実装をみながら考えることになりました。
CaseLet(state: /OpinionList.State.loaded, action: OpinionList.Action.opinionListSort) { store in OpinionListSortView(store: store) }
最終的に、action は引数を取る関数であるため、以下のようにダミーアクションを用意しました。sub-state の実態がないため、action が実行中に呼ばれることはありません。
CaseLet(state: /OpinionList.State.loading, action: { OpinionList.Action.opinionListLoading }) { store in ProgressView() }
今回は1ケースだけなので、ダミーアクションらしい名前ではなく、対応する状態の名前をつけています。他にもダミーケースが必要な場合は、まとめてしまう名前でもよいかもしれません。
Sub-State をさらにわける
一覧が表示できるようになったので、そこに機能を追加していきます。 データに応じてフィルタし、並び順を変更できるようにします。
前節の State
のとおり、APIから一覧を取得できたときの状態をSub-Stateに分離しました。
Stateをわける前は、loaded になった時点からフィルタ条件を持ちまわす実装を書いていて、なかなか大変でした。State を分離することで、持ちまわす部分はライブラリがやってくれます。
画面遷移をSwiftUIで書く
一覧画面ができたので、リストの各セルをタップして詳細を見れるようにします。
最初の実装は NavigationView
を使ったので、大きな問題なく組むことができました。
var body: some View { WithViewStore(store, observe: ViewState.init, send: OpinionListSort.Action.init) { viewState in Form { OpinionListFilterView(...) if viewState.rows.isEmpty { Text("no opinions") } else { Section { ForEach(viewState.rows) { row in NavigationLink( destination: IfLetStore( self.store.scope( state: \.selection?.value, action: OpinionListSort.Action.opinionDetail ) ) { OpinionDetailView(store: $0) }, tag: row.id, selection: viewState.binding( get: \.selection?.id, send: ViewAction.setNavigation(selection:) ) ) { OpinionListRowView(opinion: row.opinion) } } } } } .onAppear { viewState.send(.didAppear) } } }
iOS 16からは、NavigationLink
は NavigationStack
の中で使うことが推奨されるため、この実装にはwarningがありました。(自分しか使わないアプリのため、development target は 16.0 です。)
どうせならと書き換えていくのですが、ここは詰まりました。
NavigationStack/List/NavigationLink で、navigationDestination があれば動く。
— ichiko/Morichk (@ichiko_revjune) January 19, 2023
NavigationStack/Form/List/NavigationLink では動かない。
入れ子の構造しだいで動かないことに気づくのは大変でした。
フィルタのために、リストの上部に別のViewを表示しており、Form
に入れることで見た目を揃えていたのです。
しかし、Form
が邪魔なことはわかったので、UIKit でリストを実装するときのように cell type を定義していきましょう。
enum Row: Equatable, Identifiable, Hashable { case filter case opinion(Opinion) var id: String { switch self { case .filter: return "filter" case let .opinion(opinion): return opinion.id } } }
var body: some View { WithViewStore(store, observe: ViewState.init, send: OpinionListSort.Action.init) { viewState in NavigationStack { List( viewState.rows, id: \.id, selection: viewState.binding( get: \.selection?.id, send: ViewAction.setNavigation(selection:) ) ) { row in switch row { case .filter: OpinionListFilterView( sortMode: viewState.binding(get: \.sortMode, send: ViewAction.sortModeChanged), includeChecked: viewState.binding(get: \.includeChecked, send: ViewAction.includeCheckedChanged), includeTestData: viewState.binding(get: \.includeTestData, send: ViewAction.includeTestDataChanged) ) case let .opinion(opinion): NavigationLink( value: opinion ) { OpinionListRowView(opinion: opinion) } } } .navigationDestination(for: Opinion.self) { _ in IfLetStore( self.store.scope( state: \.selection?.value, action: OpinionListSort.Action.opinionDetail ) ) { OpinionDetailView(store: $0) .onDisappear { viewState.send(.opinionDetailDismissed) } } } } .onAppear { viewState.send(.didAppear) } } }
全体のState
Stateをどんどん分割することで、その画面の責務をはっきりさせることができました。ほとんどの画面で、親画面へイベントを投げることは発生していません。
例外は2つです。
- OpinionListからのログアウトイベント
- OpinionListErrorからのリトライイベント
Stateを分割することで、FatViewControllerのような問題が起きにくくなっているのはこのアーキテクチャの特徴といえそうです。
今回は対応する機能がありませんでしたが、ひとつの画面の中に複数のSub Stateと対応するViewがあるような場合は、ここまでシンプルにできるのか気になるところです。
Sub State に関わるテスト
State 分割によって責務を小さくし、各Stateごとにテストを書けるので、テストを作るのはかなり快適でした。
Sub Stateのイベントに依存するテストもScopeを使って、Sub StateのTestStoreを作ることで書くことができます。
一覧と詳細の行き来をするときに、詳細で変更された値を一覧に反映する処理が発生します。今回は、サーバーから再取得ではなく、画面間で値を伝える方法をとっています。そのため、一覧のテストコードで、詳細画面で発生するイベントを発生させてSub Stateの書き換えをしたくなりました。
Sub State で発生するイベントを send(_)
することはできますが、Sub State が受けとるイベントの検証はできませんでした。(実は書き方がある?)
Sub State が受けとるイベントに関わる検証は、Sub Stateのテストとして書く方が適切なので、悪くない使いわけと思います。
// Sub State のスコープを指定して、新たに TestStore を得る let detailState = store.scope(state: \.selection, action: OpinionListSort.Action.opinionDetail) // Sub State で起きるイベントは指定できる (ここでSub Stateの検証が可能。今回は変化しないケースだった。) await detailState.send(OpinionDetail.Action.isCheckedChanged(isOn: true)) // Sub State で上記イベントから連鎖して起きるイベントの `receive` を書きたくなるが、元 State の `Action` として書く必要がある await detailState.receive(OpinionListSort.Action.opinionDetail(OpinionDetail.Action.responseUpdate(.success(.init(isChecked: true, isTestData: false))))) { $0?.opinion.isChecked = true }
以上