Współdzielona architektura modułów w KMP: oczekiwanie/aktualność, interfejsy i wstrzykiwanie zależności za pomocą Koina
Współdzielona architektura modułów w projekcie wieloplatformowym Kotlin i różnica pomiędzy
łatwa w utrzymaniu aplikacja, która skaluje się w czasie i bałagan w kodzie, który jest trudny do przetestowania
i edytować. Mechanizm expect/actual i potężny, ale wymaga dyscypliny
użyte poprawnie: każdy expect źle umiejscowiony tworzy zależności, którymi trudno zarządzać.
W tym przewodniku pokazano, jak strukturować udostępniony formularz, stosując zasady SOLID w kontekście wieloplatformowość: jak używać interfejsów do izolowania zależności specyficznych dla platformy, jak konfigurować Koin do wstrzykiwania zależności, który działa na Androidzie i iOS oraz jak uzyskać testowalny kod w izolacji — klucz do solidnego zestawu testów.
Czego się nauczysz
- Architektura warstwowa modułu współdzielonego: domena, dane, prezentacja
- oczekiwać/faktycznie: prawidłowe wzorce i antywzorce, których należy unikać
- Interfejsy do izolowania zależności specyficznych dla platformy
- Koin do wstrzykiwania zależności wieloplatformowych
- Testowanie udostępnionego modułu w izolacji
Architektura warstwowa modułu współdzielonego
Zanim zaczniemy mówić o oczekiwaniu/rzeczywistości, ważne jest zdefiniowanie struktury modułu współdzielonego. Czysta architektura dzieli obowiązki na trzy dobrze zdefiniowane warstwy, z których każda ma swoje zadania jego reguły zależności:
// 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 Reguła zależności fundamentalne: każda warstwa może zależeć tylko od warstw wnętrze. Warstwa domeny nie wie nic o Ktorze, SQLDelight czy Androidzie/iOS. Warstwa danych implementuje interfejsy warstwy domeny. Warstwa prezentacji wykorzystuje przypadki użycia domeny.
Prawidłowy wzorzec oczekiwań/rzeczywistości
expect/actual trzeba używać wdrożenia specyficzne dla platformy,
nie dla biznesowych interfejsów API. Właściwe miejsce dla expect oraz w części infrastrukturalnej
(sterowniki baz danych, silnik HTTP, dostęp do systemu plików), a nie w szablonach lub przypadkach użycia.
// 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!
Interfejsy do izolowania zależności
Jeszcze czystszy wzór niż expect/actual w większości przypadków
i używać interfejsy commonMain z implementacjami specyficznymi dla platformy poprzez Koin.
Takie podejście nie wymaga oczekiwań/rzeczywistości i ułatwia testowanie kodu za pomocą prób:
// 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 dla wieloplatformowego wstrzykiwania zależności
Koin to framework wstrzykiwania zależności najlepiej pasujący do KMP: jest w nim napisany Czysty Kotlin, nie używa refleksji (kluczowe dla Kotlina/Native na iOS) i ma prosty interfejs API Oparty na DSL. Konfiguracja wymaga wspólnego modułu i modułów specyficznych dla platformy:
// 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 udostępniony Kotlin Flows
Warstwa prezentacji udostępnionego formularza używa ViewModel i Kotlin Flows do eksponowania stanu
reaguje zarówno na Androida, jak i iOS. Kotlin Flows są dostępne na wszystkich platformach
przez 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()
}
Testowanie modułu współdzielonego w izolacji
Jedną z głównych zalet tej architektury jest testowalność: kod domeny warstwa nie ma zależności specyficznych dla platformy, więc testy uruchamiają się w JVM bez emulatorów:
// 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)
}
}
Najlepsze praktyki dotyczące architektury KMP
- Zachowaj czystość warstwy domeny: brak zależności od Ktora, SQLDelight, Androida lub iOS. Tylko czysty Kotlin i interfejsy.
- Używaj interfejsów do wszystkiego, co różni się w zależności od platformy: preferuj interfejsy + Koin, aby oczekiwać/rzeczywistego w większości przypadków.
- oczekiwane/faktyczne dla infrastruktury niskiego poziomu: Sterowniki bazy danych, silnik HTTP, dostęp do systemu plików, systemowe API.
- Zapisz wszystkie testy w commonTest: testy działające na JVM to znacznie szybciej niż te na symulatorze iOS. Zarezerwuj testy iOS dla scenariuszy które naprawdę wymagają środowiska natywnego.
- Nie wystawiaj współprogramów bezpośrednio na platformy: użyj opakowań z wywołaniami zwrotnymi lub użyj KMP NativeCoroutines/SKIE do ekspozycji automatyczny jak asynchroniczny/await Swift.
Wnioski i dalsze kroki
Czysta architektura modułu współdzielonego — czysta warstwa domenowa, warstwa danych z interfejsami, Koin dla DI — i podstawa łatwego w utrzymaniu projektu KMP. Mechanizm oczekiwań/rzeczywistości używane tylko tam, gdzie jest to konieczne (infrastruktura specyficzna dla platformy) i interfejsy w całym resztę tworzą kod, który jest testowalny, rozszerzalny i zrozumiały dla zespołów, nad którymi pracują różne platformy.
Następny artykuł zawiera bardziej szczegółowe informacje Klient Ktor dla sieci wieloplatformowych: jak skonfigurować klienta HTTP, zarządzać uwierzytelnianiem JWT, wdrożyć ponawianie prób z wycofywaniem wykładniczy i przetestuj wywołania API za pomocą próbnego serwera w commonTest.
Seria: Kotlin Multiplatform — jedna baza kodu, wszystkie platformy
- Artykuł 1: KMP w 2026 r. — Architektura, establishment i ekosystem
- Artykuł 2: Skonfiguruj swój pierwszy projekt KMP — Android, iOS i komputer stacjonarny
- Artykuł 3 (ten): Współdzielona architektura modułów — oczekiwana/rzeczywista, interfejsy i DI
- Artykuł 4: Sieć wieloplatformowa z klientem Ktor
- Artykuł 5: Trwałość wieloplatformowa z SQLDelight
- Artykuł 6: Twórz na wielu platformach — wspólny interfejs użytkownika na Androidzie i iOS
- Artykuł 7: Zarządzanie stanem KMP — ViewModel i Kotlin Flows
- Artykuł 8: Testowanie KMP — test jednostkowy, test integracyjny i test interfejsu użytkownika
- Artykuł 9: Swift Export — Idiomatyczna współpraca z iOS
- Artykuł 10: CI/CD dla projektów KMP — GitHub Actions i Fastlane
- Artykuł 11: Studium przypadku — aplikacja Fintech z KMP w produkcji







