状态和 Jetpack Compose

应用中的状态是指可以随时间变化的任何值。这是一个非常宽泛的定义,从 Room 数据库到类的变量,全部涵盖在内。

所有 Android 应用都会向用户显示状态。下面是 Android 应用中的一些状态示例:

  • 在无法建立网络连接时显示的信息提示控件。
  • 博文和相关评论。
  • 在用户点击按钮时播放的波纹动画。
  • 用户可以在图片上绘制的贴纸。

Jetpack Compose 可帮助您明确状态在 Android 应用中的存储位置和使用方式。本指南重点介绍状态与可组合项之间的关联,以及 Jetpack Compose 提供的 API,您可以通过这些 API 更轻松地处理状态。

状态和组合

由于 Compose 是声明式工具集,因此更新它的唯一方法是通过新参数调用同一可组合项。这些参数是界面状态的表现形式。每当状态更新时,都会发生重组。因此,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 中的任何值。对于其他值,您可以将其传入自定义 Saver 对象。

其他受支持的状态类型

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 定义这些事件,就像使用 onExpandonCollapse 定义适合 ExpandingCard 的事件一样。

以这种方式提升的状态具有一些重要的属性:

  • 单一可信来源:我们会通过移动状态而不是复制状态,来确保只有一个可信来源。这有助于避免 bug。
  • 封装:只有有状态可组合项能够修改其状态。这完全是内部的。
  • 可共享:可与多个可组合项共享提升的状态。如果想在另一个可组合项中执行 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。通过遵循单向数据流,您可以将在界面中显示状态的可组合项与应用中存储和更改状态的部分解耦。

在 Compose 中恢复状态

在重新创建 activity 或进程后,您可以使用 rememberSaveable 恢复界面状态。rememberSaveable 可以在重组后保持状态。此外,rememberSaveable 也可以在重新创建 activity 和进程后保持状态。

存储状态的方式

添加到 Bundle 的所有数据类型都会自动保存。如果要保存无法添加到 Bundle 的内容,您有以下几种选择。

Parcelize

最简单的解决方案是向对象添加 @Parcelize 注解。对象将变为可打包状态并且可以捆绑。例如,以下代码会创建可打包的 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 中以不同方式管理状态。根据可组合项的复杂性,您需要考虑不同的备选方案:

  • 可组合项:用于管理简单的界面元素状态。
  • 状态容器:用于管理复杂的界面元素状态,且拥有界面元素的状态和界面逻辑。
  • 架构组件 ViewModel:一种特殊的状态容器类型,用于提供对业务逻辑以及屏幕或界面状态的访问权限。

状态容器的大小不等,具体取决于所管理的界面元素的范围(从底部应用栏等单个微件到整个屏幕)。状态容器可组合使用,也就是说,可将某个状态容器集成到其他状态容器中,尤其是在汇总状态时。

下图所示为 Compose 状态管理所涉及的各实体之间的关系概览。本部分的其余内容详细介绍了每个实体:

  • 可组合项可以依赖于 0 个或多个状态容器(可以是普通的对象、ViewModel 或二者皆有),具体取决于其复杂性。
  • 如果普通的状态容器需要访问业务逻辑或屏幕状态,则可能需要依赖于 ViewModel。
  • ViewModel 依赖于业务层或数据层。

状态管理中依赖关系的示意图(如上方列表所述)。

Compose 状态管理涉及的每个实体的(可选)依赖关系概览。

状态和逻辑的类型

在 Android 应用中,需要考虑不同类型的状态:

  • 界面元素状态是界面元素的提升状态。例如,ScaffoldState 用于处理 Scaffold 可组合项的状态。

  • 屏幕或界面状态是屏幕上需要显示的内容。例如,CartUiState 类可以包含购物车中的商品信息、向用户显示的消息或加载标记。该状态通常会与层次结构中的其他层相关联,原因是其包含应用数据。

此外,逻辑还有不同的类型:

  • 界面行为逻辑或界面逻辑与如何在屏幕上显示状态变化相关。例如,导航逻辑决定着接下来显示哪个屏幕,界面逻辑决定着如何在可能会使用信息提示控件或消息框的屏幕上显示用户消息。界面行为逻辑应始终位于组合中。

  • 业务逻辑决定着如何处理状态变化,例如如何付款或存储用户偏好设置。该逻辑通常位于业务层或数据层,但绝不会位于界面层。

