狀態與 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 組件,將單一物件儲存在記憶體中。初始組成期間,由 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,請參閱下列額外資源。

程式碼研究室

影片