Membagi aplikasi QRMomen menjadi modul Kotlin Multiplatform
Struktur proyek, graph modul, convention plugin di build-logic, hierarki source set KMP, dan Metro sebagai perakit dependency QRMomen.
Di QRMomen, pengguna bergabung ke sebuah event, mengambil foto, lalu melihat foto yang terkumpul dalam galeri. Aplikasi Android, iOS, dan desktop JVM menjalankan alur itu dari kode Kotlin yang sama: Kotlin Multiplatform untuk logika dan Compose Multiplatform untuk antarmuka. Kamera, penyimpanan, dan pembayaran berbeda di setiap platform, jadi kode bersama itu dibagi ke dalam modul Gradle dengan batas yang dapat diperiksa.
Struktur direktori proyek#
Proyek ini memakai Kotlin 2.4.21 dan terdiri dari delapan belas modul Gradle, ditambah proyek Xcode untuk iOS:
app/
android/ Aplikasi Android
desktop/ Aplikasi desktop JVM
catalogue/ Katalog komponen design system
ios/ Proyek Xcode yang menautkan framework shared
shared/ Titik rakit: AppGraph, Main(graph), framework iOS
navigation/ Rute dan perpindahan layar
feature/
common/ Layar bersama (Markdown, daftar library)
onboarding/ Splash dan intro
auth/ Login, registrasi, OTP, reset password
homepage/ Beranda, dashboard, profil, gabung event, transaksi
snappycle/ Event, overview, galeri, pemilih gambar, detail gambar
core/
domain/ Model, validasi, kontrak repository
data/ Implementasi repository
network/ Ktor client, JSON, penanganan error
datastore/ Penyimpanan lokal (AppDataStore)
camera/ Kamera per platform
presentation/ Store dan pola state layar
design-system/ Komponen visual dan ikon
common/ Utilitas lintas modul
build-logic/ Convention plugin Gradle
config/ Konfigurasi detekt dan stabilitas Composesettings.gradle.kts memasukkan build-logic sebagai included build dan mengaktifkan type-safe project accessors, sehingga dependency antarmodul ditulis sebagai projects.core.domain, bukan string path.
Graph modul#
Dependency mengalir dari aplikasi ke shared, dari shared ke feature, lalu ke modul core. Diagram berikut menyederhanakan deklarasi di setiap build.gradle.kts:
flowchart TD
A[app:android, app:desktop, iOS] --> S[shared]
S --> F[feature:*]
S --> N[navigation]
F --> N
F --> P[core:presentation]
F --> DS[core:design-system]
F --> CAM[core:camera]
P --> D[core:domain]
S --> DATA[core:data]
DATA --> D
DATA --> NET[core:network]
NET --> D
NET --> ST[core:datastore]
ST --> Dcore:domain tidak bergantung pada modul lain di proyek ini. core:data mengekspos domain melalui api dan memakai network melalui implementation, sehingga pemakai repository tidak ikut melihat Ktor. Hanya shared yang mengenal semua feature, dan feature tidak saling bergantung kecuali pada feature:common.
Graph ini belum sepenuhnya bersih. Setiap feature masih mendeklarasikan core:network dan core:datastore, dan LoginViewModel menulis token langsung melalui AppDataStore. Aturan bahwa feature hanya melihat domain belum dipaksakan oleh Gradle.
Convention plugin di build-logic#
Delapan belas modul dengan target yang sama akan mengulang konfigurasi yang sama jika setiap build.gradle.kts menulisnya sendiri. build-logic berisi tiga proyek Gradle: kmp untuk konfigurasi multiplatform, Android, dan kualitas kode, jvm untuk modul JVM murni, dan icons untuk generator ikon.
| Plugin | Isi |
|---|---|
snappycle-kmp | Kotlin Multiplatform, Android KMP library, Kover, explicitApi(), toolchain, dan dependency commonTest |
snappycle-kmp-compose | snappycle-kmp ditambah Compose Multiplatform, compiler Compose, dan Roborazzi untuk screenshot test |
snappycle-ios-framework | Satu framework statis bernama shared untuk Xcode |
snappycle-kmp-coverage-gate | check gagal di bawah 80 persen line atau branch coverage dari test JVM dan Android |
snappycle-symbolcraft | Mengunduh SVG dan mengubahnya menjadi ImageVector Compose untuk design system |
Target dan versi SDK ditentukan sekali di root build.gradle.kts:
convention {
jvmToolchainVersion = 25
jvmTarget = JvmTarget.JVM_25
multiplatform {
android = true
iOS = true
jvm = true
}
android {
compileSdk = 37
targetSdk = 36
minSdk = 28
}
}Dengan begitu, build.gradle.kts sebuah modul hanya berisi plugin dan dependency-nya. core:domain, misalnya, cukup memasang snappycle-kmp, coverage gate, dan serialization. snappycle-ios-framework hanya dipasang di shared, karena framework per modul akan memberi Swift satu salinan runtime Kotlin untuk setiap framework.
Hierarki source set#
Target yang aktif adalah Android, JVM, iosArm64, dan iosSimulatorArm64. Di atas template hierarki bawaan Kotlin, MultiplatformPlugin menambahkan dua grup:
flowchart TD
C[commonMain] --> JA[jvmAndroidMain]
C --> SK[skikoMain]
C --> IOS[iosMain]
JA --> JVM[jvmMain]
JA --> AND[androidMain]
SK --> JVM
SK --> IOS
IOS --> ARM[iosArm64Main]
IOS --> SIM[iosSimulatorArm64Main]jvmAndroidMain menampung kode yang berjalan di JVM dan Android. skikoMain menampung kode untuk target yang menggambar dengan Skia, yaitu desktop dan iOS. Contohnya ModalBottomSheet.skiko.kt di design system: Android memakai implementasinya sendiri, sedangkan desktop dan iOS berbagi satu versi. Tanpa grup ini, kode yang sama harus ditulis dua kali di jvmMain dan iosMain.
Modul yang menyentuh perangkat, yaitu core:camera, core:datastore, dan shared, memiliki androidMain, iosMain, dan jvmMain sendiri. Modul domain dan data cukup dengan commonMain.
Metro sebagai perakit dependency#
Objek aplikasi dirakit oleh Metro 1.5.2, compiler plugin dependency injection yang memeriksa graph saat kompilasi. Graph-nya didefinisikan di shared:
@DependencyGraph(AppScope::class)
public interface AppGraph : ViewModelGraph {
public val exceptionManager: ExceptionManager
public val appDataStore: AppDataStore
public val commonUtils: CommonUtils
public val inAppBilling: InAppBilling
@DependencyGraph.Factory
public fun interface Factory {
public fun create(
@Provides appDataStore: AppDataStore,
@Provides commonUtils: CommonUtils,
@Provides inAppBillingFactory: InAppBillingFactory,
): AppGraph
}
}AppGraph tidak mendaftarkan repository atau ViewModel satu per satu. Modul lain menyumbangkannya ke AppScope:
NetworkBindingsdicore:networkmenyediakanJson,ExceptionManager,PublicHttpClient, danPrivateHttpClientsebagai singleton aplikasi melalui@SingleIn(AppScope::class).DataBindingsdicore:datamenyediakan tujuh repository. Repository tidak menyimpan state, jadi setiap injeksi mendapat instance baru. Endpoint publik memakai client tanpa token, sedangkan endpoint lain memakai client yang mengirim token pengguna.- Setiap ViewModel menyumbangkan dirinya ke map ViewModel:
@Inject
@ViewModelKey
@ContributesIntoMap(AppScope::class, binding = binding<ViewModel>())
public class LoginViewModel(
private val authRepository: AuthRepository,
private val dataStore: AppDataStore,
) : Store<LoginContract.UiState, LoginContract.UiAction, LoginContract.SideEffect>(
LoginContract.UiState(),
)Main(graph) memberikan graph.metroViewModelFactory melalui LocalMetroViewModelFactory, sehingga layar Compose meminta ViewModel tanpa membuat repository, HTTP client, atau penyimpanan sendiri.
Objek yang membutuhkan platform masuk melalui factory graph. Android membuat AppDataStore(context), CommonUtils(context), dan BillingManager untuk Play Billing. Desktop dan iOS memberikan UnavailableInAppBilling, dan iOS membuat graph secara lazy di MainViewController.kt sebelum membungkus Main(graph) dengan ComposeUIViewController. Kontrak billing yang sama ada di semua target, tetapi pembelian hanya berjalan di Android.
Metro menolak build jika sebuah dependency tidak tersedia, tetapi tidak tahu ViewModel mana yang seharusnya terdaftar. Untuk itu, AppGraphTest di shared/src/jvmTest membuat graph desktop dan mencocokkan sembilan belas nama ViewModel di viewModelProviders. Test yang sama memastikan factory dapat membuat ViewModel dan billing desktop mengembalikan NotAvailable.
Domain, data, dan network#
Di dalam graph itu, core:domain menyimpan model, validasi, dan kontrak repository seperti AuthRepository, yang menyatakan operasi login tanpa menyebut HTTP. AuthRepositoryImpl di core:data memanggil endpoint melalui Ktor 3.6.1, membaca response, lalu mengubahnya menjadi DomainResult. Konfigurasi client, JSON, dan penanganan error berada di core:network.
Karena LoginViewModel menerima AuthRepository melalui constructor, LoginViewModelTest dapat memberikan fake tanpa mengirim request ke backend. Fake itu tidak membuktikan endpoint dipanggil dengan benar, sehingga AuthRepositoryImplTest di core:data menguji bagian itu dengan Ktor mock client.
Pembahasan berikutnya adalah bagaimana satu layar menerima input dan mengubah state melalui Store di core:presentation.
Comments
No comments yet.