Jetpack Compose 中的状态

1. 开始之前

本 Codelab 解释了与在 Jetpack Compose 中使用 状态 相关的核心概念。它向您展示了应用程序的状态如何决定在 UI 中显示的内容,Compose 如何通过使用不同的 API 在状态更改时更新 UI,如何优化可组合函数的结构,以及如何在 Compose 世界中使用 ViewModel。

先决条件

您将学到什么

  • 如何在 Jetpack Compose UI 中思考状态和事件。
  • Compose 如何使用状态来确定在屏幕上显示哪些元素。
  • 什么是状态提升。
  • 有状态和无状态可组合函数的工作原理。
  • Compose 如何使用 State<T> API 自动跟踪状态。
  • 可组合函数中的内存和内部状态如何工作:使用 rememberrememberSaveable API。
  • 如何使用列表和状态:使用 mutableStateListOftoMutableStateList API。
  • 如何将 ViewModel 与 Compose 一起使用。

您需要什么

推荐/可选

您将构建什么

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

775940a48311302b.png

该应用程序有两个主要功能

  • 一个水计数器,用于跟踪您的水分摄入量。
  • 一个包含全天要完成的健康任务的列表。

在您完成此 Codelab 时,请查看以下随堂练习,以获得更多支持

2. 设置

启动一个新的 Compose 项目

  1. 要启动一个新的 Compose 项目,请打开 Android Studio。
  2. 如果您在 欢迎使用 Android Studio 窗口中,请单击 启动一个新的 Android Studio 项目。如果您已经打开了 Android Studio 项目,请从菜单栏中选择 文件 > 新建 > 新建项目
  3. 对于新项目,请从可用模板中选择 空活动

New project

  1. 单击 下一步 并配置您的项目,将其命名为 "BasicStateCodelab"。

确保您选择了至少为 API 级别 21 的 minimumSdkVersion,这是 Compose 支持的最低 API 级别。

当您选择 空 Compose 活动 模板时,Android Studio 会在您的项目中为您设置以下内容

  • 一个配置了可组合函数的 MainActivity 类,该函数在屏幕上显示一些文本。
  • AndroidManifest.xml 文件,它定义了应用程序的权限、组件和自定义资源。
  • build.gradle.ktsapp/build.gradle.kts 文件包含 Compose 所需的选项和依赖项。

Codelab 的解决方案

您可以从 GitHub 获取 BasicStateCodelab 的解决方案代码

$ git clone https://github.com/android/codelab-android-compose

或者,您可以 将存储库下载为 Zip 文件

您将在 BasicStateCodelab 项目中找到解决方案代码。我们建议您按照自己的节奏逐步完成 Codelab,并在需要帮助时查看解决方案。在 Codelab 中,您会看到需要添加到项目的代码片段。

3. Compose 中的状态

应用程序的 "状态" 是任何会随时间推移而发生变化的值。这是一个非常宽泛的定义,涵盖了从 Room 数据库到类中的变量的所有内容。

所有 Android 应用程序都向用户显示状态。Android 应用程序中状态的一些示例是

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

让我们开始编写您的健康应用程序。

为了简单起见,在 Codelab 期间

  • 您可以将所有 Kotlin 文件添加到 app 模块的根 com.codelabs.basicstatecodelab 包中。但是,在生产应用程序中,文件应在子包中进行逻辑结构化。
  • 您将在片段中将所有字符串内联硬编码。在实际应用程序中,应将它们作为字符串资源添加到 strings.xml 文件中,并使用 Compose 的 stringResource API 进行引用。

您需要构建的第一个功能是水计数器,用于计算您在一天中喝的水杯数。

创建一个名为 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)
   )
}

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

  1. 创建一个名为 WellnessScreen.kt 的文件,该文件表示主屏幕,并调用我们的 WaterCounter 函数
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

@Composable
fun WellnessScreen(modifier: Modifier = Modifier) {
   WaterCounter(modifier)
}
  1. 打开 MainActivity.kt。删除 GreetingDefaultPreview 可组合函数。在 Activity 的 setContent 块中调用新创建的 WellnessScreen 可组合函数,如下所示
