狀態與 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.6.2")
}

Groovy

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.2"
}
  • 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.6.1")
}

Groovy

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

Kotlin

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

Groovy

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

Kotlin

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

Groovy

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

有狀態與無狀態

使用 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 類似,因為 API 會在重新組成後保留狀態,以及使用已儲存的例項狀態機制,在活動或程序重建期間保留狀態。例如當畫面旋轉時就會發生這種情況。

儲存狀態的方式

所有新增至 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,請參閱下列額外資源。

範例

程式碼研究室

影片

網誌