状态和 Jetpack Compose

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

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

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

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

Compose 中的状态

状态的概念是 Compose 的核心。举个简单的例子:用户在屏幕中输入自己的姓名后,系统会显示一条问候语作为响应。以下代码包含问候语的文本和用于输入姓名的文本字段:

@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 将跟踪您为了描述组合中的界面而调用的可组合项。然后,当应用的状态发生变化时,Jetpack Compose 会安排重组。重组过程中会运行可能已更改的可组合项以响应状态变化,Jetpack Compose 会更新组合以反映所有更改。

组合只能通过初始组合生成且只能通过重组进行更新。修改组合的唯一方式是通过重组实现。

如需详细了解初始组合和重组,请参阅 Compose 编程思想

引入状态

如需更新可组合项,请先传入一个表示 TextField 状态的值,然后添加代码以在 TextField 值发生变化时更新状态。

如需引入本地状态来保存应该显示的姓名,请使用 remember { mutableStateOf() } 传入文本的默认值。这样一来,每当 name 状态变化时,TextField 显示的值也会发生变化。

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by remember { mutableStateOf("") }
        Text(
            text = "Hello",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.h5
        )
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

可组合函数可以使用 remember 可组合项记住单个对象。系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组期间返回存储的值。您可以使用 remember 存储可变对象和不可变对象。

mutableStateOf 会创建 MutableState,后者是 Compose 中的可观察类型。值如有任何更改,系统会安排重组读取此值的所有可组合函数。

remember 可帮助您在重组后保持状态。如果在还未使用 remember 的情况下使用 mutableStateOf,则每次重组 HelloContent 可组合项时,系统都会将状态重新初始化为空字符串。

您可以将记住的值用作其他可组合项的参数,甚至用作语句中的逻辑来更改要显示的可组合项。例如,如果您不想在姓名为空时显示问候语,请使用 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 对象。

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by rememberSaveable { 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") }
        )
    }
}

无状态可组合项

如果某个可组合项保持自己的状态(如上述示例),就会变得难以重复使用和测试,同时该可组合项与其状态的存储方式也会紧密关联。您应将此可组合项改为无状态可组合项,即不保持任何状态的可组合项。

为此,您可以使用状态提升。状态提升是一种编程模式,在这种模式下,您可以将可组合项的状态移至该可组合项的调用方。一种简单的方式是使用参数替换状态,同时使用 lambda 表示事件。

在本示例中,您从 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 有权将状态作为以下两种形式进行访问:不可变的 String 参数,以及它在想要请求状态更改时可以调用的 lambda onNameChange

在描述可组合项上的事件时,最常见的方式是使用 lambda。在此示例中,您使用接受 String 的 lambda 定义事件 onNameChange,采用的是 Kotlin 的函数类型语法 (String) -> Unit。名为 onNameChange 的 lambda 是现在时,因为该事件并不意味着状态已更改,而是可组合项正在请求事件处理脚本更改状态。

通过从 HelloContent 中提升出状态,更容易推断该可组合项、在不同的情况下重复使用它,以及进行测试。HelloContent 与状态的存储方式解耦。解耦意味着,如果您修改或替换 HelloScreen,不必更改 HelloContent 的实现方式。

状态下降、事件上升的这种模式称为“单向数据流”。在这种情况下,状态会从 HelloScreen 下降为 HelloContent,事件会从 HelloContent 上升为 HelloScreen。通过遵循单向数据流,您可以将在界面中显示状态的可组合项与应用中存储和更改状态的部分解耦。

ViewModel 和状态

在 Jetpack Compose 中,您可以使用 ViewModel 公开可观察的存储器(如 LiveDataFlow)中的状态,还可以使用它处理影响相应状态的事件。上面的 HelloScreen 示例使用如下所示的 ViewModel 实现:

class HelloViewModel : ViewModel() {

    // LiveData holds state which is observed by the UI
    // (state flows down from ViewModel)
    private val _name = MutableLiveData("")
    val name: LiveData<String> = _name

