狀態與 Jetpack Compose

透過集合功能整理內容 你可以依據偏好儲存及分類內容。

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

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

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

Jetpack Compose 可協助您明確指定在 Android 應用程式中儲存及使用狀態的位置和方式。本指南著重說明狀態和可組合項之間的連結,以及 Jetpack Compose 為了方便您使用狀態而提供的 API。

狀態與組成

Compose 採宣告式格式,因此唯一的更新方式,就是呼叫含有新引數的相同可組合項。這些引數是 UI 狀態的表示法。每當狀態更新,系統就會進行「重組」。因此,TextField 這類東西不會自動更新,這點與在以命令式 XML 為基礎的檢視畫面中有所不同。可組合項必須明確得知新狀態,才能據此更新。

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

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

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

可組合項中的狀態

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

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

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

如果對 value 進行任何變更,所有會讀取 value 的可組合函式都會安排重組。以 ExpandingCard 來說,每當 expanded 有所變更,就會使 ExpandingCard 重組。

您可以使用下列三種方式在可組合項中宣告 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.h5
           )
       }
       OutlinedTextField(
           value = name,
           onValueChange = { name = it },
           label = { Text("Name") }
       )
   }
}

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

其他支援的狀態類型

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

Compose 搭載各種函式,以便透過 Android 應用程式中使用的常見可觀察類型建立 State<T>

您可以為 Jetpack Compose 建構擴充功能函式,以便在您的應用程式採用自訂可觀察類別的情況下,能夠讀取其他可觀察類型。查看內建項目的實作範例,瞭解如何執行。凡是允許 Jetpack Compose 訂閱所有變更的物件,都能轉換為 State<T>,並由可組合項讀取。

有狀態與無狀態

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

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

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

狀態提升

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

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

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

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

  • 單一真實資訊來源:採用移動而非複製的方式處理狀態,以確保真實資訊來源只有一個。這有助於避免錯誤。
  • 封裝:只有「有狀態」可組合項的狀態可以修改。完全在內部作業。
  • 可共用:提升過的狀態可讓多個可組合項共用。假設我們希望在不同可組合項中 name,可以透過提升辦到。
  • 可攔截:無狀態可組合項的呼叫端在變更狀態前可決定忽略或修改事件。
  • 已分離:無狀態 ExpandingCard 的狀態可以儲存在任何位置。舉例來說,現在可以將 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.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = onNameChange,
            label = { Text("Name") }
        )
    }
}

將狀態從 HelloContent 中提升出來,就能更輕鬆地分析可組合項、在不同情境中重複使用可組合項,以及進行測試。HelloContent 已從儲存其狀態的方式中分離出來。「分離」表示如果您要修改或取代 HelloScreen,不需要變更 HelloContent 的導入方式。

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

在 Compose 中還原狀態

重新建立活動或程序後,請使用 rememberSaveable 還原 UI 狀態。rememberSaveable 會在各次重組間保留狀態。此外,rememberSaveable 也會在活動和程序重建過程中保留狀態。

儲存狀態的方式

所有新增至 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 元素狀態管理。
  • 狀態持有物件:用於複雜 UI 元素狀態管理。這類持有物件擁有 UI 元素的狀態和 UI 邏輯。
  • 架構元件 ViewModel:一種特殊類型的狀態持有物件,負責提供商業邏輯和畫面或 UI 狀態的存取權。

狀態持有物件有各種不同大小,取決於其管理的對應 UI 元素範圍,從單一小工具 (例如底部應用程式列) 到整個畫面都有可能。狀態持有物件是可混合的;也就是說,一個狀態持有物件可以整合到另一個狀態持有物件中,尤其是在匯總狀態的時候。

以下圖表顯示的是 Compose 狀態管理作業中涉及的實體之間的關係摘要。本節其餘部分則說明各實體的詳情:

  • 可組合項可以依附於 0 或更多狀態持有物件 (可以是純物件、ViewModel 或兩者),視其複雜度而定。
  • 純狀態持有物件若是需要存取商業邏輯或畫面狀態的權限,可以依附於 ViewModel。
  • ViewModel 依附於業務或資料層。

圖表:在狀態管理作業中顯示依附元件,如先前清單所述。

Compose 狀態管理作業中涉及的各項實體的 (選用) 依附元件摘要。

狀態與邏輯類型

