狀態與 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 狀態指的是畫面上需要顯示「哪些內容」。舉例來說,CartUiState 類別可以包含購物車中的商品資訊、向使用者顯示的訊息或載入標記。由於這個狀態含有應用程式資料,因此通常連結著階層結構中的其他層級。

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

接著,以下是幾種不同的邏輯:

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

  • UI 邏輯,是指在螢幕上顯示狀態變更的「方式」。舉例來說,導覽邏輯會決定下個要顯示的畫面,而 UI 邏輯會決定在畫面上顯示使用者訊息時要使用 Snackbar 還是浮動式訊息。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,請參閱下列額外資源。

範例

程式碼研究室

影片