Ghi ngược

Compose thực thi một khung hình theo 3 giai đoạn có thứ tự nghiêm ngặt:

Ba giai đoạn tiến triển: 1. Composition, 2. Bố cục, 3. Đang vẽ
Hình 1. Ba giai đoạn của một khung Compose
  1. Thành phần: 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 rồi đặt các phần tử con.
  3. Vẽ: Phát các lệnh vẽ canvas để kết xuất pixel lên màn hình.

Bất cứ khi nào một State Compose được đọc trong bất kỳ giai đoạn nào, Compose sẽ tự động ghi lại một 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 diễn ra "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ạ lưu – tức 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ần 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 chạy lại giai đoạn hoặc thành phần kết hợp trước đó. Thao tác 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 chúng không hiệu quả và có thể làm giảm hiệu suất của ứng dụng theo nhiều 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 thực thi các lượt kết hợp dư thừa trên các khung hình liên tiếp, 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 để giải quyết 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ệ, mặc định hoặc 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 gây ra hiện tượng nhấp nháy bố cục hoặc xuất hiện hình ảnh đột ngột khi khung hình thứ hai kết xuất.
  • Vòng lặp kết hợp lại vô hạn: Nếu một thay đổi về 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 một giá trị mới vào trạng thái, thì bạn có thể gặp rủi ro khi tạo một vòng lặp khung vô hạn, trong đó màn hình liên tục kết hợp lại mọi khung mà không bao giờ ổn đị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 hướng tiến qua các giai đoạn:

Giai đoạn đọc Viết theo bối cảnh Có chấp nhận được không? Lý do
Bố cục (Modifier.offset { }) Thành phần Thành phần kết hợp cập nhật trạng thái → Bố cục đọc trạng thái đó sau trong cùng một khung mà không cần kết hợp lại.
Hoà (graphicsLayer { }, drawBehind { }) Thành phần Thành phần cập nhật trạng thái → Vẽ đọc trạng thái đó ở giai đoạn cuối. Giai đoạn Kết hợp và Bố cục sẽ bị bỏ qua hoàn toàn.
Hoà Bố cục Bố cục cập nhật trạng thái và bản vẽ đọc trạng thái đó, luồng hợp lệ.
Thành phần Lệnh gọi lại sự kiện (onClick, onValueChange) làm thay đổi trạng thái lái xe. Lưu ý: lệnh gọi lại bố cục không được tính là sự kiện. Một sự kiện sẽ thay đổi trạng thái được dùng để điều khiển thành phần. Nếu sự kiện xảy ra ngoài khung hình (không phải trong Thành phần, Bố cục hoặc Vẽ), thì điều này là hợp lệ.
Thành phần Coroutine (LaunchedEffect) Có, nhưng hãy 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. Các thao tác ghi từ hiệu ứng có thể hợp lệ, nhưng có thể chỉ đến việc phân lớp trạng thái không hiệu quả. Bạn nên tránh sử dụng các từ này nếu có thể.
Vị trí (Trong bố cục) Đo lường (Trong bố cục) Trong bố cục, việc cập nhật trạng thái rồi đọc trạng thái đó trong vị trí là chấp nhận được.
Đo lường (Trong bố cục) Vị trí (Trong bố cục) Không – ghi ngược Việc ghi vào trạng thái trong vị trí, sau đó ghi tiếp vào trạng thái đọc sẽ gây ra vòng lặp đo lường lại.
Thành phần Bố cục (onSizeChanged, LayoutModifier) KhôngViết ngược Bố cục làm mất hiệu lực Thành phần → Vòng kết hợp lại.
Thành phần Hoà (drawWithContent, Canvas) Không – Viết ngược Thao tác vẽ sẽ làm mất hiệu lực Composition → Vòng lặp kết hợp lại.

Kết hợp pha: Ngược và xuôi

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 các thao tác đó.

Ngược lại: Đọc trong Thành phần, ghi trong Bố cục

  • Điều gì xảy ra: Thành phần đọc componentHeight để xác định giao diện người dùng cần phát. 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ụ: bằng cách 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ần truyền 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ì điều này sẽ dẫn đến một 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 các thao tác ghi trạng thái bên trong nó càng dễ bị ảnh hưởng bởi các vòng lặp kết hợp lại và bố trí 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 nội dung của Thành phần kết hợp (giai đoạn Thành phần), nhưng bị thay đổ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 → Thành phần 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ì sẽ xảy ra: Đọc count trong hàm có khả năng kết hợp và sửa đổi count trực tiếp trong một khe nội dung có khả năng kết hợp khác sau khi đọc.
  • Kết quả: Hệ thống ảnh chụp nhanh ghi lại thao tác đọc và ghi tiếp theo trong cùng một lần truyền thành phần, 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 ghi ngược

  1. Đừng viết để nêu trong onGloballyPositioned, onSizeChanged 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ề tính 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 hoạt động vẽ tuỳ chỉnh, hãy đọc trực tiếp các toạ độ hoặc kích thước đó trong giai đoạn Vẽ hoặc Bố cục (ví dụ: bằng cách dùng Modifier.drawWithCache hoặc Modifier.layout).
    • Đối với tính năng Điều chỉnh kích thước theo cửa sổ (WindowWidthSizeClass): Nâng kích thước quan sát lên cấp độ cửa sổ. Các nhánh thành phần theo các lớp kích thước cửa sổ trước khi đo lường cục bộ diễn ra.
    • Giữ bố cục đồ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 con: 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ộ. Thận trọng khi sử dụng: Thành phần con ảnh hưởng đến hiệu suất và thường có thể tránh được.
    • Khi không còn cách nào khác: 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 nhấp nháy, giật và có nguy cơ xảy ra vòng lặp vô hạn.
  2. Không thay đổi trạng thái sau khi đọc lần đầu trong thành phần:
    • 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 chú ý để đả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. Việc ghi vào một trạng thái trong quá trình kết hợp theo cách khác thường là dấu hiệu cho thấy hiệu ứng bị thiếu hoặc trạng thái hoặc thành phần kết hợp được thiết kế không phù hợp. Hãy nhớ rằng thành phần là lạc quan 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 các lần kết hợp lại. Bạn không nên 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 hiếm khi cần được 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 thành phần (ví dụ: các 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ề các hàm lambda sự kiện (onClick), coroutine (LaunchedEffect) hoặc hiệu ứng phụ (SideEffect). rememberUpdatedState là một trường hợp ngoại lệ vì được thiết kế để thay đổi trạng thái chỉ được dùng trong 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 Draw (Modifier.graphicsLayer { alpha = ... }) hoặc Layout (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, hoàn toàn bỏ qua Giai đoạn 1 (Thành phần).