class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           BasicStateCodelabTheme {
               // A surface container using the 'background' color from the theme
               Surface(
                   modifier = Modifier.fillMaxSize(),
                   color = MaterialTheme.colorScheme.background
               ) {
                   WellnessScreen()
               }
           }
       }
   }
}
  1. 如果您现在运行应用程序,您将看到我们的基本水计数器屏幕,其中包含硬编码的水杯数。

7ed1e6fbd94bff04.jpeg

WaterCounter 可组合函数的状态是变量 count。但具有静态状态并不是很有用,因为它无法修改。为了解决这个问题,您将添加一个 Button 来增加计数并跟踪您在一天中喝的水杯数。

任何导致状态修改的操作都称为 "事件",我们将在下一节中详细了解这一点。

4. Compose 中的事件

我们讨论了状态,它是任何会随时间推移而发生变化的值,例如聊天应用程序中收到的最新消息。但是,是什么导致状态更新?在 Android 应用程序中,状态会根据事件进行更新。

事件是由应用程序外部或内部生成的输入,例如

  • 用户通过例如按下一个按钮与 UI 交互。
  • 其他因素,例如传感器发送新值或网络响应。

虽然应用程序的状态提供了关于在 UI 中显示什么的描述,但事件是状态更改的机制,从而导致 UI 发生更改。

事件通知程序的一部分发生了什么事。在所有 Android 应用程序中,都存在一个核心 UI 更新循环,其工作方式如下

f415ca9336d83142.png

  • 事件 - 事件由用户或程序的另一部分生成。
  • 更新状态 - 事件处理程序更改 UI 使用的状态。
  • 显示状态 - UI 发生更新以显示新状态。

在 Compose 中管理状态就是关于了解状态和事件如何相互作用。

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

转到 WaterCounter 可组合函数以在我们的标签 Text 下添加 Button。一个 Column 将有助于您垂直对齐 TextButton 可组合函数。您可以将外部填充移到 Column 可组合函数中,并在 Button 的顶部添加一些额外的填充,以便它与 Text 分开。

Button 可组合函数接收一个 onClick lambda 函数 - 这是单击按钮时发生的事件。您将在后面看到更多 lambda 函数的示例。

count 更改为 var 而不是 val,以便它变得可变。

import androidx.compose.material3.Button
import androidx.compose.foundation.layout.Column

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

当您运行应用程序并单击按钮时,请注意没有任何反应。将count变量的值设置为不同的值不会使Compose将其检测为状态更改,因此没有任何反应。这是因为您没有告诉Compose在状态更改时应该重新绘制屏幕(即“重新组合”可组合函数)。您将在下一步中解决此问题。

e4dfc3bef967e0a1.gif

5. 可组合函数中的内存

Compose应用程序通过调用可组合函数将数据转换为UI。我们将组合称为Compose在执行可组合项时构建的UI的描述。如果发生状态更改,Compose将使用新状态重新执行受影响的可组合函数,从而创建更新的UI,这称为重新组合。Compose还会查看单个可组合项需要哪些数据,以便它仅重新组合数据已更改的组件,并跳过不受影响的组件。

为了能够做到这一点,Compose需要知道要跟踪哪些状态,以便在收到更新时可以安排重新组合。

Compose有一个特殊的州跟踪系统,它为读取特定州的任何可组合项安排重新组合。这使Compose能够做到精细化,只重新组合需要更改的可组合函数,而不是整个UI。这是通过跟踪不仅“写入”(即状态更改),还跟踪对状态的“读取”来完成的。

使用Compose的StateMutableState类型使Compose能够观察状态。

Compose跟踪每个读取State value属性的可组合项,并在其value更改时触发重新组合。您可以使用mutableStateOf函数来创建一个可观察的MutableState。它接受一个初始值作为参数,该值被包装在一个State对象中,然后使它的value可观察。

