Tidak semua kode dapat berbagi lintas platform. Akses filesystem, panggilan jaringan, thread pool, dan logging memiliki API yang berbeda total antara JVM, Android, dan iOS. Tantangannya bukan menulis kode platform-specific—itu mudah—melainkan menentukan kapan sebuah fungsi harus pindah ke source set spesifik, dan bagaimana menjaga kode bisnis tetap bersih. Pada chapter ini kita akan melihat empat kategori umum kode platform-specific, pola injeksi interface yang menjadi alternatif expect/actual, dan demonstrasi tiga implementasi platform yang berbeda melayani satu logika bisnis tunggal.

Empat Kategori Kode Platform-Specific #

Kode yang biasanya perlu dipindah ke source set spesifik dapat dikelompokkan menjadi empat kategori. File I/O misalnya—JVM memakai java.io.File, Android memakai Context.openFileOutput, iOS memakai NSFileManager. Network—JVM memakai HttpURLConnection atau OkHttp, Android memakai OkHttp, iOS memakai NSURLSession. Threading—JVM memakai ExecutorService, Android memakai Handler atau Dispatchers.Main, iOS memakai DispatchQueue.main. Logging—JVM memakai java.util.logging, Android memakai android.util.Log, iOS memakai os_log.

KategoriJVMAndroidiOS
File I/Ojava.io.File, PathContext.openFileOutputNSFileManager
NetworkHttpURLConnection, OkHttpOkHttp, VolleyNSURLSession, Darwin
ThreadingExecutorServiceDispatchers.Main, HandlerDispatchQueue.main
Loggingjava.util.loggingandroid.util.Logos_log

Empat kategori ini tidak dapat dipakai langsung di commonMain. Solusinya: deklarasikan interface atau expect di commonMain, lalu berikan implementasi per platform.

Pola Injeksi Interface sebagai Alternatif expect/actual #

expect/actual cocok untuk fungsi kecil yang berdiri sendiri. Untuk komponen yang lebih kompleks—misalnya penyimpanan yang dipakai banyak class—pola injeksi interface sering lebih fleksibel. Kita mendefinisikan interface di commonMain, lalu menyuntikkan implementasi platform-specific melalui constructor.

Keuntungan pola ini: mudah diuji karena implementasi bisa ditukar dengan mock; tidak bergantung pada keyword expect/actual yang hanya tersedia di plugin KMP; dan dapat dipakai di proyek non-KMP. Kerugian: boilerplate sedikit lebih banyak—kita perlu menyuntikkan dependency ke setiap class yang membutuhkannya.

Simulasi Implementasi Platform #

Karena tutorial ini berjalan di JVM, kita mensimulasikan tiga platform dengan tiga class implementasi. Pola yang ditampilkan—Penyimpanan, PemutusLog, PabrikUuid sebagai interface, dengan tiga implementasi—adalah pola yang sama dipakai di proyek KMP nyata.

kotlin
// platform_specific_code.kt
// Kapan menulis kode platform-specific: file I/O, network, threading, logging.
// Demo: logika umum di commonMain, implementasi platform via interface
// (sama seperti expect/actual tetapi memakai dependency injection).

// --- commonMain: kontrak interface ---
interface Penyimpanan {
    fun simpan(kunci: String, nilai: String): Boolean
    fun baca(kunci: String): String?
}

interface PemutusLog {
    fun info(pesan: String)
    fun error(pesan: String)
}

interface PabrikUuid {
    fun baru(): String
}

// --- commonMain: logika bisnis yang memakai kontrak ---
class CatatanPengguna(
    private val penyimpanan: Penyimpanan,
    private val log: PemutusLog,
    private val uuid: PabrikUuid
) {
    fun buatCatatan(pengguna: String, isi: String): String {
        require(pengguna.isNotBlank()) { "pengguna wajib" }
        require(isi.length <= 280) { "isi maksimal 280 karakter" }

        val id = uuid.baru()
        val ok = penyimpanan.simpan("catatan:$id", "$pengguna|$isi")
        if (ok) {
            log.info("Catatan $id dibuat oleh $pengguna")
        } else {
            log.error("Gagal menyimpan catatan $id untuk $pengguna")
        }
        return id
    }

    fun bacaCatatan(id: String): String? {
        val raw = penyimpanan.baca("catatan:$id")
        if (raw == null) {
            log.error("Catatan $id tidak ditemukan")
            return null
        }
        val (pengguna, isi) = raw.split("|", limit = 2)
        log.info("Catatan $id dibaca oleh $pengguna")
        return isi
    }
}

