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