Ghi ngược

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:

Ba giai đoạn theo trình tự: 1. Composition, 2. Bố cục, 3. Đang vẽ
Hình 1. Ba giai đoạn của một khung Compose
  1. Composition: Chạy các hàm @Composable để tạo và cập nhật cây giao diện người dùng.
  2. Bố cục: Đo lường các phần tử con rồi đặt chúng.
  3. 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á.

Ví dụ về việc ghi ngược qua các giai đoạn của vòng lặp kết hợp lại
Hình 2. Ví dụ về việc ghi ngược qua các giai đoạn của vòng lặp kết hợp lại

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ào componentHeight (ví dụ: sử dụng onSizeChanged, onGloballyPositioned hoặc LayoutModifier tuỳ chỉnh).
  • Kết quả: Việc sửa đổi componentHeight trong 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ằng onSizeChanged bá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, onGloballyPositioned chạ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.drawBehind hoặc Canvas (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 count trong hàm composable và sửa đổi trực tiếp count trong 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

  1. Đừng ghi vào trạng thái trong onGloballyPositioned, onSizeChanged, onPlaced, onLayoutRectChanged hoặc LayoutModifier nế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.drawWithCache hoặc Modifier.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ư FlowRow hoặc LazyVerticalGrid để đ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 BoxWithConstraints hoặc SubcomposeLayout khi 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.
  2. 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 MutableState một cách an toàn trong quá trình kết hợp bên ngoài SideEffect, 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ụng rememberUpdatedState khi 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 ViewModel hoặ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). rememberUpdatedState là 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.
  3. 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).