Các giai đoạn trong Jetpack Compose

Giống như hầu hết bộ công cụ giao diện người dùng khác, ứng dụng Compose sẽ hiển thị một khung qua nhiều giai đoạn (phase) riêng biệt. Nếu chúng ta xem xét hệ thống Android View, thì thấy hệ thống này có ba giai đoạn chính: đo lường (measure), bố cục (layout) và bản vẽ (drawing). Compose thì rất giống nhưng có thêm một giai đoạn quan trọng gọi là thành phần (composition) khi bắt đầu.

Thành phần được mô tả trong các tài liệu Compose, bao gồm các tài liệu Tư duy trong ComposeTrạng thái và Jetpack Compose.

Ba giai đoạn của một khung

Compose có ba giai đoạn chính:

  1. Thành phần (Composition): Nội dung mà giao diện người dùng sẽ hiển thị. Compose chạy các hàm có khả năng kết hợp và tạo nội dung mô tả giao diện người dùng.
  2. Bố cục (Layout): Vị trí để đặt giao diện người dùng. Giai đoạn này bao gồm hai bước: đo lường và đặt vị trí. Các thành phần bố cục đo lường và đặt vị trí cho chính nó và cho mọi thành phần con trong các toạ độ 2D vào mỗi nút trong cây bố cục.
  3. Bản vẽ (Drawing): Cách hiển thị. Các thành phần trên giao diện người dùng vẽ vào Canvas, thường là màn hình thiết bị.
Hình ảnh 3 giai đoạn trong đó Compose chuyển đổi dữ liệu thành giao diện người dùng (theo thứ tự, dữ liệu, thành phần, bố cục, bản vẽ, giao diện người dùng).
Hình 1. Ba giai đoạn trong đó Compose chuyển đổi dữ liệu thành giao diện người dùng.

Thứ tự của các giai đoạn này thường giống nhau, cho phép dữ liệu truyền theo một hướng từ thành phần đến bố cục đến bản vẽ để tạo một khung (còn gọi là luồng dữ liệu một chiều). BoxWithConstraintsLazyColumnLazyRow là các trường hợp ngoại lệ đáng chú ý, trong đó thành phần của tệp con phụ thuộc vào giai đoạn bố cục của tệp mẹ.

Bạn có thể yên tâm giả định rằng ba giai đoạn này xảy ra hầu như đối với mọi khung. Tuy nhiên, khi xét về hiệu suất, Compose sẽ tránh lặp lại các công việc cho ra cùng một kết quả với dữ liệu đầu vào giống nhau trong tất cả các giai đoạn này. Compose không chạy một hàm có thể kết hợp nếu nó có thể sử dụng lại kết quả cũ, và giao diện người dùng Compose sẽ không tạo lại bố cục hoặc vẽ lại toàn bộ cây nếu không cần thiết. Compose chỉ thực hiện lượng công việc tối thiểu cần thiết để cập nhật giao diện người dùng. Quá trình tối ưu hoá này có thể diễn ra vì Compose theo dõi việc đọc trạng thái trong các giai đoạn khác nhau.

Tìm hiểu các giai đoạn

Phần này mô tả chi tiết hơn cách thực thi 3 giai đoạn Compose cho các thành phần kết hợp.

Bản sáng tác

Trong giai đoạn kết hợp, môi trường thời gian chạy Compose sẽ thực thi các hàm có khả năng kết hợp và đưa ra một cấu trúc cây đại diện cho giao diện người dùng của bạn. Cây giao diện người dùng này bao gồm các nút bố cục chứa tất cả thông tin cần thiết cho các giai đoạn tiếp theo, như minh hoạ trong video sau:

Hình 2. Cây đại diện cho giao diện người dùng được tạo trong giai đoạn kết hợp.

Một phần phụ của mã và cây giao diện người dùng sẽ có dạng như sau:

Một đoạn mã có 5 thành phần kết hợp và cây giao diện người dùng thu được, với các nút con phân nhánh từ các nút mẹ.
Hình 3. Một phần phụ của cây giao diện người dùng có mã tương ứng.

