状态使用入门 - Jetpack Compose 中的状态

284 阅读20分钟

Jetpack Compose 中的状态

了解应用的状态如何决定界面中显示的内容、Compose 如何在状态发生变化时更新界面、如何优化可组合函数的结构,以及如何在 Compose 应用中使用 ViewModel。

1. 准备工作

学习内容

  • 如何看待 Jetpack Compose 界面中的状态和事件?
  • Compose 如何使用状态确定要在屏幕上显示的元素?
  • 什么是状态提升?
  • 有状态可组合函数和无状态可组合函数如何运作?
  • Compose 如何使用 State<T> API 自动跟踪状态?
  • 内存和内部状态如何用于可组合函数中?用 remember 和 rememberSaveable API。
  • 如何使用列表和状态?使用 mutableStateListOf 和 toMutableStateList API。
  • 如何结合使用 ViewModel 与 Compose?

构建内容

您将实现一个简单的健康应用:

image.png

该应用具有两项主要功能:

  • 用于跟踪饮水量的饮水计数器。
  • 全天内的待完成健康任务列表。

2. 进行设置

启动新的 Compose 项目

新建一个项目名为 BasicStateCodelab

注意: 如需详细了解如何使用空 activity 设置 Compose,或如何将其添加到现有项目,请查看 Compose 设置文档

3. Compose 中的状态

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

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

  • 聊天应用中最新收到的消息。
  • 用户的个人资料照片。
  • 在项列表中的滚动位置。

关键提示: 状态决定界面在任何特定时间的显示内容。

您需要构建的第一项功能是饮水计数器,用于记录您一天喝了多少杯水。

创建一个名为 WaterCounter 的可组合函数,其中包含一个 Text 可组合函数,用于显示饮水杯数。饮水杯数应存储在名为 count 的值中,您现在可以对其进行硬编码。

创建一个包含 WaterCounter 可组合函数的新文件 WaterCounter.kt,如下所示:

import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   val count = 0
   Text(
       text = "You've had $count glasses.", 
       modifier = modifier.padding(16.dp)
   )
}

注意: 优秀实践是为所有可组合函数提供默认的 Modifier,从而提高可重用性。它应作为第一个可选参数显示在参数列表中,位于所有必需参数之后。

让我们创建一个表示整个屏幕的可组合函数,该函数将包括两个部分,即饮水计数器和健康任务列表。现阶段,我们只需添加计数器。

  1. 创建一个代表主屏幕的文件 WellnessScreen.kt,然后调用 WaterCounter 函数:
@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
    WaterCounter(modifier)
}
  1. 打开 MainActivity.kt。移除 Greeting 和 DefaultPreview 可组合函数。在 activity 的 setContent 块内调用新创建的 WellnessScreen 可组合函数,如下所示:
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            BasicStateCodelabTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Surface(
                        modifier = Modifier
                            .fillMaxSize()
                            .padding(innerPadding),
                        color = MaterialTheme.colorScheme.background
                    ) {
                        WellnessScreen()
                    }
                }
            }
        }
    }
}

WaterCounter 可组合函数的状态为变量 count。但是,静态状态无法修改,因此用处不大。如需解决此问题,请添加 Button 以增加计数并跟踪全天的饮水杯数。

任何会导致状态修改的操作都称为“事件”,我们将在下一部分中对此进行详细介绍。

4. Compose 中的事件

“状态”是指可以随时间变化的任何值,例如,聊天应用最新收到的消息。但是,是什么原因导致状态更新呢?在 Android 应用中,状态会根据事件进行更新。

事件是从应用外部或内部生成的输入,例如:

  • 用户与界面互动,例如按下按钮。
  • 其他因素,例如传感器发送新值或网络响应。

应用的状态说明了要在界面中显示的内容,而事件则是一种机制,可在状态发生变化时导致界面发生变化。

关键提示: 通常的描述为“是”某状态,“发生”某事件。

事件用于通知程序发生了某事。所有 Android 应用都有核心界面更新循环,如下所示:

image.png

  • 事件:由用户或程序的其他部分生成。
  • 更新状态:事件处理脚本会更改界面所使用的状态。
  • 显示状态:界面会更新以显示新状态。

Compose 中的状态管理主要是了解状态和事件之间的交互方式。

现在,添加按钮以便用户通过添加更多饮水杯数来修改状态。

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var count = 0
        Text(text = "You've had $count glasses.")
        Button(onClick = { count++ }, modifier = Modifier.padding(top = 8.dp)) {
            Text(text = "Add one")
        }
    }
}

Button 可组合函数接收 onClick lambda 函数 - 用户点击按钮时会发生此事件。

将 count 更改为 var(而不是 val),使其具有可变性。

运行应用并点击按钮时,您会发现没有任何反应。为 count 变量设置不同的值不会使 Compose 将其检测为状态更改,因此不会产生任何效果。这是因为,当状态发生变化时,您尚未指示 Compose 应重新绘制屏幕(即“重组”可组合函数)。

警告: 您可能想要在 Logcat 中添加日志来调试可组合函数,以尝试确定这些函数是否正常运行。不过请注意,在使用 Compose 时,此过程并不一定非常可靠。这有多种原因,例如,重组被舍弃,如 Compose 编程思想中所述。

