はじめに
iOSアプリ開発においてSwiftUIへの移行を検討するとき、「全画面をSwiftUIに書き直す」という選択肢は、理想としては正しいです。
しかし既存のUIKitアプリがある程度の規模になっていると、フルリプレイスのコストは現実的ではなく、画面数が多いほど、QAコストも含めたリライトの工数は膨らむ一方になってしまいます。
かといって「SwiftUIへの移行は将来の課題」として先送りにしていると、新機能はUIKitで積み上がり続け、移行の難易度は上がるばかりになります。
そこで現実解として有効なのが、画面単位での段階的移行です。
新規画面や改修タイミングが来た画面から順にSwiftUIで書いていく。既存画面はUIKitのまま残す。これが多くのプロダクションアプリで取られているアプローチです。
ただしここで一つ問題が生じます。
既存アプリにはすでにViewModel層やRepository層が存在している場合が多いです。RxSwiftベースのViewModelが各画面に紐づいていて、それ自体はきちんと動いている。このViewModel資産を捨ててSwiftUI・Combine向けに書き直すのか、という話です。
答え: 書き直す必要なし。
本記事では UIHostingController を活用して、既存のRxSwift製ViewModelに一切手を加えずにSwiftUI Viewを導入するパターンを紹介します。
ポイントは以下の3点です。
UIHostingControllerをそのままUIViewControllerとして扱い、UIKitのナビゲーション構造を変えない- SwiftUIの状態管理(
@Observable)とRxSwiftのバインディングをブリッジする - View → ViewModelのイベント伝達は
delegateパターンで解決し、SwiftUI ViewをUIKit層から切り離す
ViewModelは触らない。Repositoryも触らない。変えるのはView層だけ。それがこのアプローチの核心です。
1. 全体アーキテクチャの概観
まず本記事で扱う構成全体を把握しておきましょう、PokeAPIを使ったシンプルなポケモンリスト表示機能で学んでいきます。
import RxCocoa
import RxSwift
import SwiftUI
final class PokemonViewController: UIHostingController<PokemonListView> {
// MARK: - Properties
private let viewModel: PokemonViewModelProtocol
private let disposeBag = DisposeBag()
init(viewModel: PokemonViewModelProtocol) {
self.viewModel = viewModel
let view = PokemonListView(dataSource: PokemonListView.DataSource())
super.init(rootView: view)
}
@available(*, unavailable)
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGroupedBackground
bind()
}
private func bind() {
rootView.delegate = self
viewModel.outputs.viewState
.drive(rootView.dataSource.rx.viewState)
.disposed(by: disposeBag)
}
}
// MARK: - PokemonListViewDelegate
extension PokemonViewController: PokemonListViewDelegate {
func pokemonListViewrandomRefetchTrigger() {
viewModel.inputs.randomRefetchTrigger.accept(())
}
}import RxCocoa
import RxSwift
protocol PokemonViewModelInputs {
var randomRefetchTrigger: PublishRelay<Void> { get }
}
protocol PokemonViewModelOutputs: AnyObject {
var viewState: Driver<PokemonViewState> { get }
}
protocol PokemonViewModelProtocol: AnyObject {
var inputs: PokemonViewModelInputs { get }
var outputs: PokemonViewModelOutputs { get }
}
final class PokemonViewModel: PokemonViewModelProtocol, PokemonViewModelInputs, PokemonViewModelOutputs {
// MARK: - Properties
var inputs: PokemonViewModelInputs { return self }
var outputs: PokemonViewModelOutputs { return self }
// MARK: - Inputs
let randomRefetchTrigger = PublishRelay<Void>()
// MARK: - Outputs
var viewState: Driver<PokemonViewState>
// MARK: - Private
private let disposeBag = DisposeBag()
// MARK: - Initialize
init(repository: PokemonRepositoryProtocol) {
// 初回fetch + ボタン押下ごとにfetch
viewState = Observable.merge(
Observable.just(()),
randomRefetchTrigger.asObservable()
)
.flatMapLatest { _ -> Observable<PokemonViewState> in
return repository.fetchRandomPokemons(count: 20)
.map { screenModel -> PokemonViewState in .loaded(screenModel) }
.startWith(.loading)
}
.asDriver(onErrorJustReturn: .loading)
}
}import RxCocoa
import RxSwift
import SwiftUI
enum PokemonViewState: Equatable {
case loaded(PokemonScreenModel)
case loading
}
// MARK: - Delegate
protocol PokemonListViewDelegate: AnyObject {
func pokemonListViewrandomRefetchTrigger()
}
// MARK: - PokemonListView
struct PokemonListView: View {
@Observable
final class DataSource: ReactiveCompatible {
var viewState: PokemonViewState
init(viewState: PokemonViewState = .loading) {
self.viewState = viewState
}
}
weak var delegate: PokemonListViewDelegate?
@State var dataSource: DataSource
var body: some View {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
switch dataSource.viewState {
case let .loaded(screenModel):
contentView(screenModel: screenModel)
case .loading:
loadingView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var loadingView: some View {
ProgressView("読み込み中...")
.progressViewStyle(CircularProgressViewStyle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func contentView(screenModel: PokemonScreenModel) -> some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(screenModel.pokemons) { pokemon in
PokemonRowView(pokemon: pokemon)
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
Color.clear
.frame(height: 90)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .bottom) {
refetchButton
}
}
private var refetchButton: some View {
Button(action: {
delegate?.pokemonListViewrandomRefetchTrigger()
}) {
HStack(spacing: 8) {
Image(systemName: "shuffle")
.font(.system(size: 16, weight: .semibold))
Text("ランダムで20匹を取得")
.fontWeight(.semibold)
.font(.system(size: 16))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(14)
.shadow(color: Color.blue.opacity(0.4), radius: 8, x: 0, y: 4)
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
}
// MARK: - PokemonRowView
struct PokemonRowView: View {
let pokemon: PokemonItemModel
var body: some View {
HStack(spacing: 12) {
// Sprite
AsyncImage(url: pokemon.spriteURL) { image in
image
.resizable()
.scaledToFit()
} placeholder: {
ProgressView()
.frame(width: 64, height: 64)
}
.frame(width: 72, height: 72)
.background(Color(.systemGray6))
.cornerRadius(10)
VStack(alignment: .leading, spacing: 6) {
// ID + Name
HStack(spacing: 6) {
Text("#\(String(format: "%04d", pokemon.id))")
.font(.caption)
.foregroundColor(.secondary)
Text(pokemon.name)
.font(.headline)
.foregroundColor(.primary)
}
// Types
HStack(spacing: 6) {
ForEach(pokemon.types, id: \.self) { typeName in
Text(typeName)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(typeColor(for: typeName))
.foregroundColor(.white)
.cornerRadius(8)
}
}
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.white)
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.06), radius: 4, x: 0, y: 2)
}
private func typeColor(for type: String) -> Color {
switch type.lowercased() {
case "fire", "ほのお": return Color(red: 0.95, green: 0.35, blue: 0.25)
case "water", "みず": return Color(red: 0.25, green: 0.55, blue: 0.95)
case "grass", "くさ": return Color(red: 0.30, green: 0.70, blue: 0.35)
case "electric", "でんき": return Color(red: 0.95, green: 0.75, blue: 0.10)
case "psychic", "エスパー": return Color(red: 0.95, green: 0.30, blue: 0.55)
case "ice", "こおり": return Color(red: 0.40, green: 0.80, blue: 0.90)
case "dragon", "ドラゴン": return Color(red: 0.40, green: 0.25, blue: 0.90)
case "dark", "あく": return Color(red: 0.25, green: 0.20, blue: 0.20)
case "fairy", "フェアリー": return Color(red: 0.95, green: 0.55, blue: 0.75)
case "fighting", "かくとう":return Color(red: 0.75, green: 0.25, blue: 0.15)
case "poison", "どく": return Color(red: 0.55, green: 0.20, blue: 0.60)
case "ground", "じめん": return Color(red: 0.85, green: 0.65, blue: 0.30)
case "rock", "いわ": return Color(red: 0.65, green: 0.55, blue: 0.30)
case "bug", "むし": return Color(red: 0.50, green: 0.65, blue: 0.10)
case "ghost", "ゴースト": return Color(red: 0.35, green: 0.25, blue: 0.55)
case "steel", "はがね": return Color(red: 0.55, green: 0.55, blue: 0.65)
case "normal", "ノーマル": return Color(red: 0.60, green: 0.60, blue: 0.55)
case "flying", "ひこう": return Color(red: 0.55, green: 0.65, blue: 0.90)
default: return Color.gray
}
}
}
// MARK: - Screen Model
struct PokemonItemModel: Identifiable, Equatable {
let id: Int
let name: String
let spriteURL: URL?
let types: [String]
}
// APIレスポンスを、SwiftUIの画面表示用に変換したモデル
struct PokemonScreenModel: Equatable {
let pokemons: [PokemonItemModel]
}import Foundation
// MARK: - PokeAPI List Response
struct PokemonListResponse: Decodable {
let results: [PokemonListItem]
}
struct PokemonListItem: Decodable {
let name: String
let url: String
var id: Int? {
// urlの末尾からIDを取得: https://pokeapi.co/api/v2/pokemon/25/
let components = url.split(separator: "/")
return Int(components.last ?? "")
}
}
// MARK: - PokeAPI Detail Response
struct PokemonDetailResponse: Decodable {
let id: Int
let name: String
let sprites: PokemonSprites
let types: [PokemonTypeSlot]
}
struct PokemonSprites: Decodable {
let frontDefault: String?
enum CodingKeys: String, CodingKey {
case frontDefault = "front_default"
}
}
struct PokemonTypeSlot: Decodable {
let type: PokemonTypeName
}
struct PokemonTypeName: Decodable {
let name: String
}
// MARK: - Repository
import Foundation
import RxSwift
import RxCocoa
// MARK: - Protocol
protocol PokemonRepositoryProtocol: AnyObject {
func fetchRandomPokemons(count: Int) -> Observable<PokemonScreenModel>
}
// MARK: - Implementation
final class PokemonRepository: PokemonRepositoryProtocol {
private let totalCount = 1025
func fetchRandomPokemons(count: Int) -> Observable<PokemonScreenModel> {
let randomIDs = Array(1...totalCount).shuffled().prefix(count)
let requests: [Observable<PokemonItemModel>] = randomIDs.map { id in
fetchPokemonDetail(id: id)
}
return Observable.zip(requests)
.map { items in PokemonScreenModel(pokemons: items) }
}
private func fetchPokemonDetail(id: Int) -> Observable<PokemonItemModel> {
let urlString = "https://pokeapi.co/api/v2/pokemon/\(id)"
guard let url = URL(string: urlString) else {
return .error(URLError(.badURL))
}
return URLSession.shared.rx.data(request: URLRequest(url: url))
.map { data -> PokemonItemModel in
let decoder = JSONDecoder()
let response = try decoder.decode(PokemonDetailResponse.self, from: data)
return PokemonItemModel(
id: response.id,
name: response.name.capitalized,
spriteURL: response.sprites.frontDefault.flatMap { URL(string: $0) },
types: response.types.map { Self.japaneseTypeName($0.type.name) }
)
}
}
// MARK: - Type Name Localization
private static func japaneseTypeName(_ englishName: String) -> String {
switch englishName.lowercased() {
case "fire": return "ほのお"
case "water": return "みず"
case "grass": return "くさ"
case "electric": return "でんき"
case "psychic": return "エスパー"
case "ice": return "こおり"
case "dragon": return "ドラゴン"
case "dark": return "あく"
case "fairy": return "フェアリー"
case "fighting": return "かくとう"
case "poison": return "どく"
case "ground": return "じめん"
case "rock": return "いわ"
case "bug": return "むし"
case "ghost": return "ゴースト"
case "steel": return "はがね"
case "normal": return "ノーマル"
case "flying": return "ひこう"
default: return englishName
}
}
}

登場クラスの役割一覧
| クラス / 型 | 所属 | 役割 |
|---|---|---|
PokemonViewController | UIKit 層 | UIHostingController を継承した VC。バインディングと delegate の起点 |
PokemonListView | SwiftUI 層 | 画面の描画を担う SwiftUI View。状態は DataSource から受け取る |
PokemonListView.DataSource | SwiftUI 層 | @Observable で状態を保持。ReactiveCompatible を適用して Rx とブリッジ |
PokemonListViewDelegate | SwiftUI 層 | View からのイベントを UIKit 側に伝えるプロトコル |
PokemonViewModel | ViewModel 層 | Inputs / Outputs を持つ ViewModel。今回一切変更しない |
PokemonRepositoryProtocol | ViewModel 層 | API 通信の抽象。今回一切変更しない |
次に、UIHostingController の基本をざっくりと説明していきます。
UIHostingController の基本
UIHostingController は UIKit と SwiftUI をつなぐブリッジとして Apple が用意したクラスです。ジェネリクスで SwiftUI の View 型を受け取り、それを UIKit の画面として扱えるようにしてくれます。
class UIHostingController<Content: View>: UIViewControllerこれを継承することで、SwiftUI View を持ちながら UIViewController としてふるまう独自の VC を定義できます。
final class PokemonViewController: UIHostingController<PokemonListView> {
init(viewModel: PokemonViewModelProtocol) {
let view = PokemonListView(dataSource: PokemonListView.DataSource())
super.init(rootView: view)
}
@available(*, unavailable)
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}super.init(rootView:) に SwiftUI View のインスタンスを渡すだけです。これだけで PokemonViewController は既存の UIKit のナビゲーションスタックにそのまま push できる VC になります。
required init?(coder:) は Storyboard 経由のイニシャライザで、コードベースのプロジェクトでは使わないため @available(*, unavailable) で封じています。
rootView 経由で SwiftUI 側にアクセスする
UIHostingController は rootView プロパティを持っており、コンストラクタに渡した SwiftUI View のインスタンスに直接アクセスできます。
// rootView 経由で SwiftUI 側の DataSource に触れる
viewModel.outputs.viewState
.drive(rootView.dataSource.rx.viewState)
.disposed(by: disposeBag)これが今回のパターンの核心です。ViewModel が流す Driver<PokemonViewState> を、rootView.dataSource に接続することで、RxSwift のバインディングをそのまま活かせます。ViewModel 側のコードは何も変える必要がありません。
UIKit のライフサイクルがそのまま使える
UIHostingController は UIViewController のサブクラスなので、viewDidLoad や viewWillAppear などのライフサイクルメソッドがそのまま動きます。
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGroupedBackground
bind() // RxSwift バインディングをここで張る
}従来の VC と同じ感覚で書けるため、既存のコーディングルールやアーキテクチャを壊しません。ナビゲーションバーのタイトル設定や navigationItem の操作も通常通りです。
ここまでのまとめ
UIHostingController<View>を継承するだけで、SwiftUI View を持つ VC が作れるrootViewプロパティ経由で SwiftUI 側のオブジェクトに直接アクセスできる- UIKit のライフサイクルはそのまま使えるため、既存の設計に乗せやすい
次のセクションでは、この rootView.dataSource の実体である @Observable DataSource の設計に踏み込みます。
DataSource(@Observable)の設計
SwiftUI View に状態を持たせる方法はいくつかありますが、今回は @Observable + @State の組み合わせを採用しています。
なぜ @Observable + @State なのか
SwiftUI で画面の状態を管理する選択肢として、@ObservableObject + @StateObject も候補に上がります。しかし今回のような UIKit との共存構成では @Observable に軍配が上がります。
@ObservableObject は Combine に依存しており、プロパティに @Published を付ける必要があります。一方 @Observable(iOS 17+)はその制約がなく、通常のプロパティに変更追跡が自動で適用されます。
// @ObservableObject の場合(Combine 依存)
final class DataSource: ObservableObject {
@Published var viewState: PokemonViewState = .loading
}
// @Observable の場合(依存なし・シンプル)
@Observable
final class DataSource {
var viewState: PokemonViewState = .loading
}また @State で保持することで、SwiftUI のライフタイム管理に乗れます。UIHostingController が生きている間、DataSource のインスタンスが安全に保持され続けます。
struct PokemonListView: View {
@State var dataSource: DataSource
var body: some View {
// dataSource.viewState の変化で自動再描画される
}
}@Observable クラスを @State で保持した場合、そのプロパティへのアクセスを SwiftUI が追跡し、変化があったときだけ View を再描画します。@Published なしでこの挙動が得られるのが @Observable の強みです。
ReactiveCompatible を適用する設計意図
DataSource に ReactiveCompatible を適用することで、RxSwift の rx 名前空間を生やせるようになります。
@Observable
final class DataSource: ReactiveCompatible {
var viewState: PokemonViewState
init(viewState: PokemonViewState = .loading) {
self.viewState = viewState
}
}ReactiveCompatible は RxCocoa が提供するプロトコルで、これに準拠するだけで dataSource.rx.〇〇 という形でカスタム Reactive extension が書けるようになります。UIKit のコンポーネント(UILabel や UIButton など)が label.rx.text のように扱えるのと同じ仕組みです。
DataSource 自体は SwiftUI 側の概念ですが、ReactiveCompatible を適用することで UIKit 側から自然な形でバインドできる接点を作れます。これが今回のブリッジ設計の肝です。
View → ViewModel のイベント伝達
画面の状態を ViewModel → View に流す仕組みの逆方向、ボタンタップなどのユーザー操作を View → ViewModel に伝える仕組みです。
なぜ delegate パターンを選んだか
今回、View からのイベント伝達に delegate パターンを採用しています。
protocol PokemonListViewDelegate: AnyObject {
func pokemonListViewrandomRefetchTrigger()
}
struct PokemonListView: View {
weak var delegate: PokemonListViewDelegate?
private var refetchButton: some View {
Button(action: {
delegate?.pokemonListViewrandomRefetchTrigger()
}) { ... }
}
}PokemonListView はボタンが押されたとき、delegate に対してメソッドを呼ぶだけです。その先で何が起きるかは関知しません。
delegate の実装は PokemonViewController が担います。
extension PokemonViewController: PokemonListViewDelegate {
func pokemonListViewrandomRefetchTrigger() {
viewModel.inputs.randomRefetchTrigger.accept(())
}
}ViewController が PokemonListViewDelegate に準拠し、受け取ったイベントを viewModel.inputs に流しています。
このパターンの設計意図は「SwiftUI View を UIKit 層から切り離す」ことにあります。
PokemonListView は PokemonViewController の存在を知りません。PokemonViewModel の存在も知りません。知っているのは「delegate に対してメソッドを呼ぶ」という事実だけです。
PokemonListView
└─ delegate?.pokemonListViewrandomRefetchTrigger()
│ (何が受け取るかは知らない)
▼
PokemonViewController(delegate として受け取る)
└─ viewModel.inputs.randomRefetchTrigger.accept(())これにより PokemonListView は単体でプレビューやテストが書きやすい状態になります。mock の delegate を渡すだけで、ViewModel や Repository を一切用意せずにイベントの動作確認ができます。
closure で渡す案との比較
delegate の代わりに、View の初期化時に closure を渡す方法も選択肢にあります。
struct PokemonListView: View {
var onRandomRefetch: () -> Void
private var refetchButton: some View {
Button(action: { onRandomRefetch() }) { ... }
}
}
// 呼び出し側
let view = PokemonListView(
dataSource: PokemonListView.DataSource(),
onRandomRefetch: { [weak viewModel] in
viewModel?.inputs.randomRefetchTrigger.accept(())
}
)イベントの種類が少ない場合は closure の方がシンプルに書けます。一方でイベントの種類が増えてくると、初期化の引数が膨らんで見通しが悪くなります。delegate にまとめておけば、イベントが増えてもプロトコルにメソッドを追加するだけで済みます。
どちらが正解というわけではなく、イベントの数やチームの好みで選ぶのが現実的です。
注意点・ハマりどころ
rootView.delegate のセットタイミング
今回のコードでは bind() の中で delegate をセットしています。
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
private func bind() {
rootView.delegate = self // viewDidLoad のタイミングでセット
...
}init で rootView が生成されてから viewDidLoad が呼ばれるまでの間、delegate は nil のままです。今回はボタンタップが画面表示後にしか発生しないため実害はありませんが、画面表示直後に View 側からイベントが飛ぶような設計の場合は注意が必要です。
その場合は init 内でセットするか、viewWillAppear に移すかを検討してください。
init(viewModel: PokemonViewModelProtocol) {
self.viewModel = viewModel
let view = PokemonListView(dataSource: PokemonListView.DataSource())
super.init(rootView: view)
rootView.delegate = self // init でセットする場合
}エラーハンドリングを握りつぶしている
今回の ViewModel はエラーを .loading に変換して握りつぶしています。
.asDriver(onErrorJustReturn: .loading)記事のサンプルとしてはシンプルさを優先していますが、実務では PokemonViewState にエラーケースを追加する方が適切です。(もしくはVC側にoutputでエラーを流してポップアップを出してもGood)
enum PokemonViewState: Equatable {
case loaded(PokemonScreenModel)
case loading
case error(String) // 追加
}View 側でエラー状態を受け取り、リトライボタンやエラーメッセージを表示する構成にすることで、ユーザー体験が大きく改善します。この拡張は ViewModel と View の両方に手を入れる必要がありますが、今回紹介したアーキテクチャの構造は変わりません。
rootView の差し替え時の挙動
UIHostingController の rootView プロパティは書き込み可能で、後から差し替えることができます。
hostingController.rootView = NewView()ただし差し替えた場合、元の rootView に張っていた RxSwift の disposeBag のバインディングはそのまま残ります。新しい rootView に対して再バインドが必要になるため、rootView の差し替えは基本的に避けるのが無難です。
今回のように init で固定した rootView を使い続ける設計であれば、この問題は発生しません。
まとめ
本記事で紹介したパターンを整理します。
変更したもの
✅ PokemonViewController → UIHostingController を継承する形に変更
✅ PokemonListView → UIKit View を SwiftUI View に置き換え
✅ DataSource → @Observable で状態を保持
変更しなかったもの
✅ PokemonViewModel → Inputs / Outputs の構造はそのまま
✅ PokemonRepository → API 通信ロジックはそのまま
✅ PokemonViewState → 既存の enum をそのまま利用ポイントを再確認すると、以下の3つに集約されます。
UIHostingControllerを継承することで、UIKit のナビゲーション構造を一切変えずに SwiftUI View を導入できる- 既存の
Driverバインディングをそのまま接続できる delegateパターンで View → ViewModel のイベント伝達を疎結合に保てる
段階的移行の第一歩として、変えるのは View 層だけというアプローチは現実的な選択肢です。ViewModel の書き直しという最もリスクの高い作業を後回しにしながら、画面単位で SwiftUI の恩恵を受け始められます。
