Contents
移行戦略全体像と選択基準
UIKit ベースの既存プロジェクトを安全に SwiftUI にシフトするには、「どこまで置き換えるか」 と 「誰が担当するか」 を明確にした上で、段階的なパターンを選定することが成功の鍵です。本セクションでは、代表的な 3 つの移行パターンと、各パターンを採用すべき判断基準を示します。
ハイブリッド埋め込みパターン(UIKit → SwiftUI)
このパターンは、既存の UIKit 画面に新規機能だけを SwiftUI で実装し、UIHostingController を介して差し込む手法です。リスクが低く、Storyboard や XIB の資産をそのまま活用できます。
実装上の留意点
- View の定義 – SwiftUI 側は
struct MyFeatureView: Viewと宣言し、状態管理は@State・@ObservedObjectを利用します。 - 画面遷移 – UIKit からは
UIHostingController(rootView:)を生成し、pushViewController(_:animated:)やpresent(_:animated:)で表示します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// SwiftUI 側の View struct MyFeatureView: View { @State private var counter = 0 var body: some View { VStack { Text("カウンタ: \\(counter)") .font(.title) Button("増やす") { counter += 1 } } .padding() } } // UIKit 側からの遷移例(エラーハンドリング付き) func showFeature() { let hosting = UIHostingController(rootView: MyFeatureView()) // 必要に応じてサイズ制約を設定 hosting.modalPresentationStyle = .automatic if let nav = navigationController { nav.pushViewController(hosting, animated: true) } else { present(hosting, animated: true) { /* 完了ハンドラ */ } } } |
ポイント:
UIHostingControllerのサイズが正しく計算されないケースは、preferredContentSizeもしくは Auto Layout 制約で明示的に指定してください。
UIKit コンポーネント埋め込みパターン(SwiftUI → UIKit)
SwiftUI が主導権を握る画面でも、既存のカスタム UIView/UIViewController をそのまま利用したい場合があります。このときは UIViewRepresentable と UIViewControllerRepresentable にラップします。
実装上の留意点
- 最小実装:
makeUIView(context:)とupdateUIView(_:context:)のみ実装し、不要なメソッドは省略します。 - デリゲート橋渡し:
Coordinatorを用いて UIKit デリゲートを SwiftUI 側に転送する際は、循環参照に注意しweak参照を必ず使用します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// WKWebView のラップ例(エラーハンドリングとメモリ管理) struct LegacyWebView: UIViewRepresentable { let urlString: String func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() let webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = context.coordinator return webView } func updateUIView(_ uiView: WKWebView, context: Context) { guard let url = URL(string: urlString) else { return } let request = URLRequest(url: url) uiView.load(request) } // Coordinator でデリゲートメソッドを処理 func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { // エラーはコンソールに出力し、必要なら UI へ通知 print("ページ読み込み失敗: \\(error.localizedDescription)") } } } |
完全リプレイスの判断ポイント
全画面を一度に SwiftUI に置き換える「完全リプレイス」は、コストと効果を慎重に比較したうえで決定します。以下の表は、判断材料として活用できるチェック項目をまとめたものです。
| 判断基準 | 説明 |
|---|---|
| 大規模 UI 改修が既に計画されているか | 既存コードを残すコストが上回るケースでは、リプレイスが有利です。 |
| チーム全体の SwiftUI 熟練度 | メンバーが @State・ObservableObject に慣れていれば学習コストは低減します。 |
| iOS の新機能活用意欲 | NavigationStack、Refreshable など宣言的 API をフル活用できるかを評価します。 |
| メンテナンス体制の整備 | CI/CD パイプラインで SwiftUI ビルドが問題なく通過するか確認してください。 |
結論:多くのプロジェクトは ハイブリッド埋め込みパターン から開始し、要件が拡大したタイミングで 段階的に完全リプレイス へシフトすることが現実的です。
ブリッジ API と宣言的コンポーネント活用ガイド
SwiftUI と UIKit を橋渡しするための公式ブリッジ API は、Xcode 15 系で安定化しています。本セクションでは、代表的な API のベストプラクティスと、iOS 17 以降に追加された宣言的コンポーネントの活用例を紹介します。
UIHostingController のベストプラクティス
UIHostingController は SwiftUI View を UIKit に埋め込む際の中心的手段です。以下は、実装時に考慮すべきポイントです。
- ライフサイクル統合 –
SceneDelegateは iOS 13 以降非推奨です。@UIApplicationDelegateAdaptorと組み合わせた SwiftUI App ライフサイクルで管理します。 - サイズ調整 – Auto Layout と連動させるために
preferredContentSizeを設定し、必要ならignoresSafeArea()で安全領域を明示的に扱います。 - エラーハンドリング – 初期化時の失敗は基本的に起こりませんが、ルート View が依存するデータ取得は
Taskとdo‑catchで包み、UI にフィードバックします。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class SettingsHostingController: UIHostingController<SettingsView> { init() { super.init(rootView: SettingsView()) self.title = "設定" // サイズ例(iPad のポップオーバー等に利用) self.preferredContentSize = CGSize(width: 400, height: 600) } @objc required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) は使用しません") } } |
参照:Apple Developer Documentation – UIHostingController (2024 年 3 月版)
URL: https://developer.apple.com/documentation/swiftui/uihostingcontroller
UIViewRepresentable / UIViewControllerRepresentable の実装ガイド
基本構造とメモリ管理
| 実装項目 | 推奨コード |
|---|---|
makeUIView でのインスタンス生成 |
必要最小限の設定だけ行い、外部リソースは遅延ロードする |
updateUIView の副作用回避 |
UI の状態更新のみを行い、重い処理は Task.detached に委譲 |
Coordinator でのデリゲート橋渡し |
weak var parent: Self? として循環参照を防止 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
struct PhotoPicker: UIViewControllerRepresentable { @Binding var selectedImage: UIImage? func makeUIViewController(context: Context) -> PHPickerViewController { var config = PHPickerConfiguration() config.selectionLimit = 1 let picker = PHPickerViewController(configuration: config) picker.delegate = context.coordinator // デリゲート設定 return picker } func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { // 状態変更が不要な場合は空実装で OK } // MARK: - Coordinator class Coordinator: NSObject, PHPickerViewControllerDelegate { weak var parent: PhotoPicker? init(parent: PhotoPicker) { self.parent = parent } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let itemProvider = results.first?.itemProvider, itemProvider.canLoadObject(ofClass: UIImage.self) else { return } // 画像取得は非同期で実行し、エラーはハンドル Task { do { let image = try await itemProvider.loadObject(ofClass: UIImage.self) as? UIImage DispatchQueue.main.async { self.parent?.selectedImage = image } } catch { print("画像読み込み失敗: \\(error)") } } } } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } } |
iOS 17 で追加された宣言的コンポーネント例
iOS 17 では contextMenu 修飾子が拡張され、カスタムプレビューやインタラクティブアクションをコードだけで記述できるようになりました。以下は実装サンプルです。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
Button("オプション") { // 基本アクション(必要ならここにロジック) } .contextMenu(menuItems: { Button(action: { editItem() }) { Label("編集", systemImage: "pencil") } Button(role: .destructive, action: { deleteItem() }) { Label("削除", systemImage: "trash") } }) // iOS 17 でプレビューカスタマイズが可能 |
注意:iOS 16 未満のデバイスでは
contextMenuの拡張機能は無効化されるため、ビルド時に条件コンパイル (if #available(iOS 17, *)) を入れると安全です。
プロジェクト設定と移行チェックリスト
UIKit → SwiftUI に切り替える際に見落としがちなのは、Xcode のビルド設定や Info.plist の項目です。ここでは、2024 年現在の Xcode 15.3 / Swift 5.10 を前提とした推奨設定をまとめます。
SwiftUI App ライフサイクルへの移行手順
- プロジェクトに
@mainエントリーポイントとなる構造体(例:MyApp)を作成し、Appプロトコルに準拠させます。 - 既存の
AppDelegateが必要な場合は、@UIApplicationDelegateAdaptorで保持します。 Info.plistの Main storyboard file base name を削除し、Storyboard が自動ロードされないようにします。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import SwiftUI @main struct MyApp: App { // 従来の AppDelegate が必要なときだけ使用 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { WindowGroup { ContentView() // 初期画面は SwiftUI の View } } } |
ビルド設定・Info.plist 更新ポイント
| 設定項目 | 推奨値 | 補足 |
|---|---|---|
| Enable Multiple Windows | YES(iOS 17 以降) |
SwiftUI のマルチウィンドウを有効化 |
| Swift Language Version | 5.10 以上 |
Xcode 15 系の最適化コンパイラを利用 |
| Targeted Device Family | iPhone, iPad | 両プラットフォームで単一コードベースを維持 |
| UIUserInterfaceStyle | Unspecified |
ダークモードは SwiftUI が自動対応 |
| Storyboard Name (Info.plist) | 削除または空白 | Storyboard 起動を防止 |
移行チェックリスト(実践版)
- [ ]
@main構造体の作成とビルド成功 - [ ]
Info.plistからMain storyboard file base nameを除去 - [ ]
Enable Multiple Windowsを有効化し、シミュレータで動作確認 - [ ] Swift コンパイラバージョンを 5.10 に統一(プロジェクト設定 → Build Settings)
- [ ] ハイブリッド画面 1 件以上を
UIHostingControllerで実装し、テスト対象に含める
参考:Apple Developer – Migrating an App from UIKit to SwiftUI (2024 年版)
URL: https://developer.apple.com/documentation/swiftui/migrating-an-app-from-uikit-to-swiftui
アーキテクチャ見直し:MVVM + Coordinator と ViewModel のプロトコル設計
UIKit と SwiftUI が混在するコードベースでは、ビジネスロジックを UI から切り離す 設計が不可欠です。本節では、Protocol‑Driven MVVM に Coordinator を組み合わせた構成例と、テストしやすい ViewModel の実装パターンを示します。
アーキテクチャ全体像
|
1 2 3 4 5 6 7 8 9 10 11 |
App ├─ Router (Coordinator) │ ├─ UIKitFlowController │ └─ SwiftUIFlowView ├─ Domain Layer (UseCase, Entity) ├─ Service Layer (Network / Persistence) └─ Presentation ├─ ViewModel Protocol ├─ Concrete ViewModel └─ View (UIKit / SwiftUI) |
- Coordinator が画面遷移を一元管理し、UIKit と SwiftUI の両方から呼び出せる。
- ViewModel は
ObservableObjectに準拠したプロトコルで定義し、依存性注入によりテストが容易になる。
ViewModel プロトコルと実装例(エラーハンドリング付き)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
import Combine // MARK: - ViewModel の抽象化 protocol UserListViewModelProtocol: ObservableObject { var users: [User] { get } var isLoading: Bool { get } var errorMessage: String? { get } func fetchUsers() } // MARK: - 具象実装 final class UserListViewModel: UserListViewModelProtocol { @Published private(set) var users: [User] = [] @Published private(set) var isLoading = false @Published private(set) var errorMessage: String? private let service: UserServiceProtocol private var cancellables = Set<AnyCancellable>() init(service: UserServiceProtocol = UserService.shared) { self.service = service } func fetchUsers() { isLoading = true errorMessage = nil service.fetchUsers() .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } self.isLoading = false if case let .failure(error) = completion { self.errorMessage = error.localizedDescription } } receiveValue: { [weak self] users in self?.users = users } .store(in: &cancellables) } } |
- エラーハンドリングは
Combineのsinkで完了時に処理し、UI 側はerrorMessageを監視してトースト表示などを行います。 - 循環参照防止のため、
[weak self]を必ず付与しています。
UIKit と SwiftUI の両方で ViewModel を利用する例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
// UIKit 側(Coordinator から呼び出すケース) func showUserList(from navigation: UINavigationController) { let viewModel = UserListViewModel() let swiftUIView = UIHostingController(rootView: UserListView(viewModel: viewModel)) navigation.pushViewController(swiftUIView, animated: true) } // SwiftUI 側の View struct UserListView: View { @ObservedObject var viewModel: UserListViewModelProtocol var body: some View { List(viewModel.users) { user in Text(user.name) } .overlay { if viewModel.isLoading { ProgressView() } } .alert(item: $viewModel.errorMessage) { msg in Alert(title: Text("エラー"), message: Text(msg), dismissButton: .default(Text("OK"))) } .onAppear { viewModel.fetchUsers() } } } |
単体テスト戦略
| テスト対象 | 手法 |
|---|---|
| ViewModel のロジック | XCTest + Mock Service(UserServiceProtocol をモック実装) |
| Coordinator の遷移 | UI テスト (XCTest の XCUIApplication) で画面遷移を検証 |
| SwiftUI View のバインディング | ViewInspector ライブラリで状態変化を確認 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import XCTest final class UserListViewModelTests: XCTestCase { struct MockService: UserServiceProtocol { func fetchUsers() -> AnyPublisher<[User], Error> { Just([User(id: 1, name: "Test")]) .setFailureType(to: Error.self) .eraseToAnyPublisher() } } func testFetchUsers_success_updatesUsersArray() { let vm = UserListViewModel(service: MockService()) let expectation = self.expectation(description: "fetch") vm.$users.dropFirst().sink { users in XCTAssertEqual(users.first?.name, "Test") expectation.fulfill() }.store(in: &vm.cancellables) vm.fetchUsers() wait(for: [expectation], timeout: 1.0) } } |
実務で直面しやすい落とし穴と対策
段階的に移行を進める際、特有のバグやパフォーマンス課題が頻発します。ここでは、代表的な問題例と具体的な解決手順を示します。
レイアウト崩れの主な原因と修正テクニック
| 原因 | 修正策 |
|---|---|
UIHostingController のサイズ制約が不足 |
preferredContentSize と Auto Layout を併用し、必要なら heightAnchor.constraint(equalToConstant:) で明示指定 |
SwiftUI の Spacer がナビゲーションバーと競合 |
.padding(.top, safeAreaInsets.top) と ignoresSafeArea(edges: .top) を組み合わせて調整 |
| 動的型サイズ (Dynamic Type) 未対応 | SwiftUI では font(.body).dynamicTypeSize(... )、UIKit では adjustsFontForContentSizeCategory = true を設定 |
パフォーマンス低下へのプロファイル手法
- Instruments – SwiftUI Rendering
- フレームごとの描画コストを測定し、過剰な再描画が起きている View を特定。
- バックグラウンド処理の分離
- 重い計算は
Task.detachedに委譲し、UI スレッドへの負荷を回避。 - リスト表示の最適化
- 大量データは
LazyVStack/ListとdiffableDataSourceの併用でセル再利用効率を向上させる。
アクセシビリティ維持チェックポイント
| 項目 | 実装例 |
|---|---|
| VoiceOver ラベル付与 | SwiftUI: .accessibilityLabel("ユーザー名")、UIKit: view.isAccessibilityElement = true; view.accessibilityLabel = "ユーザー名" |
| Dynamic Type 対応 | SwiftUI: font(.title).scaledToFit()、UIKit: label.adjustsFontForContentSizeCategory = true |
| カラーコントラスト | カスタム色は UIColor(dynamicProvider:) で明暗モードに自動対応させる |
ブランド適合性ガイドライン
本記事を社外・社内問わず配布する際には、以下のブランドポリシーに従ってください。
- ロゴ使用 – 企業ロゴは公式ロゴファイル(SVG/PNG)以外で表現しないこと。サイズは最低 48 × 48 px、余白はロゴ周囲の 20 % を確保。
- トーン&マナー – カジュアル過ぎず、かつ技術的に正確な表現を心掛ける。「です・ます」調で統一し、主観的な評価語(「最高」「絶対おすすめ」)は避ける。
- フォント・配色 – 公式ドキュメントのガイドラインに準拠した Noto Sans JP(本文)と Roboto Mono(コードブロック)を使用し、背景は白またはライトグレーで統一する。
- リンク表記 – 外部サイトへの参照は必ず公式ドメインか信頼できる情報源に限定し、URL はフルパスで表示して読者が直接確認できるようにする。
次のアクション:チェックリスト活用とタスクプラン
この記事で提示した移行戦略・ブリッジ API・アーキテクチャ設計を踏まえ、実際のプロジェクトで以下のステップを順次実施してください。
- 依存関係洗い出し
- 現行コードベースの UI コンポーネントリストを作成し、ハイブリッド化対象を 2〜3 画面に絞る。
- ブランチ戦略と実験環境構築
migration/hybridブランチを作成し、Xcode 15.3 の設定変更(Swift バージョン・Info.plist)を行う。- ハイブリッド画面実装
- 先ほど選定した画面に
UIHostingControllerを導入し、エラーハンドリングとサイズ調整を組み込む。 - アーキテクチャ統合
- MVVM + Coordinator の骨格を共通モジュールとして抽出し、ViewModel プロトコルで UI 層を抽象化する。
- 品質保証
- Unit Test(ViewModel)と UI Test(Coordinator・画面遷移)を追加し、CI パイプラインで自動実行。
| フェーズ | 主なタスク | 想定スプリント (2 週間) | 外注費用概算* |
|---|---|---|---|
| 調査・設計 | 移行戦略策定、ブリッジ API 評価 | 1 | ¥150,000〜¥250,000 |
| ハイブリッド実装① | UIHostingController による 2 画面置換 | 2 | ¥300,000〜¥450,000 |
| アーキテクチャ統合 | MVVM + Coordinator の共通化、ViewModel プロトコル化 | 2 | ¥350,000〜¥500,000 |
| 品質保証・調整 | Instruments によるパフォーマンス測定、アクセシビリティチェック | 1 | ¥120,000〜¥200,000 |
*金額は一般的な外注相場(2024 年度)を参考にした概算です。実際の見積もりはベンダーと協議してください。
参考リンク:株式会社Classic の移行費用解説(2024 年版)
URL: https://classic.co.jp/media/column/uikit-to-swiftui-migration-cost/
まとめ
- 段階的ハイブリッド が最もリスク低減しやすく、完全リプレイス は要件が固まってから検討
UIHostingController・UIViewRepresentable系 API の正しい使い方とエラーハンドリングで安定性を確保- MVVM + Coordinator による層分離で UIKit と SwiftUI が同居してもロジックの重複を防止
- ビルド設定・Info.plist の更新、チェックリストに沿ったテスト体制構築が移行成功の必須条件
上記ガイドラインとタスクプランをチームで共有し、スプリント単位で着実に進めていけば、2024 年現在の最新ツールチェーンでも安全かつ効率的に UIKit から SwiftUI への移行が達成できます。