Hiệu suất của Compose

Mục đích của Jetpack Compose là mang lại hiệu suất vượt trội ngay từ đầu. Trang này hướng dẫn bạn cách viết và định cấu hình ứng dụng để đạt được hiệu suất tốt nhất, đồng thời chỉ ra một số kiểu cấu hình cần tránh.

Trước khi đọc nội dung này, bạn có thể tìm hiểu các khái niệm chính về Compose trong Tư duy trong Compose.

Định cấu hình đúng cách cho ứng dụng của bạn

Nếu ứng dụng của bạn hoạt động kém hiệu quả, nghĩa là có thể đã xảy ra vấn đề với cấu hình. Việc đầu tiên nên làm là kiểm tra các tuỳ chọn cấu hình sau.

Tích hợp ở chế độ phát hành và sử dụng R8

Nếu bạn tìm thấy các sự cố về hiệu suất, hãy nhớ chạy ứng dụng ở chế độ phát hành. Chế độ gỡ lỗi rất hữu ích để phát hiện nhiều vấn đề, nhưng nó cũng khiến hiệu suất giảm đáng kể và còn gây khó khăn cho việc phát hiện các vấn đề khác về mã làm ảnh hưởng đến hiệu suất. Bạn cũng nên sử dụng Trình biên dịch R8 để xoá mã không cần thiết khỏi ứng dụng của mình. Theo mặc định, việc tạo ở chế độ phát hành sẽ tự động sử dụng trình biên dịch R8.

Sử dụng hồ sơ cơ sở

Compose được phân phối dưới dạng thư viện thay vì trở thành một phần của nền tảng Android. Phương pháp này cho phép chúng tôi thường xuyên cập nhật Compose và hỗ trợ các phiên bản Android cũ. Tuy nhiên, việc phân phối Compose dưới dạng thư viện sẽ gây ra hao tổn. Mã nền tảng Android đã được biên dịch và cài đặt trên thiết bị. Trong khi đó, thư viện cần được tải khi ứng dụng khởi động và diễn giải đúng thời điểm khi cần thiết. Thao tác này có thể làm chậm ứng dụng khi khởi động và bất cứ khi nào ứng dụng sử dụng tính năng thư viện lần đầu tiên.

Bạn có thể cải thiện hiệu suất bằng cách xác định hồ sơ cơ sở. Hồ sơ này xác định các lớp và phương thức cần thiết trong những hành trình trọng yếu của người dùng và được phân phối cùng với APK của ứng dụng. Trong quá trình cài đặt ứng dụng, ART biên dịch trước mã quan trọng đó, vì vậy, mã này đã sẵn sàng để sử dụng khi ứng dụng khởi chạy.

Không phải lúc nào bạn cũng dễ dàng xác định một hồ sơ cơ sở tốt, do vậy, theo mặc định, Compose đã có sẵn một hồ sơ cơ bản. Bạn có thể có được lợi ích này mà cần không phải làm gì. Tuy nhiên, nếu tự tạo hồ sơ, hồ sơ bạn tạo có thể không thực sự cải thiện hiệu suất của ứng dụng. Bạn nên kiểm tra để xác minh hồ sơ đó hữu ích. Để làm việc đó, bạn nên viết các thử nghiệm Macrobenchmark cho ứng dụng và kiểm tra kết quả thử nghiệm khi viết và sửa đổi hồ sơ cơ sở của bạn. Để xem ví dụ về cách viết thử nghiệm Macrobenchmark cho giao diện người dùng trong Compose, hãy xem mẫu Macrobenchmark Compose.

Để biết thêm các phân tích chi tiết về tác động của chế độ phát hành, R8 và cấu hình cơ sở, vui lòng xem bài đăng trên blog với tiêu đề Tại sao bạn nên luôn thử nghiệm hiệu suất Compose trong bản phát hành?.

Ba giai đoạn Compose ảnh hưởng như thế nào đến hiệu suất

Như đã thảo luận trong Các giai đoạn trong Jetpack Compose, khi Compose cập nhật một khung, nó sẽ trải qua ba giai đoạn:

  • Thành phần: Compose xác định nội dung cần hiển thị – thành phần này chạy các hàm kết hợp và xây dựng cây giao diện người dùng.
  • Bố cục: Compose xác định kích thước và vị trí của từng phần tử trong cây giao diện người dùng.
  • Vẽ: Compose hiển thị các thành phần giao diện người dùng riêng lẻ.

Compose có thể bỏ qua bất kỳ giai đoạn nào trong số đó một cách thông minh nếu không cần thiết. Ví dụ: hãy giả sử một phần tử đồ hoạ hoán đổi giữa hai biểu tượng có cùng kích thước. Vì phần tử đó không thay đổi kích thước và không có phần tử nào trong cây giao diện người dùng được thêm hoặc xoá nên Compose có thể bỏ qua các giai đoạn thành phần và bố cục đồng thời chỉ vẽ lại một phần tử đó.