Trong những ví dụ này, mỗi hàm có khả năng kết hợp trong mã ánh xạ tới một nút bố cục duy nhất trong cây giao diện người dùng. Trong các ví dụ phức tạp hơn, thành phần kết hợp có thể chứa logic và luồng điều khiển, đồng thời tạo ra một cây khác dựa trên các trạng thái khác nhau.

Bố cục

Trong giai đoạn bố cục, Compose sử dụng cây giao diện người dùng được tạo trong giai đoạn kết hợp làm dữ liệu đầu vào. Tập hợp các nút bố cục chứa tất cả thông tin cần thiết để quyết định kích thước và vị trí của mỗi nút trong không gian 2D.

Hình 4. Đo lường và đặt từng nút bố cục trong cây giao diện người dùng trong giai đoạn bố cục.

Trong giai đoạn bố cục, cây được di chuyển bằng cách sử dụng thuật toán 3 bước sau:

  1. Đo lường phần tử con: Một nút đo lường các phần tử con của nó, nếu có.
  2. Quyết định kích thước của riêng mình: Dựa trên các thông tin đo lường này, một nút sẽ quyết định kích thước riêng của mình.
  3. Đặt nút con: Mỗi nút con được đặt tương ứng với vị trí của một nút.

Ở cuối giai đoạn này, mỗi nút bố cục có:

  • Chiều rộngchiều cao được chỉ định
  • Tọa độ x, y nơi phải được vẽ

Hãy nhớ lại cây giao diện người dùng trong phần trước:

Một đoạn mã có 5 thành phần kết hợp và cây giao diện người dùng thu được, với các nút con được phân nhánh từ các nút mẹ

Đối với cây này, thuật toán hoạt động như sau:

  1. Row đo lường các phần tử con, ImageColumn.
  2. Hệ thống sẽ đo lường Image. Lớp này không có bất kỳ thành phần con cháu nào, vì vậy thiết bị sẽ quyết định kích thước riêng và báo cáo kích thước đó lại cho Row.
  3. Tiếp theo, hệ thống sẽ đo lường Column. Lớp này đo lường các thành phần con riêng của nó (hai thành phần kết hợp Text) trước tiên.
  4. Text đầu tiên sẽ được đo. Lớp này không có bất kỳ thành phần con cháu nào, nên trình phân tích cú pháp sẽ quyết định kích thước riêng và báo cáo kích thước của nó trở lại Column.
    1. Text thứ hai sẽ được đo. Lớp này không có bất kỳ thành phần con cháu nào, nên sẽ quyết định kích thước riêng và báo cáo lại cho Column.
  5. Column sử dụng các phép đo con để quyết định kích thước riêng. Phương thức này sử dụng chiều rộng tối đa của các phần tử con và tổng chiều cao của các phần tử con.
  6. Column đặt các phần tử con tương ứng với chính nó, đặt các phần tử con bên dưới nhau theo chiều dọc.
  7. Row sử dụng các phép đo con để quyết định kích thước riêng. Hàm này sử dụng chiều cao tối đa của các phần tử con và tổng chiều rộng của các phần tử con. Sau đó, trình phân tích cú pháp sẽ đặt các phần tử con.

Lưu ý rằng mỗi nút chỉ được truy cập một lần. Môi trường thời gian chạy Compose chỉ yêu cầu một lượt truyền qua cây giao diện người dùng để đo lường và đặt tất cả các nút, giúp cải thiện hiệu suất. Khi số lượng nút trong cây tăng, thời gian truyền tải nút đó sẽ tăng theo kiểu tuyến tính. Ngược lại, nếu mỗi nút được truy cập nhiều lần, thời gian truyền tải sẽ tăng theo cấp số nhân.

Vẽ

Trong giai đoạn vẽ, cây được di chuyển lại từ trên xuống dưới, và mỗi nút sẽ lần lượt tự vẽ trên màn hình.

Hình 5. Giai đoạn vẽ sẽ vẽ các điểm ảnh trên màn hình.

Trong ví dụ trước, nội dung dạng cây sẽ được vẽ theo cách sau:

  1. Row vẽ mọi nội dung mà nó có thể có, chẳng hạn như màu nền.
  2. Image sẽ tự vẽ.
  3. Column sẽ tự vẽ.
  4. Text thứ nhất và thứ hai lần lượt vẽ chính nó.