5. 可组合函数中的记忆功能

Compose 应用通过调用可组合函数将数据转换为界面。组合是指 Compose 在执行可组合函数时构建的界面描述。如果发生状态更改,Compose 会使用新状态重新执行受影响的可组合函数,从而创建更新后的界面。这一过程称为重组。Compose 还会查看各个可组合函数需要哪些数据,以便仅重组数据发生了变化的组件,而避免重组未受影响的组件。

组合: Jetpack Compose 在执行可组合函数时构建的界面描述。

初始组合: 通过首次运行可组合函数创建组合。

重组: 在数据发生变化时重新运行可组合函数以更新组合。

为此,Compose 需要知道要跟踪的状态,以便在收到更新时安排重组。

Compose 采用特殊的状态跟踪系统,可以为读取特定状态的任何可组合函数安排重组。这让 Compose 能够实现精细控制,并且仅重组需要更改的可组合函数,而不是重组整个界面。这将通过同时跟踪针对状态的“写入”(即状态变化)和针对状态的“读取”来实现。

使用 Compose 的 State 和 MutableState 类型让 Compose 能够观察到状态。

Compose 会跟踪每个读取状态 value 属性的可组合函数,并在其 value 更改时触发重组。您可以使用 mutableStateOf 函数来创建可观察的 MutableState。它接受初始值作为封装在 State 对象中的参数,这样便可使其 value 变为可观察。

Compose 还有 mutableStateOf 的其他变体(例如 mutableIntStateOfmutableLongStateOfmutableFloatStateOf 或 mutableDoubleStateOf),这些变体已针对基元类型进行了优化。

安排重组的过程没有问题。不过,当重组发生时,变量 count 会重新初始化为 0,因此我们需要通过某种方式在重组后保留此值。为此,我们可以使用 remember 可组合内嵌函数。系统会在初始组合期间将由 remember 计算的值存储在组合中,并在重组期间一直保持存储的值。

您可以将 remember 视为一种在组合中存储单个对象的机制,就像私有 val 属性在对象中执行的操作一样。

remember 和 mutableStateOf 通常在可组合函数中一起使用。

还可以采用一些等效的编码方法,详见 Compose 状态文档

我们也可以使用 Kotlin 的委托属性来简化 count 的使用。您可以使用关键字 by 将 count 定义为 var。通过添加委托的 getter 和 setter 导入内容,我们可以间接读取 count 并将其设置为可变,而无需每次都显式引用 MutableState 的 value 属性。

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var count by remember { mutableStateOf(0) }
        Text(text = "You've had $count glasses.")
        Button(onClick = { count++ }, modifier = Modifier.padding(top = 8.dp)) {
            Text(text = "Add one")
        }
    }
}

注意: 您可能已经在使用其他可观察类型,例如使用 LiveDataStateFlowFlow 和 RxJava 的 Observable 在应用中存储状态。如需允许 Compose 使用此状态,并在状态发生变化时自动执行重组,您需要将其映射到 State。

一些扩展函数可以实现此目的,因此请务必在 Compose 和其他库文档中查询这些函数。

6. 状态驱动型界面

Compose 是一个声明性界面框架。它描述界面在特定状况下的状态,而不是在状态发生变化时移除界面组件或更改其可见性。调用重组并更新界面后,可组合函数最终可能会进入或退出组合。

image.png

此方法可避免像针对视图系统那样手动更新视图的复杂性。这也不太容易出错,因为您不会忘记根据新状态更新视图,因为系统会自动执行此过程。

如果在初始组合期间或重组期间调用了可组合函数,则认为其存在于组合中。未调用的可组合函数(例如,由于该函数在 if 语句内调用且未满足条件)不存在于组合中。

如需详细了解可组合函数的生命周期,请参阅文档。

关键提示: 如果界面是相对用户而言的,那么界面状态就是相对应用而言的。这就像同一枚硬币的两面,界面是界面状态的直观呈现。对界面状态所做的任何更改都会立即反映在界面中。

组合的输出是描述界面的树结构。

Android Studio 的布局检查器工具可用于检查 Compose 生成的应用布局。我们接下来将执行此操作。

为了演示此过程,请修改代码,以根据状态显示界面。打开 WaterCounter,如果 count 大于 0,则显示 Text

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var count by remember { mutableStateOf(0) }
        if (count > 0) {
            Text(text = "You've had $count glasses.")
        }
        Button(onClick = { count++ }, modifier = Modifier.padding(top = 8.dp)) {
            Text(text = "Add one")
        }
    }
}

运行应用,然后依次选择 Tools > Layout Inspector,打开 Android Studio 的布局检查器工具。

警告: 如需在检查器中查看 Compose 节点,请使用 API 大于或等于 29 的设备

您会看到一个分屏:左侧是组件树,右侧是应用预览。

点按屏幕左侧的根元素 BasicStateCodelabTheme 可浏览树。点击“Expand all”按钮,展开整个组件树。

点击屏幕右侧的某个元素即可访问树的相应元素。

image.png

