Jetpack Compose 中的状态

1. 开始之前

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

前提条件

您将学到什么

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

您将需要什么

推荐/可选

您将构建什么

您将实现一个简单的健康 (Wellness) 应用

775940a48311302b.png

该应用有两个主要功能

  • 一个饮水计数器,用于跟踪您的饮水量。
  • 一天中要完成的健康任务列表。

如需在完成本 Codelab 过程中获得更多支持,请查看以下代码演示

2. 环境搭建

创建一个新的 Compose 项目

  1. 要创建一个新的 Compose 项目,请打开 Android Studio。
  2. 如果您在 Welcome to Android Studio 窗口中,点击 Start a new Android Studio project。如果您已经打开了一个 Android Studio 项目,请从菜单栏中选择 File > New > New Project
  3. 对于新项目,从可用模板中选择 Empty Activity

New project

  1. 点击 Next 并配置您的项目,将其命名为 "BasicStateCodelab"。

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

当您选择 Empty Compose Activity 模板时,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.kt,包含 WaterCounter 可组合函数,如下所示

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 顶部添加一些额外内边距,以便它与文本分开。

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。我们将 Composition (组合) 指的是 Compose 执行可组合项时构建的 UI 描述。如果发生状态变化,Compose 会使用新状态重新执行受影响的可组合函数,创建更新后的 UI——这称为 recomposition (重新组合)。Compose 还会查看单个可组合项需要哪些数据,以便它只重新组合数据已更改的组件,并跳过未受影响的组件。

为了能够做到这一点,Compose 需要知道要跟踪什么状态,以便在接收到更新时可以调度重新组合。

Compose 拥有一个特殊的内置状态跟踪系统,它会为读取特定状态的任何可组合项调度重新组合。这使得 Compose 能够细粒度地只重新组合需要更改的可组合函数,而不是整个 UI。这是通过不仅跟踪状态的“写”操作(即状态变化),还跟踪状态的“读”操作来实现的。

使用 Compose 的 StateMutableState 类型使状态可被 Compose 观察。

Compose 会跟踪读取 Statevalue 属性的每个可组合项,并在其 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 计算的值在*初始组合*期间存储在 Composition 中,并且存储的值在重新组合之间保持不变。

通常 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 的更新,可组合项可能会进入或离开 Composition (组合)。

7d3509d136280b6c.png

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

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

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

Composition (组合) 的输出是一个描述 UI 的树状结构。

您可以使用 Android Studio 的 Layout Inspector 工具检查 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")
       }
   }
}

运行应用,然后通过导航到 Tools > Layout Inspector 打开 Android Studio 的 Layout Inspector 工具。

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

通过点击屏幕左侧的根元素 BasicStateCodelabTheme 来导航树。点击 Expand all 按钮展开整个组件树。

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

677bc0a178670de8.png

如果您在应用中按下 Add one 按钮

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

当您使用 Android Studio 的 Layout Inspector 工具检查组件树时,现在您也能看到 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. Composition 中的 remember

remember 将对象存储在 Composition (组合) 中,并且如果在重新组合期间调用 remember 的源位置没有再次被调用,则会忘记该对象。

为了直观地展示此行为,您将在应用中实现以下功能:当用户至少喝了一杯水后,显示一个健康任务供用户完成,他们也可以关闭该任务。因为可组合项应该小而可重用,所以创建一个名为 WellnessTaskItem 的新可组合项,它根据接收到的字符串参数显示健康任务,并带有一个*关闭*图标按钮。

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

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 相关的代码都不会被调用,并离开 Composition (组合)。

ae993e6ddc0d654a.png

  • showTask 被遗忘了,因为调用 remember showTask 的代码位置没有被调用。您回到了第一步。

  • 按下*加一杯*按钮,使 count 大于 0(重新组合)。

7624eed0848a145c.png

  • WellnessTaskItem 可组合项再次显示,因为上面离开 Composition (组合) 时,showTask 的前一个值被遗忘了。

如果我们要求 showTaskcount 返回 0 后仍然保持,并且比 remember 所允许的时间更长(也就是说,即使在重新组合期间调用 remember 的代码位置没有被调用)怎么办?我们将在下一节中探讨如何解决这些场景以及更多示例。

既然您了解了当 UI 和状态离开 Composition (组合) 时如何被重置,请清除您的代码并返回到本节开头时的 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 中的值。对于其他值,您可以传入自定义的 Saver 对象。有关在 Compose 中恢复状态的更多信息,请查看文档。

WaterCounter 中,将 remember 替换为 rememberSaveable

import androidx.compose.runtime.saveable.rememberSaveable

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

现在运行应用并尝试一些配置更改。您应该看到计数器已正确保存。

bf2e1634eff47697.gif

Activity 重建只是 rememberSaveable 的用例之一。稍后在处理列表时,我们将探讨另一个用例。

根据您应用的状态和用户体验需求,考虑是使用 remember 还是 rememberSaveable

9. 状态提升

使用 remember 存储对象的可组合项包含内部状态,这使得该可组合项成为有状态的 (stateful)。这在调用方不需要控制状态并且可以自己使用而无需管理状态的情况下很有用。然而,具有内部状态的可组合项往往可重用性较差且更难测试

