Làm theo các phương pháp hay nhất

Bạn có thể gặp phải một số lỗi thường gặp về Compose. Những lỗi này có thể cung cấp cho bạn mã có vẻ chạy tốt nhưng có thể làm giảm hiệu suất giao diện người dùng của bạn. Làm theo các phương pháp hay nhất để tối ưu hoá ứng dụng trên Compose.

Dùng remember để giảm thiểu các phép tính tốn kém

Các hàm composable có thể chạy rất thường xuyên, như mọi khung hình của một ảnh động. Vì lý do này, bạn nên hạn chế việc tính toán trong nội dung của thành phần kết hợp.

Một kỹ thuật quan trọng là lưu trữ kết quả của các phép tính bằng remember. Bằng cách này, phép tính chỉ chạy một lần và bạn có thể tìm nạp kết quả khi cần.

Ví dụ: đây là một số mã hiển thị danh sách các tên được sắp xếp, tuy nhiên việc sắp xếp lại thực hiện theo cách rất hao tổn:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier) {
        // DON’T DO THIS
        items(contacts.sortedWith(comparator)) { contact ->
            // ...
        }
    }
}

Mỗi khi ContactsList được kết hợp lại, toàn bộ danh bạ sẽ được sắp xếp lại, mặc dù danh bạ này vẫn không đổi. Nếu người dùng cuộn danh sách, thành phần kết hợp sẽ được kết hợp lại mỗi khi một hàng mới xuất hiện.

Để giải quyết vấn đề này, hãy sắp xếp danh sách bên ngoài LazyColumn và lưu trữ danh sách được sắp xếp theo remember:

@Composable
fun ContactList(
    contacts: List<Contact>,
    comparator: Comparator<Contact>,
    modifier: Modifier = Modifier
) {
    val sortedContacts = remember(contacts, comparator) {
        contacts.sortedWith(comparator)
    }

    LazyColumn(modifier) {
        items(sortedContacts) {
            // ...
        }
    }
}

Hiện danh sách được sắp xếp một lần khi ContactList được soạn lần đầu. Nếu danh bạ hoặc trình so sánh thay đổi, danh sách đã sắp xếp sẽ được tạo lại. Nếu không, thành phần kết hợp có thể tiếp tục sử dụng danh sách đã sắp xếp được lưu vào bộ nhớ đệm.

Luôn truyền mọi dữ liệu đầu vào được tham chiếu bên trong khối tính toán remember dưới dạng khoá để giá trị được lưu vào bộ nhớ đệm sẽ cập nhật khi các dữ liệu đầu vào đó thay đổi. Vì remember lưu trữ các khoá và kết quả được lưu vào bộ nhớ đệm trong bảng vị trí, đồng thời so sánh các khoá bằng Object.equals() trên mỗi lượt kết hợp lại, nên chỉ sử dụng remember khi lưu vào bộ nhớ đệm các phép tính tốn kém hoặc phân bổ đối tượng nặng (chẳng hạn như trình định dạng ngày, chương trình đổ bóng hoặc sắp xếp danh sách) hoặc khi phải giữ nguyên danh tính đối tượng.

Hãy thận trọng khi truyền một tập hợp lớn hoặc một đối tượng có phương thức triển khai equals() tốn kém làm khoá cho remember để tính toán ít tốn kém, vì việc so sánh khoá có thể tốn kém hơn so với việc chạy trực tiếp phép tính:

@Composable
fun ContactHeader(contacts: List<Contact>) {
    // Avoid: Comparing the `contacts` list with .equals() on recomposition
    // is more expensive than reading contacts.size or contacts.firstOrNull() directly.
    val firstContactName = remember(contacts) {
        contacts.firstOrNull()?.name.orEmpty()
    }
    Text(firstContactName)
}

Sử dụng các khoá bố cục lazy và loại nội dung

Bố cục lazy sử dụng lại các mục một cách hiệu quả và chỉ tạo lại hoặc kết hợp lại khi cần. Tuy nhiên, bạn có thể giúp tối ưu hoá bố cục tải từng phần để kết hợp lại và sử dụng lại vùng chứa.

Giả sử thao tác người dùng khiến một mục di chuyển trong danh sách. Ví dụ: giả sử bạn hiển thị danh sách các ghi chú được sắp xếp theo thời gian sửa đổi, trong đó ghi chú được sửa đổi gần đây nhất ở trên cùng.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes
        ) { note ->
            NoteRow(note)
        }
    }
}

Tuy nhiên, mã này gặp sự cố. Giả sử ghi chú dưới cùng bị thay đổi. Hiện đây là ghi chú được sửa đổi gần đây nhất, vì vậy, ghi chú này sẽ xuất hiện ở đầu danh sách và mọi ghi chú khác sẽ di chuyển xuống một vị trí.

