Google 致力于为黑人社区推动种族平等。查看具体举措

状态和 Jetpack Compose

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

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

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

Jetpack Compose 可帮助您明确状态在 Android 应用中的存储位置和使用方式。

界面更新循环和事件

在 Android 应用中,状态会更新以响应事件。事件是从应用外部生成的输入,如用户点按一个按钮调用 OnClickListenerEditText 调用 afterTextChanged,或加速度计发送一个新值。

所有 Android 应用都有核心界面更新循环,如下所示:

Android 应用的核心界面更新循环。

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

在 Jetpack Compose 中,状态和事件是分开的。状态表示可更改的值,而事件表示有情况发生的通知。

通过将状态与事件分开,可以将状态的显示与状态的存储和更改方式解耦。

Jetpack Compose 中的单向数据流

Compose 专为单向数据流而打造。这是一种状态向下流动而事件向上流动的设计。

图 1. 单向数据流

通过遵循单向数据流,您可以将在界面中显示状态的可组合项与应用中存储和更改状态的部分解耦。

使用单向数据流的应用的界面更新循环如下所示:

  • 事件:事件由界面的一部分生成并且向上传递。
  • 更新状态:事件处理脚本可以更改状态。
  • 显示状态:状态会向下传递,界面会观察新状态并显示该状态。

使用 Jetpack Compose 时遵循此模式可带来下面几项优势:

  • 可测试性:通过将状态与显示状态的界面解耦,更容易单独测试这两者。
  • 状态封装:因为状态只能在一个位置进行更新,所以不太可能创建不一致的状态(或产生错误)。
  • 界面一致性:通过使用可观察的状态存储器,所有状态更新都会立即反映在界面中。

ViewModel 和单向数据流

当您使用来自 Android 架构组件的 ViewModelLiveData 时,就会在应用中引入单向数据流。

在了解如何将 ViewModel 与 Compose 搭配使用之前,我们先来考虑一个使用 Android 视图和单向数据流的 Activity,它显示 "Hello, ${name}" 并允许用户输入其名称。

用户输入与 ViewModel 的示例。

使用 ViewModelActivity 的此屏幕的代码如下:

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

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

class HelloActivity : AppCompatActivity() {
   val helloViewModel by viewModels<HelloViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* … */
       // binding represents the activity layout, inflated with ViewBinding
       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString())
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}

通过使用 Android 架构组件,我们向此 Activity 引入了单向数据流设计。

图 2. 使用 ViewModelActivity 中的单向数据流

为了解单向数据流在界面更新循环中的工作原理,我们来考虑一下此 Activity 的循环:

  1. 事件:当文本输入发生变化时,界面会调用 onNameChanged
  2. 更新状态onNameChanged 会进行处理,然后设置 _name 的状态。
  3. 显示状态:系统会调用 name 的观察器,并且界面会显示新状态。

ViewModel 和 Jetpack Compose

您可以在 Jetpack Compose 中使用 LiveDataViewModel 实现单向数据流,就像您在上一部分的 Activity 中所做的一样。

使用同一 HelloViewModel 在 Jetpack Compose 中编写的与 HelloActivity 相同的屏幕的代码如下:

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

   // onNameChanged is an event we're defining that the UI can invoke
   // (events flow up from UI)
   fun onNameChanged(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("")

   Column {
       Text(text = name)
       TextField(
           value = name,
           onValueChange = { helloViewModel.onNameChanged(it) },
           label = { Text("Name") }
       )
   }
}

HelloViewModelHelloScreen 遵循单向数据流的设计。状态从 HelloViewModel 向下流动,而事件从 HelloScreen 向上流动。

ViewModel 与 HelloScreen 之间的单向流。

我们来考虑一下此可组合项的界面事件循环:

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

如需详细了解如何使用 ViewModelLiveData 在 Android 平台上构建单向数据流,请参阅应用架构指南

无状态可组合项

无状态可组合项是指本身无法改变任何状态的可组合项。无状态组件更容易测试、发生的错误往往更少,并且更有可能重复使用。

如果您的可组合项有状态,您可以通过使用状态提升使其变为无状态。状态提升是一种编程模式,在这种模式下,通过将可组合项中的内部状态替换为参数和事件,将状态移至可组合项的调用方。