在 Android 應用程式中,可考慮的幾種不同狀態類型如下:

  • UI 元素狀態是指 UI 元素的已提升狀態。舉例來說,ScaffoldState 會處理 Scaffold 可組合項的狀態。

  • 畫面或 UI 狀態是指「哪些內容」必須顯示在畫面上。舉例來說,CartUiState 類別可包含購物車商品、要向使用者顯示的訊息或載入標記。由於這個狀態含有應用程式資料,因此通常連結著該階層的其他層。

接著,以下是不同類型的邏輯:

  • UI 行為邏輯或 UI 邏輯:與「如何」在畫面上顯示狀態變化有關。舉例來說,導覽邏輯會決定下個要顯示的畫面,而 UI 邏輯會決定在畫面上顯示使用者訊息時要使用 Snackbar 還是浮動式訊息。UI 行為邏輯應一律存在於「組成」中。

  • 商業邏輯是指如何處理狀態變更。例如付款或儲存使用者偏好設定。這個邏輯通常位於業務或資料層,不會在 UI 層。

以可組合項當做真實資訊來源

如果狀態和邏輯較簡單,在可組合項中納入 UI 邏輯和 UI 元素是個不錯的做法。舉例來說,以下是 MyApp 可組合項,負責處理 ScaffoldStateCoroutineScope

@Composable
fun MyApp() {
    MyTheme {
        val scaffoldState = rememberScaffoldState()
        val coroutineScope = rememberCoroutineScope()

        Scaffold(scaffoldState = scaffoldState) {
            MyContent(
                showSnackbar = { message ->
                    coroutineScope.launch {
                        scaffoldState.snackbarHostState.showSnackbar(message)
                    }
                }
            )
        }
    }
}

由於 ScaffoldState 含有可變動的屬性,所有相關互動都應發生在 MyApp 可組合項中。否則,如果我們將其傳送給其他可組合項,則它有可能改變本身狀態,導致不符合單一真實資訊來源原則,造成更難以追蹤錯誤。

以狀態持有物件為真實資訊來源

如果可組合項含有涉及多個 UI 元素狀態的複雜 UI 邏輯,可組合項應將責任委派給狀態持有物件。這樣一來,這個邏輯會更容易在隔離狀態下進行測試,也能降低該可組合項的複雜度。這種做法有利於關注點分離原則該可組合項負責投放 UI 元素,狀態持有物件收納 UI 邏輯和 UI 元素狀態。

狀態持有物件是在「組成」中建立及記憶的純類別。由於狀態持有物件遵循可組合項的生命週期,因此可以採用 Compose 依附元件。

如果來自以可組合項當做真實資訊來源一節中的 MyApp 可組合項所負責任變多,我們可以建立 MyAppState 狀態持有物件來管理其複雜度:

// Plain class that manages App's UI logic and UI elements' state
class MyAppState(
    val scaffoldState: ScaffoldState,
    val navController: NavHostController,
    private val resources: Resources,
    /* ... */
) {
    val bottomBarTabs = /* State */

    // Logic to decide when to show the bottom bar
    val shouldShowBottomBar: Boolean
        get() = /* ... */

    // Navigation logic, which is a type of UI logic
    fun navigateToBottomBarRoute(route: String) { /* ... */ }

    // Show snackbar using Resources
    fun showSnackbar(message: String) { /* ... */ }
}

@Composable
fun rememberMyAppState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    navController: NavHostController = rememberNavController(),
    resources: Resources = LocalContext.current.resources,
    /* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
    MyAppState(scaffoldState, navController, resources, /* ... */)
}

由於 MyAppState 採用依附元件,因此提供方法讓系統能在「組成」中記住 MyAppState 的執行個體,會是個好做法。在本例中會是 rememberMyAppState 函式。

現在,MyApp 可以專注於投放 UI 元素,並將所有 UI 邏輯和 UI 元素的狀態委派給 MyAppState

@Composable
fun MyApp() {
    MyTheme {
        val myAppState = rememberMyAppState()
        Scaffold(
            scaffoldState = myAppState.scaffoldState,
            bottomBar = {
                if (myAppState.shouldShowBottomBar) {
                    BottomBar(
                        tabs = myAppState.bottomBarTabs,
                        navigateToRoute = {
                            myAppState.navigateToBottomBarRoute(it)
                        }
                    )
                }
            }
        ) {
            NavHost(navController = myAppState.navController, "initial") { /* ... */ }
        }
    }
}