更新WaterCounter可组合项,以便count使用mutableStateOf API,初始值为0。由于mutableStateOf返回MutableState类型,您可以更新它的value来更新状态,Compose将触发对读取其value的函数的重新组合。

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

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       // Changes to count are now tracked by Compose
       val count: MutableState<Int> = mutableStateOf(0)

       Text("You've had ${count.value} glasses.")
        Button(onClick = { count.value++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

如前所述,对count的任何更改都会自动安排任何读取countvalue的可组合函数的重新组合。在这种情况下,每当单击按钮时,WaterCounter都会重新组合。

如果您现在运行应用程序,您会再次注意到没有任何反应!

e4dfc3bef967e0a1.gif

调度重新组合工作正常。但是,当发生重新组合时,变量count被重新初始化为0,因此我们需要一种方法来在重新组合之间保留此值。

为此,我们可以使用remember可组合内联函数。由remember计算的值在初始组合期间存储在组合中,存储的值在重新组合之间保留。

通常,remembermutableStateOf一起在可组合函数中使用。

有几种等效的方式可以编写它,如Compose状态文档中所示。

修改WaterCounter,使用remember内联可组合函数包围对mutableStateOf的调用

import androidx.compose.runtime.remember

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

或者,我们可以通过使用Kotlin的委托属性来简化count的使用。

您可以使用by关键字将count定义为一个var。添加委托的getter和setter导入使我们能够间接读取和修改count,而无需每次都显式地引用MutableStatevalue属性。

现在WaterCounter看起来像这样

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

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
   Column(modifier = modifier.padding(16.dp)) {
       var count by remember { mutableStateOf(0) }

       Text("You've had $count glasses.")
       Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

您应该选择在您正在编写的可组合项中产生最易读代码的语法。

现在让我们检查一下我们到目前为止所做的事情

  • 定义了一个我们随时间记住的变量,名为count
  • 创建了一个文本显示,我们告诉用户我们记住的数字。
  • 添加了一个按钮,每当它被点击时,就会增加我们记住的数字。

这种安排形成了一个与用户的数据流反馈循环

  • UI向用户展示状态(当前计数显示为文本)。
  • 用户产生的事件与现有状态相结合,产生新的状态(单击按钮会将当前计数加一)

您的计数器已准备就绪并正在工作!

a9d78ead2c8362b6.gif

6. 状态驱动的UI

Compose是一个声明式UI框架。当状态发生变化时,我们不会删除UI组件或更改其可见性,而是描述UI在特定状态条件下是什么。由于调用了重新组合并更新了UI,可组合项最终可能会进入或离开组合。

7d3509d136280b6c.png

这种方法避免了手动更新视图的复杂性,就像您在使用视图系统时所做的那样。它也更不容易出错,因为您不会忘记根据新状态更新视图,因为它会自动发生。

如果在初始组合或重新组合期间调用可组合函数,我们说它在组合中存在。未调用的可组合函数(例如,因为该函数在if语句内调用并且条件不满足)在组合中不存在

您可以在文档中了解有关可组合项的生命周期的更多信息。

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

您可以使用Android Studio的布局检查器工具检查Compose生成的应用程序布局,这将是您接下来的操作。

为了演示这一点,请修改您的代码以根据状态显示UI。打开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) {
           // This text is present if the button has been clicked
           // at least once; absent otherwise
           Text("You've had $count glasses.")
       }
       Button(onClick = { count++ }, Modifier.padding(top = 8.dp)) {
           Text("Add one")
       }
   }
}

运行应用程序,并通过导航到工具 > 布局检查器打开Android Studio的布局检查器工具。

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

通过点击屏幕左侧的根元素BasicStateCodelabTheme来浏览树。通过单击展开所有按钮来展开整个组件树。

点击右侧屏幕上的元素会导航到树的对应元素。

677bc0a178670de8.png

如果您按下应用程序上的加一按钮

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

当您使用Android Studio的布局检查器工具检查组件树时,现在您也会看到Text可组合项

1f8e05f6497ec35f.png

状态决定了在给定时刻UI中存在哪些元素。

UI的不同部分可以依赖于相同的状态。修改Button,使其在count为10之前处于启用状态,然后禁用(并且您达到了当天的目标)。使用Buttonenabled参数来执行此操作。

@Composable
fun WaterCounter(modifier: Modifier = Modifier) {
    ...
        Button(onClick = { count++ }, Modifier.padding(top = 8.dp), enabled = count < 10) {
    ...
}

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

1a8f4095e384ba01.gif

7. 在组合中记住

remember在组合中存储对象,如果在重新组合期间再次调用remember所在的源位置,则会忘记该对象。

为了可视化这种行为,您将在应用程序中实现以下功能:当用户至少喝了一杯水时,显示用户需要完成的健康任务,他们也可以关闭。由于可组合项应该是小型且可重用的,因此创建一个名为WellnessTaskItem的新可组合项,它根据作为参数接收的字符串显示健康任务,以及一个关闭图标按钮。

创建一个名为 WellnessTaskItem.kt 的新文件,并添加以下代码。您将在后面的代码实验室中使用此可组合函数。

import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.padding

@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(Icons.Filled.Close, contentDescription = "Close")
        }
    }
}

