Trạng thái và Jetpack Compose

Trạng thái trong ứng dụng là giá trị bất kỳ có thể thay đổi theo thời gian. Đây là định nghĩa rất rộng và bao gồm mọi thứ từ cơ sở dữ liệu Room cho đến một biến trên một lớp (class).

Tất cả ứng dụng Android đều cho người dùng thấy trạng thái. Sau đây là một số ví dụ về trạng thái trong ứng dụng Android:

  • Một thanh thông báo nhanh cho biết thời điểm không thể thiết lập kết nối mạng.
  • Một bài đăng trên blog và các bình luận liên quan.
  • Ảnh động gợn sóng trên các nút phát khi người dùng nhấp vào.
  • Hình dán mà người dùng có thể vẽ lên hình ảnh.

Jetpack Compose giúp bạn hiểu rõ vị trí và cách thức lưu trữ cũng như dùng trạng thái trong một ứng dụng Android. Tài liệu hướng dẫn này tập trung vào hoạt động kết nối giữa các trạng thái và thành phần kết hợp (composable), đồng thời tập trung vào những API mà Jetpack Compose cung cấp để xử lý trạng thái dễ dàng hơn.

Trạng thái và thành phần

Compose mang tính khai báo và vì vậy, cách duy nhất để cập nhật Compose là gọi cùng một thành phần kết hợp (composable) với đối số mới. Các đối số này là đại diện cho trạng thái giao diện người dùng. Mỗi khi một trạng thái được cập nhật, một lượt tái cấu trúc (recomposition) diễn ra. Do đó, những thành phần như TextField sẽ không tự động cập nhật như đối với khung hiển thị dựa trên XML bắt buộc. Một thành phần kết hợp phải được thông báo rõ ràng về trạng thái mới để cập nhật tương ứng.

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       Text(
           text = "Hello!",
           modifier = Modifier.padding(bottom = 8.dp),
           style = MaterialTheme.typography.h5
       )
       OutlinedTextField(
           value = "",
           onValueChange = { },
           label = { Text("Name") }
       )
   }
}

Nếu thực hiện việc này, bạn sẽ thấy rằng không có điều gì xảy ra. Nguyên nhân là do TextField không tự cập nhật, mà sẽ cập nhật khi tham số value của nó thay đổi. Lý do nằm ở cách hoạt động của tính năng cấu trúc (composition) và tái cấu trúc (recomposition) trong Compose.

Để tìm hiểu thêm về quá trình cấu trúc ban đầu và tái cấu trúc, hãy xem bài viết Cách suy nghĩ trong Compose.

Trạng thái trong thành phần kết hợp

Các hàm có khả năng kết hợp có thể sử dụng API remember để lưu trữ đối tượng trong bộ nhớ. Một giá trị do remember tính toán được lưu trữ trong Cấu trúc (Composition) trong quá trình cấu trúc ban đầu. Giá trị đã lưu trữ được trả về trong quá trình tái cấu trúc. Bạn có thể dùng remember để lưu trữ cả đối tượng có thể thay đổi và không thể thay đổi.

mutableStateOf tạo ra MutableState<T> có thể quan sát. Đây là một loại đối tượng có thể quan sát được tích hợp với thời gian chạy Compose.

interface MutableState<T> : State<T> {
    override var value: T
}

Mọi thay đổi đối với value sẽ lên lịch tái cấu trúc mọi hàm có khả năng kết hợp có thể đọc value. Trong trường hợp ExpandingCard, bất cứ khi nào expanded thay đổi, hệ thống sẽ tái cấu trúc ExpandingCard.

Có 3 cách để khai báo đối tượng MutableState trong một thành phần kết hợp:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

Các thông tin khai báo này là tương đương và được cung cấp dưới dạng cú pháp dễ hiểu theo mục đích sử dụng của trạng thái. Bạn nên chọn định dạng tạo ra mã dễ đọc nhất trong thành phần kết hợp mà bạn đang viết.