// --- Platform: JVM (memakai java.io, java.util.logging) ---
class PenyimpananJvm(private val dir: String) : Penyimpanan {
    override fun simpan(kunci: String, nilai: String): Boolean = try {
        java.io.File(dir, "$kunci.txt").writeText(nilai)
        true
    } catch (e: Exception) {
        false
    }

    override fun baca(kunci: String): String? = try {
        val f = java.io.File(dir, "$kunci.txt")
        if (f.exists()) f.readText() else null
    } catch (e: Exception) {
        null
    }
}

class LogJvm : PemutusLog {
    override fun info(pesan: String) = println("[JVM/INFO] $pesan")
    override fun error(pesan: String) = println("[JVM/ERROR] $pesan")
}

class UuidJvm : PabrikUuid {
    private var counter = 0
    override fun baru(): String = "jvm-000${counter++}"
}

// --- Platform: Android (simulasi) ---
class PenyimpananAndroid(private val dir: String) : Penyimpanan {
    // Di Android nyata: Context.openFileOutput, SharedPreferences
    private val map = mutableMapOf<String, String>()
    override fun simpan(kunci: String, nilai: String): Boolean {
        map[kunci] = nilai
        return true
    }
    override fun baca(kunci: String): String? = map[kunci]
}

class LogAndroid : PemutusLog {
    override fun info(pesan: String) = println("[Android/INFO] $pesan")
    override fun error(pesan: String) = println("[Android/ERROR] $pesan")
}

class UuidAndroid : PabrikUuid {
    private var counter = 0
    override fun baru(): String = "android-${counter++}"
}

// --- Platform: iOS (simulasi) ---
class PenyimpananIos(private val dir: String) : Penyimpanan {
    // Di iOS nyata: NSFileManager + NSUserDefaults
    private val map = mutableMapOf<String, String>()
    override fun simpan(kunci: String, nilai: String): Boolean {
        map[kunci] = nilai
        return true
    }
    override fun baca(kunci: String): String? = map[kunci]
}

class LogIos : PemutusLog {
    override fun info(pesan: String) = println("[iOS/INFO] $pesan")
    override fun error(pesan: String) = println("[iOS/ERROR] $pesan")
}

class UuidIos : PabrikUuid {
    private var counter = 0
    override fun baru(): String = "ios-${counter++}"
}

// --- Provider per platform ---
data class PlatformBundle(
    val nama: String,
    val penyimpanan: Penyimpanan,
    val log: PemutusLog,
    val uuid: PabrikUuid
)

fun main() {
    val tmpDir = "/tmp/kmp-demo-platform"
    java.io.File(tmpDir).mkdirs()

    val bundles = listOf(
        PlatformBundle("JVM",
            PenyimpananJvm(tmpDir),
            LogJvm(),
            UuidJvm()),
        PlatformBundle("Android",
            PenyimpananAndroid(tmpDir),
            LogAndroid(),
            UuidAndroid()),
        PlatformBundle("iOS",
            PenyimpananIos(tmpDir),
            LogIos(),
            UuidIos())
    )

    for (bundle in bundles) {
        println("=== Platform: ${bundle.nama} ===")
        val catatan = CatatanPengguna(bundle.penyimpanan, bundle.log, bundle.uuid)
        val id1 = catatan.buatCatatan("budi", "Belajar KMP hari ini")
        val id2 = catatan.buatCatatan("siti", "Rapat jam 10 pagi")
        println("Baca $id1: ${catatan.bacaCatatan(id1)}")
        println("Baca catatan-tidak-ada: ${catatan.bacaCatatan("catatan:tidak-ada")}")
        println()
    }

    java.io.File(tmpDir).deleteRecursively()
}

Output yang Dihasilkan:

