every Tech Blog

株式会社エブリーのTech Blogです。

デリッシュキッチンiOSアプリにおけるSwiftUI段階的移行戦略

目次

  • はじめに
  • 2つの課題と、目指すアーキテクチャ
  • 手法1 — UIKit の中に SwiftUI を埋め込む
  • 手法2 — ViewModel の Protocol と実装の分離
  • 手法3 — UIKit 依存の画面遷移を列挙型で集約する
  • 手法4 — SwiftUI から UIKit の画面を呼ぶ
  • 手法5 — 本体プロジェクトに依存する View コンポーネントを外から差し込む
  • まとめ — 制約の中で前に進む

はじめに

デリッシュキッチンで iOS エンジニアをしている谷口恭一です。

デリッシュキッチンは今年で10年目を迎えるアプリです。この約1年間、2つの取り組みを並行して進めています。

  1. SwiftUI 化 — UIKit で書かれた既存画面を SwiftUI に置き換える
  2. マルチパッケージ化 — 本体プロジェクトからコードを SPM パッケージに切り出す

どちらも一括でやれるものではなく、通常の機能開発と並行しながら少しずつ進めるしかありません。本記事では、この2つの取り組みを同時に進めるために実践している5つの手法を紹介します。

2つの課題と、目指すアーキテクチャ

具体的な手法の話に入る前に、それぞれの課題と目指す方向を整理します。

SwiftUI 化の課題

多くの既存画面は UIKit と RxSwift で構成されています。新規の画面は SwiftUI で作っていますが、既存画面の SwiftUI 化はまだ道半ばです。

SwiftUI 化を進めるうえで最大の障壁は、ナビゲーションです。アプリ全体の画面遷移は UINavigationController の push/pop で成り立っています。SwiftUI には NavigationStack がありますが、アプリ全体のナビゲーションを一気に置き換えるのは現実的ではありません。画面数が多く、各画面の遷移ロジックが UINavigationController に深く依存しているためです。

また、各画面の ViewModel は API クライアント、永続化層、広告 SDK など、本体プロジェクトの様々なサービスに依存しています。UIKit の画面を単純に SwiftUI に書き換えるだけでは済まず、こうした依存関係をどう扱うかという設計上の判断が必要になります。

マルチパッケージ化の課題

一部のパッケージ化は進んでいるものの、まだ多くのソースコードが本体プロジェクト(.xcodeproj)のメインターゲットに含まれている状態です。依存管理は CocoaPods と SPM を併用しています。

本体プロジェクトのメインターゲットにコードが集中している構成には、チーム開発で厄介な問題があります。ファイルを追加・削除するたびに .xcodeproj 内の project.pbxproj に差分が出て、ブランチ間のコンフリクトの原因になることです。

Xcode 16 で導入されたフォルダベースのグループ管理を使えば、ファイルシステムとプロジェクト構造が自動同期されるため、この問題は解消できます。しかし、CocoaPods で管理されている古い依存がフォルダベースのグループに対応しておらず、現時点ではフォルダへの移行がまだ行えません。

方針:SwiftUI 化のタイミングでパッケージにも切り出す

この2つの課題は別々のものですが、同時に取り組むことで互いを補い合えます。

具体的には、UIKit の画面を SwiftUI に書き換えるタイミングで、書き換えた SwiftUI のコードを本体プロジェクトに残すのではなく、SPM(Swift Package Manager) の Feature パッケージに切り出すという方針を取っています。

こうすることで、SwiftUI 化によってコードが新しくなると同時に、パッケージへの移動によって本体プロジェクトのメインターゲットからコードが減っていきます。SPM パッケージ内のファイル操作は .xcodeproj に影響しないため、コンフリクト問題も根本的に回避できます。

本記事ではこの2つの場所を以下の用語で呼び分けます。

本体プロジェクト.xcodeproj のメインアプリターゲット) - UIKit の ViewController - ViewModel の実装クラス(各種サービスに依存) - Networking / Services / Repository

Feature パッケージ(SPM で管理する独立したパッケージ) - SwiftUI の View - ViewModel の Protocol - Action の列挙型 - UI コンポーネント

依存の方向は当然 本体プロジェクト → Feature パッケージ の一方向です。

理想像:本体プロジェクトをエントリーポイントにする

最終的には、UI 層だけでなく、責務ごとに適切なパッケージへコードを隠蔽し、本体プロジェクトはそれらを組み合わせるエントリーポイントとしての役割に留めるのがゴールです。今回の Feature パッケージへの UI 層切り出しは、その最初の一歩にあたります。

