เขียนย้อนกลับ

Compose จะเรียกใช้เฟรมใน 3 ระยะตามลำดับที่เข้มงวดและไหลไปข้างหน้า ดังนี้

3 ระยะที่ดำเนินไปข้างหน้า ได้แก่ 1. การเรียบเรียง 2. เลย์เอาต์ 3 การวาด
รูปที่ 1 3 ระยะของเฟรม Compose
  1. Composition: เรียกใช้ฟังก์ชัน @Composable เพื่อสร้างและอัปเดตแผนผัง UI
  2. Layout: วัดขนาดองค์ประกอบย่อยแล้วจัดวางองค์ประกอบเหล่านั้น
  3. การวาด: ส่งคำสั่งวาด Canvas เพื่อแสดงผลพิกเซลบนหน้าจอ

เมื่อใดก็ตามที่มีการอ่าน State ของ Compose ในระหว่างระยะใดก็ตาม Compose จะบันทึกความขึ้นต่อกันระหว่างสถานะนั้นกับระยะที่เกี่ยวข้องโดยอัตโนมัติ


อะไรทำให้การเขียน "ย้อนกลับ"

การเขียนย้อนกลับจะเกิดขึ้นเมื่อใดก็ตามที่มีการแก้ไขสถานะในเฟสที่ตามมา (หรือในขอบเขตดาวน์สตรีม ซึ่งหมายถึงขอบเขตที่ประกอบกันได้ซึ่งดำเนินการในภายหลังตามลำดับภายในรอบการจัดองค์ประกอบเดียวกัน) มากกว่าตำแหน่งที่อ่าน ซึ่งจะบังคับให้ Compose จัดองค์ประกอบใหม่โดยกำหนดเวลาเฟสก่อนหน้าหรือฟังก์ชันที่ประกอบกันได้ให้ทำงานอีกครั้ง การเขียนย้อนกลับคือลูปการจัดองค์ประกอบใหม่ที่ไม่ได้เพิ่มประสิทธิภาพ

ตัวอย่างการเขียนย้อนกลับผ่านระยะต่างๆ ของลูปการจัดองค์ประกอบใหม่
รูปที่ 2 ตัวอย่างการเขียนย้อนกลับผ่านระยะต่างๆ ของลูปการจัดองค์ประกอบใหม่

ผลที่ตามมาของการเขียนย้อนหลัง

การเขียนย้อนกลับไม่ใช่สิ่งที่ไม่ดีเสมอไป และไม่ได้ทำให้แอปขัดข้องเสมอไป แต่ก็ไม่มีประสิทธิภาพและอาจส่งผลเสียต่อประสิทธิภาพของแอปได้หลายวิธี ดังนี้

  • การแสดงผลเฟรมเพิ่มเติมและเฟรมที่ตกหล่น: การเขียนย้อนกลับจะบังคับให้ Compose ดำเนินการผ่านการคอมโพสิชันที่ซ้ำซ้อนในเฟรมที่ต่อเนื่องกัน ซึ่งทำให้ทรัพยากร CPU และ GPU สูญเปล่าและอาจทำให้เกิดการกระตุก
  • ปัญหาความถูกต้องของเฟรมแรก: หากคอมโพเนนต์ต้องมีการเขียนย้อนกลับเพื่อแก้ไขขนาดหรือสถานะสุดท้าย เฟรมแรกจะแสดงผลด้วยข้อมูลที่ไม่ถูกต้อง ข้อมูลเริ่มต้น หรือข้อมูลที่ยังไม่ได้รับการแก้ไข (เช่น ขนาดเป็น 0 หรือออฟเซ็ตที่ไม่ถูกต้อง) ซึ่งจะทำให้เกิดภาพกระตุกหรือเลย์เอาต์กะพริบเมื่อเฟรมที่ 2 แสดงผล
  • ลูปการจัดองค์ประกอบใหม่แบบไม่สิ้นสุด: หากการเปลี่ยนแปลงสถานะเปลี่ยนขนาดเลย์เอาต์ และขนาดเลย์เอาต์เขียนค่าใหม่กลับไปยังสถานะอย่างต่อเนื่อง คุณอาจเสี่ยงต่อการสร้างลูปเฟรมแบบไม่สิ้นสุดซึ่งหน้าจอจะจัดองค์ประกอบใหม่ทุกเฟรมอย่างต่อเนื่องโดยไม่เสถียร

