Tư duy trong Compose

Jetpack Compose là bộ công cụ khai báo giao diện người dùng hiện đại cho Android. Compose sẽ giúp bạn dễ dàng ghi và duy trì giao diện người dùng ứng dụng bằng cách cung cấp API khai báo cho phép bạn hiển thị giao diện người dùng của ứng dụng mà không cần ra lệnh thay đổi các chế độ xem giao diện người dùng. Thuật ngữ này cần được giải thích đôi chút nhưng có ý nghĩa rất quan trọng đối với cách thiết kế ứng dụng của bạn.

Mô hình lập trình khai báo

Trước đây, hệ phân cấp chế độ xem Android được biểu thị dưới dạng cây tiện ích giao diện người dùng. Khi trạng thái của ứng dụng thay đổi vì những yếu tố như hoạt động tương tác của người dùng, bạn cần cập nhật hệ thống phân cấp giao diện người dùng để hiển thị dữ liệu hiện tại. Cách phổ biến nhất để cập nhật giao diện người dùng là hướng dẫn cho cây bằng các hàm như findViewById() và thay đổi các nút bằng những phương thức gọi như button.setText(String), container.addChild(View) hoặc img.setImageBitmap(Bitmap). Các phương thức này thay đổi trạng thái nội bộ của tiện ích.

Việc điều chỉnh chế độ xem theo cách thủ công sẽ làm tăng khả năng xảy ra lỗi. Nếu một phần dữ liệu hiển thị ở nhiều vị trí thì bạn sẽ có thể quên cập nhật một trong các chế độ xem hiển thị dữ liệu đó. Việc tạo các trạng thái không hợp lệ cũng rất dễ xảy ra khi hai lượt cập nhật xung đột theo cách không mong đợi. Ví dụ: một lượt cập nhật có thể cố gắng đặt một giá trị nút vừa mới bị xoá khỏi giao diện người dùng. Nói chung, độ phức tạp của việc bảo trì phần mềm tăng lên cùng với số chế độ xem cần cập nhật.

Trong vài năm qua, toàn bộ ngành đã bắt đầu chuyển sang mô hình giao diện người dùng khai báo, giúp đơn giản hoá rất nhiều kỹ thuật liên quan đến việc xây dựng và cập nhật giao diện người dùng. Kỹ thuật này hoạt động bằng cách tạo lại từ đầu toàn bộ màn hình, sau đó chỉ áp dụng những thay đổi cần thiết. Phương pháp này giúp bạn dễ dàng cập nhật hệ phân cấp chế độ xem có trạng thái theo cách thủ công. Compose là một khung giao diện người dùng khai báo.

Một khó khăn trong việc tạo lại toàn bộ màn hình là giải pháp này có thể tốn kém về mặt thời gian, khả năng tính toán và mức sử dụng pin. Để giảm thiểu chi phí này, Compose sẽ lựa chọn một cách thông minh những phần cần vẽ lại trên giao diện người dùng bất cứ lúc nào. Điều này có một số ảnh hưởng đến cách bạn thiết kế các thành phần trên giao diện người dùng, như đã thảo luận trong bài viết về tính năng kết hợp lại.

Một hàm có khả năng kết hợp đơn giản

Bằng cách sử dụng Compose, bạn có thể xây dựng giao diện người dùng bằng cách xác định một tập hợp các hàm có khả năng kết hợp lấy dữ liệu và cung cấp các thành phần trên giao diện người dùng. Một ví dụ đơn giản là tiện ích Greeting lấy String và cung cấp tiện ích Text hiển thị tin nhắn chào mừng.

Ảnh chụp màn hình một chiếc điện thoại với dòng chữ "Hello World" (Xin chào) và mã của hàm có khả năng kết hợp đơn giản tạo ra giao diện người dùng đó

Hình 1. Một hàm có khả năng kết hợp đơn giản được chuyển dữ liệu và sử dụng dữ liệu này để hiển thị một tiện ích văn bản trên màn hình.

