🐙

TCAにおける間接参照と共通I/Fによる画面遷移設計

に公開

はじめに

TCAのStack-basedアプローチを採用したモジュール分割アプリ開発では、以下のような構造的課題に直面することがあります。

  • 新しい画面遷移を追加する際に循環参照エラーが発生した
  • チームメンバーごとに画面遷移の実装方法がバラバラになってしまった

これらの課題は、モジュール分割アプリでの構造的課題と、TCAのStack-based画面遷移実装パターンの組み合わせから発生しています。

課題

課題1:循環参照によるビルドエラー

TCAのStackStateでは、画面遷移時に遷移先のState型をコンパイル時に知る必要があります。この設計により、Feature間で相互にState型を参照することになり、循環依存が発生しやすくなります。

影響範囲:

  • 新しい画面追加が不可能
  • 既存機能の修正も困難

課題2:各Featureで異なる遷移インターフェース

各Featureが独自の画面遷移インターフェースを定義できるため、一貫性のない実装パターンが発生します。

// FeatureAの独自インターフェース
enum FeatureADelegateAction {
  case navigateToNext(id: String)
  case goBackToRoot
}

// FeatureBの独自インターフェース
enum FeatureBDelegateAction {
  case showDetail(id: String)
  case returnToPrevious
}

影響範囲:

  • 同じ「戻る」処理でも命名がバラバラ
  • 新人エンジニアの学習コスト増大
  • 遷移パターンの追加時にすべてのFeatureで個別対応が必要

解決方法

これらの課題を、間接参照と共通インターフェースによる画面遷移設計で解決します。

間接参照による循環依存の解決

従来の直接参照による循環依存:

┌─────────────┐               ┌─────────────┐
│  FeatureA   │ ────────────► │  FeatureB   │
└─────────────┘               └─────────────┘
       ▲                             │
       │                             │
       │                             ▼
┌─────────────┐               ┌─────────────┐
│  FeatureC   │ ◄──────────── │  FeatureD   │
└─────────────┘               └─────────────┘

❌ 問題: 循環依存でビルドエラー

間接参照設計による循環依存回避:

                    ┌─────────────────┐
                    │   AppScreen     │
                    │ • screenA       │ ◄─────────────────┐
                    │ • screenB(id)   │                   │
                    │ • screenC(id)   │                   │
                    └─────────────────┘                   │
                            ▲                             │
                            │                             │
            ┌───────────────┼───────────────┐             │
            │               │               │             │
    ┌─────────────┐ ┌─────────────┐ ┌─────────────┐       │
    │  FeatureA   │ │  FeatureB   │ │  FeatureC   │       │
    └─────────────┘ └─────────────┘ └─────────────┘       │
            ▲               ▲               ▲             │
            │               │               │             │
            └───────────────┼───────────────┘             │
                            │                             │
                            │                             │
                    ┌─────────────────┐                   │
                    │   AppStack      │───────────────────┘
                    └─────────────────┘

✅ 解決: 一方向依存により循環回避

共通インターフェースの提供

チームメンバーごとに画面遷移の実装方法が異なる問題を、以下のアプローチで解決します:

  • 各Featureが独自に定義していたデリゲートアクション定義を共通enum型で統一
  • 標準的なナビゲーション操作を定義してDelegateActionで利用
  • 直接的な型参照の代わりに画面識別子を利用

この設計により、循環依存によるビルドエラーを解決し、チームメンバー間での実装方法の統一を実現します。

設計

画面識別子の設計

すべての画面を型安全に識別する仕組みを共通モジュールに定義します:

// 画面識別子
public enum AppScreen: Equatable, Sendable {
  case screenA
  case screenB(id: String)
  case screenC(id: String)
}

設計効果:

  • コンパイル時での画面存在検証
  • 人間が理解可能な画面識別
  • 遷移コンテキストの構造化管理

共通インターフェースの設計

すべての遷移操作を共通の型定義で統一します:

// 遷移操作の定義
public enum NavigationType<Screen: Equatable>: Equatable {
  case push(Screen)      // 新画面への遷移
  case pop               // 前画面への復帰
  case popTo(Screen)     // 特定画面まで戻る
  case popToRoot         // ルート画面まで戻る
}

設計効果:

  • すべてのFeatureで共通の遷移インターフェース
  • 型安全な遷移操作
  • 各Featureが独自のDelegateActionを持ちながら統一された遷移処理
  • 新しい遷移パターンの一括対応

実装例

Feature側の実装

各FeatureのDelegateActionで共通のNavigationType enumを使用して画面遷移を実装します:

@Reducer
public struct FeatureA {
  public enum DelegateAction: Equatable {
    case navigate(NavigationType<AppScreen>)
    // 必要に応じて他の独自Delegate Actionも追加可能
  }

  public func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .tapNextButton(let id):
      // 他FeatureのStateを知らず、識別子のみで遷移指定
      return .send(.delegate(.navigate(.push(.screenB(id: id)))))

