Penulisan Mundur

Compose mengeksekusi frame dalam tiga fase yang diurutkan secara ketat dan mengalir ke depan:

Tiga fase yang mengalir ke depan: 1. Komposisi, 2. Tata Letak, 3. Gambar
Gambar 1. Tiga fase frame Compose
  1. Komposisi: Menjalankan fungsi @Composable untuk mem-build dan mengupdate hierarki UI.
  2. Tata letak: Mengukur turunan, lalu menempatkannya.
  3. Gambar: Memunculkan perintah gambar kanvas untuk merender piksel ke layar.

Setiap kali State Compose dibaca selama fase apa pun, Compose akan otomatis merekam dependensi antara status tersebut dan fase yang sesuai.


Apa yang membuat penulisan "mundur"?

Penulisan mundur terjadi setiap kali status diubah pada fase berikutnya (atau dalam cakupan downstream—artinya cakupan composable yang dieksekusi nanti secara berurutan dalam penerusan komposisi yang sama) daripada tempat status dibaca, sehingga memaksa Compose untuk melakukan rekomposisi dengan menjadwalkan fase atau composable sebelumnya untuk dijalankan lagi. Penulisan mundur adalah loop rekomposisi yang tidak dioptimalkan.

Contoh penulisan mundur melalui fase loop rekomposisi
Gambar 2. Contoh penulisan mundur melalui fase loop rekomposisi

Konsekuensi penulisan mundur

Penulisan mundur tidak selalu buruk, dan tidak selalu memicu error, tetapi tidak efisien dan dapat membahayakan performa aplikasi dalam beberapa cara:

  • Rendering frame tambahan dan frame yang dilepas: Penulisan mundur memaksa Compose untuk menjalankan penerusan komposisi redundan di seluruh frame berturut-turut, sehingga membuang resource CPU dan GPU serta berpotensi menyebabkan jank.
  • Masalah kebenaran frame pertama: Jika komponen Anda memerlukan penulisan mundur untuk menyelesaikan dimensi atau status akhirnya, frame pertama akan dirender dengan data yang tidak valid, default, atau tidak stabil (seperti ukuran nol atau offset yang salah). Hal ini menyebabkan munculnya kedipan visual atau kedipan tata letak yang terlihat saat frame kedua dirender.
  • Loop rekomposisi tanpa batas: Jika perubahan status mengubah ukuran tata letak, dan ukuran tata letak terus menulis kembali nilai baru ke status, Anda dapat berisiko membuat loop frame tanpa batas tempat layar terus merekomposisi setiap frame tanpa pernah stabil.

Alur maju melalui fase

Perubahan status harus selalu mengalir ke depan melalui fase:

Fase Baca Konteks Penulisan Dapat diterima? Mengapa
Tata letak (Modifier.offset { }) Komposisi Ya Komposisi memperbarui status → Tata letak membacanya nanti dalam frame yang sama tanpa rekomposisi.
Draw (graphicsLayer { }, drawBehind { }) Komposisi Ya Komposisi memperbarui status → Gambar membacanya di fase akhir. Komposisi dan Tata Letak dilewati sepenuhnya.
Gambar Tata letak Ya Tata letak memperbarui status, dan gambar membacanya, alur yang valid.
Komposisi Callback Peristiwa (onClick, onValueChange) yang mendorong perubahan status. Catatan: callback tata letak tidak dihitung sebagai peristiwa. Ya Peristiwa mengubah status yang digunakan untuk mendorong komposisi. Jika peristiwa terjadi di luar frame (bukan dalam Komposisi, Tata Letak, atau Gambar), hal ini valid.
Komposisi Coroutine (LaunchedEffect) Ya - dengan hati-hati Mengupdate status secara asinkron sebagai respons terhadap siklus proses/peristiwa. Penulisan dari efek dapat valid, tetapi dapat mengarah ke pelapisan status yang tidak efisien. Hal ini sebaiknya dihindari sebisa mungkin.
Penempatan (Dalam Tata Letak) Pengukuran (Dalam Tata Letak) Ya Di Tata Letak, memperbarui status, lalu membaca status tersebut dalam penempatan dapat diterima.
Pengukuran (Dalam Tata Letak) Penempatan (Dalam Tata Letak) Tidak - penulisan terbalik Menulis ke status dalam penempatan yang kemudian lebih lanjut dalam status baca, akan menyebabkan loop pengukuran ulang.
Komposisi Tata letak (onSizeChanged, LayoutModifier) Tidak - Menulis terbalik Tata letak membatalkan Komposisi → Loop rekomposisi.
Komposisi Draw (drawWithContent, Canvas) Tidak - Menulis mundur Gambar membatalkan Komposisi → Loop rekomposisi.