Cú pháp uỷ quyền (delegate syntax) by yêu cầu các lần nhập sau:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

Bạn có thể sử dụng giá trị đã ghi nhớ làm tham số cho các thành phần kết hợp khác hoặc thậm chí là logic trong các câu lệnh để thay đổi thành phần kết hợp được hiển thị. Ví dụ: nếu bạn không muốn hiện lời chào nếu phần tên trống, hãy sử dụng trạng thái trong câu lệnh if:

@Composable
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       var name by remember { mutableStateOf("") }
       if (name.isNotEmpty()) {
           Text(
               text = "Hello, $name!",
               modifier = Modifier.padding(bottom = 8.dp),
               style = MaterialTheme.typography.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           label = { Text("Name") }
       )
   }
}

Mặc dù remember giúp bạn giữ lại trạng thái trên các lần tái cấu trúc, trạng thái này sẽ không được giữ lại khi bạn thay đổi cấu hình. Để làm được điều này, bạn phải sử dụng rememberSaveable. rememberSaveable tự động lưu mọi giá trị có thể lưu trong Bundle. Đối với các giá trị khác, bạn có thể chuyển vào một đối tượng lưu tuỳ chỉnh.

Các loại trạng thái được hỗ trợ khác

Jetpack Compose không đòi hỏi bạn phải dùng MutableState<T> để giữ trạng thái. Jetpack Compose hỗ trợ các loại đối tượng phát ra dữ liệu khác. Trước khi đọc một loại đối tượng khác có thể quan sát trong Jetpack Compose, bạn phải chuyển đổi đối tượng đó thành State<T> để Jetpack Compose có thể tự động tái cấu trúc khi trạng thái thay đổi.

Compose cung cấp các hàm để tạo State<T> từ những loại đối tượng có thể quan sát phổ biến được dùng trong các ứng dụng Android. Trước khi sử dụng những tiện ích tích hợp này, hãy thêm (các) cấu phần phần mềm phù hợp như được nêu dưới đây:

  • Flow: collectAsStateWithLifecycle()

    collectAsStateWithLifecycle() thu thập các giá trị từ Flow theo cách nhận biết vòng đời, cho phép ứng dụng tiết kiệm những tài nguyên không cần thiết. Hàm này biểu thị giá trị được tạo ra gần đây nhất thông qua State trong Compose. Bạn nên dùng API này để thu thập các luồng trên ứng dụng Android.

    Tệp build.gradle cần phải có phần phụ thuộc sau (phải sử dụng phiên bản 2.6.0-beta01 trở lên):

Kotlin

dependencies {
      ...
      implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.0-beta01")
}

Groovy

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.0-beta01"
}
  • Flow: collectAsState()

    collectAsState tương tự như collectAsStateWithLifecycle, vì hàm này cũng thu thập các giá trị từ Flow và chuyển đổi giá trị đó thành State trong Compose.

    Hãy dùng collectAsState cho mã không phụ thuộc vào nền tảng thay vì collectAsStateWithLifecycle, vốn chỉ dành cho Android.

    collectAsState không cần có thêm phần phụ thuộc vì hàm này có sẵn trong compose-runtime.

  • LiveData: observeAsState()

    observeAsState() bắt đầu quan sát LiveData này và biểu thị các giá trị của lớp này thông qua State.

    Tệp build.gradle phải có phần phụ thuộc sau đây:

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-livedata:1.3.2")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-livedata:1.3.2"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava2:1.3.2")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava2:1.3.2"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava3:1.3.2")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava3:1.3.2"
}

Có trạng thái so với không có trạng thái

Một thành phần kết hợp sử dụng remember để lưu trữ một đối tượng sẽ tạo trạng thái nội bộ, giúp thành phần kết hợp có trạng thái. HelloContent là một ví dụ về thành phần kết hợp có trạng thái vì hàm này giữ và sửa đổi trạng thái name nội tại. Điều này có thể hữu ích trong trường hợp phương thức gọi không cần kiểm soát trạng thái và có thể sử dụng mà không phải tự quản lý trạng thái. Tuy nhiên, các thành phần kết hợp với trạng thái nội bộ có xu hướng ít có khả năng tái sử dụng và khó kiểm thử hơn.