ขั้นตอนของโฟลว์แบบเรียงตามลำดับ

การเปลี่ยนแปลงสถานะควรดำเนินไปข้างหน้าตามระยะต่างๆ เสมอ ดังนี้

ระยะการอ่าน เขียนบริบท ยอมรับได้หรือไม่ เหตุผล
เลย์เอาต์ (Modifier.offset { }) Composition ใช่ การจัดองค์ประกอบจะอัปเดตสถานะ → เลย์เอาต์จะอ่านสถานะในภายหลังในเฟรมเดียวกันโดยไม่ต้องจัดองค์ประกอบใหม่
เสมอ (graphicsLayer { }, drawBehind { }) Composition ใช่ Composition จะอัปเดตสถานะ → Draw จะอ่านสถานะในเฟสสุดท้าย ระบบจะข้ามองค์ประกอบและเลย์เอาต์ทั้งหมด
วาด เลย์เอาต์ ใช่ เลย์เอาต์จะอัปเดตสถานะ และการวาดจะอ่านสถานะดังกล่าว ซึ่งเป็นโฟลว์ที่ถูกต้อง
Composition Callback ของเหตุการณ์ (onClick, onValueChange) ที่ทำให้สถานะเปลี่ยนไป หมายเหตุ: ระบบจะไม่นับ Callback ของเลย์เอาต์เป็นเหตุการณ์ ใช่ เหตุการณ์จะเปลี่ยนสถานะที่ใช้ในการขับเคลื่อน Composition หากเหตุการณ์เกิดขึ้นนอกเฟรม (ไม่ได้อยู่ใน Composition, เลย์เอาต์ หรือการวาด) เหตุการณ์นี้จะถูกต้อง
Composition โครูทีน (LaunchedEffect) ใช่ แต่ต้องระมัดระวัง อัปเดตสถานะแบบอะซิงโครนัสเพื่อตอบสนองต่อวงจร/เหตุการณ์ การเขียนจากเอฟเฟกต์อาจถูกต้อง แต่อาจชี้ให้เห็นถึงการจัดเลเยอร์สถานะที่ไม่มีประสิทธิภาพ คุณควรหลีกเลี่ยงการใช้คำเหล่านี้หากเป็นไปได้
ตำแหน่งโฆษณา (ในเลย์เอาต์) วัดค่า (ในเลย์เอาต์) ใช่ ใน Layout การอัปเดตสถานะแล้วอ่านสถานะนั้นในตำแหน่งถือเป็นสิ่งที่ยอมรับได้
วัดค่า (ในเลย์เอาต์) ตำแหน่งโฆษณา (ในเลย์เอาต์) ไม่ - เขียนย้อนกลับ การเขียนไปยังสถานะในการวางตำแหน่งซึ่งอยู่ในสถานะอ่านต่อไปจะทำให้เกิดลูปการวัดซ้ำ
Composition เลย์เอาต์ (onSizeChanged, LayoutModifier) ไม่ - เขียนย้อนกลับ เลย์เอาต์ทำให้ Composition ไม่ถูกต้อง → ลูปการจัดองค์ประกอบใหม่
Composition เสมอ (drawWithContent, Canvas) ไม่ - เขียนย้อนกลับ การวาดทำให้ Composition ไม่ถูกต้อง → ลูปการจัดองค์ประกอบใหม่

การรวมเฟส: ย้อนกลับเทียบกับไปข้างหน้า

ต่อไปนี้คือตัวอย่างการเขียนย้อนกลับภายใน Compose และวิธีแก้ไข

