KMP'de Paylaşılan Modül Mimarisi: beklenen/gerçek, Arayüzler ve Koin ile Bağımlılık Enjeksiyonu
Kotlin Multiplatform projesinde paylaşımlı modül mimarisi ve aradaki farklar
zamanla ölçeklenen, bakımı yapılabilir bir uygulama ve test edilmesi zor bir kod karmaşası
ve düzenleyin. Mekanizma expect/actual ve güçlü ama disiplin gerektirir
doğru kullanıldığında: her expect Kötü konumlandırılmışlık, yönetilmesi zor bağımlılıklar yaratır.
Bu kılavuz, SOLID ilkelerini bağlam içinde uygulayarak paylaşılan formun nasıl yapılandırılacağını gösterir çoklu platform: platforma özgü bağımlılıkları izole etmek için arayüzlerin nasıl kullanılacağı, nasıl yapılandırılacağı Android ve iOS'ta çalışan bağımlılık enjeksiyonu için Koin ve test edilebilir kodun nasıl alınacağı tek başına - sağlam bir test paketinin anahtarı.
Ne Öğreneceksiniz
- Paylaşılan modül için katman mimarisi: etki alanı, veri, sunum
- beklenen/gerçek: kaçınılması gereken kalıpları ve anti kalıpları düzeltin
- Platforma özgü bağımlılıkları izole etmeye yönelik arayüzler
- Çoklu platform bağımlılığı enjeksiyonu için Koin
- Paylaşılan modülü ayrı ayrı test etme
Paylaşılan Modülün Katman Mimarisi
Beklenti/gerçek hakkında konuşmadan önce, paylaşılan modülün yapısını tanımlamak önemlidir. Temiz bir mimari, sorumlulukları her biri iyi tanımlanmış üç katmana ayırır. bağımlılık kuralları:
// 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 Bağımlılık Kuralı temel: her katman yalnızca katmanlara bağlı olabilir iç. Etki alanı katmanı Ktor, SQLDelight veya Android/iOS hakkında hiçbir şey bilmiyor. Veri katmanı etki alanı katmanı arayüzlerini uygular. Sunum katmanı, alanın kullanım durumlarını kullanır.
Beklenti/gerçekleşmenin Doğru Modeli
expect/actual için kullanılmalıdır uygulamalar platforma özgü,
iş API'leri için değil. için doğru yer expect ve altyapı kısımlarında
(veritabanı sürücüleri, HTTP motoru, dosya sistemi erişimi), şablonlarda veya kullanım durumlarında değil.
// 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!
Bağımlılıkları Yalıtmak için Arayüzler
Bundan daha temiz bir desen expect/actual çoğu durumda
ve kullan Koin aracılığıyla platforma özel uygulamalarla commonMain'deki arayüzler.
Bu yaklaşım, beklenen/gerçek olanı gerektirmez ve kodun taklitlerle test edilmesini kolaylaştırır:
// 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
}
Bağımlılık Enjeksiyonu Çoklu Platformu için Koin
madeni para KMP'ye en uygun bağımlılık enjeksiyon çerçevesi hangisidir: Pure Kotlin, yansıma kullanmaz (iOS'ta Kotlin/Native için çok önemlidir) ve basit bir API'ye sahiptir DSL tabanlı. Yapılandırma ortak bir modül ve platforma özel modüller gerektirir:
// 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 Kotlin Flows ile Paylaşıldı
Paylaşılan formun sunum katmanı, durumu ortaya çıkarmak için ViewModel ve Kotlin Flows'u kullanır
hem Android hem de iOS'a duyarlı. Kotlin Flows tüm platformlarda mevcuttur
aracılığıyla 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()
}
Paylaşılan Modülü Yalıtımda Test Etme
Bu mimarinin ana avantajlarından biri test edilebilirliktir: alan kodu katmanın platforma özgü bağımlılıkları yoktur, bu nedenle testler JVM'de öykünücüler olmadan çalışır:
// 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 Mimarisi için En İyi Uygulamalar
- Etki alanı katmanını saf tutun: Ktor, SQLDelight'a bağımlılık yok, Android veya iOS. Sadece saf Kotlin ve arayüzler.
- Platforma göre değişen her şey için arayüzleri kullanın: çoğu durumda beklenen/gerçek arayüzleri + Koin'i tercih edin.
- Düşük seviyeli altyapı için beklenen/gerçek: Veritabanı sürücüleri, HTTP motoru, dosya sistemi erişimi, sistem API'si.
- Tüm testleri commonTest'e yazın: JVM'de çalışan testler iOS simülatöründekilerden çok daha hızlı. Senaryolar için iOS testlerini ayırın bu gerçekten yerel ortamı gerektirir.
- Eşyordamları doğrudan platformlara maruz bırakmayın: geri aramalı sarmalayıcılar kullanın veya teşhir için KMP NativeCoroutines/SKIE kullanın async/beklemede Swift gibi otomatik.
Sonuçlar ve Sonraki Adımlar
Paylaşılan modülün temiz mimarisi — saf etki alanı katmanı, arayüzlere sahip veri katmanı, DI için Koin — ve sürdürülebilir bir KMP projesinin temeli. Beklenti/gerçek mekanizma yalnızca gerekli olduğu yerde (platforma özel altyapı) ve arayüzler boyunca kullanılır geri kalanı üzerinde çalıştıkları ekipler tarafından test edilebilir, genişletilebilir ve anlaşılabilir kodlar üretirler farklı platformlar.
Bir sonraki makale daha ayrıntılı olarak ele alınacaktır Çok platformlu ağ iletişimi için Ktor İstemcisi: HTTP istemcisinin nasıl yapılandırılacağı, JWT kimlik doğrulamasının nasıl yönetileceği, geri alma ile yeniden denemenin nasıl uygulanacağı üstel ve API çağrılarını commonTest'teki sahte bir sunucuyla test edin.
Seri: Kotlin Multiplatform — Tek Kod Tabanı, Tüm Platformlar
- Madde 1: 2026'da KMP - Mimarlık, Kuruluş ve Ekosistem
- 2. Makale: İlk KMP Projenizi Yapılandırın - Android, iOS ve Masaüstü
- Madde 3 (bu): Paylaşılan Modül Mimarisi — beklenen/gerçek, Arayüzler ve DI
- Madde 4: Ktor İstemcisi ile Çoklu Platform Ağ İletişimi
- Makale 5: SQLDelight ile Çoklu Platform Kalıcılığı
- Madde 6: Çoklu Platform Oluşturma — Android ve iOS'ta Paylaşılan Kullanıcı Arayüzü
- Madde 7: Durum Yönetimi KMP — ViewModel ve Kotlin Flows
- Madde 8: KMP Testi - Birim Testi, Entegrasyon Testi ve UI Testi
- Madde 9: Swift Export — iOS ile Deyimsel Birlikte Çalışma
- Madde 10: KMP Projeleri için CI/CD — GitHub Eylemleri ve Fastlane
- Madde 11: Örnek Olay İncelemesi - Üretimde KMP'li Fintech Uygulaması







