Banyak library lama — driver database, SDK Firebase, atau API native — masih berbasis callback: Anda memanggil api.ambilData(key) { result -> ... } dan tidak bisa langsung menunggu hasil. Bab ini membahas cara mengubah API seperti itu menjadi suspend function dengan dua fungsi bantu: suspendCoroutine (versi dasar) dan suspendCancellableCoroutine (versi yang mendukung pembatalan). Dengan trik ini, kode callback yang berantakan dapat dirapikan menjadi baris suspend yang linear.

Mengapa Perlu Konversi #

Callback terasa natural untuk library async, tetapi cepat berantakan saat digabungkan:

kotlin Callback hell: tiga operasi berurutan
api.ambilPengguna(id) { pengguna ->
    api.ambilFoto(pengguna.id) { foto ->
        api.ambilKota(pengguna.kotaId) { kota ->
            // tiga level bersarang, susah dibaca
            tampilkan(pengguna, foto, kota)
        }
    }
}

Dengan konversi ke suspend function, kode yang sama menjadi linear:

kotlin Versi suspend: linear dan mudah dibaca
suspend fun ambilProfil(id: Int): Profil = coroutineScope {
    val pengguna = ambilPengguna(id)
    val foto = async { ambilFoto(pengguna.id) }
    val kota = async { ambilKota(pengguna.kotaId) }
    Profil(pengguna, foto.await(), kota.await())
}

suspendCoroutine: Konversi Dasar #

suspendCoroutine<T> { continuation -> ... } menghentikan coroutine saat ini sampai continuation.resume(value) atau continuation.resumeWithException(throwable) dipanggil. Di dalam blok, Anda memanggil API callback dan meneruskan continuation sebagai penerima hasil.

kotlin
import kotlinx.coroutines.*
import kotlin.coroutines.*

// Simulasi API berbasis callback (mis. dari library lama)
interface CallbackApi {
    fun ambilData(key: String, callback: (Result<String>) -> Unit)
    fun batalkan()
}

// Implementasi tiruan: berhasil setelah jeda singkat
class CallbackApiTiruan : CallbackApi {
    private var dibatalkan = false

    override fun ambilData(key: String, callback: (Result<String>) -> Unit) {
        println("[api] mulai ambil data untuk key=$key")
        Thread {
            Thread.sleep(30)
            if (dibatalkan) {
                println("[api] permintaan dibatalkan sebelum selesai")
                return@Thread
            }
            val hasil = "data-untuk-$key"
            println("[api] selesai, kirim hasil: $hasil")
            callback(Result.success(hasil))
        }.start()
    }

    override fun batalkan() {
        println("[api] permintaan dibatalkan")
        dibatalkan = true
    }
}

// Versi 1: suspendCoroutine (tidak mendukung pembatalan)
suspend fun ambilDataSuspend(api: CallbackApi, key: String): String =
    suspendCoroutine { kontinuation ->
        api.ambilData(key) { result ->
            result.onSuccess { kontinuation.resume(it) }
                .onFailure { kontinuation.resumeWithException(it) }
        }
    }

// Versi 2: suspendCancellableCoroutine (mendukung pembatalan)
suspend fun ambilDataCancellable(api: CallbackApi, key: String): String =
    suspendCancellableCoroutine { kontinuation ->
        api.ambilData(key) { result ->
            result.onSuccess { kontinuation.resume(it) }
                .onFailure { kontinuation.resumeWithException(it) }
        }
        kontinuation.invokeOnCancellation { api.batalkan() }
    }

fun main() = runBlocking {
    val api = CallbackApiTiruan()

    println("=== 1. suspendCoroutine: sukses ===")
    val hasil1 = ambilDataSuspend(api, "Budi")
    println("[main] hasil = $hasil1")

    println()
    println("=== 2. suspendCancellableCoroutine: sukses ===")
    val api2 = CallbackApiTiruan()
    val hasil2 = ambilDataCancellable(api2, "Siti")
    println("[main] hasil = $hasil2")

    println()
    println("=== 3. Pembatalan: withTimeout memicu invokeOnCancellation ===")
    val api3 = CallbackApiTiruan()
    val hasil3 = withTimeoutOrNull(15) {
        ambilDataCancellable(api3, "Citra")  // ambil butuh 30ms, timeout 15ms
    }
    println("[main] hasil = $hasil3 (null karena dibatalkan)")
    delay(50)  // beri waktu thread API menyelesaikan dan mencetak pesan

    println()
    println("=== 4. Exception dirambatkan dari callback ===")
    class ApiError : CallbackApi {
        override fun ambilData(key: String, callback: (Result<String>) -> Unit) {
            callback(Result.failure(IllegalStateException("koneksinya gagal")))
        }
        override fun batalkan() {}
    }
    val apiErr = ApiError()
    try {
        ambilDataCancellable(apiErr, "Dewi")
    } catch (e: IllegalStateException) {
        println("[main] tangkap: ${e.message}")
    }

    println()
    println("=== 5. Banyak permintaan paralel dengan async ===")
    val api5 = CallbackApiTiruan()
    val hasil5 = listOf("k1", "k2", "k3").map { k -> async { ambilDataCancellable(api5, k) } }
        .awaitAll()
    println("[main] semua hasil = $hasil5")
}