Thành phần kết hợp không có trạng thái (stateless) là thành phần kết hợp không thuộc một trạng thái nào. Một cách dễ dàng để đạt được trạng thái không có trạng thái là sử dụng tính năng chuyển trạng thái lên trên (state hoisting).

Khi phát triển các thành phần kết hợp có thể sử dụng lại, bạn thường muốn hiện cả phiên bản trạng thái và phiên bản không có trạng thái của cùng một thành phần kết hợp. Phiên bản có trạng thái sẽ hữu ích cho trình gọi không quan tâm đến trạng thái, và phiên bản không trạng thái cần thiết cho trình gọi cần kiểm soát hoặc di chuyển trạng thái lên trên.

Chuyển trạng thái lên trên

Tính năng chuyển trạng thái lên trên (state hoisting) trong Compose là một dạng chuyển đổi trạng thái cho phương thức gọi của một thành phần kết hợp khiến nó trở thành không trạng thái. Mô hình chung để di chuyển trạng thái lên trên trong Jetpack Compose là thay thế biến trạng thái bằng 2 tham số:

  • value: T: giá trị hiện tại để hiển thị
  • onValueChange: (T) -> Unit: một sự kiện yêu cầu thay đổi giá trị này, trong đó T là giá trị mới được đề xuất

Tuy nhiên, bạn không bị giới hạn ở onValueChange. Nếu các sự kiện cụ thể hơn phù hợp với thành phần kết hợp, bạn nên xác định sự kiện bằng cách sử dụng lambda như ExpandingCard với onExpandonCollapse.

Trạng thái được di chuyển lên trên theo cách này có một số thuộc tính quan trọng:

  • Một nguồn đáng tin cậy (single source of truth): Bằng cách di chuyển trạng thái thay vì sao chép, chúng tôi đảm bảo rằng chỉ có một nguồn thông tin duy nhất. Điều này giúp tránh các lỗi.
  • Được đóng gói (encapsulated): Chỉ các thành phần kết hợp có trạng thái mới có thể sửa đổi trạng thái của chúng. Nó có tính nội bộ hoàn toàn.
  • Có thể chia sẻ (shareable): Bạn có thể chia sẻ trạng thái được di chuyển lên trên với nhiều thành phần kết hợp. Nếu bạn muốn đọc name trong một thành phần kết hợp khác, việc di chuyển trạng thái lên trên sẽ cho phép bạn làm việc đó.
  • Có thể chắn (interceptable): phương thức gọi đến các thành phần kết hợp không trạng thái có thể quyết định bỏ qua hoặc sửa đổi các sự kiện trước khi thay đổi trạng thái.
  • Được tách riêng (decoupled): trạng thái của ExpandingCard không có trạng thái có thể được lưu trữ ở bất cứ đâu. Ví dụ: bạn hiện có thể di chuyển name sang ViewModel.

Trong trường hợp ví dụ, bạn trích xuất nameonValueChange ra HelloContent rồi di chuyển chúng lên trên đến một thành phần kết hợp HelloScreen bằng lệnh gọi HelloContent.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

Bằng cách nâng trạng thái ra khỏi HelloContent, sẽ dễ dàng cho việc lý giải thành phần kết hợp, tái sử dụng trong nhiều tình huống và kiểm thử. HelloContent được tách riêng khỏi cách lưu trữ trạng thái của tệp. Việc tách riêng có nghĩa là nếu sửa đổi hoặc thay thế HelloScreen, bạn không phải thay đổi cách thức triển khai HelloContent.