前提:開発リソースの制約

なお、SwiftUI 化やパッケージ化だけに専念できる時期はありません。通常の機能開発・改善と並行して、できるところから少しずつ進めるしかないのが現実です。だからこそ、1画面ずつ、1コンポーネントずつ着実に移行していける手法が必要になります。

以下、5つの考え方とそれに対応する手法を順に紹介します。

手法1 — UIKit の中に SwiftUI を埋め込む

最も基本的な手法です。アプリ全体の画面遷移を一気に NavigationStack に置き換えるのは現実的ではありません。そこで、画面遷移は UIKit のまま諦めて、1画面ずつ中身だけを SwiftUI に置き換えていくという考え方を取ります。

Feature パッケージ側では、純粋な SwiftUI View を定義するだけです。

public struct FeatureView<VM: FeatureViewModel>: View {
    @ObservedObject var viewModel: VM
    var body: some View { ... }
}

本体プロジェクト側では、この SwiftUI View を UIHostingController 経由で既存の UIKit 画面に埋め込みます。

let hosting = UIHostingController(rootView: FeatureView(viewModel: viewModel))
addChild(hosting)
view.addSubview(hosting.view)
hosting.didMove(toParent: self)

ナビゲーション階層には一切手を加えないため、影響範囲がその画面だけに限定されます。既存のナビゲーションバーの設定や画面遷移ロジックをそのまま活用できます。

なお、シートやフルスクリーンカバーによるモーダル表示は、ナビゲーションの push/pop 階層とは独立しています。したがって、ホスティングされた SwiftUI View の中で .sheet()) や .fullScreenCover()) を使ったモーダル遷移は、完全に SwiftUI 内で完結できます。

手法2 — ViewModel の Protocol と実装の分離

SwiftUI View を Feature パッケージに移すとき、最初にぶつかるのが ViewModel の依存関係です。ViewModel の実装クラスは API クライアントや永続化層など、本体プロジェクトの様々なサービスに依存しています。これをそのままパッケージに持っていくことはできません。

解決策は、ViewModel を Protocol(インターフェース)と実装に分離することです。

Feature パッケージには Protocol だけを置きます。

@MainActor
public protocol FeatureViewModel: ObservableObject {
    var items: [Item] { get }
    var isLoading: Bool { get }
    func fetch() async
}

public struct FeatureView<VM: FeatureViewModel>: View {
    @ObservedObject var viewModel: VM
}

SwiftUI View はジェネリクスで ViewModel Protocol に依存し、具象型を知りません。

実装は本体プロジェクトに残します。

final class FeatureViewModelImpl: FeatureViewModel {
    @Published private(set) var items: [Item] = []
    @Published private(set) var isLoading = false
    private let service: ItemService

    func fetch() async {
        isLoading = true
        items = (try? await service.fetchItems()) ?? []
        isLoading = false
    }
}

サービス層への依存は本体プロジェクトの実装クラスに閉じ込められ、Feature パッケージは Protocol が定義するインターフェースだけを相手にします。

そして、この2つを繋ぐのが本体プロジェクトの UIKit ViewController です。手法1と組み合わせて、実装クラスを生成し SwiftUI View に渡します。

final class FeatureViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let viewModel = FeatureViewModelImpl(service: .shared)
        let hosting = UIHostingController(rootView: FeatureView(viewModel: viewModel))
        addChild(hosting)
        view.addSubview(hosting.view)
        hosting.didMove(toParent: self)
    }
}

Feature パッケージの FeatureViewFeatureViewModel Protocol しか知りませんが、本体プロジェクトが具象型 FeatureViewModelImpl を生成して渡すことで、依存関係が解決されます。Protocol の定義はパッケージに、実装の生成は本体プロジェクトに — この役割分担がパッケージ境界を成立させます。

この分離のもう一つの利点は、将来の拡張性です。Service 層や Repository 層のパッケージ化が進めば、ViewModel の実装クラスもいずれ Feature パッケージに移すことができます。今の時点では Protocol と実装を分けておくことで、将来その選択肢を確保しておけるということです。

手法3 — UIKit 依存の画面遷移を列挙型で集約する

UI 層が Feature パッケージに移ると、次の問題が浮上します。SwiftUI の画面からユーザーが「詳細を見る」「検索画面を開く」といった操作をしたとき、その遷移先がまだ本体プロジェクトに UIKit で実装されたままのケースです。依存の方向は本体プロジェクト → Feature パッケージの一方向なので、Feature パッケージから本体プロジェクトの画面を直接呼ぶことはできません。

