狀態與 Jetpack Compose

應用程式中的狀態指的是任何可能隨時間變化的值。這個定義非常廣泛,從 Room 資料庫到某類別中的變數都包含在內。

所有 Android 應用程式都會向使用者顯示狀態。以下列舉幾個 Android 應用程式中的狀態範例:

  • 無法建立網路連線時顯示的 Snackbar。
  • 網誌文章和相關留言。
  • 使用者點選按鈕時會播放的漣漪效果動畫。
  • 使用者可繪製在圖片上的貼圖。

Jetpack Compose 供您更清楚瞭解在 Android 應用程式中儲存及使用狀態的位置和方式。本指南的重點是介紹狀態和可組合項之間的關係,以及 Jetpack Compose 提供了哪些 API 來協助您運用狀態。

狀態與組成

Compose 採用宣告式框架,因此只能以新引數呼叫相同的可組合項來進行更新。這些引數是 UI 狀態的表示法。每當狀態更新,系統就會進行「重組」。因此,TextField 之類的項目不會像在以命令式 XML 為基礎的檢視畫面中一樣自動更新。可組合項必須明確得知新狀態,才能據此更新。

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

如果您執行這段程式碼並嘗試輸入文字,您會發現沒有任何作用。這是因為 TextField 不會自行更新,只有在其中的 value 參數變更時才會更新。這是 Compose 中組成與重組的運作方式所致。

如要進一步瞭解初始組成和重組,請參閱「Compose 中的思維」。

可組合項中的狀態

可組合函式可以使用 remember API,在記憶體中儲存物件。remember 計算的值會在初始組成期間儲存在「組成」中,並在重新組成時傳回所儲存的值。remember 可用來儲存可變動與不可變動的物件。

mutableStateOf 會建立可觀察的 MutableState<T>,這是已經與 Compose 執行階段整合的可觀察類型。

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

如果變更 value,系統就會為可讀取 value 的所有可組合函式安排重組作業。

下列三種方式皆在可組合項中宣告 MutableState 物件:

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

這三種宣告作用相等,僅做為語法糖,運用在不同狀態用途中。挑選時,請考量哪種方式能在您編寫的可組合項中產生最簡單易讀的程式碼。

by 委派語法需要下列匯入項目:

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

您可以將已儲存的值設為其他可組合項的參數,甚至設為陳述式中的邏輯來變更顯示的可組合項。舉例來說,如果在名稱空白的情況下,您不想顯示問候語,請在 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.bodyMedium
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

雖然 remember 可協助您在各次重組間保留狀態,但只要設定有所變更,狀態就無法保留。針對這種情況,您必須使用 rememberSaveablerememberSaveable 會自動儲存可儲存在 Bundle 中的任何值。其他值可在自訂儲存器物件中傳送。

其他支援的狀態類型

Compose 不會要求您使用 MutableState<T> 保留狀態,而是支援其他可觀察的類型。在 Compose 中讀取另一種可觀察類型之前,您必須將其轉換為 State<T>。這樣一來,在狀態變更時,可組合項就能自動進行重組。

Compose 隨附一些函式,可根據 Android 應用程式中使用的常見可觀察類型建立 State<T>:使用這些整合項目前,請新增適當的構件,如下所示:

  • FlowcollectAsStateWithLifecycle()

    collectAsStateWithLifecycle() 會以生命週期感知方式從 Flow 收集值,讓應用程式可節省應用程式資源,並代表 Compose State 中最新發出的值。請使用這個 API 做為在 Android 應用程式上收集資料流的建議方法。

    build.gradle 檔案必須包含下列依附元件 (應為 2.6.0-beta01 以上版本):

Kotlin

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

