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

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

尚、本記事は [Android推奨アーキテクチャ](https://developer.android.com/topic/architecture) と [クリーンアーキテクチャ](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) に多少の理解があり、以下のクラス図を何となく理解出来る方が対象となります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/16c58b1f-f26d-4e1a-968f-03c65d103709.png" alt="" width="300">


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

## やりたいこと

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/0a1f0c07-5c50-41bc-bb70-d50fe84b978c.png" alt="" width="600">

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/ac9a5fc5-2922-4af2-bd79-484c2392c270.png" alt="" width="500">

理由は２つ。
* iOS 側から KMP ライブラリを２つ参照する必要がある
* [Koin](https://insert-koin.io/docs/reference/koin-mp/kmp/) の `startKoin()` は一度しか呼ぶことができない

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

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

[KMP for Mobile Native Developers: The Book.](https://santimattius.github.io/kmp-for-mobile-native-developers-book/)の [Multiple Shared Modules](https://santimattius.github.io/kmp-for-mobile-native-developers-book/#179504e6-f752-8006-bfbb-fc118c12e02d) を参考にして、Android アプリ、iOS アプリとの I/F に Umbrella モジュールを配置するとうまくいかない事象が解消される。以下は Umbrella モジュールとして :shared モジュールを配置した場合の構成図である。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3494458/3f917158-6fa7-4dbf-82e1-620503d99586.png" alt="" width="500">

### :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](https://skie.touchlab.co/) を使う場合はプラグインを定義する

: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](https://www.jetbrains.com/help/kotlin-multiplatform-dev/multiplatform-upgrade-app.html#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.](https://santimattius.github.io/kmp-for-mobile-native-developers-book/)の [Multiple Shared Modules](https://santimattius.github.io/kmp-for-mobile-native-developers-book/#179504e6-f752-8006-bfbb-fc118c12e02d) では iOS アプリ向けに Umbrella モジュールを CocoaPods ライブラリとしてビルドする方法、つまり `cocoapods.framework{..}` を使用する方法が記載されているが、デフォルトのビルド方法、つまり `KotlinNativeTarget.binaries.framework{...}` を使えば良いことは今回のマルチモジュール化作業で判明した。