4
3

KMP + Koin で DI (Dependency Injection)

Last updated at Posted at 2023-11-19

はじめに

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

というのが定番みたいだし、実際にも開発しやすいので、
KMP1 でも Dependency Injection(以下DI)していきたいです。

KMP は開発言語が Kotlin なわけですが、
Android 向けに Dagger Hilt という DI フレームワークがありつつも、残念ながら KMP 非対応です。

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

ということで、自分がいちばん慣れてる Koin を使ってDIを実現してみます2

ライブラリ追加

まず、これがないことには始まりませんので。
ここを参考にしながら追加します。

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

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 のウィザードが作ってくれたやつをそのまま流用して注入します。

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 で読み込んで、必要とする場所に注入されるようにします。

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 で統一感を出したいので作りました。

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

公式ドキュメントの言う通りに実装しても良いんですけど、そうすると Koin でインスタンス管理するすべてのクラスにヘルパークラスと実装する全てのメソッドが必要になってしまいます。

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

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

val greeting: Greeting = get()

みたいに、iOS 側でも

let greeting: Greeting = get()

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

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

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 に作ったやつを呼びます。

androidApp/src/build.gradle.kts
dependencies {

    // 省略

    implementation(libs.koin.android)  // ← 追加
}
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 が呼ばれてなくて落ちるいつものやつ。

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() を使います。

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 初期化とインスタンス取得のヘルパー関数を作ります。

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 初期化のヘルパー関数を呼ぶようにします。

iosApp/iosApp/iOSApp.swift
import SwiftUI

@main
struct iOSApp: App {

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

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

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 kmp_ios.png

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

おわりに

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

あと、願望だったインスタンス化の型推論まではできず、
let greeting: Greeting = get(Greeting.self)
という形でしか実装できなかったのですけど、 これは Kotlin の [reified](https://kotlinlang.org/docs/inline-functions.html#reified-type-parameters) みたいなのが Swift に無いから無理っぽいな〜と思いました。

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

  1. "Kotlin Multiplatform の名称に関するアップデート" . The JetBrains Blog

  2. Java 界隈で著名な Jake Wharton 氏は Koin があまりお気に召さないようで「Dagger for KSP みたいなもんが出てくるまで手動DIでいいんじゃね?」的なこと言ってますが、流石に全部手動は面倒です。つーか Dagger のとっつきにくさも大概だと思う。
    ワタシは "真のDIフレームワーク" とやらを使いたいのではなく、単にDIを採用してコードのわかりやすさを向上させたいだけなのです。

4
3
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
4
3