5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【KMP】Kotlin Multiplatform プロジェクトの shared モジュールをマルチモジュール化する

Last updated at Posted at 2025-02-16

Kotlin Multiplatform プロジェクトの shared モジュールを自分がやりたいマルチモジュールに分割する際、参考になるサイトや GitHub リポジトリが存在しなかったのでマルチモジュール化に成功した内容を記事にまとめた。

尚、本記事は Android推奨アーキテクチャクリーンアーキテクチャ に多少の理解があり、以下のクラス図を何となく理解出来る方が対象となります。

本記事が対象している org.jetbrains.kotlin.multiplatform のバージョンは '2.0.0' 。

やりたいこと

Android 推奨アーキテクチャとクリーンアーキテクチャの概念を組み合わせた以下のような KMP プロジェクトを」作ることを目標とする。

単純にモジュールを分割してもうまくいかない

以下の構成にしてもうまくいかなかった。。。

理由は2つ。

  • iOS 側から KMP ライブラリを2つ参照する必要がある
  • KoinstartKoin() は一度しか呼ぶことができない

うまくいかない詳細については割愛。(後日更新予定)

Umbrella モジュールでうまくいく

KMP for Mobile Native Developers: The Book.Multiple Shared Modules を参考にして、Android アプリ、iOS アプリとの I/F に Umbrella モジュールを配置するとうまくいかない事象が解消される。以下は Umbrella モジュールとして :shared モジュールを配置した場合の構成図である。

:shared モジュール

まず、settings.gradle.kts で各モジュールを定義する。

settings.gradle.kts
...
include(":composeApp")

include(":shared") // add

include(":shared:domain") // add
project(":shared:domain").projectDir = File("$rootDir/shared-domain") // add

include(":shared:data") // add
project(":shared:data").projectDir = File("$rootDir/shared-data") // add

build.gradle.kts では以下を実装する。

  • KotlinNativeTarget.binaries.framework{} で iOS アプリからアクセスされるモジュールとして :shared:domain を export する
  • commonMain.dependencie{} に 両 OS からアクセスされるモジュールとして api(project(":shared:domain")) 、KMP 内でアクセスするモジュールとして implementation(project(":shared:data")) を定義する
  • Kotlin/Swift インタフェースとしてSKIE を使う場合はプラグインを定義する

:shared モジュールの build.gradle.kts の例を以下に示す。

builg.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    id("co.touchlab.skie") version "0.8.4"
}

kotlin {
    androidTarget {
        @OptIn(ExperimentalKotlinGradlePluginApi::class)
        compilerOptions {
            jvmTarget.set(JvmTarget.JVM_11)
        }
    }

    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64(),
    ).forEach {
        it.binaries {
            framework {
                baseName = "Shared" // Umbrella モジュールの名前
                isStatic = true

                // Accessible by the iOS app
                export(project(":shared:domain"))
            }
        }
    }

    sourceSets {
        commonMain.dependencies {
            // Accessible by the native app
            api(project(":shared:domain"))

            // Accessible only in the KMP part
            implementation(project(":shared:data"))

            // share モジュールのプログラムから参照するライブラリ
            implementation(libs.koin.core)
        }
    }
}

android {
    defaultConfig {
        namespace = "dev.seabat.kmp.multimodule.shared"
    }
    compileSdk =
        libs.versions.android.compileSdk
            .get()
            .toInt()
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    defaultConfig {
        minSdk =
            libs.versions.android.minSdk
                .get()
                .toInt()
    }
    buildTypes {
        release {
            isMinifyEnabled = true
            consumerProguardFiles("consumer-rules.pro")
        }
    }
}

:shared モジュールには基本的に build.gradle.kts を配置するだけで良い。Koin で DI するならこの :shared モジュールで startKoin() を呼ぶと良いかも。startKoin() をコールする関数の例を以下に示す。

Module.kt
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration

// for Android
fun initAndroidKoin(appDeclaration: KoinAppDeclaration) = startKoin {
    appDeclaration()
    modules(
        useCaseModule, // :shared:domain で定義
        repositoryModule // :shared:data で定義
    )
}

// for iOS
fun initIosKoin() {
    startKoin {
        modules(
            useCaseModule, // :shared:domain で定義
            repositoryModule, // :shared:data で定義
        )
    }
}

:shared:domain モジュール

:shared:domain モジュールの build.gradle.kts は以下のことに気を付けるだけ。

  • :shared:domain モジュールの iOS 向けのビルド生成物は出力されないのでKotlinNativeTarget.binaries.framework{...}KotlinNativeTarget.binaries.framework() でも問題ない
  • クリーンアーキテクチャのルールに則って commonMain.dependencies{}implementation(projects.shared.data) を定義しない

:shared:domain モジュールの build.gradle.kts の例を以下に示す。

build.gradle.kts
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
}

kotlin {
    ...

    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64(),
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "SharedDomain"
            isStatic = true
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.koin.core)
        }
        androidMain.dependencies {
            implementation(libs.koin.android)
        }
        iosMain.dependencies {
        }
    }
}

