实践 | Jetpack Compose 中的状态管理

实践 | Jetpack Compose 中的状态管理

对于开发者来说,状态 (State) 指应用中可以随时间变化的任何值。我们的应用天然便是拥有状态的,无论您将数据存储在本地还是服务器中,状态都可以使这些数据更有价值。接下来,我们就以应用 Jetsnack 作为示例,谈一谈 Compose 中的状态:

△ Jetsnack 应用屏幕截图

△ Jetsnack 应用屏幕截图

如果您更喜欢通过视频了解本文内容,请点击下方:

△ 实践 | Jetpack Compose 中的状态管理

在 Compose 中使用 State

Jetsnack 是一款使用 Compose 构建的小吃订购示例应用,状态对它来说非常重要。比如,在一屏中显示哪些商品、显示用户筛选的小吃以及记录购物车等操作,都需要状态的支持。我们将 Compose 构建的界面称之为组合 (Composition),它会在屏幕中呈现应用的当前状态。下图直观地展示了组合在视觉上呈现搜索页的过程,您可以在其中找到搜索栏 (SearchBar)、分隔线 (JetsnackDivider) 和搜索建议 (SearchSuggestions),这些都是搜索界面的组成部分:

△ 组合呈现搜索界面的过程

△ 组合呈现搜索界面的过程

在像 Compose 这样的声明式框架中,您只需描述应用的当前状态,Compose 会负责在状态发生更改时更新界面。因此,当我们导航到购物车屏幕时,Compose 也会重新执行受状态更改影响的部分界面。下图中,NavHost 更新为显示购物车界面。由于界面的每个部分都是一个可组合项,当状态更改时,这些函数会进行重组,以便在屏幕上显示新数据:

△ 组合呈现购物车界面的过程

△ 组合呈现购物车界面的过程

在购物车界面中,我们重点关注单独的购物车商品项。该元素用于显示购物车中的商品,并允许您更改数量:

△ 单独的购物车商品

△ 单独的购物车商品

我们可以使用包含两个 Button 和一个 Text 的 Row 来构建该界面,但是要如何记录购物车中商品的当前数量呢?

@Composable
fun CartItem() {
    var quantity = ... ¯\_(ッ)_/¯ ... ?
 
    Row {
        Button(onClick = { quantity++ }) {
            Text("+")
        }
        Text(quantity.toString())
        Button(onClick = { quantity-- }) {
            Text("-")
        }
    }
}

我们可以简单地在可组合函数中添加一个可变变量 quantity,但接下来您会发现,在我们通过点按增加和减少数量的按钮来修改此变量的值时,界面中显示的数量没有发生任何变化——当状态更改时,该函数没有重新组合。这是因为 Compose 不会跟踪 quantity 变量。

@Composable
fun CartItem() {
    var quantity: Int = 1
    ...
}

△ 数量的显示没有发生改变

△ 数量的显示没有发生改变

Compose 具有特殊的状态跟踪系统,可以在某个状态改变时,重组读取该状态的所有可组合项。这种机制使得 Compose 可以对界面进行精细控制,在状态发生改变时不用修改整个界面,只需重组需要更改的可组合函数即可。

这一功能是通过跟踪状态写入 (即状态更改) 以及状态读取来实现的,我们可以使用 Compose 的 State 和 MutableState 类型使状态可被观察。Compose 会跟踪读取 State 中 value 属性的可组合项,并在 value 发生更改时触发重新组合。

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

您可以使用 mutableStateOf 函数创建 MutableState,该函数需要接收一个初始值,并且它的 value 是可变的。相应的,我们需要改用 value 属性来读取和写入 quantity 状态:

@Composable
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 可组合函数将对象存储在组合中:

@Composable
fun CartItem() {
    val quantity = remember { mutableStateOf(1) }
    ...
}

Remember 可用于存储可变对象和不可变对象,您必须对在组合中创建的 State,也就是可组合函数中的 State 执行 remember 操作。在被记住后,状态将成为组合的一部分,并在函数重组时被重用,这样一来,购物车商品也可以按照我们的预期工作了。