WellnessTaskItem 函数接收一个任务描述和一个 onClose lambda 函数(就像内置的 Button 可组合函数接收一个 onClick 函数一样)。

WellnessTaskItem 的外观如下所示

6e8b72a529e8dedd.png

为了用更多功能改进我们的应用程序,更新 WaterCounter 以在 count > 0 时显示 WellnessTaskItem

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

添加一个新的 **if** 语句,如果 showTask 为 true,则显示 WellnessTaskItem。使用您在上一节中学到的 API 来确保 showTask 值在重新组合时能够保留。

@Composable
fun WaterCounter() {
   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 = { },
                   taskName = "Have you taken your 15 minute walk today?"
               )
           }
           Text("You've had $count glasses.")
       }

       Button(onClick = { count++ }, enabled = count < 10) {
           Text("Add one")
       }
   }
}

使用 WellnessTaskItemonClose lambda 函数,以便在按下 X 按钮时,变量 showTask 更改为 false,并且任务不再显示。

   ...
   WellnessTaskItem(
      onClose = { showTask = false },
      taskName = "Have you taken your 15 minute walk today?"
   )
   ...

接下来,添加一个新的 Button,文本为“清除水量计数”,并将其放在“添加一个”Button 旁边。一个 Row 可以帮助对齐这两个按钮。您还可以向 Row 添加一些填充。当按下“清除水量计数”按钮时,变量 count 将重置回 0。

您的 WaterCounter 可组合函数应如下所示

import androidx.compose.foundation.layout.Row


@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("You've had $count glasses.")
       }

       Row(Modifier.padding(top = 8.dp)) {
           Button(onClick = { count++ }, enabled = count < 10) {
               Text("Add one")
           }
           Button(
               onClick = { count = 0 }, 
               Modifier.padding(start = 8.dp)) {
                   Text("Clear water count")
           }
       }
   }
}

运行应用程序时,您的屏幕将显示初始状态

Tree of components diagram, showing the app's initial state, count is 0

在右侧,我们有一个组件树的简化版本,它将帮助您分析状态变化时发生的情况。 countshowTask 是记住的值。

现在,您可以在应用程序中执行以下步骤

  • 按下“添加一个”按钮。这将增加 count(这会导致重新组合),并且 WellnessTaskItem 和计数器 Text 都开始显示。

Tree of components diagram, showing state change, when Add one button is clicked, Text with tip appears and Text with glasses count appears.

865af0485f205c28.png

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

Tree of components diagram, showing that when close button is clicked, the task composable disappears.

82b5dadce9cca927.png

  • 按下“添加一个”按钮(另一个重新组合)。showTask 会记住您在下一个重新组合中关闭了 WellnessTaskItem,如果您继续添加水杯。

  • 按下“清除水量计数”按钮,将 count 重置为 0 并导致重新组合。显示 countText,以及与 WellnessTaskItem 相关的所有代码,都不会被调用,并且会离开组合。

ae993e6ddc0d654a.png

  • showTask 被遗忘,因为调用 remember showTask 的代码位置在重新组合期间没有被调用。您将回到第一步。

  • 按下“添加一个”按钮,使 count 大于 0(重新组合)。

7624eed0848a145c.png

  • WellnessTaskItem 可组合函数再次显示,因为之前的 showTask 值在它离开上面的组合时被遗忘。

如果我们要求 showTaskcount 回到 0 后仍然存在,比 remember 允许的时间更长(也就是说,即使调用 remember 的代码位置在重新组合期间没有被调用)?我们将在接下来的部分中探讨如何解决这些情况以及更多示例。

既然您已经了解了当 UI 和状态离开组合时如何重置,请清除您的代码,并返回到本节开头时您拥有的 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。

2c1134ad78e4b68a.gif

如果您更改语言、在深色模式和浅色模式之间切换,或进行任何其他导致 Android 重新创建正在运行的 Activity 的配置更改,也会发生同样的情况。

虽然 remember 有助于您在重新组合时保留状态,但它 **不会在配置更改时保留状态**。为此,您必须使用 rememberSaveable 而不是 remember

