8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KDDIアジャイル開発センター(KAG)Advent Calendar 2024

Day 13

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

Last updated at Posted at 2024-12-12

はじめに

こんにちは!
こちらは KDDIアジャイル開発センター(KAG) Advent Calendar 2024 の13日目の記事です。

みなさん、TCAは使われていますでしょうか?
私は少し前からTCAを使い始めたのですが、学習コストが高かったり、次々と新しいバージョンがリリースされたり、キャッチアップがなかなか追いつかないなと感じています。

そんな中、TCAのExampleやチュートリアルを見ているとふと気になることがありました。
それは「1つのReducerの中にドメインロジックと画面遷移ロジックが混在していることが多い」という点です。これは単一モジュールであればそこまで問題とはなりませんが、マルチモジュール構成のアプリにおいては望ましくないと感じました。なぜなら画面遷移ロジックがドメインロジックと混在すると、画面遷移元のモジュールが画面遷移先のモジュールに依存してしまい、結合度が高くなってしまうからです。
また別分野ではありますが、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. 画面遷移情報を状態として保持すること
  2. 画面遷移用のViewを生成する(またはmodifierをコールする)こと

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

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

モジュール構成

先にRouterパターンを導入した場合のモジュール構成を見ていきます。基本的には、Now in Androidのアーキテクチャに準拠しています。

tca_router_sample.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の定義から見ていきます。

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しています。

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

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

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

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画面を閉じて別画面に遷移したときにログを出したい場合は以下のように記述します。

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

// ...
}

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

SampleRouter.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を見ていきます。

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の基本的な形とViewと同じようになります。

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

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

画面遷移の流れ

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

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

EntryView.swift
// ...

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

// ...

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

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に追加します。

SampleRouter.swift
// ...

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

// ...
}

// ...

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

SampleRouterView.swift
// ...

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

// ...
}

// ...

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

導入すべきでないケース

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

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

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

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

実装サンプル

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

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

  • Swift 5.10
  • Xcode 16.1

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

tca_router_sample_flow.png

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

  • 検索画面(SearchRootView)
  • コミュニティ画面(CommunityRootView)
  • 通知画面(NotificationRootView)
  • メッセージ画面(MessageRootView)

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

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

tca_router_sample_demo.gif

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

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

おわりに

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

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

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

参考文献

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

  2. このTabViewに含まれる画面はXを参考にしました。

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

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?