Output yang Dihasilkan:

text
=== 1. suspendCoroutine: sukses ===
[api] mulai ambil data untuk key=Budi
[api] selesai, kirim hasil: data-untuk-Budi
[main] hasil = data-untuk-Budi

=== 2. suspendCancellableCoroutine: sukses ===
[api] mulai ambil data untuk key=Siti
[api] selesai, kirim hasil: data-untuk-Siti
[main] hasil = data-untuk-Siti

=== 3. Pembatalan: withTimeout memicu invokeOnCancellation ===
[api] mulai ambil data untuk key=Citra
[api] permintaan dibatalkan
[main] hasil = null (null karena dibatalkan)
[api] permintaan dibatalkan sebelum selesai

=== 4. Exception dirambatkan dari callback ===
[main] tangkap: koneksinya gagal

=== 5. Banyak permintaan paralel dengan async ===
[api] mulai ambil data untuk key=k1
[api] mulai ambil data untuk key=k2
[api] mulai ambil data untuk key=k3
[api] selesai, kirim hasil: data-untuk-k1
[api] selesai, kirim hasil: data-untuk-k2
[api] selesai, kirim hasil: data-untuk-k3
[main] semua hasil = [data-untuk-k1, data-untuk-k2, data-untuk-k3]

Mari kita bedah bagian-bagian penting:

  1. interface CallbackApi: API hipotetis dengan metode ambilData(key, callback). Callback menerima Result<String> untuk mempersatukan jalur sukses dan gagal. Implementasi CallbackApiTiruan menjalankan operasi di thread terpisah dengan Thread.sleep(30) — simulasi latensi API nyata.
  2. ambilDataSuspend (suspendCoroutine): konversi paling sederhana. Blok suspendCoroutine menerima kontinuation yang berperan sebagai jembatan antara callback dan coroutine. Saat callback dipanggil, kita teruskan hasil ke kontinuation.resume(value) atau exception ke kontinuation.resumeWithException(throwable). Fungsi ambilDataSuspend sendiri tidak bisa membatalkan API — bila coroutine dibatalkan, API tetap berjalan.
  3. ambilDataCancellable (suspendCancellableCoroutine): versi yang mendukung pembatalan. Baris kontinuation.invokeOnCancellation { api.batalkan() } mendaftarkan handler yang dipanggil saat coroutine dibatalkan — di sinilah kita memanggil api.batalkan() untuk benar-benar menghentikan operasi di sisi API.
  4. Bagian 3 (withTimeoutOrNull(15)): timeout 15ms lebih pendek dari 30ms API. Saat timeout terpicu, suspendCancellableCoroutine membatalkan continuation, yang memicu invokeOnCancellation, yang memanggil api.batalkan(). Output membuktikan [api] permintaan dibatalkan muncul — pembatalan dirambatkan ke API. Thread API kemudian bangun, melihat flag dibatalkan = true, dan mencetak permintaan dibatalkan sebelum selesai tanpa memanggil callback.
  5. Bagian 4 (exception): callback yang memanggil Result.failure(...) dirambatkan sebagai exception dari suspend function. Kode pemanggil dapat try/catch seperti biasa.
  6. Bagian 5 (async { ... }.awaitAll()): kombinasi suspend function dengan async memungkinkan permintaan paralel. Output menunjukkan [api] mulai ambil data muncul tiga kali hampir bersamaan — ketiga thread API berjalan paralel. awaitAll menunggu semua selesai.

Aturan Penting resume #

Ada beberapa aturan yang harus dipatuhi saat memakai suspendCoroutine / suspendCancellableCoroutine:

AturanKonsekuensi bila dilanggar
resume(...) dipanggil tepat sekaliResume kedua melempar IllegalStateException
resumeWithException(...) dipanggil tepat sekaliSama seperti di atas
Jangan menyimpan continuation di field yang long-livedMemory leak
Panggil invokeOnCancellation di akhir blok (sebelum return)Handler tidak terdaftar

Pola Umum: Susun sebagai Extension Function #

Idiom yang sering dipakai: bungkus API pihak ketiga sebagai extension function suspend agar pemanggilan terasa natural:

kotlin Extension function suspend pada CallbackApi
suspend fun CallbackApi.ambilDataOrNull(key: String): String? =
    suspendCancellableCoroutine { kontinuation ->
        ambilData(key) { result ->
            result.onSuccess { kontinuation.resume(it) }
                .onFailure { kontinuation.resumeWithException(it) }
        }
        invokeOnCancellation { batalkan() }
    }

Pemanggilan menjadi:

kotlin Penggunaan yang linear
val data = api.ambilDataOrNull("kunci") ?: return
println(data)

Referensi Lanjutan #

Selanjutnya #

Konversi callback ke coroutine selesai. Bab berikutnya, Coroutine Testing, akan membahas pengujian coroutine itu sendiri: bagaimana menulis tes yang menjalankan suspend function dengan runBlocking, menggunakan withTimeout untuk mencegah tes menggantung, dan memverifikasi exception serta pembatalan.