Compose MultiplatformとSwiftUIで作るハイブリッドモバイルアプリ(Part 2):実装ガイド

この記事は KINTOテクノロジーズアドベントカレンダー2025 の20日目の記事です🎅🎄
はじめに
こんにちは、KINTOテクノロジーズ Mobile KMPチームです。
本記事は「Compose MultiplatformとSwiftUIで作るハイブリッドモバイルアプリ」シリーズのPart 2です。Part 1ではハイブリッド開発を選んだ背景とアーキテクチャ概要を紹介しました。今回は具体的な実装方法を詳しく解説します。
本シリーズの構成
- Part 1:なぜハイブリッドなのか
- Part 2:実装ガイド ← 現在の記事
- Part 3:SwiftUI連携と技術Tips(近日公開)
実装事例
プロジェクト構造
プロジェクトのディレクトリ構成
my-cmp-app/
├── composeApp/
│ ├── src/
│ │ ├── commonMain/ // CMP Shared code
│ │ ├── androidMain/ // Android-specific
│ │ └── iosMain/ // iOS-specific
├── iosApp/
│ ├── Views/ // SwiftUI views
│ ├── Screens/ // Native screens
│ └── ComposeViewContainer.swift
└── shared/ // KMP domain layer
├── viewmodels/
└── models/
まずcomposeAppです。commonMainにはCMPの共通コード、androidMain/iosMainには各プラットフォーム固有の実装を入れています。
次にiosApp。SwiftUIのViewやネイティブ画面が含まれています。特にComposeViewContainer.swiftファイルでCMPコンポーネントをiOS側に組み込んでいます。
最後にshared。KMPのドメイン層で、viewmodelsやmodelsを共通化しています。
このように、ディレクトリレベルでも共通化できる部分はまとめ、ネイティブ依存部分は分離しています。
ハイブリッドUIの実例
実際に作成したPoCアプリの画面構成を紹介します。
MainScreenでは、ボトムナビゲーションに4つのタブを配置しています。
| タブ | UI実装 |
|---|---|
| Application | CMP |
| Lineup | CMP |
| HelpCenter | CMP |
| Settings | SwiftUI on iOS |
- Application、Lineup、HelpCenterはCMPで共通UIを実装
- Settingsだけはネイティブ(iOSではSwiftUI)で作っています
このように、共通UIとネイティブUIを組み合わせても、タブ切り替えはシームレスに行えます。
iOS固有のUIが活きるSettings Tab

ネイティブからCMPベースのハイブリッドナビゲーションへの移行
このような構成では、ネイティブ中心のナビゲーションからCMPベースのハイブリッドナビゲーションへ移行する必要がありました。どのように移行したのか、具体的な方法をご紹介します。
- 既存のナビゲーションスタックをリファクタリングし、ネイティブとCMP間でルーティング
- プラットフォーム固有のナビゲーションロジックを共有インターフェースの背後に抽象化

ルートベースのナビゲーション戦略
先ほど紹介したPoCアプリのタブ構造を振り返ると、1〜3番目のタブはCMP、4番目はSwiftUIで実装しています。
PoCアプリのタブ構造