rememberSaveable 会自动保存任何可以在 Bundle 中保存的值。对于其他值,您可以传入一个自定义保存器对象。有关 在 Compose 中恢复状态 的更多信息,请查看文档。

WaterCounter 中,用 rememberSaveable 替换 remember

import androidx.compose.runtime.saveable.rememberSaveable

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

现在运行应用程序,尝试一些配置更改。您应该看到计数器被正确地保存。

bf2e1634eff47697.gif

Activity 重新创建只是 rememberSaveable 的用例之一。我们将在后面的部分中,在处理列表时探索另一个用例。

根据您的应用程序的状态和 UX 需求,考虑使用 rememberrememberSaveable

9. 状态提升

使用 **remember** 来存储对象的可组合函数包含内部状态,这使得该可组合函数成为 **有状态的**。在调用者不需要控制状态,并且可以使用状态而无需自己管理状态的情况下,这很有用。但是,**包含内部状态的可组合函数往往难以重用,而且更难测试**。

**不包含任何状态的可组合函数称为无状态可组合函数**。创建 **无状态** 可组合函数的一种简单方法是使用状态提升。

Compose 中的状态提升是指将状态移动到可组合函数的调用者,以使可组合函数成为无状态的模式。Jetpack Compose 中状态提升的一般模式是使用两个参数替换状态变量

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

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

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

  • 单一事实来源:通过移动状态而不是复制状态,我们确保只有一个事实来源。这有助于避免错误。
  • 可共享:提升后的状态可以与多个可组合函数共享。
  • 可拦截:无状态可组合函数的调用者可以在更改状态之前选择忽略或修改事件。
  • 解耦:无状态可组合函数的状态可以存储在任何地方。例如,在 ViewModel 中。

尝试为 WaterCounter 实现此功能,以便它可以从上述所有功能中获益。

有状态与无状态

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

通过将 WaterCounter 可组合函数拆分为两个部分来重构它:有状态的 Counter 和无状态的 Counter。

StatelessCounter 的作用是显示 count,并在您增加 count 时调用一个函数。为此,请遵循上面描述的模式,并将状态 count(作为可组合函数的参数)和一个 lambda (onIncrement) 传递给可组合函数,该 lambda 在需要增加状态时被调用。 StatelessCounter 的外观如下所示

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

StatefulCounter 拥有状态。这意味着它保存 count 状态,并在调用 StatelessCounter 函数时修改它。

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

干得好!您已将 countStatelessCounter 提升到 StatefulCounter

您可以将此代码插入您的应用程序,并使用 StatefulCounter 更新 WellnessScreen

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

如前所述,状态提升有一些优点。我们将探索此代码的变体来解释其中的一些优点,**您不需要在您的应用程序中复制以下代码段**。

  1. 您的无状态可组合函数现在可以重用了。以以下示例为例。

为了计算水杯和果汁杯的数量,您需要记住 waterCountjuiceCount,但可以使用相同的 StatelessCounter 可组合函数来显示两个不同的独立状态。

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

    var juiceCount by remember { mutableStateOf(0) }

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

8211bd9e0a4c5db2.png

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

2cb0dcdbe75dcfbf.png

当用户点击以增加 juiceCount 时,StatefulCounter 会重新组合,StatelessCounter 也同样会重新组合。但是,读取了 waterCountStatelessCounter 不会重新组合。

7fe6ee3d2886abd0.png

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

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

在这种情况下,如果计数通过 StatelessCounterAnotherStatelessMethod 更新,则所有内容都将重新组合,这是预期的行为。

由于提升状态可以共享,请确保 **只传递可组合项所需的状态**,以避免不必要的重新组合,并提高可重用性。

要详细了解状态和状态提升,请查看 Compose 状态文档

10. 处理列表

接下来,添加应用程序的第二个功能,即健康任务列表。您可以对列表中的项目执行两个操作

  • 选中列表项目,以标记任务已完成。
  • 从您不感兴趣的完成的列表中删除任务。

设置

  1. 首先,修改列表项目。您可以重用 WellnessTaskItem(来自“在组合中使用 remember”部分),并更新它以包含 Checkbox。确保您提升了 checked 状态和 onCheckedChange 回调,以使函数无状态。

a0f8724cfd33cb10.png

