△Jetsnack 應用屏幕截圖
在 Compose 中使用 State
Jetsnack 是一款使用 Compose 構建的小吃訂購示例應用,狀態對它來說非常重要。比如,在一屏中顯示哪些商品、顯示用戶篩選的小吃以及記錄購物車等操作,都需要狀態的支持。我們將 Compose 構建的界面稱之為組合 (Composition),它會在屏幕中呈現應用的當前狀態。下圖直觀地展示了組合在視覺上呈現搜索頁的過程,您可以在其中找到搜索欄 (SearchBar)、分隔線 (JetsnackDivider) 和搜索建議 (SearchSuggestions),這些都是搜索界面的組成部分:
△組合呈現搜索界面的過程
在像 Compose 這樣的聲明式框架中,您只需描述應用的當前狀態,Compose 會負責在狀態發生更改時更新界面。因此,當我們導航到購物車屏幕時,Compose 也會重新執行受狀態更改影響的部分界面。下圖中,NavHost 更新為顯示購物車界面。由于界面的每個部分都是一個可組合項,當狀態更改時,這些函數會進行重組,以便在屏幕上顯示新數據:
△組合呈現購物車界面的過程
在購物車界面中,我們重點關注單獨的購物車商品項。該元素用于顯示購物車中的商品,并允許您更改數量:
△單獨的購物車商品
我們可以使用包含兩個 Button 和一個 Text 的 Row 來構建該界面,但是要如何記錄購物車中商品的當前數量呢?
我們可以簡單地在可組合函數中添加一個可變變量 quantity,但接下來您會發現,在我們通過點按增加和減少數量的按鈕來修改此變量的值時,界面中顯示的數量沒有發生任何變化——當狀態更改時,該函數沒有重新組合。這是因為 Compose 不會跟蹤 quantity 變量。fun CartItem() {
var quantity = ... ˉ\_(ッ)_/ˉ ... ?
Row {
Button(onClick = { quantity++ }) {
Text("+")
}
Text(quantity.toString())
Button(onClick = { quantity-- }) {
Text("-")
}
}
}
△數量的顯示沒有發生改變fun CartItem() {
var quantity: Int = 1
...
}
Compose 具有特殊的狀態跟蹤系統,可以在某個狀態改變時,重組讀取該狀態的所有可組合項。這種機制使得 Compose 可以對界面進行精細控制,在狀態發生改變時不用修改整個界面,只需重組需要更改的可組合函數即可。
這一功能是通過跟蹤狀態寫入 (即狀態更改) 以及狀態讀取來實現的,我們可以使用 Compose 的 State 和 MutableState 類型使狀態可被觀察。Compose 會跟蹤讀取 State 中 value 屬性的可組合項,并在 value 發生更改時觸發重新組合。
// State
interface State<out T> {
val value: T
}
// MutableState
interface MutableState<T> : State<T> {
override var value: T
}
您可以使用 mutableStateOf 函數創建 MutableState,該函數需要接收一個初始值,并且它的 value 是可變的。相應的,我們需要改用 value 屬性來讀取和寫入 quantity 狀態:
fun CartItem() {
val quantity: MutableState<Int> = mutableStateOf(1)
Row {
Button(onClick = { quantity.value++ }) {
Text("+")
}
Text(quantity.value.toString())
Button(onClick = { quantity.value-- }) {
Text("-")
}
}
}
但是,即使 Compose 已經跟蹤了 quantity 變量,并觸發了重組,您會發現界面依然沒有顯示狀態的更改。問題在于,雖然該函數已經重組,但 quantity 的值 value 總是會被初始化為 1。這是一個常見的錯誤,因此您在嘗試編寫這段代碼時也會產生編譯錯誤。為了在重組中重用 quantity 狀態,我們需要使其成為組合的一部分。要做到這一點,可以使用 remember 可組合函數將對象存儲在組合中:
fun CartItem() {
val quantity = remember { mutableStateOf(1) }
...
}
Remember 可用于存儲可變對象和不可變對象,您必須對在組合中創建的 State,也就是可組合函數中的 State 執行 remember 操作。在被記住后,狀態將成為組合的一部分,并在函數重組時被重用,這樣一來,購物車商品也可以按照我們的預期工作了。
由于重新組合期間會保留 quantity,因此屏幕上將顯示改變后的新值。此外,Compose 還提供了 rememberSaveable,其行為與 remember 類似,但存儲的值可在 Activity 和進程重建后保留下來,這是在配置變更時保留界面數據的好方法。rememberSaveable 適用于界面狀態,如商品數量或選定的標簽,但不適用諸如過渡動畫狀態一類的用例。
此外,您還可以將委托屬性與 State API 結合使用。在下面的代碼中可以看到,在實際應用中,我們可以使用 by 關鍵字來實現這一點。如果您不想每次都訪問 value 屬性的話,這不失為一種好方法。
fun CartItem() {
var quantity: Int by rememberSaveable { mutableStateOf(1) }
Row {
Button(onClick = { quantity++ }) {
Text("+")
}
Text(quantity.toString())
Button(onClick = { quantity-- }) {
Text("-")
}
}
}
注意,您只應在可組合函數的作用域之外操作狀態,因為可組合項可能會頻繁地、以任何順序執行。上面的代碼中,在 onClick 監聽器中修改 quantity 的操作是安全的,因為 onClick 不是可組合函數。您可以根據特定的用戶輸入觸發狀態更改,例如點擊按鈕或使用附帶效應:
https://developer.android.google.cn/jetpack/compose/side-effects
狀態提升
Compose 相比起 View 系統的一大優勢,便是更為良好的可復用性。然而在當前的形式下,您無法復用 CartItem 可組合函數,因為它總是會將私有的 quantity 初始化為 1。在真實的使用環境中,購物車中的商品并不總是都從 1 開始計數,而且用戶的購物車中也可能會有之前會話中的商品。與依賴注入的邏輯類似,為了使 CartItem 可重用,我們需要將 quantity 作為參數傳遞給該函數;不僅如此,為了遵循單一可信來源原則,傳遞給 CartItem 的 quantity 應該不可變。 單一可信來源原則鼓勵結構化的代碼,以便只在一個位置修改數據。在本例中,如果 CartItem 不負責特定的狀態 (即 quantity),就不應該對它進行更新。因此 CartItem 需要在用戶與按鈕交互并觸發狀態更新時通知調用方。但是這樣一來,我們就需要考慮應該由誰負責更新 quantity 等狀態的操作。我們可以先假設 Cart 可組合項應該擁有所有 CartItem 的信息,以及相應地更新這些信息的邏輯:△ 假設 Cart 可組合項負責更新每個購物車的商品數量為了使 CartItem 可被重用,我們將 quantity 狀態從 CartItem 提升至 Cart 中,這一過程被稱為狀態提升:
https://developer.android.google.cn/jetpack/compose/state
狀態提升是一種將私有狀態移出可組合項的模式,這可以使可組合項更趨于無狀態,從而提高在應用中的可重用性。無狀態可組合項是指不保存任何私有狀態的可組合項。理想情況下,可組合項應接收狀態作為參數,并使用 lambda 向上傳遞事件:
△可組合項應接收狀態 (State)
并使用 lambda 向上傳遞事件 (Events)使可組合項趨于無狀態,不但可以使其符合單一可信來源原則,而且可以提高它的可重用性和可測試性。因為在這種情況下,可組合項沒有與任何特定的數據處理方式耦合在一起,而我們還可以共享和攔截以這種方式提升的狀態。下面是無狀態版的 CartItem 的示例代碼,它接收 quantity 并做為狀態顯示,同時將用戶交互公開為事件:
fun CartItem(
quantity: Int, // 狀態
incrementQuantity: () -> Unit, // 事件
decrementQuantity: () -> Unit // 事件
) {
Row {
Button(onClick = incrementQuantity) {
Text("+")
}
Text(quantity.toString())
Button(onClick = decrementQuantity) {
Text("-")
}
}
}
接下來我們來看 Cart 可組合函數的實現。Cart 界面會在 LazyColumn 中顯示不同的 CartItem,同時負責使用正確的信息調用 CartItem。Cart 中的項目實際上是從 CartViewModel 取得的應用數據。我們對于每個 CartItem 都傳入特定的 quantity,增加或減少數量的邏輯被委托給 ViewModel,ViewModel 則作為 Cart 數據的持有者:
fun Cart(viewModel: CartViewModel = viewModel()) {
val cartItems by viewModel.cartItems
LazyColumn {
items(cartItems) { item ->
CartItem(
quantity = item.quantity,
incrementQuantity = {
viewModel.inrementQuantity(item)
},
decrementQuantity = {
viewModel.decrementQuantity(item)
}
)
}
}
}
狀態提升是一種在 Compose 中廣泛使用的模式。作為一種攔截和控制界面元素內部使用狀態的方式,您可以在大多數 Compose API 中看到它。我們也可以將攔截狀態設計為可選操作,從而可以利用強大的默認參數特性。以下面的代碼為例,如果需要控制或共享 scaffoldState,您可以傳入該狀態;而如果您沒有傳入,該函數也會創建一個默認狀態:
fun Scaffold(
scaffoldState: ScaffoldState = rememberScaffoldState(),
...
) { ... }
public fun NavHost(
navController: NavHostController,
...
){...}
在上面的例子中,我們只是假設應該由 Cart 可組合函數負責 CartItem 的狀態更新。那么在我們實際去應用時,應該將狀態提升到多高的層級呢?這其實是一個數據所有權的問題,如果您不能確定,則至少應將狀態提升到需要訪問該狀態的所有可組合項的最低公共父級。在本例中,CartItem 的最低公共父級是 Cart,也就是負責使用正確的信息調用 CartItem 的層級。狀態提升另一個原則是,可組合項應該只接受所需的參數。在 Jetsnack 中,我們使用了無狀態的 Cart 可組合項,它只接受需要的參數:
fun Cart(
orderLines: List<OrderLine>,
removeSnack: (Long) -> Unit,
increaseItemCount: (Long) -> Unit,
decreaseItemCount: (Long) -> Unit,
inspiredByCart: SnackCollection,
modifier: Modifier = Modifier
) {
...
}
這樣的 Cart 可組合項更易于預覽和測試,同時符合單一可信來源原則。這樣做的可重用性也更高,比如,如果我們需要,就可以在窗口尺寸足夠大的情況下,與另一個界面并排顯示購物車。不僅如此,我們還提供了有狀態版本,使其也可以通過特定的方式處理狀態和事件:
fun Cart(
modifier: Modifier = Modifier,
viewModel: CartViewMo = viewModel()
) {
val orderLines by viewModel.orderLines.collectAsState()
Cart(
orderLines = orderLines,
removeSnack = viewModel::removeSnack,
increaseItemCount = viewModel::increaseSnackCount,
decreaseItemCount = viewModel::decreaseSnackCount,
inspiredByCart = viewModel.inspiredByCart,
modifier = modifier
)
}
我們可以看到,這個版本的 Cart 通過處理業務邏輯和狀態的 CartViewModel 來調用無狀態版的 Cart 可組合項。這種同時提供有狀態、無狀態,或趨于無狀態組合項的模式,可以很好的兼顧各種使用場景。您既可以在需要時重用可組合項,又可以在應用中以特定的方式使用它。
狀態管理
狀態應至少提升到最低公共父級,但是否應該總是將狀態置于可組合項中?在前面的例子中我們可以看到,Jetsnack Cart 使用的是與 Compose Navigation 集成度很好的 ViewModel。在下面的表格中,列出了幾種管理和定義可信來源的方式,以及它們所對應的狀態類型,在下面的文章中將對它們進行詳細的介紹:注意: 如果對應用例不能應用 ViewModel 的優勢,那么就可以用一般的狀態持有者代替 ViewModel
在開始之前,我們要定義文中所涉及特定術語的含義:
- 界面元素狀態: 是指被提升的界面元素狀態。例如,ScaffoldState。
- 屏幕或界面的狀態:是指需要在屏幕上顯示的內容。例如,CartUiState 可以包含購物車商品、向用戶顯示的消息或加載標記。此類狀態通常會與層次結構中的其他層級相關聯,因為其包含應用數據。
- 界面的行為邏輯:與如何在界面上顯示狀態更改相關。例如,導航邏輯或顯示信息提示控件。界面行為邏輯應始終位于組合中。
- 業務邏輯:決定如何處理狀態更改。比如,進行支付或存儲用戶偏好設置。這類邏輯通常應置于業務層或數據層,絕不應置于界面層。
但是,實際情況往往更加復雜。JetsnackApp 除了會發送界面元素外,還負責顯示信息提示控件、導航到正確的屏幕、設置底部操作欄等操作。將所有這些內容都放在可組合項中,會使它難以閱讀和理解。我們可以遵循分離關注點原則,將屏幕邏輯和界面元素狀態委托給名為 "狀態容器"的類,從而讓可組合函數只負責生成界面元素。 一般類型作為狀態容器fun JetsnackApp() {
JetsnackTheme {
val scaffoldState = rememberScaffoldState()
val coroutineScope = rememberCoroutineScope()
JetsnackScaffold(scaffoldState = scaffoldState) {
Content(
showSnackbar = { message ->
coroutineScope.launch {
scaffoldState.snackbarHostState
.showSnackbar(message)
}
}
)
}
}
}
我們使用 JetsnackAppState 類作為狀態容器,它將會是 JetsnackApp 的界面元素狀態的可信來源,因此所有狀態寫入都應在該類中進行。狀態容器是在組合中創建和記住的普通類,因此,該類的作用域限定于創建它的可組合項。JetsnackAppState 只是一個普通類,而且由于它遵循可組合項的生命周期,因此可以使用 Compose 的依賴項,而不必擔心內存泄漏:
class JetsnackAppState(
// 一般的類可以接收 Compose 依賴
val scaffoldState: ScaffoldState,
val navController: NavHostController,
...
) {
val shouldShowBottomBar: Boolean
// 在讀取的值發生改變時會進行重組
get() = navController.currentBackStackEntryAsState().value
?.destination?.route in bottomBarRoutes
// 與界面相關的邏輯
fun navigateToBottomBarRoute(route: String) {
if (route != currentRoute) {
navController.navigate(route) {
launchSingleTop = true
restoreState = true
popUpTo(findStartDestination(navController.graph).id) {
saveState = true
}
}
}
}
}
狀態容器還可以包含可組合項屬性,更改此類屬性將會觸發重組,上面的代碼即為是否顯示底部操作欄的屬性。該狀態容器還包含界面相關的邏輯,比如導航邏輯。就像前面說過的,您必須使用 remember 記住數據,以便在重新組合期間重用數據,如果狀態容器使用了 State 依賴項,那么應該提供方法來記住狀態容器。在下面的代碼中,我們將依賴項傳入 remember,以便在任何依賴項發生更改時獲取 JetsnackAppState 的新實例:
fun rememberJetsnackAppState(
scaffoldState: ScaffoldState = rememberScaffoldState(),
navController: NavHostController = rememberNavController(),
...
) = remember(scaffoldState, navController, ...) {
JetsnackAppState(scaffoldState, navController, ...)
}
現在,我們在 JetsnackApp 中獲取了 appState 的新實例。我們使用該實例將被提升的狀態傳遞給可組合項,并在需要顯示界面元素時檢查該狀態;同時調用函數來觸發與界面相關的操作:
fun JetsnackApp() {
JetsnackTheme {
val appState = rememberJetsnackAppState()
JetsnackScaffold(
scaffoldState = appState.scaffoldState,
bottomBar = {
if (appState.shouldShowBottomBar) {
JetsnackBottomBar(
tabs = appState.bottomBarTabs,
navigateToRoute = {
appState.navigateToBottomBarRoute(it)
}
)
}
}
) {
NavHost(navController=appState.navController,...){
簡單來說,狀態容器是一個普通類,用于提升界面元素的狀態并包含界面相關的邏輯。狀態容器可以降低可組合項的復雜性,并提高其可測試性,從而有助于關注點分離。它還可以使狀態提升變得更為容易,因為只需提升一個狀態而不是多個狀態。狀態容器可以非常簡單并且只用于特定用途,例如,只用于搜索界面的 SearchState 類,其中僅包含 activeFilters 和 searchResults List。當您需要跟蹤狀態或界面邏輯時可以使用狀態容器來幫助控制復雜度。
ViewModel 作為狀態容器class SearchState {
var searchResults: List
by mutableStateOf(listOf()) private set
var activeFilters: List
by mutableStateOf(listOf()) private set
...
}
除了一般的狀態容器外,我們還可以使用 ViewModel,這是一種繼承架構組件 ViewModel 類的類。ViewModel 可用作由業務邏輯確定狀態的狀態容器。ViewModel 有兩項責任: 首先,提供對應用業務邏輯的訪問,這些業務邏輯通常位于層次結構的其他層級中,如存儲區或用例中;其次,準備要在特定屏幕上呈現的應用數據,通常是用可觀察類型呈現屏幕的界面狀態。
在完全使用 Compose 構建的應用中,我們可以使用 Compose 的 State 類型。但在混合應用中,您還可能會用到其他的可觀察類型,如 StateFlow:
class CartViewModel(
private val repository: SnackRepository,
private val savedState: SavedStateHandle
) : ViewModel() {
val uiState: State
= ...
fun increaseSnackCount(snackId: Long) { ... }
fun decreaseSnackCount(snackId: Long) { ... }
fun removeSnack(snackId: Long) { ... }
fun completeOrder() { ... }
}
ViewModel 在配置變更后仍然有效,因此其生命周期比組合更長。ViewModel 不屬于組合的一部分,因此不能接受組合作用域內的狀態,比如使用記住的值,您需要謹慎對待此類操作,因為這可能會導致內存泄漏。ViewModel 依賴于層次結構的其他層級,例如存儲區或用例。另外,如果您希望界面在狀態發生更改時重組,您依然需要使用 Compose State API。
class CartViewModel(
private val repository: SnackRepository,
private val savedState: SavedStateHandle
) : ViewModel() {
// 在 ViewModel 中,仍要使用 State 類型來使狀態可被 Compose 觀察
var uiState by mutableStateOf
(EmptyCart) private set
...
}
不過在本例中,由于 uiState 位于組合之外,因此您不需要記住它,而只需使用它即可。可組合函數將在 uiState 更改時重新執行:
fun Cart(viewModel: CartViewModel = viewModel()) {
val uiState by viewModel.uiState
}
層次結構的其他層通常使用流式數據來傳播更改,您可能已經開始在 ViewModel 中使用它們了。Flow 也可以很好地與 Compose 結合使用,我們提供了工具函數,可以將數據流轉換為 Compose 的可觀察 State API。例如,您可以使用 collectAsState 從數據流中收集值,并將它們呈現為 State。這樣一來,每當數據流發出新值時,就會觸發重組。
fun Cart(viewModel: CartViewModel = viewModel()) {
// 通過將 Flow 轉換為 State 來跟蹤 snacks 狀態的改變
val snacks by viewModel.snacks.collectAsState()
...
}
總的來說,ViewModel 可以在組合之外提升組合的狀態,同時具有更長的生命周期。ViewModel 負責屏幕的業務邏輯并決定要顯示哪些數據,它會從其他層級獲取數據,并準備這些要呈現的數據。因此,建議在屏幕級的可組合項中使用 ViewModel。
與普通狀態容器相比 ViewModel 具有一些優勢,其中包括,ViewModel 觸發的操作在配置變更后仍然有效,并且 ViewModel 可以與 Hilt、Navigation 等 Jetpack 庫很好地集成在一起。在使用 Hilt 時,僅使用一行代碼,就能通過 Hilt 提供的依賴項獲取 ViewModel。
當屏幕位于返回棧中時,Navigation 會緩存 ViewModel,這意味著當返回到目標時,數據已經處于可用狀態;而當目標離開返回棧后 ViewModel 又會被清除,從而確保狀態可以被自動清理。
使用遵循可組合項界面生命周期的狀態容器 (即使用一般的類作為狀態容器),將會難以做到前述操作。盡管如此,如果 ViewModel 的優勢不適用于您的用例或者您以不同的方式操作,您可以使用其他最適合您的狀態容器,而不一定是 ViewModel 來完成相應的工作。
同時使用 ViewModel 和其他狀態容器界面級可組合項也可以同時使用 ViewModel 和其他狀態容器。由于 ViewModel 的生命周期更長,普通的狀態容器可以將 ViewModel 作為依賴項。
我們來看一下實際應用。除了在 Cart 可組合項中使用 CartViewModel 之外,我們還可以另外使用包含界面元素狀態和界面邏輯的 CartState。在 CartState 中,我們使用 lazyListState 來記錄大型購物車界面的滾動位置;使用 resources 來格式化信息和價格;如果允許展開商品以顯示更多信息,還可以了解每個商品的狀態:
class CartState(
lazyListState: LazyListState,
resources: Resources,
expandedItems: List
- = emptyList()
) {
...
fun formatPrice(...) { ... }
}
Cart 中同時使用了 ViewModel 和其他狀態容器,它們具有不同的用途,并可以協同工作。我們來仔細看一下它們的生命周期: CartState 會遵循 Cart 可組合項的生命周期,當 Cart 從組合中移除后 CartState 也會一同移除;而 CartViewModel 具有不同的生命周期,即導航目的地、導航圖、Fragment 或 Activity 的生命周期:
△CartState 遵循 Cart 的生命周期
而 CartViewModel 則位于組合之外從全局來看,每個實體的作用都有明確的定義,從包含界面元素的界面層到包含業務邏輯的數據層,每個實體都有特定的用途。在下圖中,您可以看到扮演著不同角色的實體,以及它們之間潛在的依賴關系:
△實體間的關系及它們對應的用途
總結
對于我們的應用來說,狀態是十分重要的一部分。我們可以在 Compose 中使用 State API 做到簡單的狀態響應,也可以使用一般的類或者 ViewModel 作為狀態容器,以便對可組合項進行狀態提升,并使其符合單一可信來源原則。我們還可以組合不同的狀態容器,從而利用它們各自不同的生命周期。
下面是我們在文章中列出的表格,請記住它,以便您在未來做決策時可以為您的應用提供明確的狀態管理架構:
△希望現在您能更加理解這個表格的意義
希望本文能幫助您實現 "理想的 Compose 狀態",祝您擁有愉快的 Compose 使用體驗。
原文標題:實踐 | Jetpack Compose 中的狀態管理
文章出處:【微信公眾號:谷歌開發者】歡迎添加關注!文章轉載請注明出處。
-
狀態
+關注
關注
0文章
16瀏覽量
11956 -
State
+關注
關注
0文章
5瀏覽量
7672 -
開發者
+關注
關注
1文章
577瀏覽量
17026
原文標題:實踐 | Jetpack Compose 中的狀態管理
文章出處:【微信號:Google_Developers,微信公眾號:谷歌開發者】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論