    // onNameChange is an event we're defining that the UI can invoke
    // (events flow up from UI)
    fun onNameChange(newName: String) {
        _name.value = newName
    }
}

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
    // by default, viewModel() follows the Lifecycle as the Activity or Fragment
    // that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

    // name is the current value of [helloViewModel.name]
    // with an initial value of ""
    val name: String by helloViewModel.name.observeAsState("")
    HelloContent(name = name, onNameChange = { helloViewModel.onNameChange(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") }
        )
    }
}

observeAsState 可观察 LiveData<T> 并返回 State<T> 对象,每当 LiveData 发生变化时,该对象都会更新。State<T> 是 Jetpack Compose 可以直接使用的可观察类型。仅当 LiveData 在组合中时,observeAsState 才会观察 LiveData。

以下代码行:

val name: String by helloViewModel.name.observeAsState("")

…是语法糖,可自动解封由 observeAsState 返回的状态对象。您也可以使用赋值运算符 (=) 为状态对象赋值,这样会使其成为 State<String>,而不是 String

val nameState: State<String> = helloViewModel.name.observeAsState("")

HelloViewModelHelloScreen 遵循单向数据流模式,在该模式下,状态从 HelloViewModel 向下流动,而事件从 HelloScreen 向上流动。

HelloInput、HelloScreen 与 HelloViewModel 之间的状态和事件流

我们来考虑一下此屏幕所述的界面事件循环:

  1. 事件:系统调用 onValueChange 来响应用户输入字符的操作。
  2. 更新状态HelloViewModel.onNameChange 会进行处理,然后设置可变的 LiveData_name 的状态。
  3. 显示状态HelloViewmodel.name 的值发生变化,这由 Compose 在 observeAsState 中观察。然后,HelloScreen 再次运行(或重组),以根据 name 的新值描述界面。

请参阅构建 Compose 界面,详细了解如何使用 ViewModel 和 Jetpack Compose 实现单向数据流。

使用 remember

可组合函数可以使用 remember 可组合项记住单个对象。系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组期间返回存储的值。remember 既可用于存储可变对象,又可用于存储不可变对象。

使用 remember 存储不可变值

在缓存成本高昂的界面操作(如计算文本格式设置)时,您可以存储不可变值。记住的值与名为 remember 的可组合项一起存储在组合中。

@Composable
fun FancyText(text: String) {
    // by passing text as a parameter to remember, it will re-run the calculation on
    // recomposition if text has changed since the last recomposition
    val formattedText = remember(text) { computeTextFormatting(text) }
    /*...*/
}
formattedText 为子项的 FancyText 的组合

使用 remember 在可组合项中创建内部状态

当您使用 remember 存储可变对象时,会向可组合项添加状态。您可以使用此方法为单个有状态可组合项创建内部状态。

我们强烈建议让可组合项使用的所有可变状态均可观察。这样一来,每当状态发生变化时,Compose 都可以自动重组界面。Compose 附带一个内置的可观察类型 State<T>,它直接集成到 Compose 运行时。

对于可组合项中的内部状态,一个很好的例子是 ExpandingCard,当用户点击某个按钮时,它会在收起状态与展开状态之间切换呈现动画效果。

ExpandedCard 可组合项在收起状态与展开状态之间切换呈现动画效果

此可组合项有一种重要的状态:expanded。当处于 expanded 状态时,该可组合项应显示正文;当处于收起状态时,应隐藏正文。

expanded 状态为子项的 ExpandingCard 的组合

您可以通过记住 mutableStateOf(initialValue) 向可组合项添加状态 expanded

@Composable
fun ExpandingCard(title: String, body: String) {
    // expanded is "internal state" for ExpandingCard
    var expanded by remember { mutableStateOf(false) }

    // describe the card for the current state of expanded
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(text = title)

            // content of the card depends on the current value of expanded
            if (expanded) {
                // TODO: show body & collapse icon
            } else {
                // TODO: show expand icon
            }
        }
    }
}

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) }

