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 Compose và Trạng thái và Jetpack Compose.
Ba giai đoạn của một khung
Compose có ba giai đoạn chính:
- 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.
- 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.
- 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ị.
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).
BoxWithConstraints
và LazyColumn
vàLazyRow
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ẹ.
Về mặt khái niệm, mỗi giai đoạn này xảy ra đối với mọi khung hình; tuy nhiên, để tối ưu hoá 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 hàm đó 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 về cách thực thi ba 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 thành phần, 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à xuất 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ư 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 tiểu mục của mã và cây giao diện người dùng sẽ có dạng như sau:
Trong các ví dụ này, mỗi hàm có khả năng kết hợp trong mã sẽ liên kết đến 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à dòng kiểm soát, đồng thời tạo ra một cây khác nhau theo 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. Việc đo lường và đặt 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 duyệt qua bằng thuật toán 3 bước sau:
- Đ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ó.
- Quyết định kích thước riêng: Dựa trên các phép đo này, nút sẽ quyết định kích thước riêng.
- Đặt thành phần con: Mỗi nút con được đặt tương ứng với vị trí của nút.
Khi kết thúc giai đoạn này, mỗi nút bố cục sẽ có:
- Chiều rộng và chiều cao được chỉ định
- Toạ độ x, y nơi cần vẽ
Hãy nhớ lại cây giao diện người dùng trong phần trước:
Đối với cây này, thuật toán hoạt động như sau:
Row
đo lường các thành phần con,Image
vàColumn
.Image
được đo lường. Nó không có nút con nào, vì vậy, nút này sẽ quyết định kích thước của riêng mình và báo cáo kích thước đó trở lạiRow
.- Tiếp theo,
Column
được đo lường. Trước tiên, thành phần này đo lường các thành phần con của chính nó (hai thành phần kết hợpText
). Text
đầu tiên được đo lường. Nó không có nút con nào nên sẽ tự quyết định kích thước và báo cáo kích thước của nó trở lạiColumn
.Text
thứ hai được đo lường. Nó không có nút con nào nên sẽ tự quyết định kích thước và báo cáo lại choColumn
.
Column
sử dụng các phép đo lường của thành phần con để quyết định kích thước của chính nó. Phương thức 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 các phần tử con.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.Row
sử dụng các phép đo lường của thành phần con để quyết định kích thước của chính nó. Phương thức này sử dụng chiều cao tối đa của thành phần con và tổng chiều rộng của các thành phần con. Sau đó, phần tử này 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ần 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 lên, thời gian duyệt qua cây 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ì thời gian duyệt sẽ tăng theo cấp số nhân.
Vẽ
Trong giai đoạn vẽ, cây được duyệt lại từ trên xuống dưới và mỗi nút sẽ tự vẽ trên màn hình theo thứ tự.
Hình 5. Giai đoạn vẽ sẽ vẽ các pixel trên màn hình.
Sử dụng ví dụ trước, nội dung cây được vẽ theo cách sau:
Row
vẽ mọi nội dung có thể có, chẳng hạn như màu nền.Image
tự vẽ.Column
tự vẽ.Text
đầu tiên và thứ hai lần lượt tự vẽ.
Hình 6. Cây giao diện người dùng và cách thể hiện cây đó.
Đọ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ả các sự việc xảy ra khi giá trị của trạng thái được đọc trong mỗi giai đoạn đó.
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 và đặ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.drawBehind
và Modifier.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.
Đề xuất cho bạn
- Lưu ý: văn bản có đường liên kết sẽ hiện khi JavaScript tắt
- Trạng thái và Jetpack Compose
- Danh sách và lưới
- Kotlin cho Jetpack Compose