ย้อนกลับ: การอ่านใน Composition, การเขียนในเลย์เอาต์

  • สิ่งที่จะเกิดขึ้น: Composition จะอ่าน componentHeight เพื่อกำหนด UI ที่จะแสดงผล หลังจากนั้นในเฟรม ระยะเลย์เอาต์จะวัดหรือวางมุมมองและเขียนค่าใหม่ลงใน componentHeight (เช่น ใช้ onSizeChanged, onGloballyPositioned หรือ LayoutModifier ที่กำหนดเอง)
  • ผลลัพธ์: การแก้ไข componentHeight ในเลย์เอาต์จะทำให้เฟส Composition ที่เพิ่งเสร็จสมบูรณ์ไม่ถูกต้อง โปรดทราบว่า onSizeChanged จะรายงานขนาดหลังจากที่การวัดเลย์เอาต์เสร็จสมบูรณ์ หากค่าสถานะที่อัปเดตคงที่ในรอบถัดไป การจัดองค์ประกอบใหม่อาจหยุดลงหลังจากเฟรมเพิ่มเติม 1 เฟรม แต่หากค่าใหม่ยังคงเปลี่ยนขนาดต่อไป ก็จะส่งผลให้เกิดลูปเฟรมที่ไม่มีที่สิ้นสุด นอกจากนี้ onGloballyPositioned จะทำงานหลังจากทั้งเลย์เอาต์และการวาง ซึ่งทำให้การเขียนสถานะภายในมีความเสี่ยงต่อการจัดองค์ประกอบใหม่และการจัดเลย์เอาต์ใหม่แบบวนซ้ำอย่างต่อเนื่องในเฟรมที่ต่อเนื่องกันมากขึ้น

// ❌ 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)
        }
    }
}

ย้อนกลับ: การอ่านใน Composition และการเขียนใน Drawing

  • สิ่งที่เกิดขึ้น: ระบบจะอ่านสถานะในส่วนเนื้อหาของ Composable (เฟสการจัดองค์ประกอบ) แต่จะเปลี่ยนแปลงภายใน Modifier.drawWithContent, Modifier.drawBehind หรือ Canvas (เฟสการวาด)
  • ผลลัพธ์: ระยะการวาดจะเปลี่ยนสถานะ → Composition ไม่ถูกต้อง → วนซ้ำไม่สิ้นสุด

// ❌ 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
        }
    )
}

ย้อนกลับ: การอ่านใน Composition, การเขียนใน Composition (ระยะเดียวกัน)

  • สิ่งที่จะเกิดขึ้น: การอ่าน count ในฟังก์ชันที่ประกอบกันได้ และการแก้ไข count โดยตรงในช่องเนื้อหาที่ประกอบกันได้อีกช่องหนึ่งหลังจากอ่าน
  • ผลลัพธ์: ระบบสแนปชอตจะบันทึกการอ่านและการเขียนที่ตามมาภายในรอบการคอมโพสเดียวกัน ซึ่งจะทำให้ขอบเขตปัจจุบันใช้งานไม่ได้ทันที

// ❌ 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
}


