目的
- モジュールを疎結合にし、SPMマルチモジュール構成を採用するメリットを最大限享受できるようにすること。
- モジュール同士がお互いの存在を知らずとも画面遷移可能な実装を示します
- SPMマルチモジュール構成を採用したプロジェクトにおいて、画面遷移実装を行う際の検討材料になれば嬉しいです
 
本記事のゴール
SPMマルチモジュール上 で swift-dependencies を用いた、疎結合な 画面遷移 を実装すること
| 画面遷移 | 
|---|
|  | 
記事を書いた背景
SPMマルチモジュールのメリットである、ビルド時間の短縮 のために、モジュール間を疎結合にすることがとても重要だと考えています。画面遷移は、工夫をしないと 依存関係に陥り、疎結合になります。
そのため、今回は モジュール間を疎結合にした SPMマルチモジュールにおける画面遷移の実装 を考え、提案することで、ビルド時間の短縮 に貢献します。
結論
MainAppにて画面遷移を実装 + Swift-dependenciesを用いたモジュールへのDI による実装が良いと考えています。
最終的なプロジェクト構成 (※SPMマルチモジュール構成採用済みの構成)
.
├── App
│   ├── Package.swift
│   ├── Project名
│   └── Project名.xcodeproj
│
├── Package.swift
├── README.md
│
└── Sources
    ├── Core
    └── Feature
App
.
├── Routing
│   ├── Dependencies   // swift-dependencies を用いる
│   │   └── ViewBuildingClient+Impl.swift
│   └── Environment   // Environment を用いる
│       └── AppViewBuilding.swift
└── SwapWithMeApp.swift
Sources
.
├── Core
│   └── Routing
│       ├── Dependencies
│       │   └── ViewBuildingClient.swift
│       └── Environment
│           ├── Environment+extension.swift
│           ├── ModuleViewBuilding.swift
│           ├── ViewBuildingProtocol.swift
│           └── ViewType.swift
└── Feature
    └── Sample
        ├── FirstView.swift
        └── SecondView.swift
実装していきます
以下のように順を追って理解しやすく説明していきます
MainApp での単純な画面遷移 を行い、 Routerへの切り出し, DIの採用, SPMマルチモジュールへの導入 までを順に説明します。
準備体操
MainApp にて画面遷移を実装
まずは、最も単純な画面遷移実装です
ContentView → WelcomeView への遷移を例に実装していきます。
struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack(spacing: 40) {
                Text("Content View")
                    .fontWeight(.bold)
                // 画面遷移
                NavigationLink {
                    // 遷移先を指定 
                    WelcomeView()   // ! ContentViewが遷移先を知っており、密結合となっている
                } label: {
                    Text("to WelcomeView")
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.orange)
        }
    }
}
#Preview {
    ContentView()
}
import SwiftUI
/// 遷移先
struct WelcomeView: View {
    var body: some View {
        Text("Welcome View")
    }
}
#Preview {
    WelcomeView()
}
Router を用いて疎結合な画面遷移を実装
SwiftUI と UIKit の画面遷移ロジックは異なります。(UIKit の場合は画面遷移の選択肢が多いのですが)今回はSwiftUIに限って話を進めていきます。
SwiftUI では、画面遷移が NavigationView(Stack) + 遷移先画面 によって行われます。
遷移先画面 の呼び出しを変更することで、疎結合化 します。
遷移可能な画面を enum で定義します
import Foundation
/// 画面遷移先を定義
enum ViewType {
    case contentView
    case welcomeView
}
次に、画面呼び出しする Router を実装します
import SwiftUI
struct Router {
    /// 遷移先の画面を返すだけ, 画面遷移 はView側で行う
    func build(viewType: ViewType) -> AnyView {
        switch viewType {
        case .contentView:
            return AnyView(ContentView())
        case .welcomeView:
            return AnyView(WelcomeView())
        }
    }
}
Router を使い、ContentViewの画面遷移実装を変更します。
// ContentView.swift
struct ContentView: View {
+   private let router = Router()
    var body: some View {
        NavigationView {
            VStack(spacing: 40) {
                Text("Content View")
                    .fontWeight(.bold)
                // 画面遷移
                NavigationLink {
                    // Router経由で画面遷移
                    // ContentView が WelcomeView を知ることなく画面遷移可能
+                   router.build(viewType: .welcomeView)
                } label: {
                    Text("to WelcomeView")
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.orange)
        }
    }
}
#Preview {
    ContentView()
}
これで疎結合な画面遷移が可能となりました 👍
しかし より疎結合にできます。実装を続けていきましょう。
Protocol を用いて より疎結合にする
ContentView と Router を疎結合にするため、Protocolを用いて Routerの抽象化 を行います。
Protocol による抽象化のメリットとして、テストでモックしやすくなる や DIの下準備、何を行う構造体なのか分かりやすくなる 等があります。
Routerの抽象化
// Router.swift
  // Router を抽象化
