本文へスキップ / Skip to main content
KINTO Tech Blog

約14分で読めます

Kotlin

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

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

AI が生成した要約です。正確な内容は本文をご確認ください。

要約を読む

ハイブリッド開発でネイティブとCMPのUIを組み合わせる必要がありました。 そこで、共通コードとプラットフォーム固有の実装を分けて、プロジェクト構造を整えました。 この結果、800字程度のコードで複数プラットフォームに対応でき、開発効率が上がりました。 まず、アプリの構造を3つのプロジェクトに分けました。 composeAppには共通コードと各プラットフォームの実装を、iosAppにはSwiftUIの画面を、sharedにはデータモデルやViewModelをまとめました。 これにより、共通部分はまとまり、ネイティブ部分は分離され、開発がスムーズになりました。 画面のタブ構成では、1〜3番目のタブはCMPで作られ、4番目はSwiftUIで実装しました。 Application、Lineup、HelpCenterはCMPで共通UIを、SettingsだけはiOSのSwiftUIで作っています。 タブを切るとき、ナビゲーションがシームレスに進み、ユーザー体験は自然に保たれました。 ナビゲーションをCMPとネイティブで統一するために、ルートを文字列で定義し、プラットフォームごとにナビゲーションを分離しました。 AndroidではCompose Navigation、iOSではSwiftUI Navigationを使って、KotlinとSwiftUIの間をつなぐBridgeを作りました。 これにより、ルートに応じて画面遷移が自動的に処理され、コードの複雑さが減りました。 iOS側では、ComposeViewContainerを実装してSwiftUIの中にCMPを埋め込みました。 その上で、KotlinのcreateComposeViewControllerを呼び出して、画面遷移を管理しました。 タブ画面に4つのタブを配置し、1〜3番目はCMPの画面、4番目はSwiftUIのSettings画面を使い、ナビゲーションが自然に共存しました。 開発の複雑さが増えるにつれて、KoinというDIフレームワークを導入しました。 Koinを使うことで、依存関係をシンプルに管理でき、同じコードがAndroidとiOSで使えるようになりました。 特に、singleやviewModelを書くだけで自動で解決され、テストもしやすくなりました。 Koinでは、iOSとAndroidそれぞれに専用のモジュールを定義しました。 iOSではDarwin.createでHttpClientを、AndroidではOkHttp.createで登録しました。 これにより、プラットフォーム固有の実装が分離され、共通のインターフェースで使えるようになりました。 さらに、Koinで動的にモジュールを読み込む仕組みを導入しました。 必要になった機能を、featureModuleに登録してloadKoinModulesで後から読み込むことで、アプリの初期負荷を減らし、大規模アプリでも効率的に開発が可能になりました。 メールや電話番号の検証など、24個のバリデーションルートが必要でした。 従来はAndroidとiOSでそれぞれ24個のコードを書く必要があり、合計48個のテストも必要でした。 それに対して、すべてをKMPモジュールに移動させ、Kotlinで一度だけ実装しました。 結果、実装量とテストコードがそれぞれ50%削減され、バグ修正も一箇所で完了しました。

この記事は KINTOテクノロジーズアドベントカレンダー2025 の20日目の記事です🎅🎄

はじめに

こんにちは、KINTOテクノロジーズ Mobile KMPチームです。

本記事は「Compose MultiplatformとSwiftUIで作るハイブリッドモバイルアプリ」シリーズのPart 2です。Part 1ではハイブリッド開発を選んだ背景とアーキテクチャ概要を紹介しました。今回は具体的な実装方法を詳しく解説します。

本シリーズの構成

  1. Part 1:なぜハイブリッドなのか
  2. Part 2:実装ガイド ← 現在の記事
  3. 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

Settings Tab 画面(iOS)

ネイティブからCMPベースのハイブリッドナビゲーションへの移行

このような構成では、ネイティブ中心のナビゲーションからCMPベースのハイブリッドナビゲーションへ移行する必要がありました。どのように移行したのか、具体的な方法をご紹介します。

  • 既存のナビゲーションスタックをリファクタリングし、ネイティブとCMP間でルーティング
  • プラットフォーム固有のナビゲーションロジックを共有インターフェースの背後に抽象化

ハイブリッドナビゲーション移行

ルートベースのナビゲーション戦略

先ほど紹介したPoCアプリのタブ構造を振り返ると、1〜3番目のタブはCMP、4番目はSwiftUIで実装しています。

PoCアプリのタブ構造

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

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統合

Validator統合

問題状況

メール、電話番号、郵便番号、パスワード強度、日付形式など、様々な検証ロジックが必要でした。

従来方式で実装した場合:

  • Android:24個のValidator(Kotlin)
  • iOS:24個のValidator(Swift)
  • 合計48個の実装
  • 合計48個のValidationResult
  • 合計48セットのユニットテスト

KMPソリューション

すべての検証ロジックを共有KMPモジュールに移動しました。

  • Kotlinで各Validatorを一度だけ実装
  • 共通関数/クラスとして公開
  • CMP画面、Android/iOSネイティブ画面すべてから呼び出し可能

Validator統合によるコード削減

結果:

  • 実装量50%削減
  • テストコード50%削減
  • プラットフォーム間検証ロジックの一貫性保証
  • バグ修正も一箇所で完了(両OS同時に修正される)

次回予告

Part 2では、プロジェクト構造、ナビゲーション統合、Koin DI、Validator統合など具体的な実装方法を紹介しました。

Part 3:SwiftUI連携と技術Tipsでは、SwiftUIとCMPの相互埋め込み、CMP vs Flutter比較、実践で直面した落とし穴、導入戦略などを詳しく解説します。お楽しみに!


こちらは「Compose MultiplatformとSwiftUIで作るハイブリッドモバイルアプリ」シリーズのPart 2です。

Facebook

Follow Us

XConnpassWantedly

KINTOテクノロジーズの最新情報をSNSで発信中!イベント・テックブログ更新情報もお届けします。