Vài điều đáng chú ý về hàm này:

  • Hàm này được chú thích bằng chú thích @Composable. Tất cả hàm có khả năng kết hợp đều phải có chú thích này; chú thích này sẽ thông báo cho trình biên dịch Compose rằng hàm này có mục đích chuyển đổi dữ liệu thành giao diện người dùng.

  • Hàm này lấy dữ liệu. Các hàm có khả năng kết hợp có thể chấp nhận các thông số cho phép logic của ứng dụng mô tả giao diện người dùng. Trong trường hợp này, tiện ích của chúng tôi chấp nhận String để có thể chào người dùng theo tên.

  • Hàm này hiển thị văn bản trong giao diện người dùng. Hàm này thực hiện tính năng này bằng cách gọi hàm có khả năng kết hợp Text() - hàm thực sự tạo ra thành phần văn bản trên giao diện người dùng. Hàm có khả năng kết hợp cung cấp sự phân cấp giao diện người dùng bằng cách gọi các hàm có khả năng kết hợp khác.

  • Hàm này không trả về bất kỳ giá trị nào. Các hàm Compose cung cấp giao diện người dùng không cần trả về bất cứ điều gì vì các hàm này mô tả trạng thái màn hình mong muốn thay vì tạo các tiện ích giao diện người dùng.

  • Hàm này nhanh chóng, không thay đổi giá trị và không có tác dụng phụ.

    • Hàm này hoạt động theo cách tương tự như khi được gọi nhiều lần với cùng một đối số và không sử dụng các giá trị khác như biến toàn cục hoặc lệnh gọi đến random().
    • Hàm này mô tả giao diện người dùng mà không để lại bất kỳ tác dụng phụ nào, chẳng hạn như sửa đổi các thuộc tính hoặc biến toàn cục.

    Nhìn chung, bạn phải viết tất cả các hàm có khả năng kết hợp bằng các thuộc tính này vì những lý do được thảo luận trong phần kết hợp lại.

Thay đổi mô hình khai báo

Với nhiều bộ công cụ giao diện người dùng hướng đối tượng bắt buộc, bạn khởi chạy giao diện người dùng bằng cách tạo một cây tiện ích. Bạn thường thực hiện việc này bằng cách tăng cường một tệp bố cục XML. Mỗi tiện ích duy trì một trạng thái nội bộ riêng và hiển thị các phương thức getter và setter cho phép logic ứng dụng tương tác với tiện ích đó.

Trong phương pháp khai báo của Compose, các tiện ích tương đối không có trạng thái và không hiển thị các hàm setter hoặc getter. Trên thực tế, các tiện ích không hiển thị dưới dạng đối tượng. Bạn cập nhật giao diện người dùng bằng cách gọi cùng một hàm có khả năng kết hợp với các đối số khác nhau. Việc này giúp bạn dễ dàng cung cấp trạng thái cho các mẫu kiến trúc, chẳng hạn như ViewModel, như mô tả trong Hướng dẫn về cấu trúc ứng dụng. Sau đó, các yếu tố có thể kết hợp sẽ chịu trách nhiệm chuyển đổi trạng thái ứng dụng hiện tại thành giao diện người dùng mỗi khi cập nhật dữ liệu có thể quan sát.

Hình minh họa luồng dữ liệu trong giao diện người dùng Compose, từ các đối tượng cấp cao đến các đối tượng con.

Hình 2. Logic của ứng dụng cung cấp dữ liệu cho hàm có khả năng kết hợp cấp cao nhất. Hàm đó sử dụng dữ liệu để mô tả giao diện người dùng bằng cách gọi các yếu tố có thể kết hợp khác và chuyển dữ liệu thích hợp cho các yếu tố có thể kết hợp đó và xuống hệ thống phân cấp.

Khi người dùng tương tác với giao diện người dùng, giao diện người dùng sẽ đưa ra các sự kiện như onClick. Những sự kiện đó sẽ thông báo cho logic ứng dụng, qua đó có thể thay đổi trạng thái của ứng dụng. Khi trạng thái thay đổi, các hàm có khả năng kết hợp được gọi lại bằng dữ liệu mới. Việc này khiến các thành phần trên giao diện người dùng được vẽ lại–quy trình này được gọi là kết hợp lại.

Hình minh họa cách các thành phần trên giao diện người dùng phản hồi sự tương tác bằng cách kích hoạt các sự kiện do logic ứng dụng xử lý.

