Arhitectura de module partajate în KMP: aștept/actual, interfețe și injecție de dependență cu Koin
Arhitectura modulului partajat într-un proiect Kotlin Multiplatform și diferența dintre
o aplicație care poate fi întreținută, care se extinde în timp și o mizerie de cod greu de testat
și editați. Mecanismul expect/actual și puternic, dar necesită disciplină pentru a fi
folosit corect: fiecare expect prost poziționat creează dependențe greu de gestionat.
Acest ghid arată cum să structurați formularul partajat prin aplicarea principiilor SOLID în context multiplatformă: cum să utilizați interfețele pentru a izola dependențele specifice platformei, cum să configurați Koin pentru injecția de dependență care funcționează pe Android și iOS și cum să obțineți cod testabil izolat - cheia pentru o suită robustă de teste.
Ce vei învăța
- Arhitectura layer pentru modulul partajat: domeniu, date, prezentare
- aștept/actual: modele corecte și anti-tipare de evitat
- Interfețe pentru a izola dependențele specifice platformei
- Koin pentru injecția de dependență multiplatformă
- Testarea modulului partajat în mod izolat
Arhitectura de straturi a modulului partajat
Înainte de a vorbi despre aștept/actual, este important să definiți structura modulului partajat. O arhitectură curată separă responsabilitățile în trei straturi bine definite, fiecare cu regulile sale de dependență:
// 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 Regula Dependenței fundamental: fiecare strat poate depinde doar de straturi interior. Nivelul de domeniu nu știe nimic despre Ktor, SQLDelight sau Android/iOS. Stratul de date implementează interfețele stratului de domeniu. Stratul de prezentare folosește cazurile de utilizare ale domeniului.
Modelul corect de așteptare/actuală
expect/actual trebuie folosit pentru implementari specific platformei,
nu pentru API-uri de afaceri. Locul potrivit pentru expect și în părțile de infrastructură
(drivere de baze de date, motor HTTP, acces la sistemul de fișiere), nu în șabloane sau cazuri de utilizare.
// 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!
Interfețe pentru izolarea dependențelor
Un model chiar mai curat decât expect/actual pentru majoritatea cazurilor
și utilizarea interfețe în comunMain cu implementări specifice platformei prin Koin.
Această abordare nu necesită așteptare/actuală și face codul mai ușor de testat cu false:
// 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 pentru Dependency Injection Multiplatform
Koin este cadrul de injectare a dependențelor cel mai potrivit pentru KMP: este scris în Pure Kotlin, nu folosește reflectare (esențial pentru Kotlin/Native pe iOS) și are un API simplu Bazat pe DSL. Configurarea necesită un modul comun și module specifice platformei:
// 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 Partajat cu Kotlin Flows
Stratul de prezentare al formularului partajat folosește ViewModel și Kotlin Flows pentru a expune starea
răspunde atât la Android, cât și la iOS. Kotlin Flows sunt disponibile pe toate platformele
via 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()
}
Testarea modulului partajat în izolare
Unul dintre principalele avantaje ale acestei arhitecturi este testabilitatea: codul de domeniu stratul nu are dependențe specifice platformei, așa că testele rulează în JVM fără emulatori:
// 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)
}
}
Cele mai bune practici pentru arhitectura KMP
- Păstrați stratul de domeniu pur: fără dependențe de Ktor, SQLDelight, Android sau iOS. Doar Kotlin pur și interfețe.
- Utilizați interfețe pentru tot ceea ce variază în funcție de platformă: preferă interfețele + Koin la așteptare/actuală pentru majoritatea cazurilor.
- așteptați/real pentru infrastructura de nivel scăzut: Drivere pentru baze de date, motor HTTP, acces la sistemul de fișiere, API de sistem.
- Scrieți toate testele în comunTest: testele care rulează pe JVM sunt mult mai rapid decât cei de pe simulatorul iOS. Rezervați teste iOS pentru scenarii care necesită cu adevărat mediul nativ.
- Nu expuneți corutinele direct pe platforme: utilizați wrapper-uri cu apeluri inverse sau utilizați KMP NativeCoroutines/SKIE pentru expunere automat ca asincron/așteaptă Swift.
Concluzii și pașii următori
Arhitectura curată a modulului partajat — strat de domeniu pur, strat de date cu interfețe, Koin pentru DI — și fundația unui proiect KMP care poate fi întreținut. Mecanismul așteptare/real utilizate numai acolo unde este necesar (infrastructură specifică platformei) și interfețe pe tot parcursul restul produc cod care poate fi testat, extensibil și ușor de înțeles de către echipele în care lucrează platforme diferite.
Următorul articol intră în mai multe detalii Client Ktor pentru rețea multiplatformă: cum să configurați clientul HTTP, să gestionați autentificarea JWT, să implementați reîncercarea cu backoff exponențial și testați apelurile API cu un server simulat în commonTest.
Seria: Kotlin Multiplatform — O bază de cod, toate platformele
- Articolul 1: KMP în 2026 — Arhitectură, stabilire și ecosistem
- Articolul 2: Configurați primul dvs. proiect KMP - Android, iOS și desktop
- Articolul 3 (acesta): Arhitectură de module partajate — așteptare/actuală, interfețe și DI
- Articolul 4: Rețea multiplatformă cu Ktor Client
- Articolul 5: Persistența multiplatformă cu SQLDelight
- Articolul 6: Compuneți Multiplatform – UI partajat pe Android și iOS
- Articolul 7: Managementul statului KMP — ViewModel și Kotlin Flows
- Articolul 8: Testare KMP — Test unitar, Test de integrare și Test UI
- Articolul 9: Swift Export — Idiomatic Interop cu iOS
- Articolul 10: CI/CD pentru proiecte KMP — GitHub Actions și Fastlane
- Articolul 11: Studiu de caz — Aplicație Fintech cu KMP în producție