如果您按应用上的“Add one”按钮:

  • 计数增加到 1 且状态发生变化。
  • 系统调用重组。
  • 屏幕使用新元素重组。

现在,使用 Android Studio 的布局检查器工具检查组件树,您还会看到 Text 可组合函数:

image.png

状态驱动界面在给定时刻显示哪些元素。

界面的不同部分可以依赖于相同的状态。修改 Button,使其在 count 达到 10 之前处于启用状态,并在达到 10 之后停用(即您达到当天的目标)。为此,请使用 Button 的 enabled 参数。

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var count by remember { mutableStateOf(0) }
        if (count > 0) {
            Text(text = "You've had $count glasses.")
        }
        Button(
            onClick = { count++ },
            modifier = Modifier.padding(top = 8.dp),
            enabled = count < 10
        ) {
            Text(text = "Add one")
        }
    }
}

现在运行应用。对 count 状态的更改决定是否显示 Text,以及是启用还是停用 Button

7. 组合中的记忆功能

remember 会将对象存储在组合中,而如果在重组期间未再次调用之前调用 remember 的来源位置,则会忘记对象。

为了直观呈现这种行为,我们将在应用中实现以下功能:当用户至少饮用了一杯水时,向用户显示有一项待执行的健康任务,同时用户也可以关闭此任务。由于可组合函数应较小并可重复使用,因此请创建一个名为 WellnessTaskItem 的新可组合函数,该可组合函数根据以参数形式接收的字符串来显示健康任务,并显示一个 Close 图标按钮。

创建一个新文件 WellnessTaskItem.kt,并添加以下代码。您稍后将在此 Codelab 中使用此可组合函数。

@Composable
fun WellnessTaskItem(
    taskName: String,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 16.dp), text = taskName
        )
        IconButton(onClick = onClose) {
            Icon(imageVector = Icons.Filled.Close, contentDescription = "Close")
        }
    }
}

接下来为应用添加更多功能,请更新 WaterCounter,以在 count > 0 时显示 WellnessTaskItem

当 count 大于 0 时,定义一个变量 showTask,用于确定是否显示 WellnessTaskItem 并将其初始化为 true。

添加新的 if 语句,以在 showTask 为 true 时显示 WellnessTaskItem。使用之前部分介绍的 API 来确保 showTask 值在重组后继续有效。

使用 WellnessTaskItem 的 onClose lambda 函数实现:在按下 X 按钮时,变量 showTask 更改为 false,且不再显示任务。

接下来,添加一个带“Clear water count”文本的新 Button,并将其放置在“Add one”Button 旁边。Row 可帮助对齐这两个按钮。您还可以向 Row 添加一些内边距。按下“Clear water count”按钮后,变量 count 会重置为 0。

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var count by remember { mutableStateOf(0) }
        if (count > 0) {
            var showTask by remember { mutableStateOf(true) }
            if (showTask) {
                WellnessTaskItem(
                    onClose = { showTask = false },
                    taskName = "Have you taken your 15 minute walk today?"
                )
            }
            Text(text = "You've had $count glasses.")
        }
        Row(modifier = Modifier.padding(top = 8.dp)) {
            Button(
                onClick = { count++ },
                enabled = count < 10
            ) {
                Text(text = "Add one")
            }

            Button(
                onClick = { count = 0 },
                modifier = Modifier.padding(start = 8.dp)
            ) {
                Text("Clear water count")
            }
        }
    }
}

运行应用时,屏幕会显示初始状态:

image.png

count 和 showTask 是记住的值。

现在,您可以在应用中按以下步骤操作:

  • 按下 Add one 按钮。此操作会递增 count(这会导致重组),并同时显示 WellnessTaskItem 和计数器 Text

image.png

  • 按下 WellnessTaskItem 组件的 X(这会导致另一项重组)。showTask 现在为 false,这意味着不再显示 WellnessTaskItem

image.png

  • 按下“Add one”按钮(另一项重组)。如果您继续增加杯数,showTask 会记住您在下一次重组时关闭了 WellnessTaskItem

image.png

  • 按下 Clear water count 按钮可将 count 重置为 0 并导致重组。系统不会调用显示 count 的 Text 以及与 WellnessTaskItem 相关的所有代码,并且会退出组合。

image.png

  • 由于系统未调用之前调用 showTask 的代码位置,因此会忘记 showTask。这将返回第一步。

image.png

  • 按下“Add one”按钮,使 count 大于 0(重组)。

image.png

  • 系统再次显示 WellnessTaskItem 可组合函数,因为在退出上述组合时,之前的 showTask 值已被忘记。

如果我们要求 showTask 在 count 重置为 0 之后持续保留超过 remember 允许的时间(也就是说,即使重组期间未调用之前调用 remember 的代码位置),会发生什么?

现在,您已经了解了界面和状态在退出组合后的重置过程,请清除代码并返回到本部分开头的 WaterCounter

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        var count by remember { mutableStateOf(0) }
        if (count > 0) {
            Text("You've had $count glasses.")
        }
        Button(onClick = { count++ }, Modifier.padding(top = 8.dp), enabled = count < 10) {
            Text("Add one")
        }
    }
}

8. 在 Compose 中恢复状态