Hình 3. Người dùng tương tác với một thành phần trên giao diện người dùng, khiến sự kiện được kích hoạt. Logic của ứng dụng phản hồi sự kiện, sau đó các hàm có khả năng kết hợp sẽ tự động được gọi lại bằng các thông số mới (nếu cần).

Nội dung động

Vì các hàm có khả năng kết hợp được viết bằng Kotlin thay vì XML nên các hàm này có thể linh động như mọi mã Kotlin khác. Ví dụ: giả sử bạn muốn tạo giao diện người dùng chào đón một danh sách người dùng:

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

Hàm này lấy danh sách tên và tạo lời chào cho từng người dùng. Các hàm có khả năng kết hợp có thể rất phức tạp. Bạn có thể sử dụng câu lệnh if để quyết định xem có muốn hiển thị một thành phần trên giao diện người dùng cụ thể hay không. Bạn có thể sử dụng vòng lặp. Bạn có thể gọi các chức năng trợ giúp. Bạn có thể linh hoạt tuyệt đối với ngôn ngữ cơ bản. Sức mạnh và sự linh hoạt này là một trong những ưu điểm chính của Jetpack Compose.

Kết hợp lại

Trong mô hình giao diện người dùng bắt buộc, để thay đổi một tiện ích, bạn phải gọi một phương thức setter trên tiện ích đó để thay đổi trạng thái nội bộ của phương thức đó. Trong Compose, bạn gọi lại hàm có khả năng kết hợp với dữ liệu mới. Cách làm này sẽ giúp vẽ lại hàm được kết hợp lại–các tiện ích do hàm cung cấp (nếu cần) cùng với dữ liệu mới. Khung Compose có thể chỉ kết hợp lại các thành phần đã thay đổi một cách thông minh.

Ví dụ: hãy xem xét hàm có khả năng kết hợp hiển thị một nút này:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

Mỗi khi người dùng nhấp vào nút này, trình gọi sẽ cập nhật giá trị của clicks. Compose gọi lambda bằng hàm Text để hiển thị giá trị mới; quy trình này được gọi là kết hợp lại. Các hàm khác không phụ thuộc vào giá trị không được kết hợp lại.

Như chúng ta đã thảo luận, việc kết hợp lại toàn bộ cây giao diện người dùng có thể sẽ tốn kém vì sẽ mất thêm công suất tính toán và thời lượng pin. Compose giải quyết vấn đề này thông qua phương thức kết hợp lại thông minh sau.

Kết hợp lại là quá trình gọi lại các hàm có khả năng kết hợp khi giá trị nhập thay đổi. Quá trình này được thực hiện khi dữ liệu đầu vào của hàm thay đổi. Khi Compose kết hợp lại dựa trên dữ liệu nhập mới, công cụ này chỉ gọi các hàm hoặc lambda có thể đã thay đổi và bỏ qua các yếu tố còn lại. Bằng cách bỏ qua tất cả các hàm hoặc lambda không có thông số được thay đổi, Compose có thể kết hợp lại một cách hiệu quả.

Không phụ thuộc vào tác dụng phụ của việc thực thi các hàm có khả năng kết hợp vì việc kết hợp lại một hàm có thể bị bỏ qua. Nếu bạn thực hiện điều này, người dùng có thể gặp phải hành vi lạ và không thể đoán trước trong ứng dụng. Tác dụng phụ là bất kỳ thay đổi nào hiển thị với phần còn lại của ứng dụng. Ví dụ: những hành động sau đây đều là tác dụng phụ nguy hiểm:

  • Ghi vào thuộc tính của đối tượng dùng chung
  • Cập nhật giá trị có thể quan sát trong ViewModel
  • Cập nhật các tuỳ chọn dùng chung

Các hàm có khả năng kết hợp có thể được thực thi lại thường xuyên trong mỗi khung hình, chẳng hạn như khi hệ thống hiển thị một ảnh động. Các hàm có khả năng kết hợp phải nhanh chóng để tránh bị giật trong ảnh động. Nếu bạn cần thực hiện các thao tác tốn kém, chẳng hạn như đọc từ các tuỳ chọn dùng chung, hãy thực hiện việc này trong coroutine trong nền và chuyển giá trị kết quả đến hàm có khả năng kết hợp dưới dạng thông số.