由于重新组合期间会保留 quantity,因此屏幕上将显示改变后的新值。此外,Compose 还提供了 rememberSaveable,其行为与 remember 类似,但存储的值可在 Activity 和进程重建后保留下来,这是在配置变更时保留界面数据的好方法。rememberSaveable 适用于界面状态,如商品数量或选定的标签,但不适用诸如过渡动画状态一类的用例。

此外,您还可以将委托属性与 State API 结合使用。在下面的代码中可以看到,在实际应用中,我们可以使用 by 关键字来实现这一点。如果您不想每次都访问 value 属性的话,这不失为一种好方法。

@Composable
fun CartItem() {
    var quantity: Int by rememberSaveable { mutableStateOf(1) }
 
    Row {
        Button(onClick = { quantity++ }) {
            Text("+")
        }
        Text(quantity.toString())
        Button(onClick = { quantity-- }) {
            Text("-")
        }
    }
}

注意,您只应在可组合函数的作用域之外操作状态,因为可组合项可能会频繁地、以任何顺序执行。上面的代码中,在 onClick 监听器中修改 quantity 的操作是安全的,因为 onClick 不是可组合函数。您可以根据特定的用户输入触发状态更改,例如点击按钮或使用 附带效应

状态提升

Compose 相比起 View 系统的一大优势,便是更为良好的可复用性。然而在当前的形式下,您无法复用 CartItem 可组合函数,因为它总是会将私有的 quantity 初始化为 1。在真实的使用环境中,购物车中的商品并不总是都从 1 开始计数,而且用户的购物车中也可能会有之前会话中的商品。与依赖注入的逻辑类似,为了使 CartItem 可重用,我们需要将 quantity 作为参数传递给该函数;不仅如此,为了遵循单一可信来源原则,传递给 CartItem 的 quantity 应该不可变。

单一可信来源原则鼓励结构化的代码,以便只在一个位置修改数据。在本例中,如果 CartItem 不负责特定的状态 (即 quantity),就不应该对它进行更新。因此 CartItem 需要在用户与按钮交互并触发状态更新时通知调用方。但是这样一来,我们就需要考虑应该由谁负责更新 quantity 等状态的操作。我们可以先假设 Cart 可组合项应该拥有所有 CartItem 的信息,以及相应地更新这些信息的逻辑:

△ 假设 Cart 可组合项负责更新每个购物车的商品数量

△ 假设 Cart 可组合项负责更新每个购物车的商品数量

为了使 CartItem 可被重用,我们将 quantity 状态从 CartItem 提升至 Cart 中,这一过程被称为 状态提升

状态提升是一种将私有状态移出可组合项的模式,这可以使可组合项更趋于无状态,从而提高在应用中的可重用性。无状态可组合项是指不保存任何私有状态的可组合项。理想情况下,可组合项应接收状态作为参数,并使用 lambda 向上传递事件:

可组合项应接收状态 (State),并使用 lambda 向上传递事件 (Events)

△ 可组合项应接收状态 (State),并使用 lambda 向上传递事件 (Events)

使可组合项趋于无状态,不但可以使其符合单一可信来源原则,而且可以提高它的可重用性和可测试性。因为在这种情况下,可组合项没有与任何特定的数据处理方式耦合在一起,而我们还可以共享和拦截以这种方式提升的状态。下面是无状态版的 CartItem 的示例代码,它接收 quantity 并做为状态显示,同时将用户交互公开为事件:

@Composable
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 数据的持有者:

@Composable
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,您可以传入该状态;而如果您没有传入,该函数也会创建一个默认状态:

@Composable
fun Scaffold(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    ...
) { ... }
 
@Composable
public fun NavHost(
        navController: NavHostController,
        ...
) { ... }

