Compose thực thi một khung hình theo ba giai đoạn được sắp xếp nghiêm ngặt, theo thứ tự chuyển tiếp:
- Composition: Chạy các hàm
@Composableđể tạo và cập nhật cây giao diện người dùng. - Bố cục: Đo lường các phần tử con rồi đặt chúng.
- Vẽ: Phát ra các lệnh vẽ canvas để kết xuất pixel lên màn hình.
Bất cứ khi nào State của Compose được đọc trong bất kỳ giai đoạn nào, Compose sẽ tự động ghi lại phần phụ thuộc giữa trạng thái đó và giai đoạn tương ứng.
Điều gì khiến một thao tác ghi bị "ngược"?
Thao tác ghi ngược xảy ra bất cứ khi nào một trạng thái được sửa đổi ở giai đoạn sau (hoặc trong phạm vi hạ nguồn – nghĩa là phạm vi thành phần kết hợp được thực thi sau theo thứ tự trong cùng một lượt truyền thành phần) so với nơi trạng thái đó được đọc, buộc Compose phải kết hợp lại bằng cách lên lịch cho một giai đoạn hoặc thành phần kết hợp trước đó chạy lại. Ghi ngược là một vòng lặp kết hợp lại chưa được tối ưu hoá.
Hậu quả của việc ghi ngược
Việc ghi ngược không nhất thiết là điều xấu và không phải lúc nào cũng gây ra sự cố, nhưng không hiệu quả và có thể gây hại cho hiệu suất của ứng dụng theo một số cách:
- Kết xuất khung hình bổ sung và khung hình bị rớt: Thao tác ghi ngược buộc Compose phải thực thi các lượt kết hợp thừa trên các khung hình liên tiếp, gây lãng phí tài nguyên CPU và GPU, đồng thời có thể gây ra hiện tượng giật.
- Vấn đề về độ chính xác của khung hình đầu tiên: Nếu thành phần của bạn yêu cầu ghi ngược để phân giải kích thước hoặc trạng thái cuối cùng, thì khung hình đầu tiên sẽ được kết xuất bằng dữ liệu không hợp lệ, dữ liệu mặc định hoặc dữ liệu chưa được giải quyết (chẳng hạn như kích thước bằng 0 hoặc độ lệch không chính xác). Điều này khiến hình ảnh bị giật hoặc bố cục nhấp nháy khi khung hình thứ hai hiển thị.
- Vòng lặp tái cấu trúc vô hạn: Nếu thay đổi trạng thái làm thay đổi kích thước bố cục và kích thước bố cục liên tục ghi lại giá trị mới vào trạng thái, thì bạn có thể gặp rủi ro tạo ra vòng lặp khung vô hạn, trong đó màn hình liên tục tái cấu trúc mọi khung mà không bao giờ ổn định.
Quy trình chuyển tiếp qua các giai đoạn
Các thay đổi về trạng thái phải luôn diễn ra theo trình tự qua các giai đoạn:
| Giai đoạn đọc | Viết bối cảnh | Có được chấp nhận không? | Lý do |
|---|---|---|---|
Bố cục (Modifier.offset { }) |
Thành phần | Có | Thành phần cập nhật trạng thái → Bố cục đọc trạng thái sau đó trong cùng một khung hình mà không cần kết hợp lại. |
Draw (graphicsLayer { }, drawBehind { }) |
Thành phần | Có | Thành phần cập nhật trạng thái → Vẽ đọc trạng thái đó trong giai đoạn cuối. Cấu trúc và Bố cục bị bỏ qua hoàn toàn. |
| Vẽ | Bố cục | Có | Bố cục cập nhật trạng thái và vẽ đọc trạng thái đó, quy trình hợp lệ. |
| Thành phần | Lệnh gọi lại sự kiện (onClick, onValueChange) thúc đẩy thay đổi trạng thái. Lưu ý: lệnh gọi lại bố cục không được tính là sự kiện. |
Có | Sự kiện làm thay đổi trạng thái dùng để thúc đẩy quá trình kết hợp. Nếu sự kiện xảy ra ngoài khung hình (không phải trong Composition, Bố cục hoặc Bản vẽ), thì điều này là hợp lệ. |
| Thành phần | Coroutine (LaunchedEffect) |
Có, nhưng cần thận trọng | Cập nhật trạng thái không đồng bộ để phản hồi vòng đời/sự kiện. Việc ghi từ hiệu ứng có thể hợp lệ, nhưng có thể cho thấy việc phân lớp trạng thái không hiệu quả. Bạn nên tránh sử dụng tính năng này nếu có thể. |
| Vị trí (Trong bố cục) | Đo lường (Trong bố cục) | Có | Trong Layout, bạn có thể cập nhật trạng thái rồi đọc trạng thái đó trong vị trí. |
| Đo lường (Trong bố cục) | Vị trí (Trong bố cục) | Không – viết ngược | Việc ghi vào trạng thái trong vị trí, sau đó đọc trạng thái sẽ gây ra vòng lặp đo lường lại. |
| Thành phần | Bố cục (onSizeChanged, LayoutModifier) |
Không – Viết ngược | Bố cục vô hiệu hoá vòng lặp Composition → Recomposition. |
| Thành phần | Draw (drawWithContent, Canvas) |
Không – Viết ngược | Vẽ vô hiệu hoá vòng lặp Composition → Recomposition. |
Kết hợp pha: Ngược pha và cùng pha
Sau đây là các ví dụ về thao tác ghi ngược trong Compose và cách bạn có thể giải quyết.
Ngược lại: Đọc trong Thành phần, ghi trong Bố cục
- Điều gì xảy ra: Composition đọc
componentHeightđể xác định giao diện người dùng cần phát ra. Sau đó trong khung hình, giai đoạn Bố cục sẽ đo lường hoặc đặt các khung hiển thị và ghi một giá trị mới vàocomponentHeight(ví dụ: sử dụngonSizeChanged,onGloballyPositionedhoặcLayoutModifiertuỳ chỉnh). - Kết quả: Việc sửa đổi
componentHeighttrong Bố cục sẽ vô hiệu hoá giai đoạn Thành phần vừa hoàn tất. Xin lưu ý rằngonSizeChangedbáo cáo kích thước sau khi hoàn tất lượt đo lường bố cục. Nếu giá trị trạng thái được cập nhật ổn định ở lượt tiếp theo, thì quá trình kết hợp lại có thể dừng sau một khung hình bổ sung; tuy nhiên, nếu giá trị mới tiếp tục thay đổi kích thước, thì sẽ dẫn đến vòng lặp khung hình vô hạn. Hơn nữa,onGloballyPositionedchạy sau cả bố cục và vị trí, khiến việc ghi trạng thái bên trong nó thậm chí còn dễ bị ảnh hưởng hơn bởi các vòng lặp bố cục lại và kết hợp lại liên tục trên các khung hình liên tiếp.
// ❌ 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) } } }
Ngược lại: Đọc trong Composition, viết trong Drawing
- Điều gì xảy ra: Trạng thái được đọc trong phần thân của thành phần kết hợp (giai đoạn Thành phần), nhưng bị biến đổi bên trong
Modifier.drawWithContent,Modifier.drawBehindhoặcCanvas(giai đoạn Vẽ). - Kết quả: Giai đoạn vẽ làm thay đổi trạng thái → Cấu trúc không hợp lệ → vòng lặp vô tận.
// ❌ 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 } ) }
Ngược lại: Đọc trong Thành phần, ghi trong Thành phần (cùng giai đoạn)
- Điều gì xảy ra: Đọc
counttrong hàm composable và sửa đổi trực tiếpcounttrong vùng nội dung của một composable khác sau khi đọc. - Kết quả: Hệ thống tổng quan nhanh ghi lại lượt đọc và lượt ghi tiếp theo trong cùng một lượt kết hợp, ngay lập tức vô hiệu hoá phạm vi hiện tại.
// ❌ 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 }
Các quy tắc chính để ngăn chặn các lượt viết ngược
- Đừng ghi vào trạng thái trong
onGloballyPositioned,onSizeChanged,onPlaced,onLayoutRectChangedhoặcLayoutModifiernếu trạng thái đó được đọc trong Thành phần vì điều này gây ra vấn đề về độ chính xác của khung hình đầu tiên.- Nếu chỉ cần toạ độ hoặc kích thước bố cục cho bản vẽ tuỳ chỉnh, hãy đọc trực tiếp các giá trị này trong giai đoạn Vẽ hoặc Bố cục (ví dụ: sử dụng
Modifier.drawWithCachehoặcModifier.layout). - Đối với việc định cỡ ở cấp cửa sổ (
WindowWidthSizeClass): Đưa việc quan sát kích thước lên cấp cửa sổ. Các nhánh composition trên các lớp kích thước cửa sổ trước khi quá trình đo lường cục bộ diễn ra. - Giữ cho thành phần đồng nhất: Sử dụng một Bố cục tuỳ chỉnh hoặc các thành phần như
FlowRowhoặcLazyVerticalGridđể điều chỉnh việc đo lường và vị trí trong Giai đoạn 2 mà không cần kết hợp lại hoặc thay đổi trạng thái được dùng trong thành phần. - Sử dụng thành phần phụ: Sử dụng
BoxWithConstraintshoặcSubcomposeLayoutkhi các thành phần kết hợp con phải phân nhánh dựa trên chiều rộng hoặc chiều cao cục bộ. Hãy thận trọng: Thành phần phụ có thể ảnh hưởng đến hiệu suất và thường có thể tránh được. - Phương án cuối cùng: Cho phép khung hình đầu tiên bị sai, lưu trữ kích thước trong
onSizeChangedđể kích hoạt quá trình kết hợp lại khung hình thứ hai. Điều này gây ra hiện tượng bố cục bật lên, giật và có nguy cơ lặp lại vô hạn.
- Nếu chỉ cần toạ độ hoặc kích thước bố cục cho bản vẽ tuỳ chỉnh, hãy đọc trực tiếp các giá trị này trong giai đoạn Vẽ hoặc Bố cục (ví dụ: sử dụng
- Không thay đổi trạng thái sau khi trạng thái được đọc lần đầu trong cấu trúc:
- Mặc dù bạn có thể ghi vào các đối tượng
MutableStatemột cách an toàn trong quá trình kết hợp bên ngoàiSideEffect, nhưng hãy đặc biệt lưu ý để đảm bảo rằng bạn không ghi vào trạng thái mà bạn có thể đã đọc trước đó trong quá trình kết hợp. Bạn nên sử dụngrememberUpdatedStatekhi cần ghi trạng thái trong thành phần kết hợp. Việc ghi vào một state trong quá trình composition theo cách khác thường là dấu hiệu của việc thiếu effect hoặc state hay composable được thiết kế không phù hợp. Hãy nhớ rằng quá trình kết hợp luôn diễn ra theo hướng tích cực và luôn thực thi với giá trị mới nhất của một trạng thái, vì vậy, bạn có thể không thấy tất cả các thay đổi về trạng thái trong quá trình kết hợp lại. Không nên sử dụng các bản cập nhật giao diện người dùng làm cách xử lý các sự kiện một lần, điều này khiến giá trị của trạng thái không cần cập nhật do kết quả của việc kết hợp lại. - Tránh thay đổi trạng thái được quan sát bên ngoài cấu trúc (ví dụ: trường
ViewModelhoặc cờisVisible). Các trạng thái ghi ảnh hưởng đến thành phần thuộc về lambda sự kiện (onClick), coroutine (LaunchedEffect) hoặc hiệu ứng phụ (SideEffect).rememberUpdatedStatelà một ngoại lệ vì được thiết kế để thay đổi trạng thái chỉ được dùng trong phần nội dung@Composable.
- Mặc dù bạn có thể ghi vào các đối tượng
- Trì hoãn việc đọc trạng thái đến giai đoạn muộn nhất có thể:
- Việc đọc trạng thái trong giai đoạn Vẽ (
Modifier.graphicsLayer { alpha = ... }) hoặc Bố cục (Modifier.offset { IntOffset(...) }) đảm bảo rằng các thay đổi chỉ làm mất hiệu lực Giai đoạn 2 hoặc 3, bỏ qua hoàn toàn Giai đoạn 1 (Thành phần).
- Việc đọc trạng thái trong giai đoạn Vẽ (