Hình 6. Cây giao diện người dùng và cách biểu diễn cây giao diện người dùng được vẽ.

Đọc trạng thái

Khi bạn đọc giá trị của trạng thái tổng quan nhanh (snapshot state) của một trong các giai đoạn được liệt kê ở trên, Compose sẽ tự động theo dõi trạng thái của hoạt động khi giá trị được đọc. Tính năng theo dõi này cho phép Compose thực thi lại trình đọc khi giá trị trạng thái thay đổi và là cơ sở để quan sát trạng thái trong Compose.

Trạng thái thường được tạo bởi mutableStateOf(), sau đó truy cập bằng một trong hai cách: truy cập trực tiếp thuộc tính value hoặc sử dụng một đại diện thuộc tính Kotlin. Bạn có thể đọc thêm điều này trong phần Trạng thái trong các hàm có thể kết hợp. Theo mục đích của hướng dẫn này, lệnh "đọc trạng thái" ("state read") tham chiếu cho một trong các phương thức truy cập tương đương đó.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

Trong chế độ đại diện thuộc tính, các hàm "getter" và "setter" được dùng để truy cập và thiết lập value của trạng thái. Các hàm getter và setter này chỉ được gọi khi bạn tham chiếu thuộc tính dưới dạng một giá trị, chứ không phải khi thuộc tính này được tạo. Đó là lý do tại sao 2 cách trên lại tương đương nhau.

Mỗi khối mã có thể được thực hiện lại khi trạng thái đọc thay đổi được gọi là phạm vi khởi động lại. Compose theo dõi các thay đổi về giá trị của trạng thái và khởi động lại các phạm vi ở các giai đoạn khác nhau.

Đọc trạng thái theo giai đoạn

Như đã đề cập ở trên, có ba giai đoạn chính trong Compose và Compose theo dõi trạng thái nào được đọc trong mỗi giai đoạn. Điều này cho phép Compose chỉ thông báo cho các giai đoạn cần thực hiện công việc cho từng thành phần bị ảnh hưởng trong giao diện người dùng.

Hãy xem xét từng giai đoạn và mô tả những gì sẽ xảy ra khi giá trị trạng thái được đọc trong đó.

Giai đoạn 1: Thành phần

Các trạng thái đọc trong hàm @Composable hoặc khối lambda ảnh hưởng đến thành phần và có thể là các giai đoạn tiếp theo. Khi giá trị trạng thái thay đổi, trình soạn thảo lại sẽ lên lịch chạy lại tất cả hàm có thể kết hợp đã đọc giá trị trạng thái đó. Lưu ý rằng thời gian chạy có thể bỏ qua một vài hoặc tất cả hàm có thể kết hợp nếu dữ liệu đầu vào không thay đổi. Hãy xem phần Bỏ qua nếu dữ liệu đầu vào không thay đổi để biết thêm thông tin.

Tuỳ thuộc vào kết quả của thành phần, giao diện người dùng Compose sẽ thực hiện giai đoạn bố cục và vẽ. Tính năng này có thể bỏ qua các giai đoạn trên nếu nội dung được giữ nguyên và kích thước cũng như bố cục không thay đổi.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

Giai đoạn 2: Bố cục

Giai đoạn bố cục bao gồm hai bước: đo lườngđặt vị trí. Bước đo lường sẽ chạy lambda đo lường được chuyển đến thành phần kết hợp Layout, phương thức MeasureScope.measure của giao diện LayoutModifier, v.v. Bước đặt vị trí sẽ chạy khối vị trí của hàm layout, khối lambda của Modifier.offset { … }, v.v.

Trạng thái đọc trong mỗi bước này sẽ ảnh hưởng đến bố cục và có thể cả giai đoạn vẽ. Khi giá trị trạng thái thay đổi, Giao diện người dùng Compose sẽ lên lịch cho giai đoạn bố cục. Nó cũng chạy giai đoạn vẽ nếu kích thước hoặc vị trí thay đổi.

Để chính xác hơn, bước đo lường và bước đặt vị trí sẽ có các phạm vi bắt đầu riêng biệt, nghĩa là việc đọc trạng thái trong bước đặt vị trí sẽ không gọi lại bước đo lường trước đó. Tuy nhiên, hai bước này thường đan xen với nhau, vì vậy, một trạng thái được đọc trong bước đặt vị trí có thể ảnh hưởng đến các phạm vi khởi động lại khác thuộc bước đo lường.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