如需查看状态提升的示例,请从 HelloScreen 中提取出一个无状态可组合项。

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

   // name is the _current_ value of [helloViewModel.name]
   val name: String by helloViewModel.name.observeAsState("")

   HelloInput(name = name, onNameChange = { helloViewModel.onNameChanged(it) })
}

@Composable
fun HelloInput(
   /* state */ name: String,
   /* event */ onNameChange: (String) -> Unit
) {
   Column {
       Text(name)
       TextField(
           value = name,
           onValueChange = onNameChange,
           label = { Text("Name") }
       )
   }
}

HelloInput 有权将状态作为以下两种形式进行访问:不可变的 String 参数,以及它在想要请求状态更改时可以调用的事件 onNameChange

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

HelloScreen 是一个有状态可组合项,因为它依赖于最终类 HelloViewModel,该类可以直接更改 name 状态。HelloScreen 的调用方无法控制对 name 状态的更新。HelloInput 是一个无状态可组合项,因为它无法直接更改任何状态。

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

状态提升的过程可让您将单向数据流扩展到无状态可组合项。在这些可组合项的单向数据流示意图中,随着更多可组合项与状态交互,状态仍向下流动,而事件向上流动。

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

请务必了解,无状态可组合项仍可与随时间变化的状态交互,方法是使用单向数据流和状态提升。

为了理解其工作原理,我们来考虑一下 HelloInput 的界面更新循环:

  1. 事件:系统调用 onNameChange 来响应用户输入字符的操作。
  2. 更新状态HelloInput 无法直接修改状态。调用方可以选择修改状态以响应 onNameChange 事件。此处,调用方 HelloScreen 将对 HelloViewModel 调用 onNameChanged,这会使得 name 状态进行更新。
  3. 显示状态:当 name 的值发生变化时,系统会使用由于 observeAsState 而更新的 name 再次调用 HelloScreen。它进而使用新的 name 参数再次调用 HelloInput。再次调用可组合项以响应状态变化的过程称为重组。

组合和重组

组合用于描述界面,通过运行可组合项生成。组合是描述界面的可组合项的树结构。

在初始组合期间,Jetpack Compose 将跟踪您为了描述组合中的界面而调用的可组合项。然后,当应用的状态发生变化时,Jetpack Compose 会安排重组。重组过程中会运行可能已更改的可组合项以响应状态变化,Jetpack Compose 会更新组合以反映所有更改。

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

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

可组合项中的状态

可组合函数可以使用 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) }
    …
}
图 3.formattedText 为子项的 FancyText 的组合

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

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

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

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

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

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

图 5.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(Icons.Default.ExpandLess)
               }
           } else {
               // change expanded in response to click events
               IconButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

在此可组合项中,修改了状态以响应 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> 并由可组合项读取。

您也可以使用 invalidate 为不可观察状态对象构建集成层,以手动触发重组。这应该为您必须与不可观察类型互操作的情况保留。使用 invalidate 很容易出错,并且往往会导致代码非常复杂,读起来比使用可观察状态对象的相同代码更难。

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

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

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

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

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

请注意,这两个可组合项都名为 ExpandingCard,不过它们采用不同的参数。发出界面的可组合项的命名惯例是一个 CapitalCase 形式的名词,用于描述可组合项在屏幕上表示的内容。在本例中,它们都表示一个 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(Icons.Default.ExpandLess)
               }
           } else {
               IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

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

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

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

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

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

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

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

内部状态和配置更改

在组合中被 remember 记住的值在配置更改(如旋转)期间会被忘记并重新创建。

如果您使用 remember { mutableStateOf(false) },每当用户旋转手机时,有状态 ExpandingCard 都会重置为收起状态。我们可以改用保存的实例状态,在配置更改时自动保存和恢复状态,从而解决此问题。

@Composable
fun ExpandingCard(title: String, body: String) {
   var expanded by savedInstanceState { false }
   ExpandingCard(
       title = title,
       body = body,
       expanded = expanded,
       onExpand = { expanded = true },
       onCollapse = { expanded = false }
   )
}

可组合函数 savedInstanceState<T> 会返回 MutableState<T>,它会在配置更改时自动保存和恢复自身。您应将其用于用户希望在配置更改后继续留存的所有内部状态。

了解详情

如需详细了解状态和 Jetpack Compose,请参阅在 Jetpack Compose 中使用状态