+ protocol RouterProtocol {
+    func build(viewType: ViewType) -> AnyView
+ }
+ struct Router: RouterProtocol {
    /// 遷移先の画面を返すだけ, 画面遷移 はView側で行う
    func build(viewType: ViewType) -> AnyView {
        switch viewType {
        case .contentView:
            return AnyView(ContentView())
        case .welcomeView:
            return AnyView(WelcomeView())
        }
    }
}
Routerの依存関係を修正
Routerを抽象化したことにより、デメリットとして ContentView を呼び出す度にRouterを差し込む必要が出てきました。
わざわざRouterを差し込むのは面倒です。次は、Environmentを用いて画面遷移を簡単にDIできるよう修正しましょう。
// ContentView.swift
struct ContentView: View {
+    private var router: RouterProtocol
+    init(router: RouterProtocol) {
+        self.router = router
+    }
    var body: some View {
        // 〜〜〜
    }
}
#Preview {
+    ContentView(router: Router())   // 差し込み(1/3)
}
// SPMViewTranslationApp.swift
import SwiftUI
@main
struct SPMViewTranslationApp: App {
    var body: some Scene {
        WindowGroup {
+            ContentView(router: Router())   // 差し込み(2/3)
        }
    }
}
// Router.swift
// 〜〜〜
struct Router: RouterProtocol {
    /// 遷移先の画面を返すだけ, 画面遷移 はView側で行う
    func build(viewType: ViewType) -> AnyView {
        switch viewType {
        case .contentView:
+            return AnyView(ContentView(router: Router()))   // 差し込み(3/3)
        case .welcomeView:
            return AnyView(WelcomeView())
        }
    }
}
Protocol を用いることで、より疎結合になりました 👍
しかし 結合部分(DI)を修正することで、差し込むためのコードを減らした より良い実装を目指せます。実装していきましょう。
Environment を用いて DIをより簡潔に
Environment は、(簡単にまとめると)環境変数を定義できるものです。
デフォルトでいくつかの機能が用意されています。その中にある モーダルを閉じる(@ Environment(.dismiss) や ダークモードの検知 (@ Environment(.colorScheme)) を使ったことある方も多くいるのではないでしょうか。
今回、Environement を独自に定義することで、DIを実装していきます。
Environment に画面遷移実装を登録
// Router.swift
import SwiftUI
// Router を抽象化
protocol RouterProtocol {
    func build(viewType: ViewType) -> AnyView
}
struct Router: RouterProtocol {
    /// 遷移先の画面を返すだけ, 画面遷移 はView側で行う
    func build(viewType: ViewType) -> AnyView {
        switch viewType {
        case .contentView:
            return AnyView(ContentView(router: Router()))
        case .welcomeView:
            return AnyView(WelcomeView())
        }
    }
}
+ // MARK: - DI
+ 
+ struct RouterKey: EnvironmentKey {
+     public static var defaultValue: RouterProtocol = Router()
+ }
+ 
+ extension EnvironmentValues {
+     var router: RouterProtocol {
+         get { self[RouterKey.self] }
+         set { self[RouterKey.self] = newValue }
+     }
+ }
画面遷移のDIを修正
Environment を用いた画面遷移が可能となりました。
これから、ContentView を呼び出す度にRouterを差し込む必要が出てきた というRouterの抽象化によるデメリットを解決していきましょう。
// ContentView.swift
import SwiftUI
struct ContentView: View {
-    private var router: RouterProtocol
-
-    init(router: RouterProtocol) {
-        self.router = router
-    }
+    @Environment(\.router) var router
    var body: some View {
        NavigationView {
            VStack(spacing: 40) {
                Text("Content View")
                    .fontWeight(.bold)
                // 画面遷移
                NavigationLink {
                    // Router経由で画面遷移
                    // ContentView が WelcomeView を知ることなく画面遷移可能
                    router.build(viewType: .welcomeView)
                } label: {
                    Text("to WelcomeView")
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.orange)
        }
    }
}
#Preview {
-    ContentView(router: Router())   // 差し込み(1/3)
+    ContentView()
}
// SPMViewTranslationApp.swift
import SwiftUI
@main
struct SPMViewTranslationApp: App {
    var body: some Scene {
        WindowGroup {
-            ContentView(router: Router())   // 差し込み(2/3)
+            ContentView()
        }
    }
}
// Router.swift
// 〜〜〜
struct Router: RouterProtocol {
    /// 遷移先の画面を返すだけ, 画面遷移 はView側で行う
    func build(viewType: ViewType) -> AnyView {
        switch viewType {
        case .contentView:
-            return AnyView(ContentView(router: Router()))   // 差し込み(3/3)
+            return AnyView(ContentView())
        case .welcomeView:
            return AnyView(WelcomeView())
        }
    }
}
これで準備体操はおしまいです。
MainAppでの画面遷移を 抽象化+DI修正 していきました。
次からは、MainApp と マルチモジュール の違いによって発生する問題点とその解決策を考えていき、より良い画面遷移を実装していきましょう。
SPMマルチモジュール構成を導入する
記事を書きました。
以下の記事を参考にして導入してみてください。
マルチモジュール化を行えたら、以下のフォルダ構造にしてみてください。
また、コードも書いておきます。足並みを揃えたい人は参考にしてみてください。
(再)最終的なプロジェクト構成
.
├── App(Project名の場合もあり)
│   ├── Package.swift
│   ├── Project名
│   └── Project名.xcodeproj
│
├── Package.swift
├── README.md
│
└── Sources
    ├── Core
    └── Feature
App
.
├── Routing
│   ├── Dependencies   // swift-dependencies を用いる
│   │   └── ViewBuildingClient+Impl.swift
│   └── Environment   // Environment を用いる
│       └── AppViewBuilding.swift
└── SwapWithMeApp.swift
Sources
.
├── Core
│   └── Routing
│       ├── Dependencies
│       │   └── ViewBuildingClient.swift
│       └── Environment
│           ├── Environment+extension.swift
│           ├── ModuleViewBuilding.swift
│           ├── ViewBuildingProtocol.swift
│           └── ViewType.swift
└── Feature
    └── Sample
        ├── FirstView.swift
        └── SecondView.swift
FirstView.swift
import SwiftUI
public struct FirstView: View {
    public init() {}
    public var body: some View {
        NavigationView {
            VStack(spacing: 24) {
                Text("First View")
                    .foregroundStyle(.white)
                    .font(.system(size: 48, weight: .bold, design: .rounded))
                NavigationLink {
                    // SecondView の呼び出し
                } label: {
                    Text("to Second View")
                        .foregroundStyle(.blue)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.red.opacity(0.6))
        }
    }
}
#Preview {
    FirstView()
}
SecondView.swift
import SwiftUI
public struct SecondView: View {
    public init() {}
    public var body: some View {
        NavigationView {
            VStack(spacing: 24) {
                Text("Second View")
                    .foregroundStyle(.white)
                    .font(.system(size: 48, weight: .bold, design: .rounded))
                NavigationLink {
                    // FirstView の呼び出し
                } label: {
                    Text("to First View")
                        .foregroundStyle(.red)
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.blue.opacity(0.6))
        }
    }
}
#Preview {
    SecondView()
}
マルチモジュールでの画面遷移
まず、単純な画面遷移を実装してみる
import SwiftUI
import FeatureB   // Package.swift で依存関係を明示的に指定する必要あり
public struct FirstView: View {
    public init() {}
    public var body: some View {
        NavigationView {
            VStack(spacing: 40) {
                Text("First View")
                    .fontWeight(.bold)
                // 画面遷移
                NavigationLink {
                    SecondView()
                } label: {
                    Text("go SecondView")
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.yellow)
        }
    }
}
単純な画面遷移では、以下のような問題が発生します。そのため、より疎結合にするために実装を続けていきましょう。
問題点
モジュール間が密になってしまう
→ 遷移するためにモジュール同士が互いのモジュールの存在を知る必要があるから
モジュール間を疎結合にし、画面遷移する
疎結合 = 遷移先のモジュール を import せずに、画面遷移する とします。
疎結合に向けての方針として、MainAppで画面遷移を実装 + MainAppからモジュールへのDI を立てました。
MainAppで画面遷移を実装
モジュールへDIするため、Router.swift を少し修正します。
RouterProtocol をMainAppから削除していきます。
削除した理由は、MainAppで定義したものをモジュールから参照できないから です。
今回の実装では、モジュールでもRouterProtocolを使用して画面遷移のDIを行うため、RoterProtocolをモジュール内で定義します。(→ 次の項目で実装)
// Router.swift
-  // Router を抽象化
- protocol RouterProtocol {
-    func build(viewType: ViewType) -> AnyView
- }
struct Router: RouterProtocol {
    /// 遷移先の画面を返すだけ, 画面遷移 はView側で行う
    func build(viewType: ViewType) -> AnyView {
        switch viewType {
        case .firstView:
            return AnyView(FirstView())
        case .secondView:
            return AnyView(SecondView())
        }
    }
}
モジュール内にインターフェイス実装
MainApp 上から RouterProtocol を削除済み です。
モジュール上に画面遷移のインターフェイスを作るためにも、モジュールに改めて RouterProtocol を定義していきます。
また、DIするための空実装として ModuleRouter も実装しておきます。
import SwiftUI
// Module へ MainAppの画面遷移実装 を差し込むために定義
public protocol RouterProtocol {
    func build(viewType: ViewType) -> AnyView
}
import SwiftUI
// DIするために 空実装
// 将来 MainAppのViewBuilding を差し込む
public struct ModuleRouter: RouterProtocol {
    public func build(viewType: ViewType) -> AnyView {
        return AnyView(EmptyView())
    }
}
MainAppからモジュールへのDI
モジュールへのDIを可能にするため、カスタムEnvironment をモジュール内に実装していきます。
import SwiftUI
// MARK: - DI
struct RouterKey: EnvironmentKey {
    // デバッグ時 MainAppのViewBuilding を差し込む
    public static var defaultValue: any RouterProtocol = ModuleRouter()
}
extension EnvironmentValues {
    public var router: any RouterProtocol {
        get { self[RouterKey.self] }
        set { self[RouterKey.self] = newValue }
    }
}
MainAppからモジュールへ、画面遷移をDIする
(画面遷移:  struct Router{~~~})
// FirstView.swift
import SwiftUI
- import FeatureB   // Package.swift で依存関係を明示的に指定する必要あり
public struct FirstView: View {
    public init() {}
+   // 受け手
+   @Environment(\.router) var router
    public var body: some View {
        NavigationView {
            VStack(spacing: 40) {
                Text("First View")
                    .fontWeight(.bold)
                // 画面遷移
                NavigationLink {
-                   SecondView()
+                   router.build(.secondView)
                } label: {
                    Text("go SecondView")
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(.yellow)
        }
    }
}
// SPMViewTranslationApp.swift
import SwiftUI
@main
struct SPMViewTranslationApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
+               .environment(\.router, Router())   // 差し替え
        }
    }
}
これで、モジュール間を疎結合にした画面遷移が可能となりました 👍
しかし Environment を使った画面遷移やDI には、以下に示すようないくつかの問題点があります。
問題点
- インターフェイス用の空実装(ModuleRouter)を行う必要があること
- Environment を使用した場合には、MainApp と モジュール を繋ぐため必要なものですが、可能であればなくしたい実装
 
- 依存の差し替えをする必要がある
- 
.environment(\.router, Router())を用いて、Router() の差し込みを行っているが、可能であればなくしたい実装
 
- 
これらの問題に対し、swift-dependenciesを用いて解決します
swift-dependencies を用いた実装
swift-dependencies とは、pointfree が提供している DIライブラリ です。
結論から言うと、swift-depdendecies を使うことにより、モジュール用のインターフェイス実装がなくなります。これによって、上で示した問題点を解消できます。
実装を考えていきましょう。
モジュールにDI用の実装を追加する
モジュールで画面遷移を呼び出せるようDIは使用し続けます。しかし、DI用のライブラリとして swift-dependencies を使用するため、実装を追加する必要があります。
Routingモジュールに、以下の ViewBuildingClient.swift を新規作成します。
import Dependencies
import SwiftUI
public struct ViewBuildingClient {
    public var firstView: @Sendable (_ id: Int) -> AnyView
    public var secondView: @Sendable () -> AnyView
    public init(
        firstView: @escaping @Sendable (Int) -> AnyView,
        secondView: @escaping @Sendable () -> AnyView
    ) {
        self.firstView = firstView
        self.secondView = secondView
    }
}
// MARK: - Dependnecies
extension ViewBuildingClient: TestDependencyKey {
    public static let testValue: ViewBuildingClient = .init(
        firstView: unimplemented(),
        secondView: unimplemented()
    )
}
extension DependencyValues {
    public var viewBuildingClient: ViewBuildingClient {
        get { self[ViewBuildingClient.self] }
        set { self[ViewBuildingClient.self] = newValue }
    }
}
MainApp に画面遷移の実装を追加
import Dependencies
import SwiftUI
import Routing   // ViewBuildingClient を呼び出すため
extension ViewBuildingClient: DependencyKey {
    public static var liveValue: ViewBuildingClient {
        return .init(
            firstView: { id in
                return AnyView(FirstView())
            },
            secondView: {
                return AnyView(SecondView())
            }
        )
    }
}
画面遷移の確認
swift-depdencies を用いることで、依存の差し替えを行う必要がなくなりました。
また、どの画面からでも@Dpendency を使い機能の呼び出しが可能です。
// SPMViewTranslationApp.swift
import SwiftUI
@main
struct SPMViewTranslationApp: App {
    var body: some Scene {
        WindowGroup {
            FirstView()
-               .environment(\.router, Router())   // 差し替え
        }
    }
}
// FirstView.swift
import Dependencies
import Routing
import SwiftUI
public struct FirstView: View {
-    @Environment(\.viewBuilding) var viewBuilding
+    @Dependency(\.viewBuildingClient.secondView) var secondView
    public init() {}
    public var body: some View {
        NavigationView {
            NavigationLink {
-                AnyView(viewBuilding.build(viewType: .secondView))
+                secondView()
            } label: {
                Text("to Second View")
            }
        }
    }
}
これにより、SPMマルチモジュール構成上での疎結合な画面遷移実装が完了しました 👍
以上でSPMマルチモジュール構成を採用した際の画面遷移の説明は終わりです。
良いSPMマルチモジュールライフを!
まとめ
今回、マルチモジュール構成を採用しない場合の画面遷移を実装し、問題点と解決策を示しながらより良い画面遷移の実装を考えていきました。
僕は結論として、MainAppにて画面遷移を実装 + Swift-dependenciesを用いたモジュールへのDI が良いと考えています。
SPMマルチモジュール構成を採用した場合に生じる モジュール は MainApp を参照できない という前提があり、今回の実装が必要となりました。
本記事の改善点をどんどん募集しています。ぜひコメントお願いします! 🙏