在上面的例子中,我们只是假设应该由 Cart 可组合函数负责 CartItem 的状态更新。那么在我们实际去应用时,应该将状态提升到多高的层级呢?这其实是一个数据所有权的问题,如果您不能确定,则至少应将状态提升到需要访问该状态的所有可组合项的最低公共父级。在本例中,CartItem 的最低公共父级是 Cart,也就是负责使用正确的信息调用 CartItem 的层级。状态提升另一个原则是,可组合项应该只接受所需的参数。在 Jetsnack 中,我们使用了无状态的 Cart 可组合项,它只接受需要的参数:

@Composable
fun Cart(
    orderLines: List<OrderLine>,
    removeSnack: (Long) -> Unit,
    increaseItemCount: (Long) -> Unit,
    decreaseItemCount: (Long) -> Unit,
    inspiredByCart: SnackCollection,
    modifier: Modifier = Modifier
) {
    ...
}

这样的 Cart 可组合项更易于预览和测试,同时符合单一可信来源原则。这样做的可重用性也更高,比如,如果我们需要,就可以在窗口尺寸足够大的情况下,与另一个界面并排显示购物车。不仅如此,我们还提供了有状态版本,使其也可以通过特定的方式处理状态和事件:

@Composable
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 可组合项中持有 scaffoldState。由于 scaffoldState 包含可变属性,因此,与之相关的所有交互都应在该可组合项中进行。如果将 scaffoldState 传递给其他可组合项,这些可组合项可能会改变其状态。这不符合单一可信来源原则,而且会使对错误的跟踪变得更加困难。

@Composable
fun JetsnackApp() {
    JetsnackTheme {
        val scaffoldState = rememberScaffoldState()
        val coroutineScope = rememberCoroutineScope()
 
        JetsnackScaffold(scaffoldState = scaffoldState) {
            Content(
                    showSnackbar = { message ->
                        coroutineScope.launch {
                            scaffoldState.snackbarHostState
                                    .showSnackbar(message)
                        }
                    }
            )
        }
    }
}

但是,实际情况往往更加复杂。JetsnackApp 除了会发送界面元素外,还负责显示信息提示控件、导航到正确的屏幕、设置底部操作栏等操作。将所有这些内容都放在可组合项中,会使它难以阅读和理解。我们可以遵循分离关注点原则,将屏幕逻辑和界面元素状态委托给名为 "状态容器" 的类,从而让可组合函数只负责生成界面元素。

一般类型作为状态容器

我们使用 JetsnackAppState 类作为状态容器,它将会是 JetsnackApp 的界面元素状态的可信来源,因此所有状态写入都应在该类中进行。状态容器是在组合中创建和记住的普通类,因此,该类的作用域限定于创建它的可组合项。JetsnackAppState 只是一个普通类,而且由于它遵循可组合项的生命周期,因此可以使用 Compose 的依赖项,而不必担心内存泄漏:

class JetsnackAppState(
    // 一般的类可以接收 Compose 依赖
    val scaffoldState: ScaffoldState,
    val navController: NavHostController,
    ...
) {
    val shouldShowBottomBar: Boolean
        // 在读取的值发生改变时会进行重组
        @Composable 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 的新实例:

@Composable
fun rememberJetsnackAppState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    navController: NavHostController = rememberNavController(),
    ...
) = remember(scaffoldState, navController, ...) {
    JetsnackAppState(scaffoldState, navController, ...)
}

现在,我们在 JetsnackApp 中获取了 appState 的新实例。我们使用该实例将被提升的状态传递给可组合项,并在需要显示界面元素时检查该状态;同时调用函数来触发与界面相关的操作:

@Composable
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。当您需要跟踪状态或界面逻辑时可以使用状态容器来帮助控制复杂度。

class SearchState {
    var searchResults: List<Snack> by mutableStateOf(listOf())
        private set
 
    var activeFilters: List<Filter> by mutableStateOf(listOf())
        private set
 
    ...
}

ViewModel 作为状态容器

