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 thể hiện 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ả cách thực thi 3 giai đoạn trong Compose đối với các thành phần kết hợp một cách chi tiết hơn.

Bản sáng tác

Trong giai đoạn kết hợp, thời gian chạy Compose thực thi các hàm có khả năng kết hợp và cho ra một cấu trúc cây đại diện cho giao diện người dùng. 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ư xuất hiện trong video sau:

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

Một tiểu mục trong cây mã và 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, trong đó các nút con phân nhánh từ các nút mẹ.
Hình 3. Một tiểu mục trong cây giao diện người dùng có mã tương ứng.

Trong các ví dụ này, mỗi hàm có khả năng kết hợp trong mã liên kết với một bố cục duy nhất nú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à kiểm soát luồng và tạo một cây khác nhau tuỳ theo trạng thái.

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 thành phần làm đầ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 từng nút trong không gian 2D.

Hình 4. Hoạt động đo lường và vị trí của 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 3 bước sau thuật toán:

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

Khi kết thúc giai đoạn này, mỗi nút bố cục có:

  • Chiều rộngchiều cao được chỉ định
  • Toạ độ x, y tại vị trí cần vẽ

Hãy xem 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, trong đó các nút con 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. Image sẽ được đo. Tệp này không có phần tử con nào nên sẽ tự quyết định và báo cáo kích thước trở lại Row.
  3. Tiếp theo, Column sẽ được đo. Phương thức này đo lường các phần tử con của chính nó (2 Text thành phần kết hợp) trước.
  4. Text đầu tiên sẽ được đo. Tệp này không có phần tử con nào nên sẽ quyết định kích thước riêng và báo cáo kích thước của hình ảnh đó trở lại Column.
    1. Text thứ hai sẽ được đo. Tệp này không có phần tử con nào nên sẽ quyết định kích thước riêng và báo cáo kích thước đó lại cho Column.
  5. Column sử dụng các phép đo con để quyết định kích thước của riêng nó. Chiến dịch này sử dụng chiều rộng tối đa của phần tử con và tổng chiều cao của phần tử con.
  6. Column đặt các phần tử con tương ứng với chính nó, đặt chúng ở bên dưới theo chiều dọc.
  7. Row sử dụng các phép đo con để quyết định kích thước của riêng nó. Chiến dịch này sử dụng chiều cao tối đa của phần tử con và tổng chiều rộng của phần tử con. Sau đó, Google Photos đặt con của nó.

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 truyền qua cây giao diện người dùng để đo lường và đặt tất cả các nút, điều này giúp cải thiện hiệu suất. Khi số lượng nút trong cây tăng, thời gian dành cho thì chuyển hướng sẽ tăng theo kiểu tuyến tính. Ngược lại, nếu mỗi nút là truy cập nhiều lần, thì thời gian truyền tải tăng theo cấp số nhân.

Vẽ

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

Hình 5. Giai đoạn vẽ vẽ các pixel trên màn hình.

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

  1. Row vẽ bất kỳ nội dung nào có thể có, chẳng hạn như màu nền.
  2. Image tự vẽ.
  3. Column 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à nội dung mô tả đượ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 qua từng giai đoạn và mô tả những gì sẽ xảy ra khi giá trị trạng thái được đọc bên 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.