text
=== Platform: JVM ===
[JVM/INFO] Catatan jvm-0000 dibuat oleh budi
[JVM/INFO] Catatan jvm-0001 dibuat oleh siti
[JVM/INFO] Catatan jvm-0000 dibaca oleh budi
Baca jvm-0000: Belajar KMP hari ini
[JVM/ERROR] Catatan catatan:tidak-ada tidak ditemukan
Baca catatan-tidak-ada: null

=== Platform: Android ===
[Android/INFO] Catatan android-0 dibuat oleh budi
[Android/INFO] Catatan android-1 dibuat oleh siti
[Android/INFO] Catatan android-0 dibaca oleh budi
Baca android-0: Belajar KMP hari ini
[Android/ERROR] Catatan catatan:tidak-ada tidak ditemukan
Baca catatan-tidak-ada: null

=== Platform: iOS ===
[iOS/INFO] Catatan ios-0 dibuat oleh budi
[iOS/INFO] Catatan ios-1 dibuat oleh siti
[iOS/INFO] Catatan ios-0 dibaca oleh budi
Baca ios-0: Belajar KMP hari ini
[iOS/ERROR] Catatan catatan:tidak-ada tidak ditemukan
Baca catatan-tidak-ada: null

Mari kita bedah bagian kunci dari kode di atas:

  1. interface Penyimpanan, PemutusLog, PabrikUuid: tiga kontrak di commonMain. Logika bisnis CatatanPengguna hanya bergantung pada ketiga interface ini, bukan pada API platform mana pun. Inilah inti pemisahan kode bisnis dari kode platform.
  2. class CatatanPengguna: kode bisnis bersama—validasi via require, generate UUID, simpan via Penyimpanan.simpan(), log via PemutusLog.info(). Seluruh kode ini berada di commonMain dan tidak peduli platform.
  3. PenyimpananJvm: implementasi JVM yang memakai java.io.File untuk menulis dan membaca file. Pada proyek KMP nyata, class ini berada di jvmMain. Untuk Android dan iOS, kita simulasikan dengan MutableMap karena JVM tidak memiliki Context atau NSFileManager.
  4. LogJvm, LogAndroid, LogIos: tiga implementasi logging dengan prefix berbeda—[JVM/INFO], [Android/INFO], [iOS/INFO]. Di proyek nyata, LogAndroid memakai android.util.Log.i() dan LogIos memakai os_log.
  5. UuidJvm, UuidAndroid, UuidIos: tiga implementasi generator UUID. Di proyek nyata, UuidJvm memakai java.util.UUID.randomUUID(), UuidIos memakai Foundation.NSUUID(). Di sini kita pakai counter sederhana agar output deterministik.
  6. PlatformBundle: data class yang menggabungkan tiga dependency. Pola ini menyederhanakan konstruksi—kode main() cukup memilih bundle, bukan menyuntikkan tiga dependency terpisah.
  7. Output tiga platform: kode bisnis CatatanPengguna yang sama menghasilkan struktur output identik—buatCatatan mengembalikan ID, bacaCatatan mengembalikan isi—dengan prefix log yang berbeda per platform.

Memilih expect/actual vs Interface #

Kapan memilih expect/actual dan kapan memilih interface? Aturan praktis: bila fungsi platform-specific berdiri sendiri dan tidak membutuhkan state, pakai expect fun. Bila komponen membutuhkan state, dipakai banyak class, atau ingin dapat diuji dengan mock, pakai interface. Kombinasi keduanya juga lazim—expect untuk fungsi kecil (mis. namaPlatform()), interface untuk komponen stateful (mis. Penyimpanan).

kotlin commonMain: kombinasi expect dan interface
expect fun namaPlatform(): String  // fungsi kecil, pakai expect

interface Penyimpanan {            // komponen stateful, pakai interface
    fun simpan(kunci: String, nilai: String): Boolean
    fun baca(kunci: String): String?
}

Referensi Lanjutan #

Penutup #

Kode platform-specific tidak bisa dihindari—file I/O, network, threading, dan logging berbeda di setiap platform. Yang dapat kita kendalikan adalah bagaimana kode bisnis berinteraksi dengan kode platform. Pola expect/actual untuk fungsi kecil dan injeksi interface untuk komponen stateful adalah dua teknik yang saling melengkapi. Chapter berikutnya akan masuk ke dependency management: bagaimana library KMP seperti kotlinx.coroutines dan ktor-client dideklarasikan di build.gradle.kts.