【SwiftUI】マルチモジュール構成のためのTCA×Routerパターンを考えてみた

# はじめに

こんにちは！
こちらは [KDDIアジャイル開発センター（KAG） Advent Calendar 2024](https://qiita.com/advent-calendar/2024/kag) の13日目の記事です。

<!-- markdownlint-disable MD034 -->
https://qiita.com/advent-calendar/2024/kag
<!-- markdownlint-enable MD034 -->

みなさん、TCAは使われていますでしょうか？
私は少し前からTCAを使い始めたのですが、学習コストが高かったり、次々と新しいバージョンがリリースされたり、キャッチアップがなかなか追いつかないなと感じています。
<!-- markdownlint-disable MD034 -->
https://github.com/pointfreeco/swift-composable-architecture
<!-- markdownlint-enable MD034 -->

そんな中、TCAのExampleやチュートリアルを見ているとふと気になることがありました。
それは「1つのReducerの中にドメインロジックと画面遷移ロジックが混在していることが多い」という点です。これは単一モジュールであればそこまで問題とはなりませんが、マルチモジュール構成のアプリにおいては望ましくないと感じました。なぜなら画面遷移ロジックがドメインロジックと混在すると、画面遷移元のモジュールが画面遷移先のモジュールに依存してしまい、結合度が高くなってしまうからです。
また別分野ではありますが、[Now in Android](https://github.com/android/nowinandroid/blob/main/docs/ModularizationLearningJourney.md#types-of-modules-in-now-in-android)では以下のようにFeatureモジュールから他のFeatureモジュールに依存すべきでないとされています。

> A feature module should have no dependencies on other feature modules.

そこで今回はタイトルの通り「マルチモジュール構成のためのTCA×Routerパターン」を考え、サンプルアプリを実装してみました。
この記事が、マルチモジュール構成のTCAを採用したアプリを実装する何らかの参考になりましたら幸いです。

また対象読者は以下を想定しています

- TCAによるマルチモジュール構成のアプリを開発している、または検討している人
- TCAやSwiftUIについてある程度知っている人

# TCA×Routerパターン

では早速TCA×Routerパターンについて考えていきます。

ここでTCAに限らずSwiftUIにおいて画面遷移を実現するには、一般的に、

1. 画面遷移情報を状態として保持すること
1. 画面遷移用のViewを生成する（またはmodifierをコールする）こと

の両方を行う必要があります。

今回は、上記の2タスクのみを専任で行うものをRouterとして捉えることで、Routerパターンを構築したいと思います。具体的には、それぞれのタスクを**RouterReducer**と**RouterView**で行うようにします。

## モジュール構成

先にRouterパターンを導入した場合のモジュール構成を見ていきます。基本的には、[Now in Android](https://github.com/android/nowinandroid/blob/main/docs/ModularizationLearningJourney.md#types-of-modules-in-now-in-android)のアーキテクチャに準拠しています。

![tca_router_sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646568/62660c98-0a52-4ec0-8cc8-e771954a2af4.png)

まずAppはアプリのルートに相当し、エントリポイントや画面遷移を管理します。そのためRouterReducer(SampleRouter)とRouterView(SampleRouterView)はこのAppに含まれます。
また、画面遷移を行うためにRouterが各FeatureのReducerやViewを知る必要があるため、AppはFeatureモジュール群に依存しています。具体的には、RouterReducerは画面遷移先のReducerを保持する必要があるため各FeatureのReducerに、RouterViewは画面遷移用のViewを生成するため各FeatureのViewにそれぞれ依存します。

次にFeatureモジュールですが、ここではEntryFeature、HogeFeature、FugaFeatureの3つのFeatureが存在すると仮定します。それぞれのFeatureはReducerとViewを持ち、前述の通りそれぞれがRouterから依存されています。また、各Featureモジュール間での依存がないことがわかります。

## 詳細

それではいよいよRouterReducerとRouterViewの詳細について見ていきます。

### RouterReducer

RouterReducerの役割は、「画面遷移情報を状態として保持すること」です。  
ここでRouterReducerの特徴は、

- 画面遷移先のReducerを保持する
- 画面遷移先の画面遷移処理を引き受ける

です。

さきほどの[モジュール構成](#モジュール構成)で示した画面遷移を行うRouterReducerの例を用いて実装を説明します。

まずはStateの定義から見ていきます。

```swift: SampleRouter.swift
import SwiftUI
import ComposableArchitecture
import EntryFeature
import HogeFeature
import FugaFeature

@Reducer
struct SampleRouter {
    @Reducer
    enum Path {
        case hoge(HogeRoot)
        case fuga(FugaRoot)
    }

    @ObservableState
    struct State {
        var entry = Entry.State() // 初期画面のState
        var path = StackState<Path.State>() // 後続画面(HogeやFuga)のState
    }
```

ここでは、Entry画面、Hoge画面、Fuga画面の3種類の画面が存在すると仮定し、Entry画面を初期画面（First View）とします。
SampleRouterのStateとしては、初期画面のState（`entry`）と後続画面のStateを格納するスタック（`path`）のみを保持しています。そのため、画面遷移以外の情報をStateとして持っていないことがわかります。

また今回はマルチモジュール構成を想定しているため、それぞれの画面のReducerは異なるFeatureモジュールに分割されているとします。そのため、それぞれのReducerやStateなどにアクセスするためにFeatureモジュールをimportしています。

:::note warn
初期画面のStateをpathで保持していない理由は以下のとおりです。

- 後続画面から初期画面に戻りたい時に、後続画面のReducer内で`@Dependency(\.dismiss)`を使用するだけで実現できるため
- `path`が空のケースを考慮しなければならなくなるため
  - また空とならないようにしたい場合、NavigationStackの`root`パラメーターは空にできず、実際に表示されることのない何らかのViewを指定しなくなり可読性が下がるため
:::

次にActionとReducerを見ていきます。

```swift: SampleRouter.swift
    enum Action {
        case entry(Entry.Action) // 初期画面上のアクション検知用アクション
        case path(StackActionOf<Path>) // 後続画面内上でのアクション検知用アクション
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .entry(.delegate(.toHoge)):
                // 初期画面でHogeへの遷移が要求された場合、以下の処理を行う
                state.path.append(.hoge(HogeRoot.State()))
                return .none

            case let .path(.element(id: _, action: .hoge(.delegate(.toFuga)))):
                // Hoge内でFugaへの遷移が要求された場合、以下の処理を行う
                state.path.append(.fuga(FugaRoot.State()))
                return .none

            case .path:
                return .none
            }
        }
        .forEach(\.path, action: \.path) 
    }
}
```

まずAction部分では、画面遷移を発火させるために、初期画面や後続画面上のアクション検知用のみを定義しています。ここでも画面遷移関連以外のアクションは定義されていないことがわかりますね。

そしてReducer部分では、初期画面からHoge画面への遷移、Hoge画面からFuga画面への遷移をそれぞれ記述しています。また、後続画面のStateをStackで保持しているため、`forEach`を使用して後続画面のReducerを埋め込むようにしています。

補足ですが、画面遷移時に何らかの処理をフックしたい場合はイベントごとにcaseを追加することで対応できます。たとえば、Fuga画面を閉じて別画面に遷移したときにログを出したい場合は以下のように記述します。

```swift
switch action {
// ...

case let .path(.popFrom(id: id)):
    guard let childState = state.path[id: id] else { return .none }
    switch childState {
    case .fuga:
        print("Fuga画面が閉じられました")
    default:
        break
    }
    return .none

// ...
}
```

:::note info
今回はDelegate Actionを使用しています。
これはChild Reducer(`Entry`や`HogeRoot`、`FugaRoot`)のActionのうち、どれが自身の処理をRouterReducerに委譲するかを明示するためです。仮に、Child Reducerの画面上で行われた操作によって画面遷移のみ行いたい場合、Child Reducer側での処理は`return .none`のみとなります。ここでDelegate Actionを使用しないと、一見何も行わないActionのように見えてしまい、可読性が低下すると考えられます。
:::

SampleRouter.swiftの全体のコードは以下の通りです。

```swift: SampleRouter.swift
import SwiftUI
import ComposableArchitecture
import EntryFeature
import HogeFeature
import FugaFeature

@Reducer
struct SampleRouter {
    @Reducer
    enum Path {
        case hoge(HogeRoot)
        case fuga(FugaRoot)
    }

    @ObservableState
    struct State {
        var entry = Entry.State() // 初期画面のState
        var path = StackState<Path.State>() // 後続画面(HogeやFuga)のState
    }

    enum Action {
        case entry(Entry.Action) // 初期画面のアクション検知用アクション
        case path(StackActionOf<Path>) // 後続画面内でのアクション検知用アクション
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .entry(.delegate(.toHoge)):
                // 初期画面でHogeへの遷移が要求された場合、以下の処理を行う
                state.path.append(.hoge(HogeRoot.State()))
                return .none

            case let .path(.element(id: _, action: .hoge(.delegate(.toFuga)))):
                // Hoge内でFugaへの遷移が要求された場合、以下の処理を行う
                state.path.append(.fuga(FugaRoot.State()))
                return .none

            case .path:
                return .none
            }
        }
        .forEach(\.path, action: \.path) 
    }
}
```

### RouterView

RouterViewの役割は、「画面遷移用のViewを生成する（またはmodifierをコールする）こと」です。
ここでRouterViewの特徴は、

- RouterReducerのStoreを保持する
- 基本的に画面遷移に必要なViewのみ保持する
- 画面遷移先のViewを表示する

です。

それでは上記のSampleRouter.swiftを使用する場合のRouterViewを見ていきます。

```swift: SampleRouterView.swift
import SwiftUI
import ComposableArchitecture
import EntryFeature
import HogeFeature
import FugaFeature

public struct SampleRouterView: View {
    @Bindable var store = StoreOf<SampleRouter>(initialState: SampleRouter.State()) {
        SampleRouter()
    }

    public var body: some View {
        NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
            // 初期画面
            EntryView(store: store.scope(state: \.entry, action: \.entry))
        } destination: { store in
            switch store.case {
            case .hoge(let store):
                // Hoge画面
                HogeRootView(store: store)
            case .fuga(let store):
                // Fuga画面
                FugaRootView(store: store)
            }
        }
    }
}
```

基本的に、[Stack-based Navigationの基本的な形](https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/stackbasednavigation/#Basics)とViewと同じようになります。

特徴的なのは、初期画面のViewやReducerが別モジュールに含まれるため、`NavigationStack`の`root`パラメーター内で`EntryView`の生成のみ行い、テキストや色などのUI情報を与えていない点です。また、`EntryView`で使用するStoreはRouterReducerが保持しているため、`store.scope(state: \.entry, action: \.entry)`で初期画面のStateとActionを渡しています。

またRouterReducerと同様に、各画面はそれぞれのFeatureモジュールに含まれるため、それぞれのViewにアクセスするために各Featureをimportしています。

### 画面遷移の流れ

画面遷移が起きる際にどのような流れで処理が行われるかを、初期画面からHoge画面への遷移を例に説明します。

まず画面遷移を行うトリガーとなるAction(`.delegate(.toHoge)`)が初期画面（`EntryView`）で発火します。

```swift: EntryView.swift
// ...

Button("Hoge画面へ") {
    store.send(.delegate(.toHoge))
}

// ...

```

次に初期画面のReducer(ここでは`Entry`)がこのActionを受け取りますが、ここでは画面遷移のみを行うとするため、`.none`を返す以外は何も行いません。

```swift: Entry.swift
// ...

// 初期画面のアクション
enum Action {
    case .delegate(Delegate)
    
    // ...

    enum Delegate {
        case toHoge
    }
}

var body: some ReducerOf<Self> {
    Reduce { state, action in
        switch action {

        // ...

        case .delegate(.toHoge): // 👈 このアクションが発火
            return .none
        }
    }
}

// ...
```

次にRouterReducerが先ほど発火したActionを検知し、Hoge画面のStateをStackに追加します。

```swift: SampleRouter.swift
// ...

switch action {
case .entry(.delegate(.toHoge)):
    state.path.append(.hoge(HogeRoot.State()))
    return .none

// ...
}

// ...

```

最後にRouterViewがこのStateを受け取り、Hoge画面を表示します。

```swift: SampleRouterView.swift
// ...

switch store.case {
case .hoge(let store):
    // Hoge画面
    HogeRootView(store: store)

// ...
}

// ...
```

このようにして、RouterReducerとRouterViewによって画面遷移できました！
ここで重要なことは、初期画面がHoge画面についての詳細を知らなくても画面遷移が行えるという点です。これにより、初期画面のモジュールとHoge画面のモジュールの結合度を下げることができます。

## 導入すべきでないケース

最後に、Routerパターンを導入すべきでないケースについて考えてみます。

Routerパターンは画面遷移ロジックをドメインロジックから分離することができたり、モジュール間の画面遷移に適用すると副次的にモジュール間の結合度を下げられるため、これらの目的を達成したい場合は有効であると考えられます。

しかし、すべてのケースでRouterパターンが有効であるとは限らないと考えています。
たとえば、画面遷移がモジュール内で行われ、かつその遷移元画面から遷移しうる画面数やバリエーションが少ない場合は、Router導入により逆にコードの複雑さが増す可能性があります。個人的な感覚としては、TCA公式の[こちらのチュートリアル](https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/composablearchitecture/02-02-multipledestinations)のように画面遷移先が2つほどであり、それらがモジュール内画面遷移であれば[^1]、ファイルが増えることで可読性が下がる可能性があると感じています。

アーキテクチャ設計全般にいえることだと思いますが、モジュールの結合度を下げたいのか、再利用性を高めたいのかなど目的に応じて導入を検討することが重要だと考えています。

[^1]: このチュートリアルのアプリ自体はマルチモジュール構成ではないですが、遷移しうる画面がすべて同じドメインであると考えられるため、おそらくモジュール内画面遷移であるとみなせそうです

# 実装サンプル

最後に、上記のRouterパターンを採用したサンプルアプリを実装しました。

<!-- markdownlint-disable MD034 -->
https://github.com/rukawa07/TCARouterSample
<!-- markdownlint-enable MD034 -->

:::note info
サンプルアプリの実行環境は以下の通りです。

- Swift 5.10
- Xcode 16.1
:::

このアプリはInstagramやXなどのSNSアプリのような画面遷移を想定しており、具体的な画面遷移は以下の通りです。

![tca_router_sample_flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646568/a4bc0c66-3585-7653-8529-954adfd3f27b.png)

初期画面は認証画面（AuthRootView）です。認証に成功するとセットアップ画面（SetupRootView）またはホーム画面（HomeRootView）に遷移します。ホーム画面はTabViewに含まれている1画面であり、他には、

- 検索画面（SearchRootView）
- コミュニティ画面（CommunityRootView）
- 通知画面（NotificationRootView）
- メッセージ画面（MessageRootView）

があると仮定しています[^2]。また、ホーム画面からは再帰的に遷移可能な画面（HomeChildView）も存在します。

[^2]: このTabViewに含まれる画面は[X](https://apps.apple.com/jp/app/x/id333903271)を参考にしました。

このアプリのデモ動画は以下の通りです。

![tca_router_sample_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/646568/8b83eabf-03e9-661d-163b-1444df499e59.gif)

ここでは上記の画面遷移図記載の遷移パターンのすべてを網羅しています。

本記事で実装の詳細のすべてを書き切れないため、詳細はリポジトリを参照していただければと思うのですが、遷移ロジックをドメインロジックから分離しつつ、かつ各Featureを結合させずに基本的な遷移パターンを実現できたかと思います！[^3]

[^3]: NavigationStackのネストの回避やTCAによるTabViewの実装などサンプル実装で工夫したポイントがいくつかあります。これらは別記事にするかもしれません…！

# おわりに

今回の記事を執筆するに当たり、あらためてpointfreeのTCAのすごさを感じました。
とくにFeatureの分割と結合が柔軟に行える点は、マルチモジュール構成のアプリケーションにおいて非常に有用だと感じました。

また今回検討したRouterパターンはまだまだ改善の余地があると思っています。
たとえば、Reducerがネストしている場合に深い階層のReducerのActionによって画面遷移を行いたい時、現状の実装だとRouterReducerまでActionのバケツリレーが発生してしまいます。これに対してはShared Stateを導入するなどの工夫ができそうです。

今後もTCAを使ったアプリケーション開発を進めていく中で、よりよいアーキテクチャを模索していきたいと思います。

# 参考文献

- <https://github.com/android/nowinandroid>

- <https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/treebasednavigation>

- <https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/stackbasednavigation>

- <https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/composablearchitecture/02-01-yourfirstpresentation#Child-to-parent-communication>

- <https://pointfreeco.github.io/swift-composable-architecture/main/tutorials/composablearchitecture/02-04-navigationstacks>