这些声明是等效的,以语法糖的形式针对状态的不同用法提供。您选择的声明应该能够在您编写的可组合项中生成可读性最高的代码。

您可以将一个可组合项中内部状态的值用作另一个可组合项的参数,甚至可以用其更改可组合项的名称。在 ExpandingCard 中,if 语句将根据 expanded 的当前值更改卡片的内容。

if (expanded) {
   // TODO: show body & collapse icon
} else {
   // TODO: show expand icon
}

在可组合项中修改内部状态

状态应该由可组合项中的事件来修改。如果您在运行可组合项时而不是在事件中修改状态,就会产生可组合项的附带效应,应予以避免。如需详细了解 Jetpack Compose 中的附带效应,请参阅 Compose 编程思想

为了完成 ExpandingCard 可组合项,我们来显示 body 以及收起按钮(当 expandedtrue 时)和展开按钮(当 expandedfalse 时)。

@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }

    // describe the card for the current state of expanded
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(text = title)

            // content of the card depends on the current value of expanded
            if (expanded) {
                Text(text = body, Modifier.padding(top = 8.dp))
                // change expanded in response to click events
                IconButton(onClick = { expanded = false }, modifier = Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandLess, contentDescription = "Expand less")
                }
            } else {
                // change expanded in response to click events
                IconButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandMore, contentDescription = "Expand more")
                }
            }
        }
    }
}

在此可组合项中,修改了状态以响应 onClick 事件。由于 expandedvar属性委托语法搭配使用,因此 onClick 回调可以直接分配 expanded

IconButton(onClick = { expanded = true }, /* … */) {
   // ...
}

我们现在可以描述 ExpandingCard 的界面更新循环,了解 Compose 如何修改和使用内部状态。

  1. 事件:系统调用 onClick 来响应用户点按某个按钮的操作。
  2. 更新状态:使用赋值方法在 onClick 监听器中更改了 expanded
  3. 显示状态ExpandingCard 会重组,因为 expanded 是已更改的 State<Boolean>,并且 ExpandingCardif(expanded) 代码行中读取相应的值。ExpandingCard 随后会根据 expanded 的新值描述屏幕。

在 Jetpack Compose 中使用其他类型的状态

Jetpack Compose 并不要求您使用 MutableState<T> 存储状态。Jetpack Compose 支持其他可观察类型。在 Jetpack Compose 中读取其他可观察类型之前,您必须将其转换为 State<T>,以便 Jetpack Compose 可以在状态发生变化时自动重组界面。

Compose 附带一些可以根据 Android 应用中使用的常见可观察类型创建 State<T> 的函数:

如果您的应用使用自定义可观察类,您可以构建扩展函数,以使 Jetpack Compose 读取其他可观察类型。如需查看具体操作方法的示例,请参阅内置函数的实现。任何允许 Jetpack Compose 订阅每项更改的对象都可以转换为 State<T> 并由可组合项读取。

将内部状态与界面可组合项分开

上一部分中的 ExpandingCard 具有内部状态。因此,调用方无法控制状态。这意味着,例如,如果您想要在展开状态下启动 ExpandingCard,没有办法这样做。此外,您也不能让卡片展开以响应其他事件,如用户点击 Fab 的操作。这也意味着,如果您想要将 expanded 状态移入 ViewModel,无法做到这一点。

另一方面,通过使用 ExpandingCard 中的内部状态,不需要控制或提升状态的调用方可以使用状态,而不必自行管理状态。

在开发可重复使用的可组合项时,您通常想要同时提供同一可组合项的有状态和无状态版本。有状态版本对于不关心状态的调用方来说很方便,而无状态版本对于需要控制或提升状态的调用方来说是必要的。

如需提供这两个版本作为有状态和无状态接口,请使用状态提升方法提取显示界面的无状态可组合项。