Mô hình mà trạng thái giảm và các sự kiện tăng lên được gọi là luồng dữ liệu một chiều (unidirectional). Trong trường hợp này, trạng thái giảm từ HelloScreen xuống HelloContent và các sự kiện tăng từ HelloContent lên HelloScreen. Bằng cách làm theo luồng dữ liệu một chiều, bạn có thể phân tách các thành phần có thể kết hợp hiển thị trạng thái trong giao diện người dùng khỏi các phần của ứng dụng lưu trữ và thay đổi trạng thái.

Xem trang Vị trí chuyển trạng thái lên trên để tìm hiểu thêm.

Khôi phục trạng thái trong Compose

Sử dụng rememberSaveable để khôi phục trạng thái trên giao diện người dùng sau khi tái tạo một hoạt động hoặc quy trình. rememberSaveable giữ lại trạng thái trên các quá trình tái cấu trúc Ngoài ra, rememberSaveable còn giữ nguyên trạng thái trên quá trình tái tạo một hoạt động hoặc quy trình.

Các cách lưu trữ trạng thái

Tất cả loại dữ liệu được thêm vào Bundle sẽ được lưu tự động. Nếu muốn lưu nội dung nào đó mà không thể thêm vào Bundle, bạn có thể làm theo các cách sau:

Parcelize

Cách đơn giản nhất là thêm chú thích @Parcelize vào đối tượng. Đối tượng sẽ đóng gói được và nhóm được. Ví dụ: mã này tạo một loại dữ liệu City có thể đóng gói và lưu dữ liệu vào trạng thái.

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

MapSaver

Nếu vì lý do nào đó @Parcelize không phù hợp, bạn có thể sử dụng mapSaver để xác định quy tắc của riêng mình nhằm chuyển đổi một đối tượng thành một tập hợp giá trị mà hệ thống có thể lưu vào Bundle.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

ListSaver

Để tránh phải xác định các khoá cho bản đồ, bạn cũng có thể dùng listSaver và sử dụng các chỉ mục làm khoá:

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

Phần tử giữ trạng thái trong Compose

Có thể quản lý quá trình chuyển trạng thái lên trên đơn giản trong chính các hàm có khả năng kết hợp. Tuy nhiên, nếu số lượng trạng thái cần theo dõi tăng lên hoặc logic để thực hiện trong các hàm có khả năng kết hợp phát sinh, bạn nên uỷ quyền trách nhiệm về logic và trạng thái cho các lớp khác: phần tử giữ trạng thái.

Hãy xem tài liệu về quá trình chuyển trạng thái lên trên (state hoisting) trong Compose hoặc tài liệu chung hơn là trang Phần tử giữ trạng thái và Trạng thái giao diện người dùng trong hướng dẫn về cấu trúc để tìm hiểu thêm.

Kích hoạt lại tính năng ghi nhớ các tính toán khi khoá thay đổi

API remember thường được dùng cùng với MutableState:

var name by remember { mutableStateOf("") }

Ở đây, việc sử dụng hàm remember giúp giá trị MutableState vẫn tiếp tục có hiệu lực khi kết hợp lại.

Nói chung, remember nhận tham số lambda calculation. Trong lần chạy đầu tiên, remember sẽ gọi hàm lambda calculation và lưu trữ kết quả. Trong quá trình kết hợp lại, remember sẽ trả về giá trị được lưu trữ gần đây nhất.

Ngoài trạng thái lưu vào bộ nhớ đệm, bạn cũng có thể sử dụng remember để lưu trữ đối tượng bất kỳ hoặc kết quả của một thao tác trong Cấu trúc (Composition) nếu tốn kém khi khởi chạy hoặc tính toán. Bạn có thể không muốn lặp lại phép tính này trong mỗi quá trình kết hợp lại. Ví dụ như việc tạo đối tượng ShaderBrush này có thể là một thao tác tốn kém:

val brush = remember {
  ShaderBrush(
    BitmapShader(
      ImageBitmap.imageResource(res, R.drawable.myDrawable).asAndroidBitmap(),
      Shader.TileMode.REPEAT,
      Shader.TileMode.REPEAT
    )
  )
}

