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

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

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

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


การเขียน "ย้อนกลับ" คืออะไร

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

ตัวอย่างการเขียนย้อนกลับผ่านเฟสของลูปการสร้างใหม่
รูปที่ 2 ตัวอย่างการเขียนย้อนกลับผ่านลูปการสร้างใหม่ เฟส

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

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

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

ส่งต่อโฟลว์ผ่านเฟสต่างๆ

การเปลี่ยนแปลงสถานะควรเป็นไปในลักษณะไปข้างหน้าเสมอในแต่ละระยะ

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

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

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

ย้อนกลับ: อ่านในองค์ประกอบ เขียนในเลย์เอาต์

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

ย้อนกลับ: อ่านในส่วนการแต่งเพลง เขียนในส่วนการวาด

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

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

ย้อนกลับ: การอ่านในองค์ประกอบ การเขียนในองค์ประกอบ (ระยะเดียวกัน)

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

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