将可组合项作为可信来源

如果状态和逻辑比较简单,在可组合项中使用界面逻辑和界面元素状态是一种不错的方法。例如,以下是处理 ScaffoldStateCoroutineScopeMyApp 可组合项:

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

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

ScaffoldState 包含可变属性,因此,与之相关的所有交互都应在 MyApp 可组合项中进行。但是,如果我们将其传递给其他可组合项,这些可组合项可能会改变其状态,这不符合单一可信来源原则,而且会使对错误的跟踪变得更加困难。

将状态容器作为可信来源

当可组合项包含涉及多个界面元素状态的复杂界面逻辑时,应将相应事务委派给状态容器。这样做更易于单独对该逻辑进行测试,还降低了可组合项的复杂性。该方法支持分离关注点原则可组合项负责发出界面元素,而状态容器包含界面逻辑和界面元素的状态

状态容器是可在组合中创建和保存的普通类。状态容器遵循可组合项的生命周期,因此可以采用 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 侧重于发出界面元素,并将所有界面逻辑和界面元素的状态委派给 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") { /* ... */ }
        }
    }
}

如您所见,增加可组合项的责任会增加对状态容器的需求。这些责任可能存在于界面逻辑中,也可能仅与要跟踪的状态数相关。

将 ViewModel 作为可信来源

如果普通状态容器类负责界面逻辑及界面元素的状态,则 ViewModel 是一种特殊的状态容器类型,其负责:

  • 提供对应用的业务逻辑的访问权限,该逻辑通常位于层次结构的其他层(例如业务层和数据层)中;
  • 准备要在特定屏幕上呈现的应用数据,这些数据会成为屏幕或界面状态。

ViewModel 的生命周期比组合长,原因是它们在配置发生变化后仍然有效。ViewModel 可以遵循 Compose 内容(即 activity 或 fragment)的主机的生命周期,也可以遵循目的地或导航图的生命周期(如果您使用的是 Navigation 库)。ViewModel 的生命周期较长,因此不应保留对绑定到组合生命周期的状态的长期引用。否则,可能会导致内存泄漏。

我们建议屏幕级可组合项使用 ViewModel 来提供对业务逻辑的访问权限并作为其界面状态的可信来源。如需了解 ViewModel 为何适用于这种情况,请参阅 ViewModel 和状态容器部分。

以下是在屏幕级可组合项中使用的 ViewModel 示例:

data class ExampleUiState(
    dataToDisplayOnScreen: List<Example> = emptyList(),
    userMessages: List<Message> = emptyList(),
    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
    ...

    Button(onClick = { viewModel.somethingRelatedToBusinessLogic() }) {
        Text("Do something")
    }
}

ViewModel 和状态容器

ViewModel 在 Android 开发中的优势使其适用于提供对业务逻辑的访问权限以及准备要在屏幕上呈现的应用数据。也就是说,ViewModel 具有以下优势:

  • ViewModel 触发的操作在配置发生变化后仍然有效。
  • Navigation 集成:
    • 当屏幕位于返回堆栈中时,Navigation 会缓存 ViewModel。这对在返回目标位置时即时提供之前加载的数据非常重要。使用遵循可组合项屏幕的生命周期的状态容器时,这种情况会更难处理。
    • 当目标位置从返回堆栈弹出后,ViewModel 也会被一并清除,以确保自动清理状态。这不同于监听可组合项的处理,监听的原因可能有多种,例如转到新屏幕、配置发生变化等。
  • 与其他 Jetpack 库(如 Hilt)集成。

状态容器可组合,且 ViewModel 与普通状态容器的责任不同,因此屏幕级可组合项可以既包含用于提供对业务逻辑的访问权限的 ViewModel,又包含用于管理其界面逻辑和界面元素状态的状态容器。由于 ViewModel 的生命周期比状态容器长,因此状态容器可以根据需要将 ViewModel 视为依赖项。

下面的代码展示了在 ExampleScreen 上协同工作的 ViewModel 和普通状态容器:

private class ExampleState(
    val lazyListState: LazyListState,
    private val resources: Resources,
    private val expandedItems: List<Item> = emptyList()
) { ... }

@Composable
private fun rememberExampleState(...) { ... }

@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,请参阅下面列出的其他资源。

Codelab

视频