Kombinasi fase: Mundur versus maju

Berikut adalah contoh penulisan mundur dalam Compose dan cara mengatasinya.

Mundur: Membaca di Komposisi, menulis di Tata Letak

  • Yang terjadi: Komposisi membaca componentHeight untuk menentukan UI yang akan ditampilkan. Kemudian dalam frame, fase Tata Letak mengukur atau menempatkan tampilan dan menulis nilai baru ke componentHeight (misalnya, menggunakan onSizeChanged, onGloballyPositioned, atau LayoutModifier kustom).
  • Hasil: Mengubah componentHeight di Tata Letak akan membuat fase Komposisi yang baru saja selesai menjadi tidak valid. Perhatikan bahwa onSizeChanged melaporkan ukuran setelah penerusan pengukuran tata letak selesai. Jika nilai status yang diperbarui stabil pada penerusan berikutnya, rekomposisi dapat berhenti setelah satu frame tambahan; namun, jika nilai baru terus mengubah ukuran, hal ini akan menghasilkan loop frame tanpa batas. Selain itu, onGloballyPositioned berjalan setelah tata letak dan penempatan, sehingga penulisan status di dalamnya menjadi lebih rentan terhadap loop rekomposisi dan tata letak ulang yang berkelanjutan di seluruh frame berturut-turut.

// ❌ BAD: Read in Composition, Written in Layout (onSizeChanged)
@Composable
fun BadAspectRatioImage(painter: Painter) {
    var calculatedHeight by remember { mutableStateOf(0.dp) }
    val density = LocalDensity.current

    // State read during COMPOSITION:
    Image(
        painter = painter,
        contentDescription = "Dynamic Image",
        modifier = Modifier
            .fillMaxWidth()
            .height(calculatedHeight)
            .onSizeChanged { size ->
                // State write during LAYOUT phase!
                // Triggers backwards write and recomposition pass
                val aspectRatio = 16f / 9f
                val widthDp = with(density) { size.width.toDp() }
                calculatedHeight = widthDp / aspectRatio
            }
    )
}

// ✅ GOOD: Measure and calculate aspect ratio height in Phase 2 (Layout) without recomposition
@Composable
fun GoodAspectRatioImage(
    painter: Painter,
    aspectRatio: Float = 16f / 9f,
    modifier: Modifier = Modifier
) {
    Layout(
        content = {
            Image(
                painter = painter,
                contentDescription = "Dynamic Image"
            )
        },
        modifier = modifier
    ) { measurables, constraints ->
        val width = constraints.maxWidth
        val height = (width / aspectRatio).toInt() // Illustrative, you can use Modifier.aspectRatio()
        val imageConstraints = constraints.copy(
            minWidth = width,
            maxWidth = width,
            minHeight = height,
            maxHeight = height
        )
        val placeable = measurables.first().measure(imageConstraints)
        layout(width, height) {
            placeable.placeRelative(0, 0)
        }
    }
}

Mundur: Membaca di Komposisi, menulis di Gambar

  • Yang terjadi: Status dibaca dalam isi Composable (Fase komposisi), tetapi diubah di dalam Modifier.drawWithContent, Modifier.drawBehind, atau Canvas (Fase gambar).
  • Hasil: Fase gambar mengubah status → Komposisi tidak valid → loop tanpa akhir.

// ❌ BAD: Read in Composition, Written in Draw ()
@Composable
fun BadBackwardsWriteDraw() {
    var componentHeight by remember { mutableStateOf(0.dp) }
    // State read during COMPOSITION:
    Text(
        text = "Height is: $componentHeight",
        modifier = Modifier.drawBehind {
            // State write during the DRAW phase!
            // Invalidates Composition -> triggers recomposition loop!
            componentHeight = size.height.dp
        }
    )
}