如您所見,當可組合項的責任增加,也會增加對於狀態持有物件的需求。責任可以是 UI 邏輯,也可以只是需要追蹤的狀態數量。

以 ViewModel 當做真實資訊來源

如果純狀態持有物件類別是負責管理 UI 邏輯和 UI 元素的狀態,那麼 ViewModel 就是特殊類型的狀態持有物件,負責以下事項:

  • 提供應用程式的商業邏輯 (通常位於該階層的其他層,例如業務和資料層) 的存取權,以及
  • 準備應用程式資料,以便顯示在特定畫面上,成為畫面或 UI 狀態。

ViewModel 的生命週期比「組成」長,原因是在設定變更時,ViewModel 會保留下來。ViewModel 可以跟隨 Compose 內容代管物件 (也就是活動或片段) 的生命週期,或是目的地或「導覽」圖形 (如果您使用 導覽程式庫) 的生命週期。由於生命週期較長,ViewModel 不應持有綁定「組成」生命週期的狀態使用的永久參考資料。否則可能會導致記憶流失。

我們建議 畫面層級可組合項 使用 ViewModel 執行個體來提供商業邏輯的存取權,並成為這類可組合項的 UI 狀態的真實資訊來源。您不應將 ViewModel 執行個體向下傳遞至其他可組合項。請參閱「ViewModel 和狀態持有物件」一節,瞭解為何 ViewModel 可用於此用途。

以下是在畫面層級可組合項中使用 ViewModel 的範例:

data class ExampleUiState(
    val dataToDisplayOnScreen: List<Example> = emptyList(),
    val userMessages: List<Message> = emptyList(),
    val loading: Boolean = false
)

class ExampleViewModel(
    private val repository: MyRepository,
    private val savedState: SavedStateHandle
) : ViewModel() {

    var uiState by mutableStateOf(ExampleUiState())
        private set

    // Business logic
    fun somethingRelatedToBusinessLogic() { /* ... */ }
}

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    /* ... */

    ExampleReusableComponent(
        someData = uiState.dataToDisplayOnScreen,
        onDoSomething = { viewModel.somethingRelatedToBusinessLogic() }
    )
}

@Composable
fun ExampleReusableComponent(someData: Any, onDoSomething: () -> Unit) {
    /* ... */
    Button(onClick = onDoSomething) {
        Text("Do something")
    }
}

ViewModel 和狀態持有物件

在 Android 開發作業中,ViewModel 具有許多優點,因此相當適合提供商業邏輯的存取權,以及準備在畫面上顯示的應用程式資料。具體來說,優點如下:

  • ViewModel 觸發的作業在設定變更後仍會保存下來。
  • 能與導覽整合:
    • 畫面在返回堆疊時,「導覽」會快取 ViewModel。這點很重要,能讓您在返回目的地時,立即取得先前載入的資料。如果使用的是跟隨可組合項畫面生命週期的狀態持有物件,這點就難以辦到。
    • 目的地從返回堆疊彈出時,系統也會清除 ViewModel,以確保您的狀態會自動清理。這不同於監聽在多種情況下 (例如前往新畫面、設定變更等) 導致的可組合項清除。
  • 與其他 Jetpack 程式庫相互整合,例如 Hilt

由於狀態持有物件可以混合,且 ViewModel 和純狀態持有物件具有不同的責任,因此畫面層級的可組合項或許會同時擁有提供商業邏輯存取權的 ViewModel,「以及」管理其 UI 邏輯和 UI 元素狀態的狀態持有物件。由於 ViewModel 的生命週期比狀態持有物件長,如有需要,狀態持有物件可以採用 ViewModel 當做依附元件。

以下程式碼顯示的是 ViewModel 和純狀態持有物件在一個 ExampleScreen 上合作的情形:

class ExampleState(
    val lazyListState: LazyListState,
    private val resources: Resources,
    private val expandedItems: List<Item> = emptyList()
) {
    fun isExpandedItem(item: Item): Boolean = TODO()
    /* ... */
}

@Composable
fun rememberExampleState(/* ... */): ExampleState { TODO() }

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    val exampleState = rememberExampleState()

    LazyColumn(state = exampleState.lazyListState) {
        items(uiState.dataToDisplayOnScreen) { item ->
            if (exampleState.isExpandedItem(item)) {
                /* ... */
            }
            /* ... */
        }
    }
}

瞭解詳情

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

程式碼研究室

影片