KMP + Koin で DI (Dependency Injection)

# はじめに

今の時代、モジュール間は疎結合、ユニットテストはスタブを注入！💉💉💉

というのが定番みたいだし、実際にも開発しやすいので、
[KMP](https://www.jetbrains.com/kotlin-multiplatform/)[^1] でも Dependency Injection（以下DI）していきたいです。

KMP は開発言語が Kotlin なわけですが、
Android 向けに [Dagger Hilt](https://developer.android.com/codelabs/android-dagger-to-hilt?hl=ja#0) という DI フレームワークがありつつも、残念ながら KMP 非対応です。

じゃあ KMP の場合は何を使えば良いのか？

ということで、自分がいちばん慣れてる [Koin](https://insert-koin.io/) を使ってDIを実現してみます[^2]。

# ライブラリ追加

まず、これがないことには始まりませんので。
[ここ](https://insert-koin.io/docs/reference/koin-mp/kmp/)を参考にしながら追加します。

```toml: gradle/libs.versions.toml
[versions]
koin = "3.5.0"  # ← 追加

# 省略

[libraries]
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }  # ← 追加
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }  # ← 追加
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }  # ← 追加
```

# shared

## build.gradle.kts
```kotlin: shared/build.gradle.kts
kotlin {

    // 省略

    sourceSets {
        commonMain.dependencies {
            implementation(libs.koin.core)  // ← 追加
        }
        commonTest.dependencies {
            implementation(libs.kotlin.test)
            implementation(libs.koin.test)  // ←　追加
        }
        androidMain.dependencies {
            implementation(libs.koin.android)  // ← 追加
        }
```

## commonMain

### 注入したいクラス
Android Studio のウィザードが作ってくれたやつをそのまま流用して注入します。

```kotlin: shared/src/commonMain/kotlin/some/your/namespace/Greeting.kt
package some.your.namespace

class Greeting {
    private val platform: Platform = getPlatform()

    fun greet(): String {
        return "Hello, ${platform.name}! from KMP❤️"  // ← ちょっとだけ修正😅
    }
}
```

### Koin モジュール

このモジュールを [startKoin](https://insert-koin.io/docs/reference/koin-core/start-koin#the-startkoin-function) で読み込んで、必要とする場所に注入されるようにします。
```kotlin: shared/src/commonMain/kotlin/some/your/namespace/AppModule.kt
package some.your.namespace

import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module

val appModule = module {
    
    // Koin version 3 以降の書き方
    singleOf(::Greeting) bind Greeting::class
    
    // Koin version 2 以前の書き方
    //single<Greeting> { Greeting() }
    //single { Greeting() as Greeting }
}
```

### Koin 初期化用関数
こいつは必ず作らなきゃいけないわけじゃないですけど、Android と iOS で統一感を出したいので作りました。

```kotlin: shared/commonMain/kotlin/some/your/namespace/Koin.kt
package some.your.namespace

import org.koin.core.context.startKoin

fun startKoin() = startKoin {
    modules(appModule)
}
```

## androidMain

特になし。

## iosMain

[公式ドキュメント](https://insert-koin.io/docs/reference/koin-mp/kmp/#ios-app)の言う通りに実装しても良いんですけど、そうすると Koin でインスタンス管理するすべてのクラスにヘルパークラスと実装する全てのメソッドが必要になってしまいます。

そんな面倒なこと絶対やりたくないです。

願望としては、
ヘルパークラスを作らず、Android 版 Koin でこうやってる

```kotlin
val greeting: Greeting = get()
```

みたいに、iOS 側でも

```swift
let greeting: Greeting = get()
```

という具合に、インスタンスを注入したいです。

~~まるっきり願望通りというわけにはいかないのですけど、~~
なるべく近い感じになるように、こんなクラスを作ってみました。

```kotlin: shared/iosMain/kotlin/some/your/namespace/Koin.kt
package some.your.namespace

import kotlinx.cinterop.ObjCClass
import kotlinx.cinterop.getOriginalKotlinClass
import org.koin.core.component.KoinComponent

@OptIn(BetaInteropApi::class)
@Suppress("unused")
object KoinResolver : KoinComponent {

    fun resolve(objCObject: Any): Any {
        if (objCObject is ObjCProtocol) {
            return resolve(objCProtocol = objCObject)
        }
        if (objCObject is ObjCClass) {
            return resolve(objCClass = objCObject)
        }
        throw Exception(message = "Unknown Object Type.")
    }

    /** Objective-C のクラスをヒントにしてインスタンスを取得 */
    private fun resolve(objCClass: ObjCClass): Any = getKoin().get(
        clazz = getOriginalKotlinClass(objCClass = objCClass)!!
    )

    /** Objective-C のプロトコルをヒントにしてインスタンスを取得 */
    private fun resolve(objCProtocol: ObjCProtocol): Any = getKoin().get(
        clazz = getOriginalKotlinClass(objCProtocol = objCProtocol)!!
    )
}
```

# アプリ

## androidApp

公式ドキュメントの言う通りにしますが、`startKoin()` は `commonMain/Koin.kt` に作ったやつを呼びます。

```kotlin: androidApp/src/build.gradle.kts
dependencies {

    // 省略

    implementation(libs.koin.android)  // ← 追加
}
```

```kotlin: androidApp/src/main/java/some/your/namespace/android/Application.kt
package some.your.namespace.android

import android.app.Application
import some.your.namepace.startKoin

class Application : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin()
    }
}
```

Application クラスを作ったんで `AndroidManifest.xml` に設定を追加しておきます。
これやらないと `startKoin` が呼ばれてなくて落ちるいつものやつ。

```xml: androidApp/src/main/AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Application クラスからアプリが起動するように android:name を追加 -->
    <application
        android:name=".Application"
```

Activity ではコンストラクタ・インジェクションが使えないので、例によって `by inject()` を使います。

```kotlin: androidApp/src/java/some/your/namespace/android/MainActivity.kt
package some.your.namespace.android

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import some.your.namepace.Greeting
import org.koin.android.ext.android.inject

class MainActivity : ComponentActivity() {

    private val greeting: Greeting by inject() // ← 追加

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApplicationTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    GreetingView(greeting.greet())  // ← 変更
                }
            }
        }
    }
}

@Composable
fun GreetingView(text: String) {
    Text(text = text)
}

@Preview
@Composable
fun DefaultPreview() {
    MyApplicationTheme {
        GreetingView("Hello, Android!")
    }
}
```

## iosApp

Koin 初期化とインスタンス取得のヘルパー関数を作ります。

```swift: iosApp/iosApp/Koin.swift
import Foundation
import shared

func startKoin() {
    KoinKt.startKoin()  // ← KoinKt の存在を隠蔽したかったのでヘルパー関数でラップしました
}

func get<T>() -> T {
    return KoinResolver.shared.resolve(objCObject: T.self) as! T
}
```

アプリ起動時に Koin 初期化のヘルパー関数を呼ぶようにします。

```swift: iosApp/iosApp/iOSApp.swift
import SwiftUI

@main
struct iOSApp: App {

    // ↓ 追加 ↓
    init() {
        startKoin()
    }
```

UI に Koin から取得したインスタンスを注入するように修正します。

```swift: iosApp/iosApp/ContentView.swift
import SwiftUI
import shared  // ← 追加

struct ContentView: View {
    let greeting: Greeting = get()  // ← 変更
	var body: some View {
        Text(greeting.greet())  // ← 変更
	}
}

struct ContentView_Previews: PreviewProvider {
	static var previews: some View {
		ContentView()
	}
}
```

# 実行結果

|Android|iOS|
|---|---|
|![kmp_android.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/169798/7a2181ec-c9ba-c90f-9463-3f44fc1adbdf.png)|![kmp_ios.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/169798/08349b9c-06c4-383c-66e4-b60c3680416c.png)|

両 OS ともに Koin から取得した Greeting クラスのインスタンスを使って動作している模様です！！

# おわりに

Koin の全機能を iOS 側から使えるようにはなっていませんが、このサンプルみたいに簡単な依存関係の解決であれば使えそうです。



あと、願望だったインスタンス化の型推論まではできず、

```swift
let greeting: Greeting = get(Greeting.self)
```





という形でしか実装できなかったのですけど、
これは Kotlin の [reified](https://kotlinlang.org/docs/inline-functions.html#reified-type-parameters) みたいなのが Swift に無いから無理っぽいな〜と思いました。



とりあえず、自分的にはわりとエレガントな雰囲気に実装できたので満足です。


[^1]: "[Kotlin Multiplatform の名称に関するアップデート](https://blog.jetbrains.com/ja/kotlin/2023/08/update-on-the-name-of-kotlin-multiplatform/)" . [The JetBrains Blog](https://blog.jetbrains.com/)
[^2]: Java 界隈で著名な Jake Wharton 氏は Koin があまりお気に召さないようで「[Dagger for KSP みたいなもんが出てくるまで手動DIでいいんじゃね？](https://twitter.com/JakeWharton/status/1385327902359707648)」的なこと言ってますが、流石に全部手動は面倒です。つーか Dagger のとっつきにくさも大概だと思う。
ワタシは "真のDIフレームワーク" とやらを使いたいのではなく、単にDIを採用してコードのわかりやすさを向上させたいだけなのです。