除了一般的状态容器外,我们还可以使用 ViewModel,这是一种继承架构组件 ViewModel 类的类。ViewModel 可用作由业务逻辑确定状态的状态容器。ViewModel 有两项责任: 首先,提供对应用业务逻辑的访问,这些业务逻辑通常位于层次结构的其他层级中,如存储区或用例中;其次,准备要在特定屏幕上呈现的应用数据,通常是用可观察类型呈现屏幕的界面状态。

在完全使用 Compose 构建的应用中,我们可以使用 Compose 的 State 类型。但在混合应用中,您还可能会用到其他的可观察类型,如 StateFlow:

class CartViewModel(
    private val repository: SnackRepository,
    private val savedState: SavedStateHandle
) : ViewModel() {
 
    val uiState: State<CartUiState> = ...
 
    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<CartUiState>(EmptyCart) 
        private set
    
    ...
}

不过在本例中,由于 uiState 位于组合之外,因此您不需要记住它,而只需使用它即可。可组合函数将在 uiState 更改时重新执行:

@Composable
fun Cart(viewModel: CartViewModel = viewModel()) {
    val uiState by viewModel.uiState
}

层次结构的其他层通常使用流式数据来传播更改,您可能已经开始在 ViewModel 中使用它们了。Flow 也可以很好地与 Compose 结合使用,我们提供了工具函数,可以将数据流转换为 Compose 的可观察 State API。例如,您可以使用 collectAsState 从数据流中收集值,并将它们呈现为 State。这样一来,每当数据流发出新值时,就会触发重组。

@Composable
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<Item> = emptyList()
) {
    ...
    fun formatPrice(...) { ... }
}

Cart 中同时使用了 ViewModel 和其他状态容器,它们具有不同的用途,并可以协同工作。我们来仔细看一下它们的生命周期: CartState 会遵循 Cart 可组合项的生命周期,当 Cart 从组合中移除后 CartState 也会一同移除;而 CartViewModel 具有不同的生命周期,即导航目的地、导航图、Fragment 或 Activity 的生命周期:

△ CartState 遵循 Cart 的生命周期而 CartViewModel 则位于组合之外

△ CartState 遵循 Cart 的生命周期而 CartViewModel 则位于组合之外

从全局来看,每个实体的作用都有明确的定义,从包含界面元素的界面层到包含业务逻辑的数据层,每个实体都有特定的用途。在下图中,您可以看到扮演着不同角色的实体,以及它们之间潜在的依赖关系:

△ 实体间的关系及它们对应的用途

△ 实体间的关系及它们对应的用途

总结

对于我们的应用来说,状态是十分重要的一部分。我们可以在 Compose 中使用 State API 做到简单的状态响应,也可以使用一般的类或者 ViewModel 作为状态容器,以便对可组合项进行状态提升,并使其符合单一可信来源原则。我们还可以组合不同的状态容器,从而利用它们各自不同的生命周期。

下面是我们在文章中列出的表格,请记住它,以便您在未来做决策时可以为您的应用提供明确的状态管理架构:

△ 希望现在您能更加理解这个表格的意义

△ 希望现在您能更加理解这个表格的意义

希望本文能帮助您实现 "理想的 Compose 状态",祝您拥有愉快的 Compose 使用体验。

更多信息

欢迎您 点击这里 向我们提交反馈,或分享您喜欢的内容、发现的问题。您的反馈对我们非常重要,感谢您的支持!

版权声明

禁止一切形式的转载-禁止商用-禁止衍生 申请授权

脉脉不得语
脉脉不得语
Zhengzhou Website
菜鸟 Android 开发一枚/前郑州 GDG 组织者/Android 开发技术周报站长/AndroidDevTools 站长/Toast.show(∞) 播客主播

你已经成功订阅到 Android 开发技术周报
太棒了!接下来,完成检验以获得全部访问权限 Android 开发技术周报
欢迎回来!你已经成功登录了。
成功!您的帐户已完全激活,您现在可以访问所有内容。