Kotlin Multiplatform プロジェクトの shared モジュールを自分がやりたいマルチモジュールに分割する際、参考になるサイトや GitHub リポジトリが存在しなかったのでマルチモジュール化に成功した内容を記事にまとめた。
尚、本記事は Android推奨アーキテクチャ と クリーンアーキテクチャ に多少の理解があり、以下のクラス図を何となく理解出来る方が対象となります。

本記事が対象している org.jetbrains.kotlin.multiplatform のバージョンは '2.0.0' 。
やりたいこと
Android 推奨アーキテクチャとクリーンアーキテクチャの概念を組み合わせた以下のような KMP プロジェクトを」作ることを目標とする。

単純にモジュールを分割してもうまくいかない
以下の構成にしてもうまくいかなかった。。。

理由は2つ。
- iOS 側から KMP ライブラリを2つ参照する必要がある
-
Koin の
startKoin()
は一度しか呼ぶことができない
うまくいかない詳細については割愛。(後日更新予定)
Umbrella モジュールでうまくいく
KMP for Mobile Native Developers: The Book.の Multiple Shared Modules を参考にして、Android アプリ、iOS アプリとの I/F に Umbrella モジュールを配置するとうまくいかない事象が解消される。以下は Umbrella モジュールとして :shared モジュールを配置した場合の構成図である。
:shared モジュール
まず、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 の例を以下に示す。
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()
をコールする関数の例を以下に示す。
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 の例を以下に示す。
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 クラスの例を以下に示す。
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 の例を以下に示す。
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 モジュールだけで良い。
...
kotlin {
...
sourceSets {
androidMain.dependencies {
...
implementation(projects.shared)
}
}
}
Android アプリからは :shared モジュールを介して :shared:domain モジュールが単なる Android モジュールとして参照できる。例えば :shared:domain モジュール内に LoadRocketLaunchInfoUseCase
が存在する場合は以下のようになる。
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
が存在する場合は以下のようになる。
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()
を実行を済みであること想定している。
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{...}
を使えば良いことは今回のマルチモジュール化作業で判明した。