remember lưu trữ giá trị cho đến khi thoát khỏi Cấu trúc. Tuy nhiên, có một cách để vô hiệu hoá giá trị đã lưu vào bộ nhớ đệm. API remember cũng lấy tham số key hoặc keys. Nếu bất kỳ khoá nào trong số này thay đổi thì lần tiếp theo khi hàm kết hợp lại, remember sẽ vô hiệu hoá bộ nhớ đệm và thực thi lại khối lambda tính toán. Cơ chế này cho phép bạn kiểm soát toàn thời gian của một đối tượng trong Cấu trúc. Phương pháp tính toán này vẫn có hiệu lực cho đến khi giá trị nhập thay đổi, thay vì cho đến khi giá trị được ghi nhớ bị xoá khỏi Cấu trúc.

Các ví dụ sau đây minh hoạ cách hoạt động của cơ chế này.

Trong đoạn mã này, ShaderBrush được tạo và sử dụng làm màu nền của thành phần kết hợp Box. remember lưu trữ thực thể ShaderBrush vì việc tạo lại rất tốn kém, như chúng tôi đã giải thích trước đó. remember lấy avatarRes làm tham số key1, là hình nền đã chọn. Nếu avatarRes thay đổi, bút vẽ sẽ kết hợp lại với hình ảnh mới và áp dụng lại cho Box. Điều này có thể xảy ra khi người dùng chọn một hình ảnh khác trong bộ chọn làm hình nền.

@Composable
fun BackgroundBanner(
   @DrawableRes avatarRes: Int,
   modifier: Modifier = Modifier,
   res: Resources = LocalContext.current.resources
) {
   val brush = remember(key1 = avatarRes) {
       ShaderBrush(
           BitmapShader(
               ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
               Shader.TileMode.REPEAT,
               Shader.TileMode.REPEAT
           )
       )
   }

   Box(
       modifier = modifier.background(brush)
   ) {
       // ...
   }
}

Trong đoạn mã tiếp theo, trạng thái được chuyển lên trên lớp phần tử giữ trạng thái thuần tuý MyAppState. Phương thức này hiển thị một hàm rememberMyAppState để khởi chạy một phiên bản của lớp bằng cách dùng remember. Việc hiển thị các hàm như vậy để tạo phiên bản tiếp tục có hiệu lực trong quá trình kết hợp lại là một mẫu phổ biến trong Compose. Hàm rememberMyAppState nhận windowSizeClass, đóng vai trò là tham số key cho remember. Nếu tham số này thay đổi, ứng dụng cần tạo lại phần tử giữ trạng thái thuần tuý có giá trị mới nhất. Điều này có thể xảy ra nếu người dùng xoay thiết bị chẳng hạn.

@Composable
fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { ... }

Compose sử dụng cách triển khai tương đương của lớp để quyết định xem một khoá đã thay đổi hay chưa và vô hiệu hoá giá trị được lưu trữ.

Lưu giữ trạng thái bằng các khoá khi kết hợp lại

API rememberSaveable là một trình bao bọc xung quanh remember có thể lưu trữ dữ liệu trong một Bundle. API này cho phép trạng thái tồn tại không chỉ trong quá trình kết hợp lại, mà còn trong quá trình tạo lại hoạt động và sự kiện bị buộc tắt do hệ thống gây ra. rememberSaveable nhận các tham số input cho cùng một mục đích khi remember nhận keys. Bộ nhớ đệm bị vô hiệu hoá khi bất kỳ dữ liệu đầu vào nào thay đổi. Vào lần tới hàm này kết hợp lại, rememberSaveable sẽ thực thi lại khối lambda tính toán.

Trong ví dụ sau, rememberSaveable lưu trữ userTypedQuery cho đến khi typedQuery thay đổi:

var userTypedQuery by
  rememberSaveable(inputs = typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
      TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
  }

Tìm hiểu thêm

Để tìm hiểu thêm về trạng thái và Jetpack Compose, hãy tham khảo thêm các tài nguyên sau đây:

Mẫu

Lớp học lập trình

Video

Blog