このような構成でルーティングを実現するために、各画面のルートをstring constantで定義しておきます。
// Routes.kt - Navigation routes defined in shared code
object Routes {
const val LOGIN = "login"
const val SCREEN_A = "screen_a"
const val SCREEN_B = "screen_b"
const val SCREEN_C = "screen_c"
const val SCREEN_D = "screen_d"
}
プラットフォームごとに実装を分けて、AndroidではCompose Navigation、iOSではSwiftUI NavigationでComposeをつなぐBridgeをします。
Kotlinナビゲーション実装
こちらはComposeViewController.ktの例です。パラメータとしてinitialRouteを受け取り、そのルートに応じてwhen分岐で遷移先を切り替えています。
// ComposeViewController.kt
fun createComposeViewController(
initialRoute: String,
onCallback: (Boolean) -> Unit = {}
): UIViewController {
return ComposeUIViewController {
MyAppTheme {
when (initialRoute) {
"login" -> LoginNavigation(onLoginSuccess = onCallback)
"screen_a" -> ScreenANavigation()
"screen_b" -> ScreenBNavigation(iosCallback = onCallback)
"screen_c" -> ScreenCNavigation()
"screen_d" -> ScreenDNavigation()
else -> FallbackNavigation() // default fallback
}
}
}
}
例えば、"login"ならLoginNavigationへ、"screen_a"や"screen_b"なら、それぞれの画面のNavigationを呼び出します。このように、ルートを定義しておけば、プラットフォーム固有のナビゲーション処理と簡単に接続できます。
iOS側でCompose Navigationを統合する仕組み
まずComposeViewContainerを定義して、UIViewControllerRepresentableを実装します。これによってSwiftUIの中にCompose UIを埋め込むことができます。
// ComposeViewContainer.swift
struct ComposeViewContainer: UIViewControllerRepresentable {
let initialRoute: String
let onCallback: (Bool) -> Void
func makeUIViewController(context: Context) -> UIViewController {
// Wrap the Swift callback into the KotlinBoolean signature
let kotlinCallback: (KotlinBoolean) -> Void = { kotlinBool in
onCallback(kotlinBool.boolValue)
}
// Create the Compose UIViewController, passing the wrapped callback
return ComposeViewControllerKt.createComposeViewController(
initialRoute: initialRoute,
onCallback: kotlinCallback
)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// No dynamic updates needed here
}
}
次に、makeUIViewControllerの中でKotlin側のcreateComposeViewControllerを呼び出します。
iOS Navigationの実践
こちらは、統合コードを使ったiOS Navigation codeです。TabViewの中に4つのタブを定義しています。1〜3番目のタブはCMP画面で、NavigationViewの中にComposeViewContainerを埋め込み、初期ルート"screen_a"を指定しています。
// MainScreen.swift
struct MainScreen: View {
var body: some View {
TabView {
// Tab 1~3: CMP screen
NavigationView {
ComposeViewContainer(initialRoute: "screen_a", onCallback: { _ in })
}.tabItem {
Label("Application", systemImage: "doc.plaintext")
}
...
// Tab 4: SwiftUI screen
NavigationView {
SettingsScreen()
}.tabItem {
Label("Settings", systemImage: "gearshape.fill")
}
}
}
}
4番目のタブはSwiftUIネイティブのSettingsScreen()を使用しています。このように、CMPとネイティブ画面を同じナビゲーション構造内で自然に共存させることができます。
Koinによるモジュール化
ここまでナビゲーション統合を見てきましたが、実際のアーキテクチャはCMP + MVVMでどんどん複雑になります。そこで、Multiplatform向けDIのKoinを導入しました。結果、構造がシンプルになり、テストや保守もしやすくなりました。