Ví dụ: mã này tạo một yếu tố có thể kết hợp để cập nhật một giá trị trong SharedPreferences. Yếu tố có thể kết hợp không được đọc hoặc ghi từ chính các tuỳ chọn dùng chung. Thay vào đó, mã này sẽ đọc và ghi vào ViewModel trong coroutine trong nền. Logic ứng dụng chuyển giá trị hiện tại bằng lệnh gọi lại để kích hoạt lệnh cập nhật.

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

Tài liệu này thảo luận một số điều cần lưu ý khi bạn lập trình trong Compose:

  • Các hàm có khả năng kết hợp có thể thực thi theo thứ tự bất kỳ.
  • Các hàm có khả năng kết hợp có thể thực thi đồng thời.
  • Việc kết hợp lại bỏ qua nhiều hàm có khả năng kết hợp và lambda nhất có thể.
  • Việc kết hợp lại là khả quan và có thể bị hủy.
  • Một hàm có khả năng kết hợp có thể chạy khá thường xuyên, như trong mỗi khung ảnh động.

Các phần sau sẽ trình bày cách tạo hàm có khả năng kết hợp để hỗ trợ quá trình kết hợp lại. Trong mọi trường hợp, phương pháp hay nhất là đảm bảo các hàm có khả năng kết hợp chạy nhanh, không thay đổi giá trị và không có tác dụng phụ.

Các hàm có khả năng kết hợp có thể thực thi theo thứ tự bất kỳ

Nếu kiểm tra mã cho một hàm có khả năng kết hợp, bạn có thể giả định rằng mã được chạy theo thứ tự nó xuất hiện. Tuy nhiên, điều này không nhất thiết đúng. Nếu một hàm có khả năng kết hợp chứa lệnh gọi đến các hàm có khả năng kết hợp khác thì các hàm đó có thể chạy theo thứ tự bất kỳ. Bạn có thể dùng Compose để nhận diện một số thành phần trên giao diện người dùng có mức độ ưu tiên cao hơn các thành phần khác và vẽ ra các thành phần đó trước.

Ví dụ: giả sử bạn có mã như sau để vẽ ba màn hình trong một bố cục thẻ:

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

Các lệnh gọi đến StartScreen, MiddleScreenEndScreen có thể xuất hiện theo thứ tự bất kỳ. Điều này có nghĩa là bạn không thể để StartScreen() đặt một số biến toàn cục (một tác dụng phụ) và để MiddleScreen() tận dụng thay đổi đó. Thay vào đó, mỗi hàm đó cần phải khép kín.

Các hàm có khả năng kết hợp có thể chạy song song

Compose có thể tối ưu hoá chức năng kết hợp lại bằng cách chạy song song các hàm có khả năng kết hợp. Cách này cho phép Compose tận dụng nhiều lõi và chạy các hàm có khả năng kết hợp không có trên màn hình ở mức độ ưu tiên thấp hơn.

Tính năng tối ưu hoá này có nghĩa là một hàm có khả năng kết hợp có thể thực thi trong một nhóm các chuỗi nền. Nếu một hàm có khả năng kết hợp gọi một hàm trên ViewModel thì Compose có thể gọi hàm đó từ nhiều chuỗi cùng lúc.

Để đảm bảo ứng dụng hoạt động chính xác, tất cả hàm có khả năng kết hợp không được có tác dụng phụ. Thay vào đó, hãy kích hoạt các tác dụng phụ của lệnh gọi lại, chẳng hạn như onClick luôn thực thi trên chuỗi giao diện người dùng.

Khi một hàm có khả năng kết hợp được gọi, lệnh gọi có thể xảy ra trên một chuỗi khác với phương thức gọi. Điều đó có nghĩa là bạn nên tránh sử dụng mã sửa đổi các biến dưới dạng lambda có thể kết hợp vì loại mã này không an toàn cho chuỗi và vì đây là một tác dụng phụ không thể chấp nhận được của lambda có thể kết hợp.

Dưới đây là ví dụ về một yếu tố có thể kết hợp hiển thị danh sách và số lượng:

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

Mã này không có tác dụng phụ và chuyển đổi danh sách nhập vào giao diện người dùng. Đây là mã lý tưởng cho việc hiển thị một danh sách nhỏ. Tuy nhiên, nếu hàm này ghi vào một biến cục bộ thì mã này sẽ không đúng hoặc không an toàn cho chuỗi:

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}