Giai đoạn 3: Bản vẽ

Việc đọc trạng thái trong khi vẽ mã sẽ ảnh hưởng đến giai đoạn vẽ. Các ví dụ phổ biến bao gồm Canvas(), Modifier.drawBehindModifier.drawWithContent. Khi giá trị trạng thái thay đổi, giao diện Compose chỉ chạy giai đoạn vẽ.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

Tối ưu hoá việc đọc trạng thái

Khi Compose theo dõi việc đọc trạng thái được bản địa hoá, chúng ta có thể giảm thiểu công việc bằng cách đọc từng trạng thái trong từng giai đoạn phù hợp.

Hãy cùng tham khảo ví dụ dưới đây. Ở đây chúng ta có một Image() sử dụng bộ sửa đổi chênh lệch để bù cho vị trí bố cục cuối cùng, tạo ra hiệu ứng thị sai là các đường cuộn của người dùng.

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

Mã này hoạt động nhưng mang lại hiệu quả hoạt động không tối ưu. Như đã viết, mã sẽ đọc giá trị của trạng thái firstVisibleItemScrollOffset và chuyển giá trị đó đến hàm Modifier.offset(offset: Dp). Khi người dùng cuộn, giá trị firstVisibleItemScrollOffset sẽ thay đổi. Như đã biết, ứng dụng Compose sẽ theo dõi việc đọc trạng thái để có thể khởi động lại (gọi lại) mã đọc, như trong ví dụ này là nội dung của Box.

Đây là ví dụ về việc trạng thái được đọc trong giai đoạn thành phần. Điều này không nhất thiết là một điều xấu và trên thực tế, nó lại là cơ sở của việc tái cấu trúc, cho phép các thay đổi dữ liệu tạo ra giao diện người dùng mới.

Mặc dù vậy, trong ví dụ này, phương pháp này không tối ưu vì mọi sự kiện cuộn sẽ dẫn đến việc toàn bộ nội dung của thành phần kết hợp bị đánh giá lại, đo lường lại, bố cục lại và cuối cùng là vẽ lại. Chúng tôi đang kích hoạt giai đoạn Compose cho mỗi lần cuộn mặc dù nội dung chúng tôi hiển thị không thay đổi, mà chỉ thay đổi vị trí hiển thị. Chúng tôi có thể tối ưu hoá việc đọc trạng thái để chỉ kích hoạt lại giai đoạn bố cục.

Hiện có một phiên bản khác của công cụ sửa đổi mức chênh lệch: Modifier.offset(offset: Density.() -> IntOffset).

Phiên bản này lấy thông số lambda, trong đó độ lệch kết quả được khối lambda trả về. Hãy cập nhật mã của chúng tôi để sử dụng thông số đó:

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

Vậy tại sao việc này có hiệu suất cao hơn? Khối lambda dùng cho công cụ sửa đổi được gọi trong giai đoạnbố cục (cụ thể là trong bước đặt vị trí của giai đoạn bố cục), có nghĩa làfirstVisibleItemScrollOffset trạng thái không còn được đọc trong thành phần. Vì Compose theo dõi thời điểm đọc trạng thái nên sự thay đổi này có nghĩa là nếu giá trị firstVisibleItemScrollOffset thay đổi thì Compose chỉ cần khởi động lại giai đoạn bố cục và giai đoạn vẽ.

Ví dụ này dựa trên các công cụ sửa đổi mức chênh lệch khác nhau để có thể tối ưu hoá mã kết quả, nhưng ý tưởng chung là cố gắng: cố gắng bản địa hoá trạng thái đọc cho giai đoạn thấp nhất có thể, cho phép Compose thực hiện số lượng công việc tối thiểu.

Tất nhiên, bạn thường phải đọc các trạng thái trong giai đoạn thành phần. Mặc dù vậy, có những trường hợp mà chúng ta có thể giảm thiểu số lần tái cấu trúc bằng cách lọc các thay đổi về trạng thái. Để biết thêm thông tin về vấn đề này, hãy xem mục derivedStateOf: chuyển đổi một hoặc nhiều đối tượng trạng thái thành trạng thái khác.

