译文 | Jetpack Compose 状态管理
提示
本文是《Managing State in Jetpack Compose》的译文,仅供个人学习使用,不得且不可用于商业用途,若是因用于商业用途而产生的一切法律责任与本人无关。
译者已获得原组织/原作者翻译授权,版权归原组织/原作者所有。@kodeco
原文信息:
发布日期:2022年04月11日
Jetpack Compose 官网资源:
前言
Jetpack Compose 是 Android 中构建 UI 的新工具包。你可以使用 Kotlin 代码构建 UI,从而放弃使用之前的 XML 布局方式。
能力越大,需要担当的责任也就越大。所以 Jetpack Compose 管理 UI 组件状态的方式与 XML 布局不同。
在本教程中,你将构建一个名为 iState 的 app。这个 app 有两个界面:一个是可以让你往列表中添加用户的注册界面,另一个界面是显示注册用户列表。
在本教程中,你将学习以下内容:
- 可组合函数
- 重组
- 有状态的可组合项
- 无状态的可组合项
- 状态提升
提示
注意:本教程假设你了解 Jetpack Compose 的基础知识。如果你对 Jetpack Compose 还不熟悉,可以参考 译文 | Jetpack Compose 入门教程。
启航
你可以在本文顶部原文信息中访问 原文地址 来获取本教程所需要的资源,也可以通过访问 原文资源 来获取。
使用 Android Studio Bumblebee 或者更新的版本打开下载资源中的 starter 项目。
以下是每个包所含内容的简介:
- models:代表一个用户类。
- ui.composables:构建 UI 的可组合项。
- ui.theme:定义 Jetpack Compose 主题。
编译并运行,你将看到有一个 FloatingActionButton
的界面。
点击这个按钮将看到用户注册的界面。
注意,当你试图与注册界面交互时,什么都不会发生 —— 你看不到你输入的文本,无法改变单选按钮的选中状态以及无法显示下拉菜单去选择你喜爱的复仇者。接下来你将学习在 Jetpack Compose 中管理状态,从而让上述 UI 可以正常响应操作。
但首先,请花点时间了解有关 Jetpack Compose 的更多知识。
Jetpack Compose 简介
Jetpack Compose 使用声明式的方式来构建 UI。Compose 不像处理 XML 文件中的视图那样创建一次布局并手动更新每个组件的状态,而是从头开始渲染每个界面。每次界面中的任何值发生变化时,它都会重新渲染。
要使用 Compose 构建 UI,你需要创建 可组合 函数。可组合函数是 Compose 中的 构建块。它们可以接收数据,使用数据构建 UI,然后构建用户在屏幕上看到的 UI 组件。
是时候创建本教程中的第一个可组合项了。
打开 MainScreenComposables.kt 文件。在那里,你将找到空的可组合 UserList(),它将显示 app 中的注册用户列表。
这个函数接收一个用户列表参数,并且没有返回值。可组合函数用于构建 UI 元素,所以它们不需要返回值。
在函数体中,添加必要的可组合项以在列表上显示用户:
// 1.
LazyColumn() {
// 2.
items(
items = users,
key = { user -> user.email }
) { user ->
// 3.
ItemUser(user)
Divider()
}
}
提示
注意:在 MainScreenComposables
的顶部添加 import androidx.compose.foundation.lazy.items
和 import androidx.compose.material.Divider
。
使用 LazyColumn()
来显示列表中的子项。在上述函数中发生了以下几件事情:
LazyColumn()
是一个可组合函数,它可以构建一个延迟加载其子项的垂直列。你只能从另一个可组合函数中调用可组合函数。为了遵循这条规则,UserList()
函数有Composable
注解。- 使用
items()
将列表中的子项填充到垂直列。你还可以使用 key 属性为列表中的每个子项添加独特的标识符。 - 对于每个子项,构建一个
ItemUser()
行和一个Divider()
,这会使列表看起来很漂亮。
UserList()
是没有 副作用 的,这意味着对于相同的输入数据它总是显示相同的结果。同时它也不会改变任何全局变量和任何状态。另外,注意 UserList()
有一个默认值为 emptyList()
的参数。这意味着如果你可以提供任何数据,它将显示一个空的列表。
现在打开 MainActivity.kt 文件。在这里,你将找到构建所有用户界面的可组合函数:UserListScreen()
。此时你不需要添加任何内容,但请记住,稍后你将更改此代码以显示真实用户的列表。
牢记这些 Jetpack Compose 基本概念,你可以为示例 app 创建任何可组合项,并开始学习如何在 Jetpack Compose 中处理状态。但首先,必须了解 Compose 如何处理数据流并更新界面。
理解单向数据流
Jetpack Compose 的工作方式完全不同于 XML 布局。一旦 app 渲染了可组合项,就无法更改它。但是,你可以更改传递给每个可组合项的值,这意味着你可以更改每个可组合项接收的状态。
另一方面,可组合项可能会生成可以更新状态的事件。比如,EditTextField
生成带有用户输入文本的事件。此事件更新可组合项的状态,以便它可以显示输入的文本。
Compose 使用 单向数据流 设计模式,这表示数据或状态向下沉,而事件向上升,如下图所示:
上图表示 Compose 中的 UI 更新循环:
- 可组合项接收状态并把它显示在屏幕上。
- 事件可以修改状态中的值,它可以来自可组合项或者 app 中的其他部分。
- 状态持有者可以是一个 ViewModel,它接收事件并修改状态。
如你所见,更新可组合项的唯一方式是重新渲染它。那么,Compose 如何知道何时重新渲染可组合项?这就是 重组 的用武之地了。
提示
译者注:理解 单向数据流 设计模式对后续理解 状态提升 的概念有很大帮助。译者的理解:单向数据流 模式是思想理论指导,状态提升 是对思想理论的具体实现。如上图所示,状态在上,可组合项在下,或许可以理解为 状态提升 的字面含义。在学习完本教程后,或许对 状态提升 还有另外一种理解。
了解重组
要更新可组合项,你需要传递新的数据来调用可组合函数时,从而触发 重组 过程。在重组期间,Compose 会智能的重新渲染仅状态发生变化的可组合项(译者注:这里指 Compose 的智能重组)。
Compose 总是尝试在需要再次重组之前完成重组。但是,有时状态会在重组完成之前发生变化。在这种情况下,Compose 会取消重组并使用新的状态值重新启动重组。
重组可以以任意顺序执行可组合函数。一个可组合函数不应该产生副作用,比如:改变一个全局变量,因为 Compose 不保证执行顺序。
可组合函数可以并发执行。这是可组合函数不能有副作用的另一个原因。
比如,假设你更改可组合项中局部变量的值。在这种情况下,变量可能会得到不正确的值。在可组合项中触发副作用的一个推荐方法是使用将事件传递到 状态持有者 的 回调。
最后,可组合函数通常执行的非常快,这意味着你不应该在其中执行耗时/繁重的操作。
现在,你已经了解了重组以及它的重要性,是时候来聊一聊 状态 了。
创建有状态的可组合项
状态 是在 app 运行期间可以更改的任何值。它可以包括用户输入的值、从数据库获取的数据或表单中选择的选项。
Compose 提供了 remember()
函数,使用这个函数你可以在内存中存储一个单例对象。在第一次组合执行时,remember()
函数会存储初始值。
在每次重组时,remember()
函数会返回之前存储的值以供可组合项使用。每当它存储的值需要发生变化时,你可以更新它,并且 remember()
函数会存储更新后的值。当下一次重组触发时,remember()
函数会返回最新的值。
创建 TextField 可组合项
是时候开始使用 remember()
了。打开 RegisterUserComposables.kt 文件。在 EditTextField()
函数体的顶部添加以下代码:
// 1.
val text = remember {
// 2.
mutableStateOf("")
}
在上面的代码中,你:
- 使用
remember()
创建了一个变量。text
变量在重组期间将持有一个String
类型的值。 - 使用
mutableStateOf()
并传递一个空文本作为初始值。
现在,使用你刚刚创建的变量。将 OutlinedTextField()
中的 value
和 onValueChange()
替换为以下代码:
// 1.
value = text.value,
// 2.
onValueChange = { text.value = it },
编译并运行。打开用户注册界面并输入邮箱和用户名。你将看到文本框显示你输入的内容,如下图所示:
创建单选按钮可组合项
remember()
可以存储任何值类型的状态。在 RadioButtonWithText()
函数体顶部添加以下代码:
val isSelected = remember {
mutableStateOf(false)
}
在这种情况下,remember()
将存储一个 Boolean
类型的可变状态,用于表示用户是否选中了单选按钮。现在,使用 isSelected
更新 RadioButton
可组合项:
RadioButton(
// 1.
selected = isSelected.value,
// 2.
onClick = { isSelected.value = !isSelected.value }
)
与之前的代码类似,你:
- 使用
remember()
的值来设置单选按钮的selected
属性。 - 每当用户单选按钮时更新
isSelected
的值。
再次编译并运行,然后打开用户注册界面。现在你可以选中单选按钮,也可以取消选中单选按钮。然而,你可以同时选中两个单选按钮。你将在本教程后面修复此问题。
创建下拉菜单可组合项
最后,你将使 DropDown
成为有状态的可组合项。在 DropDown
函数体的顶部添加以下代码:
// 1.
val selectedItem = remember {
mutableStateOf("Select your favorite Avenger:")
}
// 2.
val isExpanded = remember {
mutableStateOf(false)
}
在上面的代码中,你需要使用两次 remember
函数:
selectedItem
会持有用户从下拉菜单选择的子项。同时你也会提供默认值。isExpanded
会持有下拉菜单的展开状态。
将以下代码行添加到 Row
修饰符的 .padding(vertical = 16.dp)
下面:
.clickable { isExpanded.value = true }
这样,每当用户点击下拉菜单时,你就将 isExpanded
的值设置为 true
,使其展开并显示其内容。使用以下代码更新 Text("")
:
Text(selectedItem.value)
这样,你可以将 selectedItem
值传递给 Text()
可组合项,以便用户在关闭下拉菜单后可以看到他们选择的值,如果尚未选择任何内容,则可以看到默认值。
在 DropdownMenu
中,使用以下代码修改 expanded = false
:
expanded = isExpanded.value,
这样,DropdownMenu
就知道它是否需要展开。现在,使用以下代码更新 onDismissRequest = { },
:
onDismissRequest = { isExpanded.value = false },
这样,每当收到关闭请求时,你就可以折叠下拉菜单了。
最后,你需要实现用户选择他们喜爱的复仇者的代码。使用以下代码更新 DropdownMenuItem()
中的 onClick
:
onClick = {
// 1.
selectedItem.value = menuItems[index]
// 2.
isExpanded.value = false
}
在上面的代码中,你:
- 将选中的复仇者名称保存在
selectedItem
中。 - 在用户选中一个子项后折叠下拉菜单。
编译并运行。点击下拉菜单并选择你喜爱的复仇者。一旦你选中,下拉菜单就会折叠,然后你会看到你选中的复仇者的名字。干的好!
使用 remember()
创建和存储状态的可组合项是 有状态的组件。每个组件都存储和修改其状态。
当调用者不需要知道或修改可组合项的状态时,有状态组件非常有用。然而,这些组件都难以被重用。并且,正如你在单选按钮看到的那样,它不可能在可组合项之间共享状态。
当你需要一个其调用者需要控制和修改其状态的组件时,你需要创建 无状态的可组合项。
创建无状态的可组合项
Compose 使用 状态提升 模式使可组合项成为 无状态的。状态提升将可组合项的状态转移至其调用者。
但是,可组合项仍然需要具有可以在发生操作时更改和发出事件的值。你可以用两种类型的参数替换状态:
- value:在此变量中,你接收要在可组合项中显示的值。
- onEventCallback:对于每个需要触发的事件,你的可组合项将会调用
onEventCallback()
。这样,组件的调用者就会知道有操作发生。
每个可组合项可以有多个 value
参数和多个事件回调。一旦可组合项变为无状态的,就需要有人来管理状态。
状态持有者
ViewModel
可以保存视图中可组合项的状态。ViewModel
为 UI 提供了对其他层(例如业务层和数据层)的访问。另一个优点是 ViewModel
的生命周期比可组合项更长,因此使它们成为保存 UI 状态的好地方。
然后,你可以使用 LiveData
、Flow
或 RxJava
定义状态变量,并定义更改这些变量状态的方法。你可以查看 FormViewModel.kt 和 MainViewModel.kt 以了解 iState 对状态持有者的实现。
接下来,你将开始实现状态提升。
实现状态提升
打开 RegisterUserComposables.kt 文件。开始在 EditTextField()
中实现状态提升。该可组合项的状态需要两个变量:一个持有用户输入的文本,另一个持有是否显示错误的状态。同时,它还需要一个回调用于通知状态持有者有文本变更。
在 EditTextField()
的参数列表顶部添加以下代码:
// 1.
value: String,
// 2.
isError: Boolean,
// 3.
onValueChanged: (String) -> Unit,
以下是对上述代码的解释:
value
接收EditTextField
当前显示的文本数据。isError
表示当前文本数据是有效还是无效,因此EditTextField
在需要时显示错误指示符。- 每当输入的
value
变化时都将调用onValueChanged
。
接下来,删除以下代码:
val text = remember {
mutableStateOf("")
}
因为现在你从参数中接收状态,所以该可组合项不再需要存储它自身的状态。
现在,如以下代码更新 OutlinedTextField()
:
OutlinedTextField(
// 1.
value = value,
// 2.
isError = isError,
// 3.
onValueChange = { onValueChanged(it) },
leadingIcon = { Icon(leadingIcon, contentDescription = "") },
modifier = modifier.fillMaxWidth(),
placeholder = { Text(stringResource(placeholder)) }
)
在上述代码中,你:
- 使用
value
参数设置OutlinedTextField()
的当前值。 - 使用
isError
参数设置isError
的值。 - 当
text
参数改变时执行onValueChanged()
。现在,你不需要再更新remember()
的值,你只需要向上提升这个值。
神奇吧!EditTextField
现在是无状态的。因为你实现了状态提升,所以现在 RegistrationFormScreen()
需要接管 EditTextFields
的状态。
为 RegistrationFormScreen()
函数添加以下参数:
// 1.
registrationFormData: RegistrationFormData,
// 2.
onEmailChanged: (String) -> Unit,
// 3.
onUsernameChanged: (String) -> Unit,
在上述代码中,你添加了:
- 一个包含注册表单所需所有数据的
registrationFormData
参数。 onEmailChanged()
将在用户更新邮箱TextField
时执行。onUsernameChanged()
将在用户更新用户名TextField
时执行。
最后,你需要为每一个 EditTextField
传入这些参数。使用以下代码更新两个 EditTextField
:
EditTextField(
leadingIcon = Icons.Default.Email,
placeholder = R.string.email,
// 1.
value = registrationFormData.email,
// 2.
isError = !registrationFormData.isValidEmail,
// 3.
onValueChanged = { onEmailChanged(it) }
)
EditTextField(
leadingIcon = Icons.Default.AccountBox,
placeholder = R.string.username,
modifier = Modifier.padding(top = 16.dp),
// 4.
value = registrationFormData.username,
// 5.
isError = false,
// 6.
onValueChanged = { onUsernameChanged(it) }
)
在上述代码中,你:
- 使用
registrationFormData.email
设置邮箱的值。 - 使用
registrationFormData.isValidEmail
显示邮箱的值是否有错误。 - 每当邮箱的值发生变化时执行
onEmailChanged()
。 - 使用
registrationFormData.username
设置用户名的值。 - 将
isError
设置为false
,因为该字段没有校验。 - 每当用户名的值发生变化时执行
onUsernameChanged()
。
打开 MainActivity.kt 文件,并在声明 formViewModel
的下面添加以下代码:
val registrationFormData by formViewModel.formData.observeAsState(RegistrationFormData())
提示
注意:确保你在 MainActivity
的顶部添加 import androidx.compose.runtime.getValue
。
译者注:上述代码中使用了 by
关键字,这里是指属性委托。对于属性委托,可以通过操作符重载来直接获取和修改其委托的值,在上述代码中仅是获取值,所以需要导入相应的操作符重载函数。
formViewModel
中有一个名为 formData
的 LiveData
类型的变量,它其中包含注册界面所需的状态。
通过这行代码,你使用 observeAsState()
函数将该变量转换为状态。你需要设置它的默认值。你可以使用 RegistrationFormData()
作为默认值,这使得表单具有空文本和预选的单选按钮。
每当 formData
中的值发生变化时,FormViewModel
都会有相应的逻辑来更新 registrationFormData
的状态。这个新的值会一直向下传播到使用它的可组合项,从而触发重组过程。
最后,使用以下代码更新 RegistrationFormScreen()
的调用:
RegistrationFormScreen(
// 1.
registrationFormData = registrationFormData,
// 2.
onUsernameChanged = formViewModel::onUsernameChanged,
// 3.
onEmailChanged = formViewModel::onEmailChanged,
)
在上述代码中,你:
- 传递
registrationFormData
状态到用户注册界面。 - 当用户名发生变化时,在
ViewModel
中调用onUsernameChanged()
。此函数使用新的用户名数据更新registrationFormData
的内容。 - 当邮箱发生变化时,在
ViewModel
中调用onEmailChanged()
。此函数使用新的邮箱数据更新registrationFormData
的内容。
编译并运行,然后打开用户注册界面。你可以看到你输入的用户名和邮箱数据。同时,你也可以检查你输入的邮箱是否正确。
接下来,你将实现单选按钮和下拉菜单的状态提升。
实现其他可组合项的状态提升
是时候将单选按钮和下拉菜单可组合项成为无状态的了。打开 RegisterUserComposables.kt 文件并在 RadioButtonWithText
函数的 text
参数上面添加以下代码:
isSelected: Boolean,
onClick: () -> Unit,
你为单选按钮传入了 isSelected
参数及其 onClick()
回调。
现在,删除以下代码:
val isSelected = remember {
mutableStateOf(false)
}
因为现在 RadioButtonWithText
通过参数接收状态,已不再需要 remember()
。
接下来,如下所示更新 RadioButton
:
RadioButton(
selected = isSelected,
onClick = { onClick() }
)
在上面的代码中,你使用了 isSelected
和 onClick
回调。这样,就将 RadioButtonWithText()
变为了无状态。
下拉菜单的工作方式与之前的可组合项不同。在这种情况下,下拉菜单需要一个状态来指示它是否处于展开状态。你不需要提升此状态,因为只有下拉可组合项本身需要它。
另一方面,该组件需要具有所选子项的状态。在这种情况下,你需要一个混合可组合项:其部分状态被提升,而组件仍然具有一些内在状态。
在 DropDown()
的 menuItems
参数上方添加以下参数:
selectedItem: String,
onItemSelected: (String) -> Unit,
selectedItem
将持有选中的子项,onItemSelected()
是用户选择某个子项时执行的回调。
接下来,删除以下代码:
val selectedItem = remember {
mutableStateOf("Select your favorite Avenger:")
}
因为可组合项接收 selectedItem
,它不再需要 remember()
了。
接下来,如下所示更新 Text(selectedItem.value)
:
Text(selectedItem)
在上述代码中,Text()
使用 selectedItem
参数来显示它的值。
最后,如下所示更新 DropDownMenuItem()
:
DropdownMenuItem(onClick = {
onItemSelected(menuItems[index])
isExpanded.value = false
}) {
Text(text = name)
}
在上述代码中,当用户选择一个子项时你调用 onItemSelected()
。
最终,DropDown()
现在是一个混合的可组合项。
现在,更新这些可组合项的调用者。在 RegistrationFormScreen()
的 onUsernameChanged: (String) -> Unit,
下面添加以下参数:
onStarWarsSelectedChanged: (Boolean) -> Unit,
onFavoriteAvengerChanged: (String) -> Unit,
在上述代码中,你新增了参数以接收单选按钮和下拉菜单所需的回调。
如下所示更新 RegistrationFormScreen()
中两个单选按钮的代码:
RadioButtonWithText(
text = R.string.star_wars,
isSelected = registrationFormData.isStarWarsSelected,
onClick = { onStarWarsSelectedChanged(true) }
)
RadioButtonWithText(
text = R.string.star_trek,
isSelected = !registrationFormData.isStarWarsSelected,
onClick = { onStarWarsSelectedChanged(false) }
)
在上述代码中,你使用了状态参数并将回调分配给两个单选按钮。
现在,如下所示更新下拉菜单:
DropDown(
menuItems = avengersList,
// 1.
onItemSelected = { onFavoriteAvengerChanged(it) },
// 2.
selectedItem = registrationFormData.favoriteAvenger
)
在上述代码中,你:
- 当用户选择一个子项时执行
onFavoriteAvengerChanged()
。 - 设置选中的值以显示你喜爱的复仇者。
打开 MainActivity.kt 文件并更新 RegistrationFormScreen()
的调用,如下所示:
RegistrationFormScreen(
registrationFormData = registrationFormData,
onUsernameChanged = formViewModel::onUsernameChanged,
onEmailChanged = formViewModel::onEmailChanged,
onStarWarsSelectedChanged = formViewModel::onStarWarsSelectedChanged,
onFavoriteAvengerChanged = formViewModel::onFavoriteAvengerChanged,
)
在上述代码中,你将单选按钮和下拉菜单的事件回调分配给用户注册界面。
编译并运行,然后打开用户注册界面。现在,你可以使用单选按钮进行选择并选择你喜爱的复仇者,如下图所示:
最后,为了完成用户注册,还需要让按钮可以正常工作。
实现注册和清除按钮
打开 RegisterUserComposables.kt 文件并在 RegistrationFormScreen()
的 onFavoriteAvengerChanged
下面添加以下参数:
onRegisterClicked: (User) -> Unit,
onClearClicked: () -> Unit
在上述代码中,你添加了按钮相关的回调。
现在,如下所示更新 RegistrationFormScreen()
中的两个按钮:
OutlinedButton(
onClick = {
// 1.
onRegisterClicked(
User(
username = registrationFormData.username,
email = registrationFormData.email,
favoriteAvenger = registrationFormData.favoriteAvenger,
likesStarWars = registrationFormData.isStarWarsSelected
)
)
},
modifier = Modifier.fillMaxWidth(),
// 2.
enabled = registrationFormData.isRegisterEnabled
) {
Text(stringResource(R.string.register))
}
OutlinedButton(
// 3.
onClick = { onClearClicked() },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
Text(stringResource(R.string.clear))
}
在上述代码中,你:
- 创建了一个
User
对象并将它传递给onRegisterClicked()
。 - 设置注册按钮的
enabled
状态值。 - 当用户点击清除按钮时执行
onClearClicked()
。
接下来,打开 MainActivity.kt 文件并在 RegistrationFormScreen()
的 onFavoriteAvengerChanged
下面添加以下参数:
// 1.
onClearClicked = formViewModel::onClearClicked,
// 2.
onRegisterClicked = { user ->
formViewModel.onClearClicked()
mainViewModel.addUser(user)
navController.popBackStack()
}
在上述代码中,你:
- 当你点击清除按钮时执行
onClearClicked()
。 - 添加用户创建代码:清除表单、添加用户以及返回主界面的功能。
编译并运行,然后打开用户注册界面并注册一个用户,如下图所示:
点击注册按钮,你将返回用户列表界面,但是刚刚注册的用户并没有显示。接下来,你将修复此问题。
修复用户列表
在 MainActivity.kt 文件中,修改 UserListScreen()
以接收用户列表作为参数,如下所示:
@Composable
fun UserListScreen(
navController: NavController,
users: List<User>
)
在上述代码中,UserListScreen()
接收一个已注册用户的列表。MainViewModel
是持有用户列表的状态持有者。
在 UserListScreen()
中将用户列表传递给 UserList()
:
UserList(users)
这样,你就可以向 UserList()
提供显示用户列表所需的状态。
在 onCreate()
的 navController
下面,创建一个持有用户列表的状态变量:
val users by mainViewModel.users.observeAsState(emptyList())
users
是一个 LiveData
类型的状态变量,你将观察它的状态并分配一个空列表作为默认值。
最后,在下面四行,将 users
状态变量传递给 UserListScreen()
,如下所示:
UserListScreen(navController, users)
编译并运行,然后注册一个新的用户,最后你将在主界面的列表中看到刚刚注册的用户。如下图所示:
太棒了!你已经向界面中添加了状态,并使 app 正常工作,并成为构建和修改可组合项的专家。恭喜!
总结
通过本教程,你了解了:
- 什么是单向数据流模式,
- Jetpack Compose 为何使用单向数据流模式,
- 什么是重组,重组的特点以及副作用,
- 什么是有状态和无状态的可组合项,以及各自的使用场景,
- 什么是状态提升,以及如何实现状态提升
最后,对 状态提升 是否有新的理解与思考呢?