Ở ví dụ này, items được sửa đổi với các thành phần kết hợp lại. Đó có thể là mọi khung ảnh động hoặc khi danh sách cập nhật. Dù là điều nào thì giao diện người dùng cũng sẽ hiển thị số lượng không chính xác. Do đó, cách ghi này sẽ không được hỗ trợ trong Compose, với việc cấm các cách ghi đó, chúng tôi cho phép khung thay đổi các chuỗi thực thi lambda có thể kết hợp.

Bỏ qua việc kết hợp lại nhiều nhất có thể

Khi các phần trong giao diện người dùng của bạn không hợp lệ, Compose sẽ cố gắng hết sức để kết hợp lại các phần cần cập nhật. Điều này có nghĩa là công cụ này có thể bỏ qua để chạy lại thành phần có thể kết hợp của một nút mà không cần thực thi bất kỳ thao tác có thể kết hợp nào ở trên hoặc bên dưới trong cây giao diện người dùng.

Mọi hàm và lambda có khả năng kết hợp đều có thể tự phân tích lại. Dưới đây là ví dụ minh họa về cách việc kết hợp lại có thể bỏ qua một số phần tử khi hiển thị một danh sách:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.h5)
        Divider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

Mỗi phạm vi này có thể là nội dung duy nhất cần thực thi trong quá trình kết hợp lại. Compose có thể bỏ qua cho đến lambda Column mà không cần thực thi bất kỳ yếu tố gốc nào của yếu tố này khi header thay đổi. Và khi thực thi Column, Compose có thể chọn bỏ qua các mục của LazyColumn nếu names không thay đổi.

Xin nhắc lại, việc thực thi tất cả hàm hoặc lambda có khả năng kết hợp không được có tác dụng phụ. Khi bạn cần thực hiện tác dụng phụ, hãy kích hoạt từ lệnh gọi lại.

Việc kết hợp lại là khả quan

Việc kết hợp lại bắt đầu lại bất cứ khi nào Compose cho rằng các thông số của một thành phần có thể kết hợp có thể đã thay đổi. Việc kết hợp lại là khả quan, nghĩa là Compose sẽ hoàn thành việc kết hợp lại trước khi các thông số thay đổi lần nữa. Nếu một thông số thay đổi trước khi quá trình kết hợp lại hoàn tất thì Compose có thể sẽ hủy quá trình kết hợp lại đó và bắt đầu lại bằng thông số mới.

Khi quá trình kết hợp lại bị hủy, Compose sẽ loại bỏ cây giao diện người dùng khỏi quá trình kết hợp lại. Nếu bạn có bất kỳ tác dụng phụ nào phụ thuộc vào giao diện người dùng đang hiển thị thì tác dụng phụ này sẽ được áp dụng ngay cả khi quá trình kết hợp lại bị hủy. Điều này có thể làm cho trạng thái ứng dụng không nhất quán.

Hãy đảm bảo rằng tất cả hàm và lambda có khả năng kết hợp đều giống nhau và không có tác dụng phụ khi xử lý việc kết hợp lại một cách khả quan.

Các hàm có khả năng kết hợp có thể chạy khá thường xuyên

Trong một số trường hợp, một hàm có khả năng kết hợp có thể chạy cho mọi khung giao diện người dùng. Nếu thực hiện các thao tác tốn kém (chẳng hạn như đọc từ bộ nhớ của thiết bị) thì hàm này có thể khiến giao diện người dùng bị giật.

Ví dụ: nếu tiện ích cố gắng đọc chế độ cài đặt của thiết bị thì tiện ích đó có thể đọc chế độ cài đặt hàng trăm lần một giây và gây ra ảnh hưởng tiêu cực đến hiệu suất của ứng dụng.

Nếu hàm có khả năng kết hợp của bạn cần dữ liệu thì hàm đó phải xác định các thông số cho dữ liệu. Sau đó, bạn có thể chuyển thao tác tốn kém sang một chuỗi khác, bên ngoài thành phần kết hợp và chuyển dữ liệu sang Compose bằng mutableStateOf hoặc LiveData.