android {
    namespace = "dev.seabat.kmp.multimodule.domain"
    ...
}

UseCase クラスの例を以下に示す。

LoadRocketLaunchInfoUseCase.kt
class LoadRocketLaunchInfoUseCase(
    private val rocketRepository: RocketRepositoryContract
) : LoadRocketLaunchInfoUseCaseContract {

    override operator fun invoke(): Flow<String> = flow {
        emit(rocketRepository.launchPhrase())
    }
}

interface LoadRocketLaunchInfoUseCaseContract {
    operator fun invoke(): Flow<String>
}

:shared:data モジュール

:shared:data モジュールの build.gradle.kts は以下のことに気を付けるだけ。

  • :shared:domain モジュールの iOS 向けのビルド生成物は出力されないのでKotlinNativeTarget.binaries.framework{...}KotlinNativeTarget.binaries.framework() でも問題ない

:shared:data モジュールの build.gradle.kts の例を以下に示す。

build.gradle.kts

import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
    alias(libs.plugins.kotlinSerialization)
}

kotlin {
    ...

    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64(),
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "SharedData"
            isStatic = true
        }
    }

    sourceSets {
        commonMain.dependencies {
            implementation(projects.shared.domain)

            implementation(libs.kotlinx.coroutines.core)
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.content.negotiation)
            implementation(libs.ktor.serialization.kotlinx.json)
            implementation(libs.koin.core)
        }
        androidMain.dependencies {
            implementation(libs.ktor.client.android)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
        }
    }
}

android {
    namespace = "dev.seabat.kmp.multimodule.data"
    ...
}

Android アプリ

Android アプリから KMP モジュールへの依存は :shared モジュールだけで良い。

build.gradle.kts
...
kotlin {
    ...
    sourceSets {

        androidMain.dependencies {
            ...
            implementation(projects.shared)
        }
    }
}

Android アプリからは :shared モジュールを介して :shared:domain モジュールが単なる Android モジュールとして参照できる。例えば :shared:domain モジュール内に LoadRocketLaunchInfoUseCase が存在する場合は以下のようになる。

RocketLaunchViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import xxxx.shared.usecase.LoadRocketLaunchInfoUseCaseContract
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

class RocketLaunchViewModel : ViewModel(), KoinComponent {
    private val loadRocketLaunchInfoUseCase: LoadRocketLaunchInfoUseCaseContract by inject()
    private val _rocketLaunchPhrase = MutableStateFlow("")

    val rocketLaunchPhrase: StateFlow<String>
        get() = _rocketLaunchPhrase

    init {
        viewModelScope.launch {
            loadRocketLaunchInfoUseCase().collect { phrase ->
                _rocketLaunchPhrase.update {
                    phrase
                }
            }
        }
    }
}

iOS アプリ

iOS アプリからは :shared モジュールへの依存だけで良い。元々シンプルな :shared モジュールだけの構成でビルドできていたならマルチモジュール化した後で iOS のビルド設定をいじらなくて良いはずだ。例えば :shared:domain モジュール内に LoadRocketLaunchInfoUseCase が存在する場合は以下のようになる。

ContentView.swift
import SwiftUI
import Shared

struct ContentView: View {
    @ObservedObject private(set) var viewModel: ViewModel
    ...
                        Text(viewModel.rocketLaunchPhrase).task {
                            await self.viewModel.startRocketLaunchInfoObserving()
                        }
    ...                 
}

extension ContentView {
    @MainActor
    class ViewModel: ObservableObject {
        let loadRocketLaunchInfoUseCase: LoadRocketLaunchInfoUseCaseContract

        @Published var rocketLaunchPhrase: String = ""
        
        init() {
            loadRocketLaunchInfoUseCase = KoinHelperKt.getLoadRocketLaunchInfoUseCase() // DI を実行。
        }
        
        func startRocketLaunchInfoObserving() async {
            for await phrase in loadRocketLaunchInfoUseCase.invoke() {
                self.rocketLaunchPhrase = phrase
            }
        }
    }
}

補足1

Swift 側で Koltin の Flow から値を取り出す方法は KMP 公式ドキュメントの Option 1. Configure SKIEを参照してください。

補足2

KoinHelperKt.getLoadRocketLaunchInfoUseCase() は :shared モジュールに以下のようなコードが存在し、かつアプリ起動時に initIosKoin() を実行を済みであること想定している。

KoinHelper.kt
class KoinHelper : KoinComponent {
    val loadRocketLaunchInfoUseCase: LoadRocketLaunchInfoUseCaseContract by inject()
    ...
}

fun getGreetingSharedViewModel(): GreetingSharedViewModel {
    return KoinHelper().greetingSharedViewModel // Koin の get() をブリッジ
}

あとがき

KMP for Mobile Native Developers: The Book.Multiple Shared Modules では iOS アプリ向けに Umbrella モジュールを CocoaPods ライブラリとしてビルドする方法、つまり cocoapods.framework{..} を使用する方法が記載されているが、デフォルトのビルド方法、つまり KotlinNativeTarget.binaries.framework{...} を使えば良いことは今回のマルチモジュール化作業で判明した。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?