このギャップを埋めるために、Feature パッケージで Action 列挙型を定義し、本体プロジェクトのクロージャで処理します。

public enum FeatureAction {
    case showDetail(Item)
    case showSearch
    case showSettings
}

public protocol FeatureViewModel: ObservableObject {
    var actionHandler: ((FeatureAction) -> Void)? { get set }
}

Feature パッケージが知っているのは「こういうアクションが起きうる」という列挙型の定義だけです。それをどう処理するかは、本体プロジェクトに委ねます。

viewModel.actionHandler = { [weak self] action in
    switch action {
    case .showDetail(let item):
        self?.navigationController?.pushViewController(DetailVC(item: item), animated: true)
    case .showSearch:
        SearchVC.present(from: self)
    case .showSettings:
        SettingsVC.push(from: self)
    }
}

UIKit に依存する処理はこの switch 文の中に集約されます。新しい遷移が増えたら enum にケースを追加し、switch にハンドリングを書くだけです。遷移先が SwiftUI 化されたら、対応する case の処理を SwiftUI 内の .navigationDestination) 等に移せばいい。enum の associated values によって遷移に必要なパラメータが型安全に保証されるため、実行時エラーのリスクも低くなります。

なお、このアクションハンドリングの処理を ViewController に直接書くのではなく、Coordinator に切り出して責務を閉じ込めるという選択肢もあります。画面遷移のパターンが多い画面では、そちらのほうが見通しが良くなるかもしれません。

手法4 — SwiftUI から UIKit の画面を呼ぶ

手法3は、主にナビゲーション(push)ベースの画面遷移で機能します。一方で、SwiftUI の .fullScreenCover().sheet() によるモーダル遷移は事情が異なります。

シートやフルスクリーンカバーによるモーダル遷移は、ナビゲーションの push/pop 階層から独立しています。つまり、理想的にはモーダルで表示する画面とその先をまるごと SwiftUI 化できる領域です。しかし現実には、.fullScreenCover() の遷移先にまだ UIKit の画面が残っていることがあります。

ここでの考え方は、遷移先が UIKit のままでも、呼び出し元の SwiftUI 化を止めないということです。UIKit の画面を UIViewControllerRepresentable でラップして SwiftUI から呼べるようにし、UIKit → SwiftUI → UIKit という「サンドイッチ」構造を移行の過渡期として許容します。

具体的な手法は3つのステップで構成されます。

UIKit 画面を Representable でラップする

まだ SwiftUI 化されていない UIKit の画面を、UIViewControllerRepresentable で薄くラップします。

extension LegacyDetailViewController {
    struct Representable: UIViewControllerRepresentable {
        let item: Item
        func makeUIViewController(context: Context) -> LegacyDetailViewController {
            .init(item: item)
        }
        func updateUIViewController(_ vc: LegacyDetailViewController, context: Context) {}
    }
}

このラッパーは本体プロジェクトに置きます。

Feature パッケージは遷移先を外から受け取る

ここが最も重要なポイントです。Feature パッケージの SwiftUI View は、遷移先の具体的な画面を自分では持たず、ジェネリクスの @ViewBuilder クロージャとして外部から受け取ります。

public struct FeatureRootView<Destination: View>: View {
    @ViewBuilder let detailDestination: (Item) -> Destination

    var body: some View {
        content
            .fullScreenCover(isPresented: $viewModel.showDetail) {
                if let item = viewModel.selectedItem {
                    NavigationStack { detailDestination(item) }
                }
            }
    }
}

Destination: View というジェネリクス制約だけがあり、具体的にどんな View(あるいは Representable)が来るかは知りません。Feature パッケージは本体プロジェクトのレガシー画面に一切依存していません。

本体プロジェクトで Representable を注入する

組み立ては本体プロジェクトが担当します。

let rootView = FeatureRootView(
    viewModel: viewModel,
    detailDestination: { item in
        LegacyDetailViewController.Representable(item: item)
    }
)
present(UIHostingController(rootView: rootView), animated: true)

レガシーな UIKit 画面への依存があるのは、この注入の一箇所だけです。

この構造の大きな利点は、遷移先が SwiftUI 化されたときの変更が最小限で済むことです。注入するクロージャの中身を差し替えるだけで、Feature パッケージのコードには一切触れる必要がありません。

detailDestination: { item in
    NewDetailView(item: item)
}

サンドイッチ構造はあくまで移行の過渡期のものであり、最終的には UIKit の層が消えて自然な SwiftUI のコードになります。