    case .tapBackButton:
      return .send(.delegate(.navigate(.pop)))
    }
  }
}

AppStack側の実装

画面遷移を制御するAppStackで、画面識別子からState生成への変換とNavigationTypeの処理を一元管理します:

// AppStack内でのPath State定義
@Reducer(state: .equatable)
public enum AppPath {
  case screenA(FeatureA)
  case screenB(FeatureB)
  case screenC(FeatureC)
}

// AppStackのState定義
@Reducer
public struct AppStack {
  @ObservableState
  public struct State: Equatable {
    public var path = StackState<AppPath.State>()
  }

  public enum Action {
    case path(StackAction<AppPath.State, AppPath.Action>)
  }
}

// 識別子→State変換(AppStack内で実装)
public extension AppScreen {
  var state: AppPath.State {
    switch self {
    case .screenA:
      return .screenA(FeatureA.State())
    case let .screenB(id):
      return .screenB(FeatureB.State(id: id))
    case let .screenC(id):
      return .screenC(FeatureC.State(id: id))
    }
  }
}

// State→識別子変換(popTo実装で必要)
public extension AppPath.State {
  var screen: AppScreen {
    switch self {
    case let .screenA(state):
      return .screenA
    case let .screenB(state):
      return .screenB(id: state.id)
    case let .screenC(state):
      return .screenC(id: state.id)
    }
  }
}

// AppStack内のreduce実装
public var body: some ReducerOf<Self> {
  Reduce { state, action in
    switch action {
    case .path(.element(id: _, action: .screenA(.delegate(.navigate(let navType))))):
      return handleNavigation(navType, state: &state)
    case .path(.element(id: _, action: .screenB(.delegate(.navigate(let navType))))):
      return handleNavigation(navType, state: &state)
    case .path(.element(id: _, action: .screenC(.delegate(.navigate(let navType))))):
      return handleNavigation(navType, state: &state)
    default:
      return .none
    }
  }
  .forEach(\.path, action: \.path) {
    AppPath()
  }
}

// 遷移制御
private func handleNavigation(
  _ type: NavigationType<AppScreen>,
  state: inout State
) -> Effect<Action> {
  switch type {
  case .push(let screen):
    state.path.append(screen.state)
  case .pop:
    state.path.removeLast()
  case .popTo(let targetScreen):
    // 指定画面まで戻る処理
    while !state.path.isEmpty {
      guard let currentState = state.path.last else { break }
      if currentState.screen == targetScreen {
        break
      }
      state.path.removeLast()
    }
  case .popToRoot:
    state.path.removeAll()
  }
  return .none
}

設計による解決効果

間接参照と共通インターフェースによる画面遷移設計は、TCA Stack-basedアプローチにおけるモジュール分割アプリの構造的課題を以下のように解決します:

開発効率の向上:

  • 循環参照エラーの解消による開発継続性確保
  • 共通パターンによる実装時間短縮
  • Feature実装の独立性向上(循環依存回避による)

保守性の向上:

  • 意味のある画面識別による直感的な状態把握
  • 一貫した遷移処理による品質向上
  • 新機能追加時の影響範囲限定

チーム開発の最適化:

  • 共通インターフェースによる学習コスト削減
  • 実装品質の標準化

注意すべき制約

AppScreen enum管理の複雑化
大規模アプリでは数百の画面がAppScreen enumに集約され、認知負荷が大幅に増大します:

// 100画面を超えると管理が困難
public enum AppScreen: Equatable, Sendable {
  case homeScreen
  case loginScreen
  case profileScreen
  case settingsScreen
  // ... 100以上のケースが続く
  case obscureFeatureDetailScreen(id: String, mode: Int, config: SomeConfig)
  case complexWorkflowScreen(step: WorkflowStep, data: WorkflowData, context: Context)
  // 開発者がすべての画面を把握するのは現実的に困難
}

新機能追加時の広範囲な修正
1つの画面追加で複数箇所の修正が必要となり、修正漏れリスクが増大:

新画面追加時の必須修正箇所:
┌─────────────────────┐
│ 1. AppScreen enum   │ ← 画面識別子追加
│ 2. AppScreen.state  │ ← State生成ロジック
│ 3. AppPath enum     │ ← 新しいPathケース
│ 4. AppStack.reduce  │ ← DelegateAction処理
└─────────────────────┘
         ↓
修正漏れ = コンパイルエラー

その他の制約

  • 共有リソース競合リスク(複数開発者による同時AppScreen編集時のマージ競合)
  • 初期学習コストの存在
  • 抽象化による設計複雑性

終わりに

TCA Stack-basedアプローチを使ったモジュール分割アプリ開発において、間接参照と共通インターフェースによる画面遷移設計は、循環依存問題を解決する一つのソリューションです。

本記事がTCAを使った開発に携わる皆様のお役に立てれば幸いです。

この記事は2025年11月に執筆されました。技術情報は執筆時点のものです。

Discussion