本节的 WellnessTaskItem 可组合项应如下所示

import androidx.compose.material3.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. 在同一文件中,添加一个有状态的 WellnessTaskItem 可组合函数,该函数定义一个状态变量 checkedState 并将其传递给同名无状态方法。目前不要担心 onClose,您可以传递一个空 lambda 函数。
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember

@Composable
fun WellnessTaskItem(taskName: String, modifier: Modifier = Modifier) {
   var checkedState by remember { mutableStateOf(false) }

   WellnessTaskItem(
       taskName = taskName,
       checked = checkedState,
       onCheckedChange = { newValue -> checkedState = newValue },
       onClose = {}, // we will implement this later!
       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 来帮助垂直对齐列表和您已经拥有的计数器。
import androidx.compose.foundation.layout.Column

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

f9cbc49c960fd24c.gif

在 LazyList 中还原项目状态

现在仔细查看 WellnessTaskItem 可组合项中的某些内容。

checkedState 独立地属于每个 WellnessTaskItem 可组合项,就像私有变量一样。当 checkedState 发生变化时,只有该 WellnessTaskItem 实例会重新组合,而不是 LazyColumn 中的所有 WellnessTaskItem 实例。

按照以下步骤尝试一下

  1. 选中此列表顶部的任何元素(例如,元素 1 和 2)。
  2. 滚动到列表底部,使它们不在屏幕上。
  3. 滚动回顶部,回到您之前选中的项目。
  4. 请注意,它们已取消选中。

正如您在上一节中看到的那样,存在一个问题,当项目离开组合时,记住的状态会被遗忘。对于 LazyColumn 上的项目,当您滚动经过它们时,它们会完全离开组合,并且不再可见。

a68b5473354d92df.gif

如何解决此问题?再次使用 rememberSaveable。您的状态将通过保存的实例状态机制在活动或进程重新创建后存活。由于 rememberSaveableLazyList 的协作方式,您的项目也能够在离开组合后存活。

只需在有状态的 WellnessTaskItem 中将 remember 替换为 rememberSaveable,就完成了。

import androidx.compose.runtime.saveable.rememberSaveable

var checkedState by rememberSaveable { mutableStateOf(false) }

85796fb49cf5dd16.gif

Compose 中的常见模式

请注意 LazyColumn 的实现。

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

可组合函数 rememberLazyListState 使用 rememberSaveable 为列表创建一个初始状态。当 Activity 重新创建时,滚动状态将得以保持,而无需您编写任何代码。

许多应用程序需要对滚动位置、项目布局更改以及与列表状态相关的其他事件做出反应和监听。Lazy 组件(如 LazyColumnLazyRow)通过提升 LazyListState 来支持此用例。您可以在 列表状态文档 中了解更多有关此模式的信息。

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

11. 可观察的 MutableList

接下来,要添加从列表中删除任务的行为,第一步是将列表改为可变列表。

使用可变对象(如 ArrayList<T>mutableListOf)将不起作用。这些类型不会通知 Compose 列表中的项目已更改,也不会安排重新组合 UI。您需要一个不同的 API。

您需要创建一个 Compose 可观察的 MutableList 实例。此结构允许 Compose 跟踪更改,并在从列表中添加或删除项目时重新组合 UI。

首先定义我们的可观察的 MutableList。扩展函数 toMutableStateList() 是从初始可变或不可变 Collection(如 List)创建可观察的 MutableList 的方法。

或者,您也可以使用工厂方法 mutableStateListOf 创建可观察的 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") }
  1. 修改 WellnessTasksList 可组合函数,通过删除列表的默认值,因为列表已提升到屏幕级别。添加一个新的 lambda 函数参数 onCloseTask(接收要删除的 WellnessTask)。将 onCloseTask 传递给 WellnessTaskItem

您需要再进行一次更改。 items 方法接收一个 key 参数。默认情况下,每个项目的 state 都是根据项目在列表中的位置进行键入的。

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

您可以通过使用每个 WellnessTaskItemid 作为每个项目的 key 来轻松解决此问题。

要详细了解 列表中的项目 key,请查看文档。

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 重新组合屏幕。

47f4a64c7e9a5083.png

如果您尝试使用 rememberSaveable()WellnessScreen 中存储列表,您将获得一个运行时异常

此错误告诉您需要提供一个 自定义 Saver。但是,您不应该使用 **rememberSaveable** 来存储大量数据或需要长时间序列化或反序列化的复杂数据结构。

类似的规则适用于使用 Activity 的 onSaveInstanceState;您可以在 保存 UI 状态文档 中找到更多信息。如果您想这样做,您需要另一种存储机制。您可以在文档中了解有关不同 保存 UI 状态选项 的更多信息。

接下来,我们将探讨 ViewModel 作为应用程序状态持有者的作用。

12. ViewModel 中的状态

屏幕或 UI 状态指示屏幕上应该显示什么(例如,任务列表)。**此状态通常与层次结构的其他层相关联,因为它包含应用程序数据**。

虽然 UI 状态描述了屏幕上要显示的内容,但应用程序的逻辑描述了应用程序的行为方式以及如何对状态更改做出反应。逻辑有两种类型:UI 行为或 UI 逻辑,以及业务逻辑。

  • UI 逻辑与如何显示屏幕上的状态更改相关(例如,导航逻辑或显示 Snackbar)。
  • 业务逻辑是如何处理状态更改(例如,进行支付或存储用户偏好)。此逻辑通常放置在业务层或数据层中,而不是 UI 层中。

ViewModels 提供 UI 状态和对位于应用程序其他层中的业务逻辑的访问。此外,ViewModels 在配置更改后仍然存在,因此它们的生存期比 Composition 更长。它们可以遵循 Compose 内容宿主的生命周期——即,如果您使用 Compose Navigation,则可以遵循 Activity、Fragment 或 Navigation 图的目的地。

要了解有关架构和 UI 层的更多信息,请查看 UI 层文档

迁移列表并删除方法

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

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

  1. 创建一个文件 WellnessViewModel.kt 来添加您的 ViewModel 类。

将您的“数据源” getWellnessTasks() 移动到 WellnessViewModel

定义一个内部 _tasks 变量,使用 toMutableStateList 与之前一样,并将 tasks 作为列表公开,因此它无法从 ViewModel 外部修改。

实现一个简单的 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:{latest_version}")

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

  1. 打开 WellnessScreen。通过调用 viewModel() 来实例化 wellnessViewModel ViewModel,作为 Screen 可组合项的参数,以便在测试此可组合项时可以替换它,并在需要时提升它。向 WellnessTasksList 提供任务列表和删除函数,以 onCloseTask lambda 形式。
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 中使用,则 viewModel() 将返回相同的实例,直到 Activity 完成或进程被杀死。

就是这样!您已经将 ViewModel 与部分状态和业务逻辑集成到了您的屏幕中。由于状态保持在 Composition 之外并由 ViewModel 存储,因此对列表的修改将在配置更改后保留。

ViewModel 不会在任何情况下自动保留应用程序的状态(例如,针对系统启动的进程死亡)。有关 保留应用程序 UI 状态 的详细信息,请查看文档。

迁移选中状态

最后一个重构是将选中状态和逻辑迁移到 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)
           }
       )
   }
}
  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 状态将提升到 List 级别。该文件只有此可组合函数
@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. 运行应用程序并尝试选中任何任务。请注意,选中任何任务目前还不起作用。

1d08ebcade1b9302.gif

这是因为 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 在构造函数中接收一个 initialChecked 变量,其默认值为 false,然后我们可以使用工厂方法 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)
}

就是这样!此解决方案有效,所有更改都将在重新组合和配置更改后保留!

e7cc030cd7e8b66f.gif

测试

现在业务逻辑已经重构到 ViewModel 中,而不是与可组合函数耦合,因此单元测试变得更加简单。

您可以使用仪器测试来验证 Compose 代码的正确行为,以及 UI 状态是否正常工作。考虑参加 Compose 中的测试 代码实验室,了解如何测试您的 Compose UI。

13. 恭喜

干得好!您已成功完成此代码实验室,并学习了在 Jetpack Compose 应用程序中使用状态的所有基本 API!

您学习了如何思考状态和事件,以在 Compose 中提取无状态可组合项,以及 Compose 如何使用状态更新来驱动 UI 中的更改。

接下来是什么?

查看 Compose 学习路径 上的其他代码实验室。

示例应用程序

  • JetNews 演示了此代码实验室中解释的最佳实践。

更多文档

参考 API