Tuy nhiên, một số lỗi lập trình có thể khiến Compose khó nhận biết được giai đoạn nào có thể bỏ qua một cách an toàn. Nếu có nghi ngờ, Compose sẽ chạy cả ba giai đoạn. Việc này có thể khiến giao diện người dùng của bạn chậm hơn mức cần thiết. Vì vậy, nhiều phương pháp hay nhất về hiệu suất xoay quanh việc giúp Compose bỏ qua các giai đoạn mà phương thức này không cần làm.

Có một vài nguyên tắc chung mà bạn có thể áp dụng để cải thiện hiệu suất chung.

Trước tiên, bất cứ khi nào có thể, hãy di chuyển các phép tính ra khỏi các hàm kết hợp. Bạn có thể cần chạy lại các hàm có khả năng kết hợp bất cứ khi nào giao diện người dùng thay đổi; mọi mã bạn đặt trong thành phần kết hợp sẽ được thực thi lại, có thể cho tất cả các khung của ảnh động. Vì vậy, bạn nên giới hạn mã của thành phần kết hợp ở cấp độ chỉ thực sự cần thiết để tạo giao diện người dùng.

Thứ hai, trạng thái trì hoãn khi đọc càng lâu càng tốt. Bằng cách chuyển trạng thái đọc cho thành phần kết hợp con hoặc giai đoạn sau, bạn có thể giảm thiểu việc kết hợp lại hoặc bỏ qua hẳn giai đoạn thành phần. Bạn có thể thực hiện việc này bằng cách truyền các hàm lambda thay vì giá trị trạng thái cho trạng thái thay đổi thường xuyên, và bằng cách ưu tiên các giá trị sửa đổi trên hàm lambda khi truyền ở trạng thái thường xuyên thay đổi. Bạn có thể xem ví dụ về kỹ thuật này trong phần Trì hoãn việc đọc càng lâu càng tốt.

Phần sau đây mô tả một số lỗi mã cụ thể có thể gây ra các vấn đề này. Hy vọng các ví dụ cụ thể được đề cập ở đây cũng sẽ giúp bạn phát hiện các lỗi tương tự khác trong mã.

Sử dụng các công cụ để giúp tìm ra vấn đề

Thật khó để biết vấn đề về hiệu suất nằm ở đâu và mã nào để bắt đầu tối ưu hoá. Hãy bắt đầu bằng cách sử dụng các công cụ giúp thu hẹp vấn đề bạn gặp phải.

Tính số lần tái cấu trúc

Bạn có thể sử dụng trình kiểm tra bố cục để kiểm tra tần suất thành phần kết hợp được tái cấu trúc hoặc bị bỏ qua.

Số lần tái cấu trúc sẽ được hiển thị trong trình kiểm tra bố cục

Để biết thêm thông tin, vui lòng xem bài viết về phần công cụ.

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 thẻ nhớ để 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 đó, phép tính sẽ chạy một lần và có thể tìm nạp được 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, tuy nhiên về cơ bả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 ->
            // ...
        }
    }
}

Vấn đề ở đây là mỗi khi ContactsList được tổng hợp lại, toàn bộ danh bạ liên hệ sẽ được sắp xếp lại, mặc dù danh sách này 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 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í.

Vấn đề ở đây là nếu không có sự giúp đỡ của bạn, Compose sẽ không nhận ra là 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 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 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 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 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

Bạn nên trì hoãn việc đọc các biến trạng thái càng lâu càng tốt. Việc trì hoãn các lần đọc trạng thái có thể giúp đảm bảo Compose chạy lại mã tối thiểu có thể có trên 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.

Để đạ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 và vô hiệu hoá phạm vi đó. Trong trường hợp này, lớp mẹ gần nhất là thành phần kết hợp Box. Vì vậy, Compose sẽ tổng hợp lại Box, đồng thời cũng tổng hợp lại các thành phần kết hợp bên trong Box. 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 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
}

Mã này cập nhật số lượng ở cuối thành phần kết hợp, sau khi đọc mã đó ở dòng trên. Nếu chạy mã này, bạn sẽ thấy rằng quá trình tái cấu trúc sẽ diễn ra sau khi nhấp vào nút, bộ đếm sẽ nhanh chóng tăng lên trong vòng lặp vô hạn. Khi Compose tái cấu trúc thành phần kết hợp này sẽ thấy trạng thái được đọc đã lỗi thời, và vì thế đã lên lịch cấu trúc 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 đó.