运行应用,为计数器增加一些饮水杯数,然后旋转设备。请确保已为设备启用自动屏幕旋转设置。

由于系统会在配置更改后(在本例中,即改变屏幕方向)重新创建 activity,因此已保存状态会被忘记:计数器会在重置为 0 后消失。

如果您更改语言、在深色模式与浅色模式之间切换,或者执行任何导致 Android 重新创建运行中 activity 的其他配置更改时,也会发生相同的情况。

虽然 remember 可帮助您在重组后保持状态,但不会帮助您在配置更改后保持状态。为此,您必须使用 rememberSaveable,而不是 remember

rememberSaveable 会自动保存可保存在 Bundle 中的任何值。对于其他值,您可以将其传入自定义 Saver 对象。如需详细了解如何在 Compose 中恢复状态,请参阅相关文档。

在 WaterCounter 中,将 remember 替换为 rememberSaveable

import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
        ...
        var count by rememberSaveable { mutableStateOf(0) }
        ...
}

重新创建 activity 只是 rememberSaveable 的用例之一。我们稍后会在使用列表时探索另一个用例。

在重新创建 activity 后,您可以使用 rememberSaveable 恢复界面状态。除了在重组后保持状态之外,rememberSaveable 还会在重新创建 activity 和系统发起进程终止之后保留状态。

请考虑是使用 remember 还是 rememberSaveable,具体取决于应用的状态和用户体验需求。

9. 状态提升

使用 remember 存储对象的可组合函数包含内部状态,这会使该可组合函数有状态。在调用方不需要控制状态,并且不必自行管理状态便可使用状态的情况下,“有状态”会非常有用。但是,具有内部状态的可组合函数往往不易重复使用,也更难测试

不保存任何状态的可组合函数称为无状态可组合函数。如需创建无状态可组合函数,一种简单的方法是使用状态提升。

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

  • value: T:要显示的当前值
  • onValueChange: (T) -> Unit:请求更改值的事件,包含一个新值 T

其中,此值表示任何可修改的状态。

状态下降、事件上升的这种模式称为单向数据流 (UDF),而状态提升就是我们在 Compose 中实现此架构的方式。如需了解相关详情,请参阅 Compose 架构文档

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

  • 单一可信来源:通过移动状态,而不是复制状态,我们可确保只有一个可信来源。这有助于避免 bug。
  • 可共享:可与多个可组合函数共享提升的状态。
  • 可拦截:无状态可组合函数的调用方可以在更改状态之前决定忽略或修改事件。
  • 分离:无状态可组合函数的状态可以存储在任何位置。例如,存储在 ViewModel 中。

请尝试为 WaterCounter 实现状态提升,以便从以上所有方法中受益。

有状态与无状态

当所有状态都可以从可组合函数中提取出来时,生成的可组合函数称为无状态函数。

无状态可组合函数是指不具有任何状态的可组合函数,这意味着它不会存储、定义或修改新状态。

有状态可组合函数是一种具有可以随时间变化的状态的可组合函数。

在实际应用中,让可组合函数 100% 完全无状态可能很难实现,具体取决于可组合函数的职责。在设计可组合函数时,您应该让可组合函数拥有尽可能少的状态,并能够在必要时通过在可组合函数的 API 中公开状态来提升状态。

重构 WaterCounter 可组合函数,将其拆分为两部分:有状态和无状态计数器。

StateLessCounter 无状态计数器:

@Composable
fun StateLessCounter(count: Int, onIncrement: () -> Unit, modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(16.dp)) {
        if (count > 0) {
            Text(text = "You've had $count glasses.")
        }
        Button(
            onClick = onIncrement,
            modifier = Modifier.padding(top = 8.dp),
            enabled = count < 10
        ) {
            Text(text = "Add one")
        }
    }
}

StatefulCounter 拥有状态。这意味着,它会存储 count 状态,并在调用 StatelessCounter 函数时对其进行修改。

太棒了!您已将 count 从 StatelessCounter 提升到 StatefulCounter

@Composable
fun StatefulCounter(modifier: Modifier = Modifier) {
    var count by rememberSaveable { mutableStateOf(0) }
    StateLessCounter(count = count, onIncrement = { count++ }, modifier = modifier)
}

您可以将其插入到应用中,并使用 StatefulCounter 更新 WellnessScreen

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   StatefulCounter(modifier)
}

要点: 提升状态时,有三条规则可帮助您弄清楚状态应去向何处:

  1. 状态应至少提升到使用该状态(读取)的所有可组合函数的最低共同父项
  2. 状态应至少提升到它可以发生变化(写入)的最高级别
  3. 如果两种状态发生变化以响应相同的事件,它们应提升到同一级别

您可以将状态提升到高于这些规则要求的级别,但如果未将状态提升到足够高的级别,则遵循单向数据流会变得困难或不可能。

如前所述,状态提升具有一些好处。

  1. 您的无状态可组合函数现在已可重复使用。请看以下示例。

如需记录饮用水和果汁的杯数,请记住 waterCount 和 juiceCount,但请使用相同的 StatelessCounter 可组合函数来显示两种不同的独立状态。