Nếu không có sự trợ giúp của bạn, Compose sẽ không nhận ra các mục không thay đổi vừa được chuyển vị trí trong danh sách. Thay vào đó, Compose cho rằng "mục 2" cũ đã bị xoá và một mục mới đã được tạo cho mục 3, mục 4, v.v. Kết quả là Compose kết hợp lại toàn bộ mục trong danh sách, mặc dù chỉ có một mục trong số đó thực sự thay đổi.

Giải pháp cho vấn đề này là cung cấp mã khoá mục. Việc cung cấp một khoá ổn định cho mỗi mục cho phép Compose tránh những lần kết hợp lại không cần thiết. Trong trường hợp này, Compose có thể xác định mục ở vị trí 3 lúc này chính là mục ở vị trí 2 trước đó. Vì không có dữ liệu nào cho mục thay đổi này, nên Compose không cần phải tổng hợp lại dữ liệu đó.

@Composable
fun NotesList(notes: List<Note>) {
    LazyColumn {
        items(
            items = notes,
            key = { note ->
                // Return a stable, unique key for the note
                note.id
            }
        ) { note ->
            NoteRow(note)
        }
    }
}

Khi làm việc với bố cục tải từng phần (LazyColumn, LazyRow, LazyGrid), bạn cũng nên lưu ý các nguyên tắc sau:

  • Sử dụng các khoá duy nhất, có thể lưu được trong Gói: Khoá phải là duy nhất, có tính tất định và có thể lưu được trong Bundle của Android vì bố cục tải từng phần sử dụng rememberSaveable trong nội bộ. Ưu tiên các loại dữ liệu gốc như Int, Long hoặc String và không bao giờ sử dụng chỉ mục danh sách hoặc hashCode() làm khoá.
  • Luôn chỉ định contentType cho danh sách không đồng nhất: Truyền contentType = { it.type } đến items hoặc itemsIndexed để Compose chỉ sử dụng lại các vùng chứa thành phần giữa các mục có cùng cấu trúc hình ảnh trong khi cuộn.
  • Sử dụng itemsIndexed thay vì indexOf: Tránh gọi list.indexOf(item) bên trong khối items, vì việc này sẽ biến bố cục thành một lần quét O(N²); hãy sử dụng itemsIndexed.
  • Chuyển trạng thái dùng chung lên trên và CompositionLocal đọc: Thu thập các thực thể Flow dùng chung và đọc các giá trị CompositionLocal (chẳng hạn như LocalContext.current hoặc LocalDensity.current) một lần trong thành phần composable mẹ phía trên bố cục tải từng phần, sau đó truyền các giá trị đã phân giải xuống thành phần composable mục.
  • Sử dụng Row hoặc Column cho danh sách nhỏ, có giới hạn: Đối với các tập hợp tĩnh hoặc có giới hạn nhỏ (chẳng hạn như 1 đến 10 mục), Row hoặc Column tiêu chuẩn có forEach sẽ tránh được tình trạng ảo hoá từng phần và hao tổn tài nguyên khi tìm nạp trước.

Dùng computedStateOf hoặc derivedStateOf để giới hạn các bản tái cấu trúc

Một trong những nguy cơ khi sử dụng trạng thái trong các bản cấu trúc là nếu trạng thái đó thay đổi nhanh chóng, giao diện người dùng của bạn có thể sẽ được tổng hợp lại nhiều hơn mức cần thiết. Ví dụ: giả sử bạn đang cho thấy một danh sách có thể cuộn. Bạn kiểm tra trạng thái của danh sách để xem mục nào hiển thị đầu tiên trong danh sách:

val listState = rememberLazyListState()

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

val showButton = listState.firstVisibleItemIndex > 0

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Vấn đề ở đây là nếu người dùng cuộn danh sách, listState sẽ liên tục thay đổi khi người dùng kéo ngón tay. Điều đó có nghĩa là danh sách liên tục được tổng hợp. Tuy nhiên, bạn không cần phải tổng hợp lại thường xuyên – cho đến khi một mục mới hiển thị ở trên cùng. Do đó, việc thực hiện nhiều thao tác tính toán quá mức sẽ khiến giao diện người dùng của bạn hoạt động kém.

