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.

text
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.

kotlin build.gradle.kts: Compose Multiplatform
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.

kotlin
// 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:

text
=== 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:

  1. data class LayarState: model state untuk Composable. Immutable—Compose membandingkan instance lama vs baru untuk menentukan recomposition.
  2. class LayarViewModel: logika state di commonMain. Pola listener di sini digantikan StateFlow di CMP nyata. Setiap perubahan state memicu emit() yang men-notify listener.
  3. 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.
  4. Column, Row, Text, Button, CircularProgressIndicator: lima Composable pseudo. Mereka membungkus pembuatan ComposeNode. Di CMP nyata, fungsi ini dianotasi @Composable dan memakai API runtime Compose.
  5. TodoScreen(state): Composable top-level yang sama untuk semua target. Menerima state, mengembalikan tree UI. Inilah inti CMP—satu fungsi, empat runtime target.
  6. 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).
  7. 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.

AspekCompose MultiplatformNative per Platform
Kode UISatu codebaseEmpat codebase terpisah
Look & FeelKonsisten lintas platform100% asli per platform
Ukuran aplikasiBesar (Skia runtime ~5MB)Ringan
Akses API nativeVia expect/actualLangsung
StabilitasStabil untuk Android/iOS/Desktop, beta untuk WebSangat 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.