KMP の高度なパターン
このガイドでは、Kotlin Multiplatform プロジェクトにおける Koin の高度なパターンについて説明します。
INFO
基本的なセットアップについては KMP セットアップを、モジュールの構成については 共有パターンを、ViewModel については ViewModel を参照してください。
ソースプロジェクト
INFO
Kotlin Multiplatform プロジェクトはこちらにあります: https://github.com/InsertKoinIO/hello-kmp
高度な expect/actual パターン
基本的な expect val platformModule: Module パターンに加えて、プラットフォーム固有のコードに対する高度なアプローチをいくつか紹介します。
パターン 1: expect/actual クラス
プラットフォーム固有の API(Android の Context、iOS の UIDevice など)が必要な場合に使用します:
// commonMain - 宣言
expect class PlatformContext
expect fun createPlatformModule(): Module
// androidMain - Android での実装
actual class PlatformContext(val context: Context)
actual fun createPlatformModule() = module {
single<PlatformContext>() // コンパイラプラグイン DSL
}
// iosMain - iOS での実装
actual class PlatformContext
actual fun createPlatformModule() = module {
single<PlatformContext>()
}パターン 2: インターフェース + プラットフォームの実装
プラットフォームごとに異なる実装を注入したい場合に使用します:
// commonMain - インターフェース
interface Logger {
fun log(message: String)
}
// androidMain
class AndroidLogger : Logger {
override fun log(message: String) {
android.util.Log.d("App", message)
}
}
val androidModule = module {
single<AndroidLogger>() bind Logger::class
}
// iosMain
class IOSLogger : Logger {
override fun log(message: String) {
println("iOS: $message")
}
}
val iosModule = module {
single<IOSLogger>() bind Logger::class
}パターン 3: アノテーションを使用した expect モジュール
expect/actual とアノテーションを組み合わせて、コードをよりクリーンにします:
// commonMain
expect val platformModule: Module
// androidMain
@Module
@ComponentScan("com.myapp.android")
class AndroidPlatformModule
actual val platformModule = AndroidPlatformModule().module
// iosMain
@Module
@ComponentScan("com.myapp.ios")
class IosPlatformModule
actual val platformModule = IosPlatformModule().moduleINFO
どのパターンを使用すべきか:
- expect/actual クラス: プラットフォーム API(Context、UIDevice)や、単純なプラットフォーム間の差異に使用します。
- インターフェース: プラットフォームごとに異なるビジネスロジックや、テスト可能なコードに使用します。
- expect モジュール: 複雑なプラットフォーム固有の依存関係グラフに使用します。
共有コード内での Android Context
共有コード内で Android の Context にアクセスすることは、よくあるニーズです。推奨されるパターンは次のとおりです:
ContextWrapper パターン
// commonMain - ラッパーインターフェース
interface AppContext
// androidMain - Android での実装
class AndroidAppContext(val context: Context) : AppContext
val androidContextModule = module {
single<AndroidAppContext>() bind AppContext::class
}
// iosMain - 空の実装
class IOSAppContext : AppContext
val iosContextModule = module {
single<IOSAppContext>() bind AppContext::class
}共有コードでの使用例:
// commonMain - リポジトリがプラットフォームコンテキストを使用する
class FileRepository(private val appContext: AppContext) {
fun saveFile(data: String) {
when (appContext) {
is AndroidAppContext -> {
val file = File(appContext.context.filesDir, "data.txt")
file.writeText(data)
}
is IOSAppContext -> {
// iOS 固有のファイル操作
}
}
}
}
val sharedModule = module {
single<FileRepository>()
}NOTE
純粋な共有ロジックの場合は、when 文を使用するよりも、プラットフォームの操作をインターフェースに抽象化することを優先してください。
アーキテクチャパターン
Ktor を使用したリポジトリパターン
// commonMain
interface UserRepository {
suspend fun getUser(id: String): User
suspend fun saveUser(user: User)
}
@Singleton
class UserRepositoryImpl(
private val api: UserApi,
private val database: UserDatabase
) : UserRepository {
override suspend fun getUser(id: String): User {
return try {
api.fetchUser(id).also { database.saveUser(it) }
} catch (e: Exception) {
database.getUser(id)
}
}
override suspend fun saveUser(user: User) {
database.saveUser(user)
api.updateUser(user)
}
}
val dataModule = module {
single<UserRepositoryImpl>() bind UserRepository::class
}ネットワークレイヤー (Ktor + Koin)
// commonMain
@Singleton
class ApiClient(private val client: HttpClient) {
suspend fun fetchUser(id: String): User {
return client.get("https://api.example.com/users/$id").body()
}
}
val networkModule = module {
single {
HttpClient {
install(ContentNegotiation) {
json()
}
}
}
single<ApiClient>()
}データベースレイヤー (SqlDelight)
// commonMain
expect class DriverFactory {
fun createDriver(): SqlDriver
}
val databaseModule = module {
single { DriverFactory().createDriver() }
single { AppDatabase(get()) }
single { get<AppDatabase>().userQueries }
}
// androidMain
actual class DriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema, context, "app.db")
}
}
// iosMain
actual class DriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
}KMP モジュールのテスト
共有モジュールのユニットテスト
// commonTest
class UserRepositoryTest : KoinTest {
@Test
fun testGetUser() = runTest {
startKoin {
modules(module {
single<UserApi> { FakeUserApi() }
single<UserDatabase> { FakeUserDatabase() }
single<UserRepositoryImpl>() bind UserRepository::class
})
}
val repository: UserRepository = get()
val user = repository.getUser("123")
assertEquals("John", user.name)
stopKoin()
}
}プラットフォーム固有の依存関係を伴うテスト
// commonTest
expect fun createTestPlatformModule(): Module
// androidTest
actual fun createTestPlatformModule() = module {
single<PlatformContext> { TestAndroidContext() }
}
// iosTest
actual fun createTestPlatformModule() = module {
single<PlatformContext> { TestIOSContext() }
}
// commonTest - プラットフォームモジュールを使用したテスト
class PlatformDependentTest : KoinTest {
@Test
fun testWithPlatformContext() {
startKoin {
modules(
createTestPlatformModule(),
module {
single<MyService>()
}
)
}
val service: MyService = get()
// サービスのテスト
stopKoin()
}
}よくある落とし穴
推奨 (DO): テスト可能な共有コードのためにインターフェースを使用する
// 良い例 - テスト可能
interface Logger {
fun log(message: String)
}
val sharedModule = module {
single<UserService>() // Logger インターフェースに依存
}非推奨 (DON'T): ビジネスロジックに expect クラスを使用しない
// 悪い例 - テストが困難で、プラットフォームとの結合が強すぎる
expect class Logger {
fun log(message: String)
}推奨 (DO): プラットフォームモジュールを分離しておく
// 良い例 - 明確な分離
fun initKoin() {
startKoin {
modules(commonModules() + platformModule)
}
}非推奨 (DON'T): 共有モジュール内にプラットフォーム固有のコードを混ぜない
// 悪い例 - commonMain 内にプラットフォーム固有のコードがある
val sharedModule = module {
single {
if (Platform.isAndroid) { /* ... */ } // これは避けてください!
}
}推奨 (DO): 大規模なアプリでは遅延(lazy)モジュールを使用する
// 良い例 - 起動の最適化
val lazyFeatureModule = lazyModule {
single<HeavyService>()
}
startKoin {
modules(coreModules)
lazyModules(lazyFeatureModule)
}非推奨 (DON'T): スコープのクローズを忘れない
// 悪い例 - メモリリーク
class FeatureScreen : KoinComponent {
val scope = getKoin().createScope<FeatureScreen>()
// スコープのクローズを忘れている!
}
// 良い例 - 適切なクリーンアップ
class FeatureScreen : KoinComponent {
val scope = getKoin().createScope<FeatureScreen>()
fun onDestroy() {
scope.close()
}
}デスクトッププラットフォームの統合
JVM デスクトップアプリ(Compose Desktop)の場合:
// desktopMain
fun main() = application {
startKoin {
modules(
sharedModule,
desktopModule
)
}
Window(onCloseRequest = ::exitApplication) {
App()
}
}
val desktopModule = module {
single<DesktopLogger>() bind Logger::class
single<DesktopFileManager>()
}Web プラットフォームの統合(実験的)
Kotlin/JS および Kotlin/WASM の場合:
// jsMain または wasmJsMain
fun main() {
startKoin {
modules(
sharedModule,
webModule
)
}
// Web アプリの初期化
}
val webModule = module {
single<ConsoleLogger>() bind Logger::class
single<BrowserStorage>()
}WARNING
WASM サポートは実験的です。一部の機能が期待通りに動作しない場合があります。
iOS Swift 相互運用
Swift のための KoinComponent
// shared/src/iosMain/kotlin/Helper.kt
class GreetingHelper : KoinComponent {
private val greeting: Greeting by inject()
fun greet(): String = greeting.greeting()
}Swift 内での記述:
struct ContentView: View {
let greet = GreetingHelper().greet()
var body: some View {
Text(greet)
}
}スレッドに関する考慮事項
iOS やその他の Native ターゲットでは、Koin インスタンスは新しいメモリモデルとシームレスに動作します:
- Koin の定義はスレッドセーフです
- スコープはスレッドをまたいで作成・使用できます
- 必要に応じて、グローバルな Koin インスタンスに
@SharedImmutableを使用してください
NOTE
新しい Kotlin/Native メモリモデル(Kotlin 1.7.20 以降でデフォルト)により、Koin の使用が大幅に簡素化されました。
次のステップ
- KMP セットアップ - 基本的な KMP 設定
- 共有パターン - モジュールの構成
- ViewModel - マルチプラットフォーム ViewModel
- Koin for Compose - Compose との統合
- アノテーション KMP - KMP におけるアノテーションベースの DI