@Composable
fun StatefulCounter() {
    var waterCount by remember { mutableStateOf(0) }

    var juiceCount by remember { mutableStateOf(0) }

    StatelessCounter(waterCount, { waterCount++ })
    StatelessCounter(juiceCount, { juiceCount++ })
}

image.png

如果修改了 juiceCount,则重组 StatefulCounter。在重组期间,Compose 会识别哪些函数读取 juiceCount,并触发系统仅重组这些函数。

image.png

当用户点按以递增 juiceCount 时,系统会重组 StatefulCounter,同时也会重组 juiceCount 的 StatelessCounter。但不会重组读取 waterCount 的 StatelessCounter

image.png

  1. 有状态可组合函数可以为多个可组合函数提供相同的状态
@Composable
fun StatefulCounter() {
   var count by remember { mutableStateOf(0) }

   StatelessCounter(count, { count++ })
   AnotherStatelessMethod(count, { count *= 2 })
}

如果通过 StatelessCounter 或 AnotherStatelessMethod 更新计数,则系统会按预期重组所有项目。

由于可以共享提升的状态,因此请务必仅传递可组合函数所需的状态,以避免不必要的重组并提高可重用性。

要点: 设计可组合函数的最佳实践是仅向它们传递所需要的参数。

如需详细了解状态和状态提升,请参阅 Compose 状态文档

10. 使用列表

设置

  1. 首先,修改列表项。您可以重复使用“组合中的记忆功能”部分中的 WellnessTaskItem,并将其更新为包含 Checkbox。请务必提升 checked 状态和 onCheckedChange 回调,使函数变为无状态。

image.png

@Composable
fun WellnessTaskItem(
    taskName: String,
    check: Boolean,
    onCheckedChange: (Boolean) -> Unit,
    onClose: () -> Unit,
    modifier: Modifier = Modifier
) {
    Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 16.dp),
            text = taskName
        )
        Checkbox(checked = check, onCheckedChange = onCheckedChange)
        IconButton(onClick = onClose) {
            Icon(imageVector = Icons.Filled.Close, contentDescription = "Close")
        }
    }
}
  1. 在同一文件中,添加一个有状态 WellnessTaskItem 可组合函数,用于定义状态变量 checkedState 并将其传递给同名的无状态方法。暂时不用担心 onClose,您可以传递空的 lambda 函数。
@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
    var checkedState by remember { mutableStateOf(false) }
    WellnessTaskItem(
        taskName = taskName, checked = checkedState,
        onCheckedChange = { newValue -> checkedState = newValue },
        onClose = {}, modifier = modifier
    )
}
  1. 创建一个文件 WellnessTask.kt,对包含 ID 和标签的任务进行建模。将其定义为数据类
data class WellnessTask(val id: Int, val label: String)
  1. 对于任务列表本身,请创建一个名为 WellnessTasksList.kt 的新文件,并添加一个方法用于生成一些虚假数据:
fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

请注意,在真实应用中,您将从数据层获取数据。

  1. 在 WellnessTasksList.kt 中,添加一个用于创建列表的可组合函数。定义 LazyColumn 以及您所创建的列表方法中的列表项。如需帮助,请参阅列表文档
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.runtime.remember

@Composable
fun WellnessTasksList(
    modifier: Modifier = Modifier,
    list: List<WellnessTask> = remember { getWellnessTasks() }
) {
    LazyColumn(
        modifier = modifier
    ) {
        items(list) { task ->
            WellnessTaskItem(taskName = task.label)
        }
    }
}
  1. 将列表添加到 WellnessScreen。使用 Column 有助于列表与已有的计数器垂直对齐。

注意: 如果在 Android Studio 的编辑器区域键入 WC,系统会打开一个建议框。如果您按下 Enter 并选择第一个选项,系统会显示可供使用的 Column 模板。

如需详细了解 Android Studio 中适用于 Compose 的实时模板和其他实用工具,请参阅 Compose 工具文档

import androidx.compose.foundation.layout.Column

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()
       WellnessTasksList()
   }
}
  1. 运行应用并试一下效果!现在,您应该能够勾选任务,但不能删除任务。我们将在稍后的部分中实现此功能。

在 LazyList 中恢复项状态

我们来详细了解一下 WellnessTaskItem 可组合函数中的一些内容。

checkedState 属于每个 WellnessTaskItem 可组合函数,就像私有变量一样。当 checkedState 发生变化时,系统只会重组 WellnessTaskItem 的实例,而不是重组 LazyColumn 中的所有 WellnessTaskItem 实例。

当一个项退出组合时,系统会忘记之前记住的状态。对于 LazyColumn 上的项,当您滚动至项不可见的位置时,这些不可见的项会完全退出组合。

如何解决此问题?同样,使用 rememberSaveable。它采用保存的实例状态机制,可确保在重新创建 activity 或进程之后,状态会继续保留。得益于 rememberSaveable 与 LazyList 配合工作的方式,您的项在离开组合后也能继续保留。

只需在有状态 WellnessTaskItem 中将 remember 替换为 rememberSaveable 即可,如下所示:

import androidx.compose.runtime.saveable.rememberSaveable

var checkedState by rememberSaveable { mutableStateOf(false) }

Compose 中的常见模式

请注意 LazyColumn 的实现:

@Composable
fun LazyColumn(
...
    state: LazyListState = rememberLazyListState(),
...

可组合函数 rememberLazyListState 使用 rememberSaveable 为列表创建初始状态。重新创建 activity 后,无需任何编码即可保持滚动状态。

许多应用需要对滚动位置、列表项布局更改以及其他与列表状态相关的事件作出响应,并进行监听。延迟组件(例如 LazyColumn 或 LazyRow)可通过提升 LazyListState 来支持此用例。如需详细了解此模式,请参阅介绍列表中的状态的文档

状态参数使用由公共 rememberX 函数提供的默认值是内置可组合函数中的常见模式。另一个示例可以在 BottomSheetScaffold 中找到,它使用 rememberBottomSheetScaffoldState 提升状态。

11. 可观察的可变列表

接下来,如需添加从列表中移除任务的行为,第一步是让列表成为可变列表。

使用可变对象(例如 ArrayList<T> 或 mutableListOf,)对此不起作用。这些类型不会向 Compose 通知列表中的项已发生更改并安排界面重组。您需要使用其他 API。

您需要创建一个可由 Compose 观察的 MutableList 实例。此结构可允许 Compose 跟踪更改,以便在列表中添加或移除项时重组界面。

首先,定义可观察的 MutableList。扩展函数 toMutableStateList() 用于根据初始可变或不可变的 Collection(例如 List)来创建可观察的 MutableList

或者,您也可以使用工厂方法 mutableStateListOf 来创建可观察的 MutableList,然后为初始状态添加元素。

mutableStateOf 函数会返回一个类型为 MutableState<T> 的对象。

mutableStateListOf 和 toMutableStateList 函数会返回一个类型为 SnapshotStateList<T> 的对象。在本部分中,“可观察的 MutableList”一词表示此类。

  1. 打开 WellnessScreen.kt 文件。将 getWellnessTasks 方法移至此文件中以便使用该方法。如需创建列表,请先调用 getWellnessTasks(),然后使用之前介绍的扩展函数 toMutableStateList
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   Column(modifier = modifier) {
       StatefulCounter()

       val list = remember { getWellnessTasks().toMutableStateList() }
       WellnessTasksList(list = list, onCloseTask = { task -> list.remove(task) })
   }
}

private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

警告:您可以改用 mutableStateListOf API 来创建列表。但是,如果使用方法不当,则可能会导致意外重组和界面性能欠佳。

如果您仅定义列表,然后在不同的操作中添加任务,则会导致系统在每次重组时都添加重复项。

// Don't do this!

val list = remember { mutableStateListOf<WellnessTask>() }

list.addAll(getWellnessTasks())

而是应当在单一操作中创建包含初始值的列表,然后将其传递给 remember 函数,如下所示:

// Do this instead. Don't need to copy

val list = remember {

mutableStateListOf<WellnessTask>().apply { addAll(getWellnessTasks()) >}

}

  1. 通过移除列表的默认值来修改 WellnessTasksList 可组合函数,因为列表会提升到屏幕级别。添加一个新的 lambda 函数参数 onCloseTask(用于接收 WellnessTask 以进行删除)。将 onCloseTask 传递给 WellnessTaskItem

您还需要再进行一项更改。items 方法会接收一个 key 参数。默认情况下,每个项的状态均与该项在列表中的位置相对应。

在可变列表中,当数据集发生变化时,这会导致问题,因为实际改变位置的项会丢失任何记住的状态。

使用每个 WellnessTaskItem 的 id 作为每个项的键,即可轻松解决此问题。

如需详细了解列表中的项键,请参阅相关文档。

WellnessTasksList 将如下所示:

@Composable
fun WellnessTasksList(
   list: List<WellnessTask>,
   onCloseTask: (WellnessTask) -> Unit,
   modifier: Modifier = Modifier
) {
   LazyColumn(modifier = modifier) {
       items(
           items = list,
           key = { task -> task.id }
       ) { task ->
           WellnessTaskItem(taskName = task.label, onClose = { onCloseTask(task) })
       }
   }
}
  1. 修改 WellnessTaskItem:将 onClose lambda 函数作为参数添加到有状态 WellnessTaskItem 中并进行调用。
@Composable
fun WellnessTaskItem(
   taskName: String, onClose: () -> Unit, modifier: Modifier = Modifier
) {
   var checkedState by rememberSaveable { mutableStateOf(false) }

   WellnessTaskItem(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = onClose,
       modifier = modifier,
   )
}

此功能已经完成,现在已经可以从列表中删除项。

如果您点击每行中的 X,则事件会一直到达拥有状态的列表,从列表中移除相应项,并导致 Compose 重组界面。

image.png

如果您尝试使用 rememberSaveable() 将列表存储在 WellnessScreen 中,则会发生运行时异常:

cannot be saved using the current SaveableStateRegistry. The default implementation only supports types which can be stored inside the Bundle. Please consider implementing a custom Saver for this class and pass it to rememberSaveable().

此错误消息指出,您需要提供自定义 Saver。但是,您不应使用 rememberSaveable 来存储需要长时间序列化或反序列化操作的大量数据或复杂数据结构。

