Architektura sdílených modulů v KMP: očekávaná/skutečná, rozhraní a vkládání závislostí s koinem
Architektura sdílených modulů v projektu Kotlin Multiplatform a rozdíl mezi nimi
udržovatelná aplikace, která se časem škáluje, a změť kódu, který se obtížně testuje
a upravit. Mechanismus expect/actual a silný, ale vyžaduje disciplínu, aby byl
správně použito: každý expect špatné umístění vytváří závislosti, které je obtížné spravovat.
Tato příručka ukazuje, jak strukturovat sdílený formulář použitím principů SOLID v kontextu multiplatformní: jak používat rozhraní k izolaci závislostí specifických pro platformu, jak konfigurovat Koin pro vkládání závislostí, které funguje na Androidu a iOS, a jak získat testovatelný kód v izolaci – klíč k robustní testovací sadě.
Co se naučíte
- Architektura vrstev pro sdílený modul: doména, data, prezentace
- očekávané/skutečné: správné vzory a antivzory, kterým je třeba se vyhnout
- Rozhraní pro izolaci závislostí specifických pro platformu
- Koin pro multiplatformní vkládání závislostí
- Testování sdíleného modulu v izolaci
Architektura vrstev sdíleného modulu
Než budeme mluvit o očekávání/skutečnosti, je důležité definovat strukturu sdíleného modulu. Čistá architektura rozděluje odpovědnosti do tří dobře definovaných vrstev, z nichž každá obsahuje jeho pravidla závislosti:
// 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 Závislostní pravidlo základní: každá vrstva může záviset pouze na vrstvách interiér. Vrstva domény neví nic o Ktor, SQLDelight nebo Android/iOS. Datová vrstva implementuje rozhraní doménové vrstvy. Prezentační vrstva využívá případy použití domény.
Správný vzorec očekávání/skutečnosti
expect/actual musí být použit pro implementací specifické pro platformu,
ne pro obchodní API. Správné místo pro expect a v částech infrastruktury
(ovladače databáze, jádro HTTP, přístup k souborovému systému), nikoli v šablonách nebo případech použití.
// 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!
Rozhraní pro izolaci závislostí
Ještě čistší vzor než expect/actual pro většinu případů
a používat rozhraní v commonMain s implementacemi specifickými pro platformu prostřednictvím Koin.
Tento přístup nevyžaduje očekávaný/skutečný a usnadňuje testování kódu pomocí simulací:
// 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 pro Multiplatformní vstřikování závislostí
Koin je framework pro vkládání závislostí nejvhodnější pro KMP: je napsán v Pure Kotlin, nepoužívá reflexi (zásadní pro Kotlin/Native na iOS) a má jednoduché API založené na DSL. Konfigurace vyžaduje společný modul a moduly specifické pro platformu:
// 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()
}
}
}
ViewModel sdílený s Kotlin Flows
Prezentační vrstva sdíleného formuláře používá k zobrazení stavu ViewModel a Kotlin Flows
reagující na Android i iOS. Kotlin Flows jsou k dispozici na všech platformách
přes 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()
}
Testování sdíleného modulu v izolaci
Jednou z hlavních výhod této architektury je testovatelnost: doménový kód vrstva nemá žádné závislosti specifické pro platformu, takže testy běží v JVM bez emulátorů:
// 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)
}
}
Nejlepší postupy pro architekturu KMP
- Udržujte doménovou vrstvu čistou: žádné závislosti na Ktor, SQLDelight, Android nebo iOS. Jen čistý Kotlin a rozhraní.
- Používejte rozhraní pro vše, co se liší podle platformy: ve většině případů preferujte rozhraní + Koin před očekáváním/skutečností.
- očekávané/skutečné pro nízkoúrovňovou infrastrukturu: Databázové ovladače, HTTP engine, přístup k souborovému systému, systémové API.
- Napište všechny testy do commonTest: testy, které běží na JVM, jsou mnohem rychlejší než ty na simulátoru iOS. Rezervujte si testy iOS pro scénáře které opravdu vyžadují přirozené prostředí.
- Nevystavujte corutiny přímo platformám: použijte obaly se zpětnými voláními nebo použijte KMP NativeCoroutines/SKIE pro vystavení automatické jako async/wait Swift.
Závěry a další kroky
Čistá architektura sdíleného modulu — čistá doménová vrstva, datová vrstva s rozhraními, Koin pro DI — a základ udržovatelného projektu KMP. Očekávaný/skutečný mechanismus používá se pouze tam, kde je to nutné (infrastruktura specifická pro platformu) a rozhraní v celém systému zbytek vytvářejí kód, který je testovatelný, rozšiřitelný a srozumitelný týmům, na kterých pracují různé platformy.
Další článek jde podrobněji Klient Ktor pro multiplatformní sítě: jak nakonfigurovat klienta HTTP, spravovat ověřování JWT, implementovat opakování s backoffem exponenciální a otestujte volání API pomocí falešného serveru v commonTest.
Série: Kotlin Multiplatform — Jedna kódová základna, všechny platformy
- Článek 1: KMP v roce 2026 — Architektura, zřízení a ekosystém
- Článek 2: Nakonfigurujte svůj první projekt KMP – Android, iOS a Desktop
- Článek 3 (tento): Architektura sdílených modulů – očekávaná/aktuální, rozhraní a DI
- Článek 4: Multiplatformní síť s klientem Ktor
- Článek 5: Multiplatformní vytrvalost s SQLDelight
- Článek 6: Compose Multiplatform – Sdílené uživatelské rozhraní pro Android a iOS
- Článek 7: State Management KMP — ViewModel a Kotlin Flows
- Článek 8: Testování KMP — Unit Test, Integration Test a UI Test
- Článek 9: Rychlý export – Idiomatic Interop s iOS
- Článek 10: CI/CD pro projekty KMP — GitHub Actions a Fastlane
- Článek 11: Případová studie — Fintech App s KMP ve výrobě







