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