使用 activity 的 onSaveInstanceState 时,应遵循类似的规则;如需了解详情,请参阅保存界面状态文档。如果要执行此操作,您需要替代存储机制。如需详细了解其他保留界面状态的选项,请参阅相关文档。

接下来,我们来看看 ViewModel 在存储应用状态方面的作用。

12. ViewModel 中的状态

屏幕或界面状态指示应在屏幕上显示的内容(例如任务列表)。该状态通常会与层次结构中的其他层相关联,原因是其包含应用数据

界面状态描述屏幕上显示的内容,而应用逻辑则描述应用的行为方式以及应如何响应状态变化。逻辑分为两种类型:第一种是界面行为或界面逻辑,第二种是业务逻辑。

  • 界面逻辑涉及如何在屏幕上显示状态变化(例如导航逻辑或显示信息提示控件)。
  • 业务逻辑决定如何处理状态更改(例如付款或存储用户偏好设置)。该逻辑通常位于业务层或数据层,但绝不会位于界面层。

ViewModels 提供界面状态以及对位于应用其他层中的业务逻辑的访问。此外,ViewModel 还会在配置更改后继续保留,因此其生命周期比组合更长。ViewModel 可以遵循 Compose 内容(即 activity 或 fragment)的主机的生命周期,也可以遵循导航图的目的地的生命周期(如果您使用的是 Compose Navigation 库)。

如需详细了解架构和界面层,请参阅界面层文档

警告: ViewModel 并不是组合的一部分。因此,您不应保留可组合函数中创建的状态(例如,记住的值),因为这可能会导致内存泄漏。

迁移列表并移除方法

虽然前面的步骤展示了如何直接在可组合函数中管理状态,但最好将界面逻辑和业务逻辑与界面状态分开,并将其迁移到 ViewModel。

让我们将界面状态(列表)迁移到 ViewModel,并开始将业务逻辑提取到 ViewModel 中。

  1. 创建文件 WellnessViewModel.kt 以添加 ViewModel 类。

将“数据源”getWellnessTasks() 移至 WellnessViewModel

像前面一样使用 toMutableStateList 定义内部 _tasks 变量,并将 tasks 作为列表公开,这样将无法从 ViewModel 外部对其进行修改。

实现一个简单的 remove 函数,用于委托给列表的内置 remove 函数。

import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel

class WellnessViewModel : ViewModel() {
    private val _tasks = getWellnessTasks().toMutableStateList()
    val tasks: List<WellnessTask>
        get() = _tasks


   fun remove(item: WellnessTask) {
       _tasks.remove(item)
   }
}

private fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }
  1. 我们可以通过调用 viewModel() 函数,从任何可组合函数访问此 ViewModel。

如需使用此函数,请打开 app/build.gradle.kts 文件,添加以下库,并在 Android Studio 中同步新的依赖项:

implementation("androidx.lifecycle:lifecycle-viewmodel-compose:{2.9.2}")

在处理 Android Studio Giraffe 时使用版本 2.6.2。否则,请点击此处查看该库的最新版本。

  1. 打开 WellnessScreen。实例化 wellnessViewModel ViewModel,方法是以 Screen 可组合函数的参数的形式调用 viewModel(),以便在测试此可组合函数时进行替换,并根据需要进行提升。为 WellnessTasksList 提供任务列表,并为 onCloseTask lambda 提供 remove 函数。
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun WellnessScreen(
    modifier: Modifier = Modifier, 
    wellnessViewModel: WellnessViewModel = viewModel()
) {
   Column(modifier = modifier) {
       StatefulCounter()

       WellnessTasksList(
           list = wellnessViewModel.tasks,
           onCloseTask = { task -> wellnessViewModel.remove(task) })
   }
}

viewModel() 会返回一个现有的 ViewModel,或在给定作用域内创建一个新的 ViewModel。只要作用域处于活动状态,ViewModel 实例就会一直保留。例如,如果在某个 activity 中使用了可组合函数,则在该 activity 完成或进程终止之前,viewModel() 会返回同一实例。

大功告成!您已将 ViewModel 与部分状态和业务逻辑集成到了屏幕上。由于状态保留在组合之外并由 ViewModel 存储,因此对列表的更改在配置更改后继续有效。

ViewModel 在任何情况下(例如,对于系统发起的进程终止)都不会自动保留应用的状态。如需详细了解如何保留应用的界面状态,请参阅相关文档。

建议将 ViewModel 用于屏幕级可组合函数,即靠近从导航图的 activity、fragment 或目的地调用的根可组合函数。绝不应将 ViewModel 传递给其他可组合函数,而是应当仅向它们传递所需的数据以及以参数形式执行所需逻辑的函数。

如需了解详情,请参阅 ViewModel 和状态容器部分以及我们的 Compose 和其他库的相关文档。

迁移选中状态

最后一个重构是将选中状态和逻辑迁移到 ViewModel。这样一来,代码将变得更简单且更易于测试,并且所有状态均由 ViewModel 管理。

  1. 首先,修改 WellnessTask 模型类,使其能够存储选中状态并将 false 设置为默认值。
