Compose Multiplatform (CMP) memperluas Jetpack Compose ke iOS, Desktop, dan Web. Dengan CMP, satu @Composable fun TodoScreen() dapat berjalan di empat target—Android memakai runtime Compose standar, iOS dan Desktop memakai Skia untuk rendering, Web memakai Canvas atau DOM. Trade-off utama: aplikasi tidak memiliki nuansa 100% asli per platform, tetapi kode UI hanya ditulis sekali. Pada chapter ini kita akan melihat struktur proyek CMP, bagaimana satu Composable di-render oleh empat target, dan trade-off yang harus dipertimbangkan sebelum memilih CMP.
Struktur Proyek CMP #
Proyek CMP mengikuti pola KMP standar—commonMain berisi UI bersama, source set spesifik berisi entry point per platform. Composable yang sama (App()) dipanggil dari entry point berbeda: setContent { App() } di Android, ComposeUIViewController { App() } di iOS, application { Window { App() } } di Desktop.
composeApp/
├── build.gradle.kts
└── src/
├── commonMain/kotlin/
│ ├── App.kt // @Composable fun App()
│ └── TodoScreen.kt // @Composable fun TodoScreen(state)
├── androidMain/kotlin/
│ └── MainActivity.kt // setContent { App() }
├── iosMain/kotlin/
│ └── MainViewController.kt // ComposeUIViewController { App() }
├── desktopMain/kotlin/
│ └── Main.kt // application { Window { App() } }
└── jsMain/kotlin/
└── Main.kt // CanvasBasedWindow { App() }Deklarasi Plugin dan Target #
build.gradle.kts untuk CMP memerlukan plugin org.jetbrains.compose dan target yang sesuai. Plugin ini menambahkan runtime Compose dan compiler ke KMP build.
plugins {
kotlin("multiplatform") version "2.0.21"
id("org.jetbrains.compose") version "1.6.11"
compose.compiler
}
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
jvm("desktop")
js { browser() }
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
}
}Plugin compose menyediakan extension compose.runtime, compose.foundation, compose.material3 yang otomatis di-resolve ke artifact per target.
Composable Bersama di Empat Target #
Demo berikut mensimulasikan satu Composable TodoScreen(state) yang di-render oleh empat target berbeda. Pada proyek CMP nyata, Composable dijalankan oleh runtime Compose per target; di sini kita simulasikan dengan tree node pseudo-Compose.
// compose_multiplatform.kt
// Compose Multiplatform: satu UI codebase untuk Android, iOS, Desktop, Web.
// Demo: simulasi Composable hierarki + target output per platform.
// --- commonMain: model dan ViewModel ---
data class LayarState(
val judul: String,
val items: List<String>,
val tombolAktif: Boolean
)
class LayarViewModel {
private var listener: ((LayarState) -> Unit)? = null
private val items = mutableListOf<String>()
private var loading = false
fun bind(cb: (LayarState) -> Unit) {
listener = cb
emit()
}
fun tambah(item: String) {
items.add(item)
emit()
}
fun toggleLoading() {
loading = !loading
emit()
}
private fun emit() {
listener?.invoke(LayarState(
judul = "Layar (${items.size} item)",
items = items.toList(),
tombolAktif = items.isNotEmpty() && !loading
))
}
}
// --- commonMain: Composable functions (simulasi) ---
// Pada proyek CMP nyata, fungsi ini dianotasi @Composable
// dan dipanggil oleh runtime Compose per target.
data class ComposeNode(
val type: String,
val props: Map<String, String>,
val children: List<ComposeNode> = emptyList()
) {
fun render(indent: Int = 0): String {
val pad = " ".repeat(indent)
val sb = StringBuilder()
val propStr = if (props.isEmpty()) "" else props.entries.joinToString(", ") { "${it.key}=${it.value}" }
sb.appendLine("$pad$type($propStr)")
for (c in children) {
sb.append(c.render(indent + 1))
}
return sb.toString()
}
}
// Composable: Column (vertical layout)
fun Column(vararg children: ComposeNode) = ComposeNode("Column", emptyMap(), children.toList())
// Composable: Row (horizontal layout)
fun Row(vararg children: ComposeNode) = ComposeNode("Row", emptyMap(), children.toList())
// Composable: Text
fun Text(text: String) = ComposeNode("Text", mapOf("text" to "\"$text\""))
// Composable: Button
fun Button(label: String, enabled: Boolean, onClick: String) =
ComposeNode("Button", mapOf(
"label" to "\"$label\"",
"enabled" to enabled.toString(),
"onClick" to onClick
))
// Composable: CircularProgressIndicator
fun CircularProgressIndicator() = ComposeNode("CircularProgressIndicator", emptyMap())
// Top-level Composable yang sama untuk semua target
fun TodoScreen(state: LayarState): ComposeNode {
return Column(
Text(state.judul),
if (state.items.isEmpty()) {
Text("(belum ada item)")
} else {
Column(*state.items.mapIndexed { i, t ->
Row(Text("$i. $t"))
}.toTypedArray())
},
if (loadingState(state)) CircularProgressIndicator()
else Button("Tambah", state.tombolAktif, "vm.tambah()")
)
}
private fun loadingState(state: LayarState): Boolean {
// Untuk demo: loading ketika tombol tidak aktif dan items tidak kosong
return false
}
// --- Target renderer ---
data class TargetRender(val nama: String, val outputType: String, val runtime: String)
fun renderComposeTree(node: ComposeNode, target: TargetRender): String {
return buildString {
appendLine("=== ${target.nama} ===")
appendLine("Output: ${target.outputType}")
appendLine("Runtime: ${target.runtime}")
appendLine("Render tree:")
append(node.render(2))
}
}
// --- Demo ---
fun main() {
println("=== Compose Multiplatform Demo ===\n")
val targets = listOf(
TargetRender("Android", "APK", "Jetpack Compose Runtime"),
TargetRender("iOS", "Framework", "Compose UI for iOS (Skia)"),
TargetRender("Desktop", "JAR", "Compose Desktop (Skia)"),
TargetRender("Web", "JS/WASM", "Compose HTML/Canvas")
)
val vm = LayarViewModel()
val stateHolder = mutableListOf<LayarState>()
vm.bind { state -> stateHolder.add(state) }
vm.tambah("Belajar CMP")
vm.tambah("Setup proyek")
vm.tambah("Build ke 4 target")
val state = stateHolder.last()
println("State terakhir: $state")
println()
for (target in targets) {
println(renderComposeTree(TodoScreen(state), target))
println()
}
println("=== Verifikasi satu source -> empat target ===")
println("Source code: commonMain/kotlin/com/example/TodoScreen.kt")
println("Compiler : Kotlin Multiplatform + Compose Compiler Plugin")
println("Output : 4 artifact berbeda, satu UI codebase")
println()
println("Struktur proyek:")
println(" composeApp/")
println(" build.gradle.kts")
println(" src/")
println(" commonMain/kotlin/")
println(" App.kt // @Composable fun App()")
println(" TodoScreen.kt // @Composable fun TodoScreen(...)")
println(" androidMain/")
println(" MainActivity.kt // setContent { App() }")
println(" iosMain/")
println(" MainViewController.kt // ComposeUIViewController { App() }")
println(" desktopMain/")
println(" Main.kt // application { Window { App() } }")
}Output yang Dihasilkan:
=== Compose Multiplatform Demo ===
State terakhir: LayarState(judul=Layar (3 item), items=[Belajar CMP, Setup proyek, Build ke 4 target], tombolAktif=true)
=== Android ===
Output: APK
Runtime: Jetpack Compose Runtime
Render tree:
Column()
Text(text="Layar (3 item)")
Column()
Row()
Text(text="0. Belajar CMP")
Row()
Text(text="1. Setup proyek")
Row()
Text(text="2. Build ke 4 target")
Button(label="Tambah", enabled=true, onClick=vm.tambah())
=== iOS ===
Output: Framework
Runtime: Compose UI for iOS (Skia)
Render tree:
Column()
Text(text="Layar (3 item)")
Column()
Row()
Text(text="0. Belajar CMP")
Row()
Text(text="1. Setup proyek")
Row()
Text(text="2. Build ke 4 target")
Button(label="Tambah", enabled=true, onClick=vm.tambah())
=== Desktop ===
Output: JAR
Runtime: Compose Desktop (Skia)
Render tree:
Column()
Text(text="Layar (3 item)")
Column()
Row()
Text(text="0. Belajar CMP")
Row()
Text(text="1. Setup proyek")
Row()
Text(text="2. Build ke 4 target")
Button(label="Tambah", enabled=true, onClick=vm.tambah())
=== Web ===
Output: JS/WASM
Runtime: Compose HTML/Canvas
Render tree:
Column()
Text(text="Layar (3 item)")
Column()
Row()
Text(text="0. Belajar CMP")
Row()
Text(text="1. Setup proyek")
Row()
Text(text="2. Build ke 4 target")
Button(label="Tambah", enabled=true, onClick=vm.tambah())
=== Verifikasi satu source -> empat target ===
Source code: commonMain/kotlin/com/example/TodoScreen.kt
Compiler : Kotlin Multiplatform + Compose Compiler Plugin
Output : 4 artifact berbeda, satu UI codebase
Struktur proyek:
composeApp/
build.gradle.kts
src/
commonMain/kotlin/
App.kt // @Composable fun App()
TodoScreen.kt // @Composable fun TodoScreen(...)
androidMain/
MainActivity.kt // setContent { App() }
iosMain/
MainViewController.kt // ComposeUIViewController { App() }
desktopMain/
Main.kt // application { Window { App() } }Mari kita bedah bagian kunci dari kode di atas:
data class LayarState: model state untuk Composable. Immutable—Compose membandingkan instance lama vs baru untuk menentukan recomposition.class LayarViewModel: logika state dicommonMain. Pola listener di sini digantikanStateFlowdi CMP nyata. Setiap perubahan state memicuemit()yang men-notify listener.data class ComposeNode: model pseudo-Compose tree. Setiap node memiliki tipe (Column,Text,Button), props, dan children. Di CMP nyata, tree ini dipegang oleh runtime Composer.Column,Row,Text,Button,CircularProgressIndicator: lima Composable pseudo. Mereka membungkus pembuatanComposeNode. Di CMP nyata, fungsi ini dianotasi@Composabledan memakai API runtime Compose.TodoScreen(state): Composable top-level yang sama untuk semua target. Menerima state, mengembalikan tree UI. Inilah inti CMP—satu fungsi, empat runtime target.TargetRender: model untuk satu target—nama, jenis output, runtime. Empat target di demo: Android (APK + Jetpack Compose Runtime), iOS (Framework + Compose for iOS dengan Skia), Desktop (JAR + Compose Desktop dengan Skia), Web (JS/WASM + Compose HTML/Canvas).- Output menunjukkan empat render identik: tree
TodoScreen(state)yang sama dirender oleh empat target dengan struktur identik. Inilah inti CMP—satu kode UI, empat output artifact.
Trade-off Compose Multiplatform #
CMP tidak cocok untuk semua proyek. Beberapa trade-off yang harus dipertimbangkan: ukuran aplikasi yang lebih besar karena runtime Skia di iOS; nuansa UI yang konsisten lintas platform tetapi tidak 100% asli per platform; akses ke API platform native melihat masih perlu bridge (mis. camera, sensor) via expect/actual atau library seperti arkivanov/reaktive.
| Aspek | Compose Multiplatform | Native per Platform |
|---|---|---|
| Kode UI | Satu codebase | Empat codebase terpisah |
| Look & Feel | Konsisten lintas platform | 100% asli per platform |
| Ukuran aplikasi | Besar (Skia runtime ~5MB) | Ringan |
| Akses API native | Via expect/actual | Langsung |
| Stabilitas | Stabil untuk Android/iOS/Desktop, beta untuk Web | Sangat stabil |
Referensi Lanjutan #
- Compose Multiplatform — halaman GitHub Compose Multiplatform untuk UI bersama Android, iOS, Desktop, dan Web
- JetBrains KMP docs — panduan JetBrains untuk integrasi Compose Multiplatform dalam proyek KMP
Penutup #
Compose Multiplatform menawarkan janji menarik: satu Composable, empat target. Trade-off utama adalah ukuran aplikasi yang lebih besar dan nuansa yang konsisten—tidak 100% asli per platform. Tim yang lebih menghargai konsistensi lintas platform dan satu codebase akan memilih CMP; tim yang lebih menghargai nuansa asli per platform akan tetap memilih native. Chapter berikutnya akan masuk ke topik deployment: bagaimana artifact KMP dibangun dan didistribusi per target.