Groovy

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.7"
}
  • FlowcollectAsState()

    collectAsState 類似於 collectAsStateWithLifecycle,也會從 Flow 收集值,並將資料流轉換成 Compose State

    請為適用於各種平台的程式碼使用 collectAsStatecollectAsStateWithLifecycle 只適用於 Android。

    collectAsState 可在 compose-runtime 中使用,因此不需要其他依附元件。

  • LiveDataobserveAsState()

    observeAsState() 會開始觀察這個 LiveData,並透過 State 表示其值。

    build.gradle 檔案中必須包含下列依附元件

Kotlin

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

Groovy

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

Kotlin

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

Groovy

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

Kotlin

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

Groovy

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

有狀態與無狀態

使用 remember 儲存物件的可組合項會建立內部狀態,使該可組合項「有狀態」。舉例來說,HelloContent 就是個有狀態的可組合項,因為這個可組合項會在內部保留並修改自身的 name 狀態。這種做法在呼叫端不需要控制狀態的情況下很有用,不必自行管理狀態也能使用。不過,具有內部狀態的可組合項往往不易重複使用,也更難測試。

「無狀態」可組合項是指不含任何狀態的可組合項。如要達成無狀態,最簡單的方式就是使用狀態提升

開發可重複使用的可組合項時,通常會想同時提供有狀態和無狀態的版本。有狀態版本對於不考慮狀態的呼叫端來說很方便,而對於需要控制或提升狀態的呼叫端來說,則一定要使用無狀態版本。

狀態提升

Compose 中的狀態提升是指將狀態移至可組合項呼叫端的模式,目的是讓可組合項變成無狀態。在 Jetpack Compose 中進行狀態提升的常見做法,是將狀態變數替換成兩個參數:

  • value: T目前顯示的值
  • onValueChange: (T) -> Unit要求變更值的事件,其中 T 是提議的新值

不過,您並未受限於使用 onValueChange。如果該可組合項比較適合較特定的事件,請使用 lambda 定義事件。

以這種方式提升的狀態具備下列重要屬性:

  • 單一真實資訊來源:採用移動而非複製的方式處理狀態,以確保真實資訊來源只有一個。這有助於避免錯誤。
  • 封裝:必須是「有狀態」的可組合項才能修改狀態。這完全屬於內部。
  • 可共用:提升過的狀態可讓多個可組合項共用。使用提升即可在其他可組合項中讀取 name
  • 可攔截:無狀態可組合項的呼叫端可在變更狀態前決定忽略或修改事件。
  • 已分離:無狀態可組合函式的狀態可以儲存在任何位置。舉例來說,現在可以將 name 移至 ViewModel 中。

在這個例子裡,您從 HelloContent 中擷取出 nameonValueChange,然後將兩者往上層移至呼叫 HelloContentHelloScreen 可組合項。

@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.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
    }
}

將狀態從 HelloContent 中提升出來,就能更輕鬆地分析可組合項、在不同情境中重複使用可組合項,以及進行測試。HelloContent 已從儲存其狀態的方式中分離出來。「分離」的意思是,當您修改或取代 HelloScreen 時,不需要調整 HelloContent 的實作方式。

當狀態向下移動而事件向上移動時,這種模式稱為「單向資料流」。在這種情況下,狀態會從 HelloScreen 下降至 HelloContent,而事件則從 HelloContent 上升至 HelloScreen。跟隨單向資料流,即可從應用程式中儲存及變更狀態的部分,分離出在 UI 中顯示狀態的可組合項。

詳情請參閱「在何種情況下提升狀態」頁面。

在 Compose 中還原狀態

rememberSaveable API 的行為與 remember 類似,因為它會在重新組成期間保留狀態,也會在使用已儲存的執行個體狀態機制重新建立活動或程序時保留狀態。舉例來說,當螢幕旋轉時就會發生這種情況。

儲存狀態的方式

所有新增至 Bundle 的資料類型都會自動儲存。如果想儲存無法新增至 Bundle 的項目,請參考以下幾種方法。

Parcelize

最簡單的解決方法是在物件中新增 @Parcelize 註解。物件會變得可包裝 (parcel) 且可組合 (bundle)。舉例來說,以下程式碼會產生一個可包裝的 City 資料類型,並儲存到狀態中。

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

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

