はじめに
今の時代、モジュール間は疎結合、ユニットテストはスタブを注入!💉💉💉
というのが定番みたいだし、実際にも開発しやすいので、
KMP1 でも Dependency Injection(以下DI)していきたいです。
KMP は開発言語が Kotlin なわけですが、
Android 向けに Dagger Hilt という DI フレームワークがありつつも、残念ながら KMP 非対応です。
じゃあ KMP の場合は何を使えば良いのか?
ということで、自分がいちばん慣れてる Koin を使ってDIを実現してみます2。
ライブラリ追加
まず、これがないことには始まりませんので。
ここを参考にしながら追加します。
[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 {
// 省略
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 のウィザードが作ってくれたやつをそのまま流用して注入します。
package some.your.namespace
class Greeting {
private val platform: Platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}! from KMP❤️" // ← ちょっとだけ修正😅
}
}
Koin モジュール
このモジュールを startKoin で読み込んで、必要とする場所に注入されるようにします。
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 で統一感を出したいので作りました。
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()
という具合に、インスタンスを注入したいです。
まるっきり願望通りというわけにはいかないのですけど、
なるべく近い感じになるように、こんなクラスを作ってみました。
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
に作ったやつを呼びます。
dependencies {
// 省略
implementation(libs.koin.android) // ← 追加
}
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 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()
を使います。
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 初期化とインスタンス取得のヘルパー関数を作ります。
import Foundation
import shared
func startKoin() {
KoinKt.startKoin() // ← KoinKt の存在を隠蔽したかったのでヘルパー関数でラップしました
}
func get<T>() -> T {
return KoinResolver.shared.resolve(objCObject: T.self) as! T
}
アプリ起動時に Koin 初期化のヘルパー関数を呼ぶようにします。
import SwiftUI
@main
struct iOSApp: App {
// ↓ 追加 ↓
init() {
startKoin()
}
UI に Koin から取得したインスタンスを注入するように修正します。
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 |
---|---|
両 OS ともに Koin から取得した Greeting クラスのインスタンスを使って動作している模様です!!
おわりに
Koin の全機能を iOS 側から使えるようにはなっていませんが、このサンプルみたいに簡単な依存関係の解決であれば使えそうです。
let greeting: Greeting = get(Greeting.self)
とりあえず、自分的にはわりとエレガントな雰囲気に実装できたので満足です。
-
Java 界隈で著名な Jake Wharton 氏は Koin があまりお気に召さないようで「Dagger for KSP みたいなもんが出てくるまで手動DIでいいんじゃね?」的なこと言ってますが、流石に全部手動は面倒です。つーか Dagger のとっつきにくさも大概だと思う。
ワタシは "真のDIフレームワーク" とやらを使いたいのではなく、単にDIを採用してコードのわかりやすさを向上させたいだけなのです。 ↩