いつこの手法を使うか

この手法を使わず「遷移先もすべて SwiftUI 化してからでないと手を付けられない」と考えてしまうと、SwiftUI 化できる範囲がなかなか広がりません。たとえば画面 A から .fullScreenCover() で画面 B を表示していて、画面 B がまだ UIKit だとします。画面 A の SwiftUI 化は画面 B の完了を待つことになり、画面 B にも UIKit の遷移先があれば、さらにその先を待つ…と連鎖してしまいます。

ただし、すべてのモーダル遷移にこの手法を適用すべきというわけではありません。遷移先の UIKit 画面が少数であればラップして先に進めるメリットがありますが、遷移先の大半が UIKit であれば、ラップのコストが見合わないのでその画面の SwiftUI 化自体を後回しにする判断もありえます。ラップにかかるコストと、SwiftUI 化を先に進められるメリットを天秤にかけて判断することが重要です。

手法5 — 本体プロジェクトに依存する View コンポーネントを外から差し込む

手法1〜4で多くの UI を Feature パッケージに移せますが、もう一つ厄介なケースがあります。画面の一部に、本体プロジェクトの依存がないと成立しないコンポーネントが含まれている場合です。

典型的な例がインフィード広告です。広告の表示には広告 SDK への依存が必要ですが、これは本体プロジェクトにしかありません。だからといって、広告を含む画面全体を Feature パッケージに移せないとなると、移行が大きく停滞してしまいます。

考え方は手法4と同じです。Feature パッケージ側では「ここに何かの View が入る」というジェネリクスの枠だけを定義し、具体的な実装は本体プロジェクトから差し込みます。

Feature パッケージの View は、広告コンポーネントの表示位置をジェネリクスの @ViewBuilder パラメータとして受け取ります。

public struct FeatureTabView<VM: FeatureTabViewModel, AdContent: View>: View {
    @ObservedObject var viewModel: VM
    private let adContent: () -> AdContent

    public init(
        viewModel: VM,
        @ViewBuilder adContent: @escaping () -> AdContent
    ) {
        self.viewModel = viewModel
        self.adContent = adContent
    }

    public var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {
                SomeSection(viewModel: viewModel)
                adContent()
                AnotherSection(viewModel: viewModel)
            }
        }
    }
}

Feature パッケージはこの AdContent が何であるかを一切知りません。広告でもプレースホルダーでも EmptyView でも構わないという設計です。

本体プロジェクト側では、広告 SDK に依存する具体的な View を差し込みます。

let view = FeatureTabView(viewModel: viewModel) {
    InFeedAdSectionView(adType: .infeed)
}
let hosting = UIHostingController(rootView: view)

InFeedAdSectionView は本体プロジェクトにあり、内部で広告 SDK を使って広告をロード・表示します。Feature パッケージにはこの View の存在も広告 SDK の存在も見えていません。

この手法のポイントは、1つのコンポーネントが Feature パッケージに移せないからといって、画面全体のパッケージ移行を諦めないということです。移せない部分だけを抽象化して外から差し込めば、画面の大部分は Feature パッケージに移すことができます。

まとめ — 制約の中で前に進む

本記事で紹介した5つの手法を整理します。

手法 主に解決する課題 いつ不要になるか
手法1: UIKit に SwiftUI を埋め込む SwiftUI 化 NavigationStack 全面移行時
手法2: ViewModel の Protocol / 実装分離 マルチパッケージ化 Service 層のパッケージ化完了時
手法3: UIKit 依存処理の列挙型集約 マルチパッケージ化 UIKit 画面の SwiftUI 化完了時
手法4: SwiftUI から UIKit を呼ぶ SwiftUI 化 遷移先の SwiftUI 化完了時
手法5: 依存のあるコンポーネントを外から差し込む マルチパッケージ化 依存のパッケージ化完了時

重要なのは、すべての手法に「不要になる日」があるということです。これらは最終的なアーキテクチャではなく、移行期を乗り越えるための手法です。

これらの手法を組み合わせることで、通常の機能開発と並行しながら少しずつ SwiftUI 化とマルチパッケージ化を進められています。一気に大きな時間を確保しなくても、1画面ずつ、1コンポーネントずつ着実に移行を進めていける実感があります。

大規模アプリの SwiftUI 移行は、短距離走ではなくマラソンです。今日の制約の中で最善の一歩を選び、長い戦略スパンで着実に前に進めていく。本記事がその一助になれば幸いです。