Giải pháp là sử dụng trạng thái bắt nguồn (hoặc computedStateOf trong Compose Runtime 1.13 trở lên). Trạng thái dẫn xuất cho phép bạn cho Compose biết những thay đổi trạng thái nào sẽ kích hoạt tái cấu trúc. Trong trường hợp này, hãy chỉ định rằng bạn quan tâm đến mục đầu tiên hiển thị thay đổi. Khi giá trị trạng thái đó thay đổi, giao diện người dùng cần phải kết hợp lại, nhưng nếu người dùng chưa cuộn đủ để đưa một mục mới lên trên cùng, thì không nhất thiết phải kết hợp lại.

val listState = rememberLazyListState()

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

val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

AnimatedVisibility(visible = showButton) {
    ScrollToTopButton()
}

Chọn phương pháp lấy trạng thái thích hợp dựa trên phiên bản Compose và chi phí tính toán:

  • remember { computedStateOf { ... } } (Môi trường thời gian chạy Compose phiên bản 1.13 trở lên): Đánh giá lại phép tính của nó trên mỗi lần đọc mà không lưu kết quả vào bộ nhớ đệm, đồng thời chỉ vô hiệu hoá thành phần khi kết quả tính toán thay đổi. Vì tránh được việc ghi sổ bộ nhớ đệm và hao tổn tài nguyên khi ghi lại dữ liệu không hợp lệ, nên bạn nên dùng remember { computedStateOf { listState.firstVisibleItemIndex > 0 } } để kiểm tra điều kiện cơ bản và các dẫn xuất đơn giản dựa trên các trạng thái thay đổi nhanh (chẳng hạn như độ lệch cuộn).
  • remember { derivedStateOf { ... } }: Lưu kết quả tính toán vào bộ nhớ đệm trên nhiều lần đọc. Sử dụng derivedStateOf trên các phiên bản Compose thấp hơn 1.13 hoặc khi bản thân phép tính tốn kém về mặt tính toán và được đọc nhiều lần trong một lượt kết hợp. Đừng truyền các giá trị trạng thái tổng quan nhanh được đọc bên trong derivedStateOf (chẳng hạn như listState.firstVisibleItemIndex) dưới dạng khoá remember, vì làm như vậy sẽ tạo lại DerivedState trên mọi thay đổi trạng thái và ngăn việc bỏ qua.
  • Phương thức getter Kotlin thuần tuý (get()) và các lượt đọc thuộc tính trực tiếp: Ưu tiên phương thức getter tuỳ chỉnh thuần tuý cho các biểu thức boolean đơn giản không cần giảm thiểu việc vô hiệu hoá, đồng thời đọc trực tiếp tập hợp .size thay vì gói tập hợp đó trong derivedStateOf. Tránh sử dụng produceState với snapshotFlow cho các dẫn xuất trạng thái đồng bộ, vì việc này làm tăng thêm mức hao tổn không cần thiết cho việc điều phối coroutine.

Trì hoãn việc đọc càng lâu càng tốt

Khi đã xác định được vấn đề về hiệu suất, việc trì hoãn các lần đọc trạng thái có thể có ích. Việc trì hoãn các lần đọc trạng thái đảm bảo Compose chạy lại mã tối thiểu có thể có trong bản tái cấu trúc. Ví dụ: nếu giao diện người dùng có trạng thái được nâng lên trong cây thành phần kết hợp và bạn có thể đọc trạng thái trong thành phần kết hợp con, thì bạn có thể gói trạng thái đó trong hàm lambda. Cách này khiến việc đọc chỉ xảy ra khi thực sự cần thiết. Để tham khảo, hãy xem cách triển khai trong ứng dụng mẫu Jetsnack. Jetsnack triển khai hiệu ứng collapsing-toolbar-like trên màn hình chi tiết của ứng dụng. Để hiểu nguyên lý hoạt động của kỹ thuật này, hãy xem bài đăng trên blog Jetpack Compose: Gỡ lỗi cấu tạo lại.

Để đạt được hiệu ứng này, composable Title cần có độ lệch cuộn để bù vào bằng cách sử dụng Modifier. Dưới đây là phiên bản mã Jetsnack được đơn giản hoá trước khi thực hiện tối ưu hoá:

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value)
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

Khi trạng thái cuộn thay đổi, Compose sẽ vô hiệu hoá phạm vi tổng hợp lại gần nhất. Trong trường hợp này, phạm vi gần nhất là thành phần kết hợp SnackDetail. Xin lưu ý rằng Box là một hàm cùng dòng, do đó không phải là một phạm vi kết hợp lại. Vì vậy, Compose sẽ kết hợp lại SnackDetail và mọi thành phần kết hợp bên trong SnackDetail. Nếu bạn thay đổi mã để chỉ đọc trạng thái mình cần, bạn có thể giảm số phần tử cần kết hợp lại.

@Composable
fun SnackDetail() {
    // ...

    Box(Modifier.fillMaxSize()) { // Recomposition Scope Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value }
        // ...
    } // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset)
    ) {
        // ...
    }
}

