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

Một số lỗi thường gặp về Compose mà bạn có thể gặp phải. 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. Phần này liệt kê một số phương pháp hay nhất nhằm giúp bạn tránh những hạn chế đó.

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, 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 tổng hợp lại, toàn bộ danh sách liên hệ sẽ được sắp xếp lại mặc dù danh sách 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 tổng 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, sortComparator) {
        contacts.sortedWith(sortComparator)
    }

    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 cố sử dụng lại các mục một cách thông minh và chỉ tạo lại hoặc tổng hợp lại khi cần. Tuy nhiên, bạn cũng có thể giúp đưa ra quyết định tối ưu nhất.

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ự 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, tương tự đối với 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 tổng hợp lại không cần thiết. Trong trường hợp này, Compose có thể thấy rằng 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)
        }
    }
}

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 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:

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ị ở 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 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 tổng 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 tổng 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. Bạn có thể xem cách chúng tôi áp dụng phương pháp này cho ứ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: Gỡ lỗi cấu tạo lại.

Để đạt được hiệu ứng này, thành phần kết hợp Title cần phải biết mức chênh lệch của thanh 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ẽ tìm phạm vi tổng hợp lại gần nhất rồi vô hiệu hoá phạm vi đó. Trong trường hợp này, phạm vi gần nhất là thành phần kết hợp SnackDetail. Lưu ý: Box là một hàm cùng dòng, do đó không đóng vai trò như một phạm vi kết hợp lại. Vì vậy, Compose sẽ tổng hợp lại SnackDetail, cũng như tổng hợp lại các 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 bộ chỉnh sửa, 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, chúng ta có thể 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 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
}

Mã này cập nhật số lượng ở cuối thành phần kết hợp sau khi đọc ở dòng trên. 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 đó.