Logika bisnis sudah berbagi melalui commonMain. Pertanyaan berikutnya: bagaimana dengan UI? Tiga pendekatan umum dipakai di proyek KMP. Pertama, UI sepenuhnya native—SwiftUI di iOS, Jetpack Compose di Android, JavaFX di JVM. Kedua, ViewModel bersama dengan UI native—logika state di commonMain, rendering tetap per platform. Ketiga, Compose Multiplatform—UI Compose ditulis sekali untuk Android, iOS, Desktop, dan Web. Pada chapter ini kita akan membandingkan ketiga pendekatan, melihat demo ViewModel bersama yang di-render oleh empat toolkit berbeda, dan memahami trade-off masing-masing.
Tiga Strategi UI #
Setiap strategi memiliki trade-off antara berbagi kode dan nuansa native. Native memberikan nuansa 100% asli per platform tetapi tidak ada kode UI yang dibagi. Shared ViewModel berbagi logika state tetapi tetap menulis UI per platform. Compose Multiplatform berbagi UI penuh tetapi dengan nuansa yang konsisten lintas platform—tidak 100% asli.
| Strategi | Kode yang dibagi | Look & Feel | Tim yang cocok |
|---|---|---|---|
| Native | Tidak ada | 100% native per platform | Tim kecil, prioritas nuansa asli |
| Shared ViewModel | Logika state | 100% native per platform | Tim dengan UI designer per platform |
| Compose Multiplatform | UI + ViewModel | Konsisten lintas platform | Tim yang ingin satu codebase UI |
Pilihan strategi tidak harus seragam—beberapa layar dapat pakai native, layar lain pakai Compose Multiplatform. Yang penting konsisten dalam satu layar agar maintenance mudah.
Shared ViewModel: Pola Paling Umum #
Strategi paling umum di proyek KMP adalah berbagi ViewModel dan state, sementara UI ditulis per platform. ViewModel di commonMain memakai kotlinx.coroutines.flow untuk emit state; UI di Android (Compose) dan iOS (SwiftUI) berlangganan ke flow tersebut.
class TodoViewModel {
private val _state = MutableStateFlow(TodoState())
val state: StateFlow<TodoState> = _state
fun tambahTugas(judul: String) {
_state.update { it.copy(daftar = it.daftar + judul) }
}
}@Composable
fun TodoScreen(vm: TodoViewModel) {
val state by vm.state.collectAsState()
Column {
Text(state.judul)
state.daftar.forEach { tugas -> Text(tugas) }
}
}class TodoObserver: ObservableObject {
let vm = TodoViewModel()
@Published var state: TodoState = TodoState()
init() {
vm.state.collect { snapshot in
DispatchQueue.main.async {
self.state = snapshot
}
}
}
}ViewModel tidak peduli toolkit UI—ia hanya mengelola state. UI platform berlangganan dan merender.
Demo Empat Binding UI #
Demo berikut mensimulasikan satu ViewModel yang sama di-render oleh empat binding berbeda—Compose, SwiftUI, JavaFX, dan Compose Multiplatform. Pola yang ditampilkan—state berubah, listener di-notify, binding merender—adalah pola yang sama dipakai di ViewModel + StateFlow di KMP nyata.
// multiplatform_ui_overview.kt
// Opsi UI di KMP: native (SwiftUI/Compose), shared ViewModel, Compose Multiplatform.
// Demo: simulasi pola UI dengan ViewModel bersama + 3 binding platform.
// --- commonMain: model dan ViewModel bersama ---
data class StatusLayar(val judul: String, val daftar: List<String>, val isLoading: Boolean)
class TodoViewModel {
private val _state = mutableListOf<String>()
private var listener: ((StatusLayar) -> Unit)? = null
fun setObserver(cb: (StatusLayar) -> Unit) {
listener = cb
emitState()
}
fun tambahTugas(judul: String) {
if (judul.isBlank()) return
_state.add(judul)
emitState()
}
fun hapusTugas(index: Int) {
if (index < 0 || index >= _state.size) return
_state.removeAt(index)
emitState()
}
fun toggleLoading() {
emitState(loading = true)
// Simulasi delay singkat - di kode nyata pakai coroutine
emitState(loading = false)
}
private fun emitState(loading: Boolean = false) {
listener?.invoke(StatusLayar(
judul = "Daftar Tugas (${_state.size})",
daftar = _state.toList(),
isLoading = loading
))
}
}
// --- Platform: binding UI ---
// Pada Android (Compose): ViewModel dipanggil dari Composable
// Pada iOS (SwiftUI): ViewModel dipanggil dari ObservableObject
// Pada JVM (Swing/JavaFX): ViewModel dipanggil dari controller
interface UiBinding {
val nama: String
fun render(state: StatusLayar)
}
class ComposeBinding : UiBinding {
override val nama = "Jetpack Compose (Android)"
override fun render(state: StatusLayar) {
println(" [Compose] @Composable")
println(" Text(\"${state.judul}\")")
if (state.isLoading) println(" CircularProgressIndicator()")
else state.daftar.forEachIndexed { i, t ->
println(" Row { Text(\" $i. $t\") IconButton { hapus($i) } }")
}
}
}
class SwiftUiBinding : UiBinding {
override val nama = "SwiftUI (iOS)"
override fun render(state: StatusLayar) {
println(" [SwiftUI] struct TodoView: View")
println(" Text(\"\\(state.judul)\")")
if (state.isLoading) println(" ProgressView()")
else state.daftar.forEachIndexed { i, t ->
println(" HStack { Text(\" $i. $t\"); Button(\"hapus\") { hapus($i) } }")
}
}
}
class JavaFxBinding : UiBinding {
override val nama = "JavaFX (JVM Desktop)"
override fun render(state: StatusLayar) {
println(" [JavaFX] VBox")
println(" Label(\"${state.judul}\")")
if (state.isLoading) println(" ProgressIndicator()")
else state.daftar.forEachIndexed { i, t ->
println(" HBox: Label(\" $i. $t\"), Button(\"hapus\").setOnAction { hapus($i) }")
}
}
}
class ComposeMultiplatformBinding : UiBinding {
override val nama = "Compose Multiplatform (Android + iOS + Desktop)"
override fun render(state: StatusLayar) {
println(" [Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)")
println(" Text(\"${state.judul}\")")
if (state.isLoading) println(" CircularProgressIndicator()")
else state.daftar.forEachIndexed { i, t ->
println(" Row { Text(\" $i. $t\"); IconButton { vm.hapusTugas($i) } }")
}
}
}
// --- Demo ---
fun main() {
println("=== Multiplatform UI Overview ===\n")
println("Pendekatan UI yang berbeda dengan ViewModel yang sama:\n")
val bindings = listOf(
ComposeBinding(),
SwiftUiBinding(),
JavaFxBinding(),
ComposeMultiplatformBinding()
)
for (binding in bindings) {
println("--- Binding: ${binding.nama} ---")
val vm = TodoViewModel()
vm.setObserver { state -> binding.render(state) }
vm.tambahTugas("Belajar KMP")
vm.tambahTugas("Setup Gradle")
vm.hapusTugas(0)
println()
}
println("=== Perbandingan strategi UI ===")
println()
println("Strategi | Kode UI yang dibagi | Look & Feel")
println("------------------------|---------------------|-------------")
println("Native (1) | Tidak | 100% native")
println("Shared ViewModel (2) | ViewModel saja | 100% native")
println("Compose Multiplatform (3)| UI + ViewModel | Konsisten lintas platform")
println()
println("(1) Tulis UI lengkap per platform (SwiftUI + Compose + JavaFX)")
println("(2) Bagikan ViewModel/logika; tulis UI per platform")
println("(3) Bagikan UI Compose untuk Android, iOS, Desktop (beta), Web")
}Output yang Dihasilkan:
=== Multiplatform UI Overview ===
Pendekatan UI yang berbeda dengan ViewModel yang sama:
--- Binding: Jetpack Compose (Android) ---
[Compose] @Composable
Text("Daftar Tugas (0)")
[Compose] @Composable
Text("Daftar Tugas (1)")
Row { Text(" 0. Belajar KMP") IconButton { hapus(0) } }
[Compose] @Composable
Text("Daftar Tugas (2)")
Row { Text(" 0. Belajar KMP") IconButton { hapus(0) } }
Row { Text(" 1. Setup Gradle") IconButton { hapus(1) } }
[Compose] @Composable
Text("Daftar Tugas (1)")
Row { Text(" 0. Setup Gradle") IconButton { hapus(0) } }
--- Binding: SwiftUI (iOS) ---
[SwiftUI] struct TodoView: View
Text("\(state.judul)")
[SwiftUI] struct TodoView: View
Text("\(state.judul)")
HStack { Text(" 0. Belajar KMP"); Button("hapus") { hapus(0) } }
[SwiftUI] struct TodoView: View
Text("\(state.judul)")
HStack { Text(" 0. Belajar KMP"); Button("hapus") { hapus(0) } }
HStack { Text(" 1. Setup Gradle"); Button("hapus") { hapus(1) } }
[SwiftUI] struct TodoView: View
Text("\(state.judul)")
HStack { Text(" 0. Setup Gradle"); Button("hapus") { hapus(0) } }
--- Binding: JavaFX (JVM Desktop) ---
[JavaFX] VBox
Label("Daftar Tugas (0)")
[JavaFX] VBox
Label("Daftar Tugas (1)")
HBox: Label(" 0. Belajar KMP"), Button("hapus").setOnAction { hapus(0) }
[JavaFX] VBox
Label("Daftar Tugas (2)")
HBox: Label(" 0. Belajar KMP"), Button("hapus").setOnAction { hapus(0) }
HBox: Label(" 1. Setup Gradle"), Button("hapus").setOnAction { hapus(1) }
[JavaFX] VBox
Label("Daftar Tugas (1)")
HBox: Label(" 0. Setup Gradle"), Button("hapus").setOnAction { hapus(0) }
--- Binding: Compose Multiplatform (Android + iOS + Desktop) ---
[Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)
Text("Daftar Tugas (0)")
[Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)
Text("Daftar Tugas (1)")
Row { Text(" 0. Belajar KMP"); IconButton { vm.hapusTugas(0) } }
[Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)
Text("Daftar Tugas (2)")
Row { Text(" 0. Belajar KMP"); IconButton { vm.hapusTugas(0) } }
Row { Text(" 1. Setup Gradle"); IconButton { vm.hapusTugas(1) } }
[Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)
Text("Daftar Tugas (1)")
Row { Text(" 0. Setup Gradle"); IconButton { vm.hapusTugas(0) } }
=== Perbandingan strategi UI ===
Strategi | Kode UI yang dibagi | Look & Feel
------------------------|---------------------|-------------
Native (1) | Tidak | 100% native
Shared ViewModel (2) | ViewModel saja | 100% native
Compose Multiplatform (3)| UI + ViewModel | Konsisten lintas platform
(1) Tulis UI lengkap per platform (SwiftUI + Compose + JavaFX)
(2) Bagikan ViewModel/logika; tulis UI per platform
(3) Bagikan UI Compose untuk Android, iOS, Desktop (beta), WebMari kita bedah bagian kunci dari kode di atas:
data class StatusLayar: model state dicommonMain. Berisijudul,daftar,isLoading. Immutable sehingga aman dipassing ke UI.class TodoViewModel: logika state dicommonMain. Memakai listener callback(StatusLayar) -> Unit—di proyek nyata digantikanStateFlow. MethodtambahTugas,hapusTugas,toggleLoadingadalah operasi bisnis.interface UiBinding: abstraksi rendering dicommonMain. Hanya satu methodrender(state). Binding platform mengimplementasikan interface ini dengan toolkitnya masing-masing.ComposeBinding,SwiftUiBinding,JavaFxBinding,ComposeMultiplatformBinding: empat implementasi yang menampilkan struktur UI yang akan di-generate oleh toolkit masing-masing. Output adalah pseudo-code—bukan kode Compose/SwiftUI sebenarnya—tetapi memperlihatkan perbedaan toolkit.main()loop empat binding: ViewModel yang sama (TodoViewModel) dihubungkan ke empat binding berbeda. Setiap kalitambahTugasdipanggil, listener di-notify, dan binding yang aktif merender. Struktur state identik—hanya cara rendering yang berbeda.- Output menunjukkan empat toolkit: setiap binding menampilkan struktur UI yang sedikit berbeda—
@Composablevsstruct ViewvsVBox—tetapi semua merender state yang sama.
Compose Multiplatform: UI Lengkap Bersama #
Compose Multiplatform (CMP) adalah perluasan Jetpack Compose untuk berjalan di iOS, Desktop, dan Web. Dengan CMP, kita menulis satu @Composable fun TodoScreen() yang berjalan di Android, iOS, Desktop—tidak ada kode UI yang diduplikasi.
@Composable
fun TodoScreen(vm: TodoViewModel) {
val state by vm.state.collectAsState()
Column {
Text(state.judul)
for ((i, tugas) in state.daftar.withIndex()) {
Row {
Text(tugas)
Button(onClick = { vm.hapusTugas(i) }) { Text("Hapus") }
}
}
}
}Kode Compose ini di-compile ke bytecode JVM untuk Desktop, kode native iOS untuk iOS, dan kode IR untuk Web. Satu file .kt, empat target.
Referensi Lanjutan #
- Compose Multiplatform — halaman GitHub Compose Multiplatform untuk UI deklaratif lintas platform
Penutup #
Tiga strategi UI—native, shared ViewModel, Compose Multiplatform—memberi tim fleksibilitas. Tim kecil yang ingin nuansa asli memilih native atau shared ViewModel; tim yang ingin satu codebase memilih CMP. Chapter berikutnya akan masuk lebih dalam ke Compose Multiplatform: cara setup, target yang didukung, dan trade-off yang harus dipertimbangkan.