Tham số cuộn hiện là tham số hàm lambda. Điều đó có nghĩa là Title vẫn có thể tham chiếu trạng thái nâng, nhưng giá trị chỉ được đọc bên trong Title khi thực sự cần thiết. Kết quả là khi giá trị cuộn thay đổi, phạm vi tổng hợp lại gần nhất hiện là thành phần kết hợp Title – Compose không cần phải tổng hợp lại toàn bộ Box nữa.

Đây là một điểm cải tiến tốt, nhưng bạn có thể làm tốt hơn! Bạn nên cân nhắc nếu tạo ra tái cấu trúc chỉ để bố trí hoặc vẽ lại một Thành phần kết hợp. Trong trường hợp này, tất cả những gì bạn đang làm là thay đổi độ lệch của thành phần kết hợp Title. Bạn có thể thực hiện việc này trong giai đoạn bố cục.

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    Column(
        modifier = Modifier
            .offset { IntOffset(x = 0, y = scrollProvider()) }
    ) {
        // ...
    }
}

Trước đây, mã này sử dụng Modifier.offset(x: Dp, y: Dp) để lấy giá trị chênh lệch làm thông số. Bằng cách chuyển sang phiên bản hàm lambda của đối tượng sửa đổi, bạn có thể đảm bảo hàm đọc trạng thái cuộn trong giai đoạn bố cục. Do đó, khi trạng thái cuộn thay đổi, Compose có thể bỏ qua toàn bộ giai đoạn cấu trúc và chuyển thẳng đến giai đoạn bố cục. Khi thường xuyên truyền các biến Trạng thái thay đổi thành công cụ sửa đổi, bạn nên sử dụng phiên bản hàm lambda bất cứ khi nào có thể.

Sau đây là một ví dụ khác về phương pháp này. Mã này chưa được tối ưu hoá:

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(
    Modifier
        .fillMaxSize()
        .background(color)
)

Tại đây, màu nền của hộp sẽ chuyển đổi nhanh giữa hai màu. Do đó, trạng thái này thường xuyên thay đổi. Sau đó, thành phần kết hợp sẽ đọc trạng thái này trong công cụ sửa đổi nền. Do đó, hộp phải sắp xếp lại trên mọi khung hình, vì màu sắc đang thay đổi trên các khung hình đó.

Để cải thiện điều này, hãy dùng công cụ sửa đổi trong hàm lambda. Trong trường hợp này là drawBehind. Điều đó có nghĩa là trạng thái màu chỉ được đọc trong giai đoạn vẽ. Do đó, Compose có thể bỏ qua hoàn toàn giai đoạn sáng tác và bố cục – khi màu thay đổi, trạng thái Compose sẽ chuyển thẳng đến giai đoạn vẽ.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
    Modifier
        .fillMaxSize()
        .drawBehind {
            drawRect(color)
        }
)

Tránh các lượt viết ngược

Compose có một giả định cốt lõi là bạn sẽ không bao giờ viết vào trạng thái đã được đọc. Khi bạn làm việc này, tính năng đó được gọi là viết ngược, nó có thể khiến quá trình tái cấu trúc diễn ra liên tục trên mọi khung hình.

Thành phần kết hợp sau đây cho thấy một ví dụ về loại lỗi này.

@Composable
fun BadComposable() {
    var count by remember { mutableIntStateOf(0) }

    // Causes recomposition on click
    Button(onClick = { count++ }, Modifier.wrapContentSize()) {
        Text("Recompose")
    }

    Text("$count")
    count++ // Backwards write, writing to state after it has been read</b>
}

Mã này cập nhật bộ đếm ở cuối composable sau khi đọc ở dòng trước. Nếu chạy mã này, bạn sẽ thấy sau khi nhấp vào nút, quá trình tái cấu trúc diễn ra khiến bộ đếm nhanh chóng tăng lên một cách vô hạn do Compose tái cấu trúc thành phần kết hợp này thấy trạng thái được đọc đã lỗi thời và vì thế đã lên lịch kết hợp lại.

Bạn có thể tránh viết lại toàn bộ nội dung bằng cách đừng bao giờ viết trạng thái ở dạng Bản cấu trúc. Nếu có thể, hãy luôn ghi trạng thái để phản hồi sự kiện và trong hàm lambda như ở ví dụ onClick trước đó.

Để xem hướng dẫn chuyên sâu về hoạt động ghi ngược, vòng lặp tái cấu trúc và điều phối giai đoạn, hãy xem bài viết Hoạt động ghi ngược trong Compose.

Tài nguyên khác