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

Bạn có thể gặp phải các lỗi thường gặp về Compose. Những lỗi này có thể khiến bạn mã có vẻ chạy đủ tốt nhưng có thể làm giảm hiệu suất của giao diện người dùng. Làm theo các phương pháp hay nhất để tối ưu hoá ứng dụng của bạn 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, nhiều nhất là với mỗi khung ả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 này, phép tính sẽ 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 việc sắp xếp lại 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 bạ sẽ được sắp xếp lại, mặc dù danh sách này không 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 hiệu quả các mục, 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 cho việc 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ị 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 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ự giúp đỡ của bạn, Compose sẽ không nhận ra các mục không thay đổi vừa được di chuyển 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à hoàn toàn ngừng hoạt động. 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 cho vấn đề này là cung cấp khoá mục. Việc cung cấp một khoá ổn định cho mỗi mục cho phép Compose tránh các thành phầ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 hiện ở vị trí 3 chính là mục 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 phải kết hợp lại.

@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 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 kết hợp lại thường xuyên – bạn không cần phải kết hợp lại cho đến khi một mục mới xuất hiện ở 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 nào thực sự sẽ kích hoạt quá trình kết hợp lại. Trong trường hợp này, hã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 giá trị trạng thái đó thay đổi, 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 cuộn đủ để đưa một mục mới lên đầu thì 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 ứng dụng mẫu Jetsnack. Jetsnack triển khai hiệu ứng thu gọn-thanh công cụ trên màn hình chi tiết. Để hiểu lý do vì sao kỹ thuật này hoạt động, hãy xem bài đăng trên blog Jetpack Compose: Debugging Recomposition (Jetpack Compose: Gỡ lỗi kết hợp lại).

Để có được hiệu ứng này, thành phần kết hợp Title cần có độ lệch cuộn để bù cho chính nó 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 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 kết 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 và do đó không phải là 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 thay đổi mã để chỉ đọc trạng thái mà bạn thực sự sử dụng, 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ã sử dụng Modifier.offset(x: Dp, y: Dp) để lấy phần bù trừ dưới dạng tham số. Bằng cách chuyển sang phiên bản 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 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 giai đoạn thành phần và bố cục – khi màu sắc thay đổ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 { 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ẽ nhận thấy sau khi nhấp vào nút, điều này dẫn đến quá trình kết hợp lại, bộ đếm sẽ nhanh chóng tăng trong một 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, thấy một trạng thái được đọc đã lỗi thời và do đó, 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 đó.

Tài nguyên khác