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

Bạn có thể gặp phải các lỗi Compose thường gặp. Những lỗi này có thể khiến bạn nhận được mã dường như chạy đủ tốt, nhưng có thể ảnh hưởng xấu đến hiệu suất giao diện người dùng. Theo dõi 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 có khả năng kết hợp có thể chạy rất thường xuyên, với tần suất như trên mọi khung hình của ả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ả tính toán bằng remember. Bằng cách đó, phép tính chỉ chạy một lần và bạn có thể tìm nạp kết quả bất cứ khi nào cần thiết.

Ví dụ: đây là một số mã hiển thị danh sách các tên được sắp xếp, nhưng một cách rất tốn kém:

@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 sách liên hệ sẽ được sắp xếp tất cả lặp lại, mặc dù danh sách chưa thay đổ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.

Dùng các mã khoá bố cục lazy

Bố cục lazy sử dụng lại các mục một cách hiệu quả, chỉ tạo lại hoặc kết hợp lại các mục đó 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 cho kết hợp lại.

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ị một danh sách ghi chú được sắp xếp theo thời gian sửa đổi ghi chú được sửa đổi gần đây ở trên cùng.

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

Tuy nhiên, mã này có vấn đề. Giả sử ghi chú dưới cùng đã 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 rằng các mục không thay đổi đang đã chuyển trong danh sách. Thay vào đó, Compose cho rằng "mục 2" cũ đã bị xoá và một cái mới đã được tạo cho mục 3, mục 4 và cho đến hết. Kết quả là Compose kết hợp lại mọi 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 ở đây là cung cấp khoá mục. Cung cấp khoá ổn định cho mỗi mục cho phép Compose tránh các 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 rằng vật phẩm hiện ở vị trí 3 chính là vật phẩm đã từng ở vị trí 2. Vì không có dữ liệu nào cho mục đó thay đổi nên Compose không cần kết hợp lại nó.

@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)
        }
    }
}

Dùng 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 hiển thị 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 là mục hiển thị đầu tiên trên 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 kết hợp lại báo cáo thường xuyên như vậy không cần kết hợp lại cho đến khi một mục mới hiển thị ở dưới 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. 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 sẽ kích hoạt quá trình kết hợp lại. Trong trường hợp này, chỉ định rằng bạn quan tâm đến thời điểm mục hiển thị đầu tiên thay đổi. Khi điều đó giá trị trạng thái thay đổi thì giao diện người dùng cần kết hợp lại, nhưng nếu người dùng chưa kết hợp lại cuộn đủ để đưa một mục mới lên trên cùng, thì bạn không cần 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()
}

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 Jetsnack ứng dụng mẫu. Jetsnack triển khai hiệu ứng thu gọn giống như thanh công cụ trên màn hình chi tiết. Để hiểu lý do hiệu quả của kỹ thuật này, hãy xem bài đăng trên blog Jetpack Compose: Gỡ lỗi kết hợp lại.

Để đạt được hiệu ứng này, thành phần kết hợp Title cần độ lệch cuộn để bù trừ bằng cách sử dụng Modifier. Dưới đây là phiên bản đơn giản của Mã Jetsnack 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á thành phần mẹ gần nhất phạm vi kết hợp lại. Trong trường hợp này, phạm vi gần nhất là SnackDetail thành phần kết hợp. Lưu ý Box là hàm cùng dòng nên không phải là quá trình kết hợp lại phạm vi. 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à bạn thực sự thì bạn có thể giảm số lượng 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 hàm độ lệch dưới dạng tham số. Bằng cách chuyển sang phiên bản lambda của công cụ sửa đổi, bạn có thể đảm bảo hàm này đọ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 sử dụng đối tượng sửa đổi dựa trên 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 các giai đoạn thành phần và bố cục (khi màu sắc) thì 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 { mutableStateOf(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 số lượng ở cuối thành phần kết hợp sau khi đọc trên dòng trước đó. Nếu chạy mã này, bạn sẽ thấy điều đó sau khi nhấp vào , dẫn đến quá trình kết hợp lại, bộ đếm sẽ tăng nhanh theo vòng lặp vô hạn khi Compose kết hợp lại Thành phần kết hợp này, sẽ thấy trạng thái được đọc lỗi thời, rồi lên lịch kết hợp lại khác.

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 đó.

Tài nguyên khác