Koinの利点:
- 依存関係をきれいに管理できる
- 同じ定義を使って複数プラットフォームで再利用できる
- モジュール単位で分離でき、構造がシンプルになる
- テストのしやすさが向上
// commonMain/di/AppModule.kt
val appModule = module {
single { NetworkClient() }
single { AuthRepository(get()) }
viewModel { HomeViewModel(get()) }
viewModel { ProductViewModel(get()) }
factory<Validator> {
ValidatorImpl(get(), get())
}
}
singleやviewModelをシンプルに書くだけで、依存関係を自動的に解決できます。
プラットフォーム固有のDI
Koinでは、iOSとAndroidそれぞれに専用のモジュールを定義できます。
// iosMain/di/PlatformModule.ios.kt
val iosModule = module {
single<HttpClientEngine> { Darwin.create {} }
single<DataStore<Preferences>> { createDataStore() }
single { IOSLocationService() as LocationService }
single { AppStoreConnectTracker() as AnalyticsTracker }
}
// androidMain/di/PlatformModule.android.kt
val androidModule = module {
single<HttpClientEngine> { OkHttp.create {} }
single<DataStore<Preferences>> { createDataStore(androidContext()) }
single { AndroidLocationService() as LocationService }
single { FirebaseAnalyticsTracker() as AnalyticsTracker }
}
iOS側ではiosModuleを定義し、Darwin.create()を使ったHttpClientを登録しています。一方Android側ではandroidModuleを定義し、OkHttp.create()を使ったHttpClientを登録しています。
このように、プラットフォーム固有の実装はモジュールごとに分離しながら、共通のインターフェースを通して利用できるようにしています。
Koinの初期化
ここではKoinの初期化方法です。まず共通コード側にinitKoin関数を用意しておきます。
// commonMain/di/KoinSetup.kt
fun initKoin(platformModule: Module) = startKoin {
modules(appModule, networkModule, platformModule, ...)
}
// Android Application class
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
initKoin(androidModule)
}
}
AndroidではApplicationクラスのonCreateからandroidModuleを渡して初期化します。
// iOS AppDelegate
func application(_ application: UIApplication, didFinishLaunchingWithOptions...) {
KoinKt.doInitKoin(platformModule: IOSPlatformModuleKt.iosModule)
return true
}
iOSではAppDelegateからiosModuleを渡すだけです。このようにして、両方のプラットフォームで同じ初期化処理を共通化できます。
Koinでの動的モジュールロード
次に、Koinでの動的モジュールロードです。
// Feature module definition
val featureModule = module {
viewModel { FeatureViewModel(get(), get()) }
factory { FeatureValidator() }
single { FeatureRepository(get()) }
}
// Register dynamically after the Koin's initialization
loadKoinModules(featureModule)
まずfeatureModuleを定義して、ViewModel・Validator・Repositoryを登録します。その後、loadKoinModulesにfeatureModuleを渡してロードします。
Koin初期化後にモジュールを動的にロードできます。これにより、大規模アプリでも必要な機能だけを後から読み込む構成が可能になります。これがKoinの強みです。
開発効率化事例:Validator統合

問題状況
メール、電話番号、郵便番号、パスワード強度、日付形式など、様々な検証ロジックが必要でした。
従来方式で実装した場合:
- Android:24個のValidator(Kotlin)
- iOS:24個のValidator(Swift)
- 合計48個の実装
- 合計48個のValidationResult
- 合計48セットのユニットテスト
KMPソリューション
すべての検証ロジックを共有KMPモジュールに移動しました。
- Kotlinで各Validatorを一度だけ実装
- 共通関数/クラスとして公開
- CMP画面、Android/iOSネイティブ画面すべてから呼び出し可能

結果:
- 実装量50%削減
- テストコード50%削減
- プラットフォーム間検証ロジックの一貫性保証
- バグ修正も一箇所で完了(両OS同時に修正される)
次回予告
Part 2では、プロジェクト構造、ナビゲーション統合、Koin DI、Validator統合など具体的な実装方法を紹介しました。
Part 3:SwiftUI連携と技術Tipsでは、SwiftUIとCMPの相互埋め込み、CMP vs Flutter比較、実践で直面した落とし穴、導入戦略などを詳しく解説します。お楽しみに!
こちらは「Compose MultiplatformとSwiftUIで作るハイブリッドモバイルアプリ」シリーズのPart 2です。
関連記事 | Related Posts
We are hiring!
【プロジェクトマネージャー(iOS/Android/Flutter)】モバイルアプリ開発G/東京
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら、品質の高いモバイルアプリを開発し、サービスの発展に貢献することを目標としています。
【ソフトウェアエンジニア(メンバークラス)】共通サービス開発G/東京・大阪・福岡
共通サービス開発グループについてWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームなどの企画・開発を手がけるグループです。KINTOの名前が付くサービスやKINTOに関わりのあるサービスを同一のユーザーアカウントに対して提供し、より良いユーザー体験を実現できるよう、様々な共通機能や顧客基盤を構築していくことを目的としています。