不持有任何状态的可组合项称为无状态可组合项 (stateless composables)。创建无状态可组合项的一种简单方法是使用状态提升 (state hoisting)。

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

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

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

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

  • 单一数据源 (Single source of truth):通过移动状态而不是复制状态,我们确保只有一个数据源。这有助于避免错误。
  • 可共享:提升的状态可以与多个可组合项共享。
  • 可截获:无状态可组合项的调用方可以在更改状态之前决定忽略或修改事件。
  • 解耦:无状态可组合函数的*状态*可以存储在任何位置。例如,在 ViewModel 中。

尝试为 WaterCounter 实现这一点,以便它可以受益于上述所有优点。

有状态 vs 无状态

当所有状态都可以从可组合函数中提取时,结果可组合函数称为无状态。

通过将 WaterCounter 可组合项拆分为两部分来重构它:有状态计数器和无状态计数器。

StatelessCounter 的作用是显示 count 并在您增加 count 时调用一个函数。为此,请遵循上述模式,传递状态 count(作为可组合函数的参数)以及一个 lambda (onIncrement),当需要增加状态时调用它。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 会重新组合,读取 juiceCountStatelessCounter 也会重新组合。但是读取 waterCountStatelessCounter 不会被重新组合。

7fe6ee3d2886abd0.png

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

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

在这种情况下,如果 StatelessCounterAnotherStatelessMethod 更新了计数,则所有内容都会被重新组合,这是预期的结果。

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

要阅读更多关于状态和状态提升的信息,请查看Compose 状态文档

10. 处理列表

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

  • 勾选列表项,将任务标记为已完成。
  • 从列表中移除您不感兴趣完成的任务。

设置

  1. 首先,修改列表项。您可以重用 Composition (组合) 中的 remember 部分的 WellnessTaskItem,并更新它以包含 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 和标签的*任务 (task)*。将其定义为数据类 (data class)
data class WellnessTask(val id: Int, val label: String)
  1. 对于任务列表本身,创建一个名为 WellnessTasksList.kt 的新文件,并添加一个生成一些假数据的方法
fun getWellnessTasks() = List(30) { i -> WellnessTask(i, "Task # $i") }

请注意,在实际应用中,您的数据来自您的数据层 (data layer)

  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. 注意它们已取消勾选。

存在一个问题,正如您在前面章节中看到的那样,当一个项目离开 Composition (组合) 时,记住的状态会被遗忘。对于 LazyColumn 中的项目,当您滚动超出它们不再可见时,它们会完全离开 Composition。

a68b5473354d92df.gif

如何解决这个问题?再次使用 rememberSaveable。您的状态将使用 saved instance state 机制在 activity 或进程重建后得以保留。得益于 rememberSaveableLazyList 的协同工作方式,您的项目也能够保留下来,即使它们离开了 Composition (组合)。

只需在您的有状态 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 被重新创建时,无需您编写任何代码,滚动状态就会得到维护。

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

具有由公共 rememberX 函数提供的默认值的状态参数是内置可组合函数中的常见模式。另一个示例可以在 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 参数。默认情况下,每个项目的状态都根据该项目在列表中的位置进行键控。

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

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

要了解更多关于列表中的项目键的信息,请查看文档。

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

如果您尝试在 WellnessScreen 中使用 rememberSaveable() 来存储列表,您将收到运行时异常

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

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

接下来,我们将看看 ViewModel 作为应用状态持有者的作用。

12. ViewModel 中的状态

屏幕或 UI 状态指示屏幕上应该显示什么(例如,任务列表)。这种状态通常与层次结构中的其他层连接,因为它包含应用数据

虽然 UI 状态描述了屏幕上显示的内容,但应用的逻辑描述了应用的表现方式以及对状态变化的反应。逻辑有两种类型:UI 行为或 UI 逻辑,以及业务逻辑。

  • UI 逻辑与如何显示屏幕上的状态变化有关(例如,导航逻辑或显示 snackbar)。
  • 业务逻辑是对状态变化做什么(例如进行支付或存储用户偏好设置)。这种逻辑通常放在业务层或数据层,绝不放在 UI 层。

ViewModel 提供 UI 状态并访问位于应用其他层的业务逻辑。此外,ViewModel 在配置更改后仍然存在,因此它们的生命周期比 Composition (组合) 长。它们可以跟随 Compose 内容宿主的生命周期——即 Activity、Fragment,如果您使用Compose Navigation,还可以跟随 Navigation 图的目标。

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

迁移列表和移除方法

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

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

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

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

定义一个内部变量 _tasks,使用之前使用过的 toMutableStateList,并将 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:{latest_version}")

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

  1. 打开 WellnessScreen。通过调用 viewModel() 实例化 wellnessViewModel ViewModel,作为屏幕可组合项的参数,以便在测试此可组合项时可以替换它,并在需要时进行提升。为 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 状态将提升到列表级别。该文件仅包含此可组合函数
@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 在构造函数中接收一个带有默认值 falseinitialChecked 变量,然后我们可以使用工厂方法 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 中,而不是耦合在可组合函数内部,单元测试变得简单多了。

您可以使用 instrumented testing 来验证 Compose 代码的正确行为以及 UI 状态是否正常工作。可以考虑学习 Codelab 在 Compose 中测试,了解如何测试您的 Compose UI。

13. 恭喜您

做得好!您已成功完成本 Codelab,并学习了在 Jetpack Compose 应用中处理状态的所有基本 API!

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

接下来做什么?

查看Compose 学习路线上的其他 Codelab。

示例应用

  • JetNews 演示了本 Codelab 中解释的最佳实践。

更多文档

参考 API