กฎสำคัญในการป้องกันการเขียนย้อนกลับ

  1. อย่าเขียนไปยังสถานะใน onGloballyPositioned, onSizeChanged, onPlaced, onLayoutRectChanged หรือ LayoutModifier หากมีการอ่านสถานะนั้นใน Composition เนื่องจากจะทำให้เกิดปัญหาความถูกต้องของเฟรมแรก
    • หากต้องการพิกัดหรือขนาดเลย์เอาต์สำหรับการวาดที่กำหนดเองเท่านั้น ให้อ่านโดยตรงในระยะวาดหรือเลย์เอาต์ (เช่น ใช้ Modifier.drawWithCache หรือ Modifier.layout)
    • สำหรับการปรับขนาดระดับหน้าต่าง (WindowWidthSizeClass): ยกระดับการสังเกตขนาดเป็นระดับหน้าต่าง Composition จะแยกสาขาตามคลาสขนาดหน้าต่างก่อนที่จะมีการวัดผลในเครื่อง
    • รักษาองค์ประกอบให้สอดคล้องกัน: ใช้เลย์เอาต์หรือคอมโพเนนต์ที่กำหนดเองรายการเดียว เช่น FlowRow หรือ LazyVerticalGrid ซึ่งจะปรับการวัดและการวางตำแหน่งในระยะที่ 2 โดยไม่ต้องจัดองค์ประกอบใหม่หรือเปลี่ยนแปลงสถานะที่ใช้ในการจัดองค์ประกอบ
    • ใช้ Sub-composition: ใช้ BoxWithConstraints หรือ SubcomposeLayout เมื่อ Composable ย่อยต้องแยกสาขาตามความกว้างหรือความสูงในเครื่อง โปรดใช้ความระมัดระวัง: การจัดองค์ประกอบย่อยส่งผลต่อประสิทธิภาพและมักจะหลีกเลี่ยงได้
    • ทางเลือกสุดท้าย: อนุญาตให้เฟรมแรกผิดพลาด โดยจัดเก็บขนาดไว้ใน onSizeChanged เพื่อทริกเกอร์การจัดองค์ประกอบเฟรมที่ 2 ใหม่ ซึ่งจะทำให้เลย์เอาต์ปรากฏขึ้นอย่างเห็นได้ชัด เกิดความกระตุก และเสี่ยงต่อการเกิดลูปไม่สิ้นสุด
  2. อย่าเปลี่ยนแปลงสถานะหลังจากที่อ่านครั้งแรกใน Composition
    • แม้ว่าคุณจะเขียนไปยังออบเจ็กต์ MutableState ได้อย่างปลอดภัยในระหว่างการคอมโพสิชันนอก SideEffect แต่โปรดระมัดระวังเป็นพิเศษเพื่อไม่ให้เขียนไปยังสถานะที่คุณอาจเคยอ่านในคอมโพสิชัน ขอแนะนำให้ใช้ rememberUpdatedState เมื่อจำเป็นต้องเขียนสถานะในคอมโพสิชัน การเขียนไปยังสถานะระหว่างการคอมโพสิชันด้วยวิธีอื่นมักเป็นสัญญาณว่าไม่มีเอฟเฟกต์หรือสถานะหรือ Composable ที่ออกแบบมาอย่างไม่เหมาะสม โปรดทราบว่า Composition เป็นแบบมองโลกในแง่ดีและจะทำงานด้วยค่าล่าสุดของสถานะเสมอ ดังนั้นคุณจึงอาจไม่เห็นการเปลี่ยนแปลงสถานะทั้งหมดในการจัดองค์ประกอบใหม่ ไม่ควรใช้การอัปเดต UI เป็นวิธีจัดการเหตุการณ์แบบครั้งเดียว ซึ่งทำให้ค่าของสถานะไม่จำเป็นต้องได้รับการอัปเดตอันเป็นผลมาจากการจัดองค์ประกอบใหม่
    • หลีกเลี่ยงการเปลี่ยนแปลงสถานะที่สังเกตได้นอก Composition (เช่น ช่อง ViewModel หรือแฟล็ก isVisible) การเขียนสถานะที่ส่งผลต่อ Composition จะอยู่ในแลมบ์ดาของเหตุการณ์ (onClick), โครูทีน (LaunchedEffect) หรือผลข้างเคียง (SideEffect) rememberUpdatedState เป็นข้อยกเว้นเนื่องจากออกแบบมาเพื่อเปลี่ยนแปลงสถานะที่ใช้ในเนื้อหา @Composable เท่านั้น
  3. เลื่อนการอ่านสถานะไปยังเฟสล่าสุดที่เป็นไปได้
    • การอ่านสถานะในเฟสวาด (Modifier.graphicsLayer { alpha = ... }) หรือเฟสเลย์เอาต์ (Modifier.offset { IntOffset(...) }) ช่วยให้มั่นใจได้ว่าการเปลี่ยนแปลงจะทำให้เฟส 2 หรือ 3 ไม่ถูกต้องเท่านั้น โดยข้ามเฟส 1 (การจัดองค์ประกอบ) ไปเลย