水無月の余韻 開発Sc.

プログラミング関連の雑記

The Composable Architecture 入門録

The Composable Architecture (以下 TCA) は、Swift UIを使ったアプリを作る上で知っておきたいアーキテクチャのひとつだったので、休暇を利用して入門してみました。そのときの、自分がつまったところを記録します。

ドキュメントを読む

まずは、ライブラリのドキュメントを読みます。

GitHub - pointfreeco/swift-composable-architecture: A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.

  • 振る舞いの定義と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
        }
    }

なにかやりようはあるだろうと思っていたところ、より最適な方法がサンプルにもあると教えてもらいました。

それに従って書きかえたのがこちら。

    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からは、NavigationLinkNavigationStack の中で使うことが推奨されるため、この実装にはwarningがありました。(自分しか使わないアプリのため、development target は 16.0 です。)

どうせならと書き換えていくのですが、ここは詰まりました。

入れ子の構造しだいで動かないことに気づくのは大変でした。

フィルタのために、リストの上部に別の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分割

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
}

以上