请注意,这两个可组合项都名为 ExpandingCard,不过它们采用不同的参数。发出界面的可组合项的命名惯例是一个大写形式的名词,用于描述可组合项在屏幕上表示的内容。在本例中,它们都表示一个 ExpandingCard。此命名惯例应用于整个 Compose 库,例如,在 TextFieldTextField 中。

ExpandingCard 分成有状态和无状态可组合项的代码如下:

// this stateful composable is only responsible for holding internal state
// and defers the UI to the stateless composable
@Composable
fun ExpandingCard(title: String, body: String) {
    var expanded by remember { mutableStateOf(false) }
    ExpandingCard(
        title = title,
        body = body,
        expanded = expanded,
        onExpand = { expanded = true },
        onCollapse = { expanded = false }
    )
}

// this stateless composable is responsible for describing the UI based on the state
// passed to it and firing events in response to the buttons being pressed
@Composable
fun ExpandingCard(
    title: String,
    body: String,
    expanded: Boolean,
    onExpand: () -> Unit,
    onCollapse: () -> Unit
) {
    Card {
        Column(
            Modifier
                .width(280.dp)
                .animateContentSize() // automatically animate size when it changes
                .padding(top = 16.dp, start = 16.dp, end = 16.dp)
        ) {
            Text(title)
            if (expanded) {
                Spacer(Modifier.height(8.dp))
                Text(body)
                IconButton(onClick = onCollapse, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandLess, contentDescription = "Expand less")
                }
            } else {
                IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                    Icon(imageVector = Icons.Default.ExpandMore, contentDescription = "Expand more")
                }
            }
        }
    }
}

Compose 中的状态提升是一种将状态移至可组合项的调用方以使可组合项无状态的模式。Jetpack Compose 中的常规状态提升模式是将状态变量替换为两个参数:

  • value: T:要显示的当前值
  • onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值

不过,并不局限于 onValueChange。如果更具体的事件适合可组合项,您应使用 lambda 定义这些事件,就像使用 onExpandonCollapse 定义适合 ExpandingCard 的事件一样。

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

  • 单一可信来源:通过移动状态而不是复制状态,我们确保 expanded 只有一个可信来源。这有助于避免错误。
  • 封装:只有有状态 ExpandingCard 能够修改其状态。这完全是内部的。
  • 可共享:可与多个可组合项共享提升的状态。假设我们想要在 Card 处于展开状态时隐藏 Fab 按钮,可以通过提升来做到这一点。
  • 可拦截:无状态 ExpandingCard 的调用方可以在更改状态之前决定忽略或修改事件。
  • 解耦:无状态 ExpandingCard 的状态可以存储在任何位置。例如,现在可以将 titlebodyexpanded 移入 ViewModel

以这种方式提升也遵循单向数据流。状态从有状态可组合项向下传递,而事件从无状态可组合项向上流动。

有状态和无状态 ExpandingCard 的单向数据流示意图

在重新创建 activity 或进程后恢复界面状态

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

@Composable
fun MyExample() {
    var selectedId by rememberSaveable<String?> { mutableStateOf(null) }
    /*...*/
}

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

最简单的解决方案是向对象添加 @Parcelize 注解。对象将变为可打包状态并且可以捆绑。例如,以下代码会创建可打包的 City 数据类型并将其保存到状态。

@Parcelize
data class City(val name: String, val country: String)

@Composable
fun MyExample() {
  var selectedCity = rememberSaveable { mutableStateOf(City("Madrid", "Spain")) }
}

如果某种原因导致 @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, nameKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun MyExample() {
    var selectedCity = rememberSaveable(saver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) }
}

为了避免需要为映射定义键,您也可以使用 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 MyExample() {
    var selectedCity = rememberSaveable(saver = CitySaver) { mutableStateOf(City("Madrid", "Spain")) }
    /*...*/
}

了解详情

如需详细了解状态和 Jetpack Compose,请学习“在 Jetpack Compose 中使用状态”Codelab