Vòng lặp tái kết hợp (phần phụ thuộc giai đoạn tuần hoàn)

Trước đó, chúng tôi đã đề cập rằng các giai đoạn Compose luôn được gọi theo cùng một thứ tự và không có cách nào để quay ngược lại khi ở trong cùng một khung. Tuy nhiên, điều đó không ngăn các ứng dụng tham gia vào vòng lặp thành phần trên các khung khác nhau. Hãy xem xét ví dụ sau:

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

Ở đây, chúng tôi đã triển khai (không tốt) một cột dọc, với hình ảnh ở trên cùng và văn bản bên dưới. Chúng tôi sử dụng Modifier.onSizeChanged() để biết kích thước đã xử lý của hình ảnh, sau đó sử dụng Modifier.padding() trên văn bản để giảm kích thước đó. Việc chuyển đổi không tự nhiên từ Px trở về Dp đã chỉ ra rằng mã đó có một vài vấn đề.

Vấn đề với ví dụ này là chúng tôi không đạt được bố cục "cuối cùng" trong một khung duy nhất. Mã này dựa trên nhiều khung, thực hiện các công việc không cần thiết và kết quả là giao diện người dùng nhảy xung quanh màn hình cho người dùng.

Hãy đi qua từng khung để xem điều gì đang diễn ra:

Ở giai đoạn kết hợp của khung đầu tiên, imageHeightPx có giá trị là 0 và văn bản được cung cấp với Modifier.padding(top = 0). Sau đó là giai đoạn bố cục, và lệnh gọi lại cho công cụ sửa đổi onSizeChanged sẽ được gọi. Đây là thời điểm imageHeightPx được cập nhật chiều cao thực của hình ảnh. Compose lên lịch tái cấu trúc cho khung tiếp theo. Ở giai đoạn vẽ, văn bản được hiển thị với khoảng đệm 0 vì thay đổi giá trị chưa được phản ánh.

Sau đó, Compose sẽ bắt đầu khung thứ hai được xếp lịch bởi sự thay đổi giá trị của imageHeightPx. Trạng thái được đọc trong khối nội dung Box và được gọi trong giai đoạn thành phần. Lần này, văn bản được cung cấp với một khoảng đệm phù hợp với chiều cao của hình ảnh. Ở giai đoạn bố cục, mã sẽ đặt lại giá trị của imageHeightPx nhưng không đặt lịch tái cấu trúc vì giá trị được giữ nguyên.

Cuối cùng, chúng ta có khoảng đệm mong muốn trên văn bản. Tuy nhiên, bạn không nên sử dụng thêm một khung để chuyển giá trị khoảng đệm trở về một giai đoạn khác và gây ra một khung có nội dung chồng chéo.

Ví dụ này có vẻ hơi dàn dựng, nhưng bạn nên cẩn thận với mẫu hình chung này:

  • Modifier.onSizeChanged(), onGloballyPositioned() hoặc một số thao tác bố cục khác
  • Cập nhật một số trạng thái
  • Sử dụng trạng thái đó làm đầu vào cho công cụ sửa đổi bố cục (padding(),height() hoặc tương tự)
  • Có thể lặp lại

Khắc phục mẫu ở trên bằng cách sử dụng nguyên hàm bố cục thích hợp. Ví dụ trên có thể được triển khai bằng một Column() đơn giản. Ví dụ phức tạp hơn cần một số tuỳ chỉnh, do đó sẽ yêu cầu viết một bố cục tuỳ chỉnh. Xem hướng dẫn Bố cục tuỳ chỉnh để biết thêm thông tin.

Nguyên tắc chung ở đây là có một nguồn chân lý duy nhất cho nhiều thành phần của giao diện người dùng mà những thành phần đó cần được đo lường và đặt vị trí liên quan đến nhau. Việc sử dụng một nguyên hàm bố cục hợp lý hoặc tạo một bố cục tuỳ chỉnh có nghĩa là thuộc tính mẹ tối thiểu mà bạn dùng để làm nguồn chân lý có thể điều phối mối quan hệ giữa nhiều thành phần. Việc áp dụng trạng thái động sẽ phá vỡ nguyên tắc này.