Mundur: Membaca dalam Komposisi, menulis dalam Komposisi (fase yang sama)

  • Yang terjadi: Membaca count dalam fungsi composable, dan mengubah count secara langsung di slot konten composable lain setelah membacanya.
  • Hasil: Sistem snapshot mencatat pembacaan dan penulisan berikutnya dalam pass komposisi yang sama, sehingga langsung membatalkan cakupan saat ini.

// ❌ BAD: Direct write in Composable body after read
@Composable
fun BadCounter() {
    var count by remember { mutableIntStateOf(0) }
    Text("Count: $count") // State read in Composition
    Button(onClick = {}) {
        count++ // State write in Composition (Backwards write!)
    }
}
// Acceptable - but error-prone as someone may add a read before the write : Direct write in Composable body before read
@Composable
fun OkCounter() {
    var count by remember { mutableIntStateOf(0) }
    Button(onClick = {}) {
        count++ // State  write in Composition
    }
    Text("Count: $count") // State read in Composition
}


Aturan utama untuk mencegah penulisan mundur

  1. Jangan menulis ke status di onGloballyPositioned, onSizeChanged, onPlaced, onLayoutRectChanged, atau LayoutModifier jika status tersebut dibaca dalam Komposisi karena hal ini menyebabkan masalah kebenaran frame pertama.
    • Jika koordinat atau ukuran tata letak hanya diperlukan untuk gambar kustom, baca langsung di fase Gambar atau Tata Letak (misalnya, menggunakan Modifier.drawWithCache atau Modifier.layout).
    • Untuk Pengukuran Tingkat Jendela (WindowWidthSizeClass): Naikkan pengamatan ukuran ke tingkat jendela. Komposisi bercabang pada class ukuran jendela sebelum pengukuran lokal terjadi.
    • Pertahankan Keseragaman Komposisi: Gunakan satu Tata Letak kustom atau komponen seperti FlowRow atau LazyVerticalGrid yang menyesuaikan pengukuran dan penempatan selama Fase 2 tanpa merekomposisi atau mengubah status yang digunakan dalam komposisi.
    • Gunakan Sub-komposisi: Gunakan BoxWithConstraints atau SubcomposeLayout saat composable turunan harus bercabang berdasarkan lebar atau tinggi lokal. Berhati-hatilah: Subkomposisi memerlukan biaya performa dan biasanya dapat dihindari.
    • Sebagai upaya terakhir: Biarkan frame pertama salah, simpan ukuran di onSizeChanged untuk memicu rekomposisi frame kedua. Hal ini menyebabkan tampilan tata letak yang muncul, jank, dan risiko loop tak terbatas.
  2. Jangan ubah status setelah dibaca pertama kali dalam komposisi:
    • Meskipun Anda dapat menulis ke objek MutableState dengan aman selama komposisi di luar SideEffect, berhati-hatilah untuk memastikan bahwa Anda tidak menulis ke status yang mungkin telah dibaca sebelumnya dalam komposisi. Sebaiknya gunakan rememberUpdatedState saat kebutuhan untuk penulisan status dalam komposisi ini muncul. Menulis ke status selama komposisi dengan cara lain biasanya merupakan tanda efek yang tidak ada atau status atau composable yang tidak dirancang dengan tepat. Ingat bahwa komposisi bersifat optimis dan selalu dieksekusi dengan nilai status terbaru, sehingga Anda mungkin tidak melihat semua perubahan status dalam rekomposisi. Update UI tidak boleh digunakan sebagai cara untuk menangani peristiwa satu kali, sehingga nilai status jarang perlu diupdate sebagai hasil dari rekomposisi.
    • Hindari mengubah status yang diamati di luar komposisi (misalnya, kolom ViewModel atau flag isVisible). Penulisan status yang memengaruhi komposisi termasuk dalam lambda peristiwa (onClick), coroutine (LaunchedEffect), atau efek samping (SideEffect). rememberUpdatedState adalah pengecualian karena dirancang untuk mengubah status yang hanya digunakan dalam isi @Composable.
  3. Menunda pembacaan status ke fase terakhir yang memungkinkan:
    • Membaca status di Draw (Modifier.graphicsLayer { alpha = ... }) atau Layout (Modifier.offset { IntOffset(...) }) memastikan bahwa perubahan hanya membatalkan Fase 2 atau 3, dan sepenuhnya melewati Fase 1 (Komposisi).