KMP の共有モジュール アーキテクチャ: 期待/実際、Koin を使用したインターフェイスと依存関係の注入
Kotlin マルチプラットフォーム プロジェクトの共有モジュール アーキテクチャとその違い
時間の経過とともにスケールする保守可能なアプリケーションと、テストが困難な混乱したコード
そして編集します。仕組み expect/actual 強力ですが、それには規律が必要です
正しく使用: すべて expect 不適切に配置すると、管理が困難な依存関係が作成されます。
このガイドでは、コンテキスト内で SOLID 原則を適用して共有フォームを構造化する方法を説明します。 マルチプラットフォーム: インターフェースを使用してプラットフォーム固有の依存関係を分離する方法、構成方法 Android と iOS で動作する依存関係注入のための Koin とテスト可能なコードを取得する方法 単独で — 堅牢なテスト スイートの鍵となります。
何を学ぶか
- 共有モジュールのレイヤー アーキテクチャ: ドメイン、データ、プレゼンテーション
- 期待/実際: 正しいパターンと回避すべきアンチパターン
- プラットフォーム固有の依存関係を分離するためのインターフェイス
- マルチプラットフォームの依存関係注入のためのKoin
- 共有モジュールを単独でテストする
共有モジュールのレイヤーアーキテクチャ
期待/実際について話す前に、共有モジュールの構造を定義することが重要です。 クリーンなアーキテクチャでは、責任が明確に定義された 3 つの層に分離されます。 その依存関係のルールは次のとおりです。
// Struttura raccomandata per il modulo condiviso
shared/src/commonMain/kotlin/
└── com.example.app/
├── domain/ # Layer 1: Regole business (zero dipendenze esterne)
│ ├── model/
│ │ ├── User.kt
│ │ └── Product.kt
│ ├── repository/
│ │ └── UserRepository.kt # Interfacce (non implementazioni!)
│ └── usecase/
│ ├── GetUsersUseCase.kt
│ └── SaveUserUseCase.kt
│
├── data/ # Layer 2: Accesso ai dati
│ ├── network/
│ │ ├── UserApiClient.kt # Ktor HTTP client
│ │ └── dto/ # Data Transfer Objects
│ ├── local/
│ │ └── UserLocalDataSource.kt # SQLDelight
│ └── repository/
│ └── UserRepositoryImpl.kt
│
├── presentation/ # Layer 3: ViewModels e state
│ └── UserListViewModel.kt
│
└── di/ # Dependency Injection modules
├── CommonModule.kt
└── PlatformModule.kt # expect (implementazione platform-specific)
La 依存関係ルール 基本: 各レイヤーはレイヤーにのみ依存できます。 インテリア。ドメイン層は、Ktor、SQLDelight、Android/iOS については何も知りません。データ層 ドメイン層インターフェイスを実装します。プレゼンテーション層はドメインのユースケースを使用します。
期待値/実際値の正しいパターン
expect/actual に使用する必要があります 実装 プラットフォーム固有の、
ビジネス API 用ではありません。に最適な場所 expect そしてインフラ部分では
(データベース ドライバー、HTTP エンジン、ファイル システム アクセス)、テンプレートやユース ケースには含まれていません。
// CORRETTO: expect per infrastruttura platform-specific
// commonMain/kotlin/com/example/app/data/local/DatabaseDriverFactory.kt
expect class DatabaseDriverFactory(context: Any? = null) {
fun create(): SqlDriver
}
// androidMain: usa Android SQLite
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import android.content.Context
actual class DatabaseDriverFactory(private val context: Any? = null) {
actual fun create(): SqlDriver {
return AndroidSqliteDriver(
AppDatabase.Schema,
context as Context,
"app.db"
)
}
}
// iosMain: usa Native SQLite
import app.cash.sqldelight.driver.native.NativeSqliteDriver
actual class DatabaseDriverFactory(private val context: Any? = null) {
actual fun create(): SqlDriver {
return NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
}
// CORRETTO: expect per funzionalita OS-specific senza logica business
// commonMain
expect fun getCurrentTimestamp(): Long
expect fun getDeviceLocale(): String
// androidMain
actual fun getCurrentTimestamp(): Long = System.currentTimeMillis()
actual fun getDeviceLocale(): String = java.util.Locale.getDefault().toLanguageTag()
// iosMain
import platform.Foundation.NSDate
import platform.Foundation.NSLocale
actual fun getCurrentTimestamp(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
actual fun getDeviceLocale(): String = NSLocale.currentLocale.localeIdentifier
// SBAGLIATO: expect per logica business (anti-pattern)
// Non fare questo: la logica business deve essere in commonMain
expect fun calculateDiscount(price: Double, percentage: Int): Double
// Questo NON ha senso come expect perche non varia per piattaforma!
依存関係を分離するためのインターフェイス
よりもさらにきれいなパターン expect/actual ほとんどの場合
そして使用します Koin を介したプラットフォーム固有の実装との commonMain のインターフェイス。
このアプローチでは、expect/actual を必要とせず、モックを使用したコードのテストが容易になります。
// commonMain: interfaccia nel domain layer
interface PlatformFileStorage {
suspend fun readFile(path: String): ByteArray?
suspend fun writeFile(path: String, data: ByteArray)
suspend fun deleteFile(path: String)
suspend fun listFiles(directory: String): List<String>
fun getStorageRoot(): String
}
// commonMain: use case che dipende dall'interfaccia (non dall'implementazione)
class ExportDataUseCase(
private val storage: PlatformFileStorage,
private val serializer: DataSerializer
) {
suspend fun execute(data: AppData, fileName: String): String {
val bytes = serializer.serialize(data)
val path = "${storage.getStorageRoot()}/$fileName"
storage.writeFile(path, bytes)
return path
}
}
// androidMain: implementazione Android
class AndroidFileStorage(private val context: Context) : PlatformFileStorage {
override suspend fun readFile(path: String): ByteArray? =
withContext(Dispatchers.IO) {
runCatching { File(path).readBytes() }.getOrNull()
}
override suspend fun writeFile(path: String, data: ByteArray) =
withContext(Dispatchers.IO) {
File(path).apply { parentFile?.mkdirs() }.writeBytes(data)
}
override fun getStorageRoot(): String = context.filesDir.absolutePath
// ... altri metodi
}
// iosMain: implementazione iOS
class IosFileStorage : PlatformFileStorage {
override suspend fun readFile(path: String): ByteArray? =
withContext(Dispatchers.Default) {
NSData.dataWithContentsOfFile(path)?.toByteArray()
}
override fun getStorageRoot(): String {
val docs = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory, NSUserDomainMask, true
).first() as String
return docs
}
// ... altri metodi
}
依存性注入マルチプラットフォーム用のKoin
光陰 KMP に最適な依存関係注入フレームワークです。 純粋な Kotlin、リフレクションを使用せず (iOS 上の Kotlin/ネイティブには重要)、シンプルな API を備えています DSLベース。構成には、共通モジュールとプラットフォーム固有のモジュールが必要です。
// commonMain/kotlin/com/example/app/di/CommonModule.kt
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val domainModule = module {
// Use cases: dipendono da interfacce, non da implementazioni
singleOf(::GetUsersUseCase)
singleOf(::SaveUserUseCase)
singleOf(::ExportDataUseCase)
}
val dataModule = module {
// Repository: usa l'interfaccia del domain
single<UserRepository> { UserRepositoryImpl(get(), get()) }
// Network client condiviso
single {
HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
}
}
}
}
// expect: il modulo platform-specific deve essere fornito da ogni piattaforma
expect val platformModule: Module
// androidMain/kotlin/com/example/app/di/PlatformModule.android.kt
actual val platformModule = module {
// Android Context via androidContext() helper
single<PlatformFileStorage> { AndroidFileStorage(androidContext()) }
single { DatabaseDriverFactory(androidContext()).create() }
single { AppDatabase(get()) }
}
// androidMain: inizializzazione Koin in Application class
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApplication)
modules(domainModule, dataModule, platformModule)
}
}
}
// iosMain/kotlin/com/example/app/di/PlatformModule.ios.kt
actual val platformModule = module {
single<PlatformFileStorage> { IosFileStorage() }
single { DatabaseDriverFactory().create() }
single { AppDatabase(get()) }
}
// iosMain: inizializzazione Koin (chiamata dallo Swift AppDelegate o App struct)
fun initKoin() {
startKoin {
modules(domainModule, dataModule, platformModule)
}
}
// iosApp/iOSApp.swift - Inizializzazione da Swift
import SwiftUI
import Shared
@main
struct iOSApp: App {
init() {
// Inizializza Koin all'avvio dell'app iOS
KoinKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Kotlin フローと共有される ViewModel
共有フォームのプレゼンテーション層は ViewModel と Kotlin Flow を使用して状態を公開します
Android と iOS の両方に対応します。 Kotlin フローはすべてのプラットフォームで利用できます
経由 kotlinx-coroutines-core:
// commonMain: ViewModel condiviso
class UserListViewModel(
private val getUsersUseCase: GetUsersUseCase
) : KoinComponent {
private val _state = MutableStateFlow<UserListState>(UserListState.Loading)
val state: StateFlow<UserListState> = _state.asStateFlow()
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun loadUsers() {
coroutineScope.launch {
_state.value = UserListState.Loading
try {
val users = getUsersUseCase.execute()
_state.value = UserListState.Success(users)
} catch (e: Exception) {
_state.value = UserListState.Error(e.message ?: "Errore sconosciuto")
}
}
}
fun onCleared() {
coroutineScope.cancel()
}
}
sealed class UserListState {
object Loading : UserListState()
data class Success(val users: List<User>) : UserListState()
data class Error(val message: String) : UserListState()
}
共有モジュールを分離してテストする
このアーキテクチャの主な利点の 1 つは、ドメイン コードのテスト容易性です。 レイヤーにはプラットフォーム固有の依存関係がないため、テストはエミュレーターなしで JVM で実行されます。
// commonTest: test del use case con mock dell'interfaccia
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
class GetUsersUseCaseTest {
// Mock dell'interfaccia UserRepository
private val mockRepository = object : UserRepository {
override suspend fun getUsers() = listOf(
User(1, "Federico", "federico@example.com"),
User(2, "Marco", "marco@example.com")
)
override suspend fun getUserById(id: Long) = null
override suspend fun saveUser(user: User) = user
}
private val useCase = GetUsersUseCase(mockRepository)
@Test
fun testGetUsersReturnsCorrectList() = runTest {
val result = useCase.execute()
assertEquals(2, result.size)
assertEquals("Federico", result[0].name)
}
@Test
fun testGetUsersEmpty() = runTest {
val emptyRepo = object : UserRepository {
override suspend fun getUsers() = emptyList<User>()
override suspend fun getUserById(id: Long) = null
override suspend fun saveUser(user: User) = user
}
val result = GetUsersUseCase(emptyRepo).execute()
assertEquals(0, result.size)
}
}
class UserListViewModelTest {
@Test
fun testInitialStateIsLoading() = runTest {
val viewModel = UserListViewModel(GetUsersUseCase(mockRepository))
// Prima di loadUsers(), lo stato e Loading
assertIs<UserListState.Loading>(viewModel.state.value)
}
@Test
fun testLoadUsersProducesSuccess() = runTest {
val viewModel = UserListViewModel(GetUsersUseCase(mockRepository))
viewModel.loadUsers()
// Aspetta che il coroutine completi
testScheduler.advanceUntilIdle()
val state = viewModel.state.value
assertIs<UserListState.Success>(state)
assertEquals(2, state.users.size)
}
}
KMP アーキテクチャのベスト プラクティス
- ドメイン層を純粋に保ちます。 Ktor、SQLDelight、 アンドロイドかiOS。純粋な Kotlin とインターフェイスだけです。
- プラットフォームによって異なるすべてのものにインターフェイスを使用します。 ほとんどの場合、インターフェイス + Koin を期待/実際に優先します。
- 低レベルのインフラストラクチャの予想/実際: データベースドライバー、HTTPエンジン、ファイルシステムアクセス、システムAPI。
- すべてのテストを commonTest に記述します。 JVM 上で実行されるテストは次のとおりです。 iOS シミュレータよりもはるかに高速です。シナリオ用に iOS テストを予約する 本当にネイティブ環境が必要です。
- コルーチンをプラットフォームに直接公開しないでください。 コールバックを含むラッパーを使用するか、公開に KMP NativeCoroutines/SKIE を使用します。 async/await Swift と同様に自動。
結論と次のステップ
共有モジュールのクリーンなアーキテクチャ — 純粋なドメイン層、インターフェイスを備えたデータ層、 DI のための Koin — および保守可能な KMP プロジェクトの基盤。期待/実際のメカニズム 必要な場合にのみ使用され (プラットフォーム固有のインフラストラクチャ)、インターフェイス全体で使用されます。 それ以外の場合は、テスト可能で拡張可能で、作業するチームが理解できるコードを作成します。 異なるプラットフォーム。
次の記事でさらに詳しく説明します マルチプラットフォーム ネットワーキング用の Ktor クライアント: HTTP クライアントの構成方法、JWT 認証の管理方法、バックオフによる再試行の実装方法 指数関数的に実行し、commonTest でモック サーバーを使用して API 呼び出しをテストします。
シリーズ: Kotlin マルチプラットフォーム — 1 つのコードベース、すべてのプラットフォーム
- 記事 1: 2026 年の KMP — アーキテクチャ、確立、およびエコシステム
- 記事 2: 初めての KMP プロジェクトを構成する — Android、iOS、およびデスクトップ
- 第 3 条 (本): 共有モジュール アーキテクチャ — 期待/実際、インターフェイスおよび DI
- 第 4 条: Ktor クライアントを使用したマルチプラットフォーム ネットワーキング
- 記事 5: SQLDelight によるマルチプラットフォームの永続性
- 第 6 条: マルチプラットフォームの構成 — Android と iOS での共有 UI
- 記事 7: 状態管理 KMP — ViewModel と Kotlin フロー
- 第 8 条: KMP テスト — 単体テスト、統合テスト、UI テスト
- 第 9 条: 迅速なエクスポート — iOS との慣用的な相互運用性
- 第 10 条: KMP プロジェクトの CI/CD — GitHub Actions と Fastlane
- 第 11 条: ケーススタディ — KMP を使用したフィンテック アプリの運用中