data class WellnessTask(val id: Int, val label: String, var checked: Boolean = false)
  1. 在 ViewModel 中,实现一个 changeTaskChecked 方法,该方法将接收使用选中状态的新值进行修改的任务。
class WellnessViewModel : ViewModel() {
   ...
   fun changeTaskChecked(item: WellnessTask, checked: Boolean) =
       _tasks.find { it.id == item.id }?.let { task ->
           task.checked = checked
       }
}
  1. 在 WellnessScreen 中,通过调用 ViewModel 的 changeTaskChecked 方法为列表的 onCheckedTask 提供行为。函数现在应如下所示:
@Composable
fun WellnessScreen(
    modifier: Modifier = Modifier, 
    wellnessViewModel: WellnessViewModel = viewModel()
) {
   Column(modifier = modifier) {
       StatefulCounter()

       WellnessTasksList(
           list = wellnessViewModel.tasks,
           onCheckedTask = { task, checked ->
               wellnessViewModel.changeTaskChecked(task, checked)
           },
           onCloseTask = { task ->
               wellnessViewModel.remove(task)
           }
       )
   }
}

警告: 将 ViewModel 实例传递给其他可组合函数是一种不好的做法。您应仅传递它们需要的数据以及将所需逻辑作为参数来执行的函数。如需了解详情,请参阅将 Compose 与您现有的应用架构集成页面。

  1. 打开 WellnessTasksList 并添加 onCheckedTask lambda 函数参数,以便将其传递给 WellnessTaskItem.
@Composable
fun WellnessTasksList(
   list: List<WellnessTask>,
   onCheckedTask: (WellnessTask, Boolean) -> Unit,
   onCloseTask: (WellnessTask) -> Unit,
   modifier: Modifier = Modifier
) {
   LazyColumn(
       modifier = modifier
   ) {
       items(
           items = list,
           key = { task -> task.id }
       ) { task ->
           WellnessTaskItem(
               taskName = task.label,
               checked = task.checked,
               onCheckedChange = { checked -> onCheckedTask(task, checked) },
               onClose = { onCloseTask(task) }
           )
       }
   }
}
  1. 清理 WellnessTaskItem.kt 文件。我们不再需要有状态方法,因为 CheckBox 状态将提升到列表级别。该文件仅包含以下可组合函数:
@Composable
fun WellnessTaskItem(
   taskName: String,
   checked: Boolean,
   onCheckedChange: (Boolean) -> Unit,
   onClose: () -> Unit,
   modifier: Modifier = Modifier
) {
   Row(
       modifier = modifier, verticalAlignment = Alignment.CenterVertically
   ) {
       Text(
           modifier = Modifier
               .weight(1f)
               .padding(start = 16.dp),
           text = taskName
       )
       Checkbox(
           checked = checked,
           onCheckedChange = onCheckedChange
       )
       IconButton(onClick = onClose) {
           Icon(Icons.Filled.Close, contentDescription = "Close")
       }
   }
}
  1. 运行应用并尝试勾选任何任务。您会发现无法勾选任何任务。

这是因为 Compose 将跟踪 MutableList 与添加和移除元素相关的更改。这就是删除功能能够正常运行的原因。但是,它对行项的值(在本例中为 checkedState)的更改一无所知,除非您指示它跟踪这些值。

解决此问题的方法有两种:

  • 更改数据类 WellnessTask,使 checkedState 变为 MutableState<Boolean>(而非 Boolean),这会使 Compose 跟踪项更改。
  • 复制您要更改的项,从列表中移除相应项,然后将更改后的项重新添加到列表中,这会使 Compose 跟踪该列表的更改。

这两种方法各有利弊。例如,根据您所使用的列表的实现,移除和读取该元素可能会产生非常高的开销。

因此,假设您想要避免可能开销高昂的列表操作,并将 checkedState 设为可观察,因为这种方式更高效且更符合 Compose 的规范。

您的新 WellnessTask 应如下所示:

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf

data class WellnessTask(val id: Int, val label: String, val checked: MutableState<Boolean> = mutableStateOf(false))

如前所述,在本例中,您可以使用委托属性,这样可以更轻松地使用变量 checked

将 WellnessTask 更改为类,而不是数据类。让 WellnessTask 在构造函数中接收默认值为 false 的 initialChecked 变量,然后可以使用工厂方法 mutableStateOf 来初始化 checked 变量并接受 initialChecked 作为默认值。

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class WellnessTask(
    val id: Int,
    val label: String,
    initialChecked: Boolean = false
) {
    var checked by mutableStateOf(initialChecked)
}

大功告成!这种解决方案行之有效,并且所有更改在重组和配置更改后仍然保持有效!

测试

现在,业务逻辑已重构为 ViewModel,而不是在可组合函数内形成耦合,因此单元测试要简单得多。

您可以使用插桩测试来验证 Compose 代码的正确行为以及界面状态是否正常运行。建议学习在 Compose 中进行测试 Codelab,了解如何测试 Compose 界面。

13. 恭喜

太棒了!您学习了用于在 Jetpack Compose 应用中处理状态的所有基本 API!

您了解了如何使用状态和事件在 Compose 中提取无状态可组合函数,以及 Compose 如何使用状态更新来促使界面发生变化。