MapSaver

如果 @Parcelize 因故不再適用,可改用 mapSaver 自行定義規則,將物件轉換為一組可讓系統儲存至 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

如果不想為地圖定義索引鍵,也可以使用 listSaver,並使用其索引當做索引鍵:

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"))
    }
}

Compose 中的狀態容器

可組合函式本身可用於管理簡易的狀態提升。不過,如果要追蹤的狀態數量增加,或是在可組合函式中產生要執行的邏輯,建議的做法是將邏輯和狀態責任委派給其他類別:狀態容器

詳情請參閱 Compose 說明文件中的狀態提升頁面,或架構指南中更廣泛的「狀態容器和 UI 狀態」頁面。

在索引鍵變更時重新觸發 remember 計算作業

remember API 經常與 MutableState 搭配使用:

var name by remember { mutableStateOf("") }

在這裡使用 remember 函式,可讓 MutableState 值在重組後繼續有效。

一般來說,remember 會採用 calculation lambda 參數。初次執行 remember 時,系統會叫用 calculation lambda 並儲存相關結果。而在重組期間,remember 會傳回上次儲存的值。

除了用來快取狀態之外,您也可以使用 remember 在組合中儲存作業的所有物件或結果,這些項目的初始化/計算費用十分昂貴。因此,您可能不會在每次重組時重複這個計算程序。舉例來說,建立 ShaderBrush 物件就是一項所費不貲的作業:

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

remember 會儲存這個值,直到離開組合為止。然而,有一種方法可使快取值失效。由於 remember API 也會使用 keykeys 參數,「如果其中有任何索引鍵發生異動,下次函式重組時」remember 就會「讓快取失效,並再次執行 lambda 區塊的計算作業」。此機制可讓您控管物件在組合內的生命週期。請放心,計算作業的效力會持續到輸入內容變更為止,而非儲存的值離開組合為止。

以下舉例說明此機制的運作方式。

這個程式碼片段會建立 ShaderBrush,並將其做為 Box 可組合元件的背景繪製。remember 則會儲存 ShaderBrush 例項,因為其重建成本較高 (如前文所述)。此外,remember 也會使用 avatarRes 做為 key1 參數,也就是所選的背景圖片。如果 avatarRes 有所變更,筆刷會隨新圖片重組,並重新套用至 Box。當使用者從挑選器中選取其他圖片做為背景時,就可能會發生這種情況。

@Composable
private 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)
    ) {
        /* ... */
    }
}

在下一個程式碼片段中,狀態會提升至純狀態容器類別 MyAppState。此類別會公開 rememberMyAppState 函式,以便使用 remember 初始化類別的例項。公開這類函式,建立能在重組後持續有效的例項,是 Compose 中常見的模式。rememberMyAppState 函式會接收 windowSizeClass,後者可做為 rememberkey 參數。如果此參數有所變更,應用程式就需要利用最新的值重新建立純狀態容器類別。舉例來說,當使用者旋轉裝置時就可能發生這種情況。

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

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

Compose 會利用類別的 equals 實作成果來判定索引鍵是否已變更,並使儲存的值失效。

透過索引鍵儲存狀態以在重組後繼續運作

rememberSaveable API 是 remember 周圍的包裝函式,可將資料儲存在 Bundle 中。這個 API 不僅可讓狀態在重組後繼續運作,還能在活動重建和系統發起的程序終止時持續有效。rememberSaveable 接收 input 參數的目的與 remember 接收 keys 相同。「如有任何輸入內容變更,快取就會失效」。下次函式重組時,rememberSaveable 會重新執行 lambda 區塊的計算作業。

在以下範例中,rememberSaveable 會儲存 userTypedQuery,直到 typedQuery 變更為止:

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

瞭解詳情

如要進一步瞭解狀態與 Jetpack Compose,請參閱下列額外資源。

範例

程式碼研究室

影片

網誌