Compose 中 TextField 的有效状态管理
为了防止同步问题和意外行为:
- 避免在输入和更新
TextField
状态之间出现延迟/异步行为。 - 避免使用响应式流收集
StateFlow
的数据来保存TextField
状态,例如使用默认调度程序。 - 使用Compose API,例如
MutableState<String>
,定义TextField
状态变量。 需要时,将TextField
状态提升到ViewModel
,例如将业务验证应用于TextField
内容。
假设我们必须在Jetpack Compose应用中实现注册页面,并收到以下设计:
我们有两个文本输入框和一个按钮。
让我们从顶部的文本输入框开始,它是用户名字段。
为了在Compose中实现一个文本输入框,我们需要定义一个状态变量:
- 存储当前显示的值,并将其传递给TextField的值参数。
- 每当用户在TextField的onValueChange回调中输入新文本时,就会更新它。
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
var myValue = ...
OutlinedTextField(
value = myValue, // #1
onValueChange = { newValue -> myValue = newValue } // #2
...
)
}
在处理状态时,重要的事情是决定将状态变量放在何处。在我们的例子中,我们希望对用户名进行一些业务逻辑校验,因此我们将状态提升到ViewModel中,而不是将其保留在组成函数中。如需更多关于此及如何组织应用架构的信息,可以阅读我们的架构指南。
通过将状态放在ViewModel中,TextField值将在配置更改时免费持久化。
基于这些要求,我们创建一个包含类似于此的OutlinedTextField组件的组合注册屏幕:
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpScreen.kt
@Composable
fun SignUpScreen(...) {
OutlinedTextField(
...
value = viewModel.username.collectAsStateWithLifecycle(),
onValueChange = { viewModel.updateUsername(it) }
...
)
}
}
接下来,在 ViewModel 中,我们将定义状态变量并执行业务逻辑。
目前,不建议使用响应式流来定义 TextField 的状态变量。我们将在接下来的章节中探讨为什么以及其他陷阱,但是现在假设我们犯了这个错误。我们错误地定义了一个类型为 MutableStateFlow
的 _username
变量来存储 TextField 状态,并通过定义不可变的 backed 变量 username 来公开它。
异步方法 updateUsername
将在用户在 TextField 上键入新字符时,每次调用服务来验证用户名是否可用(例如以前是否已使用)。如果验证失败,它将显示一个错误消息,要求选择不同的用户名。
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpViewModel.kt
class SignUpViewModel(private val userRepository: UserRepository) : ViewModel() {
// DO NOT DO THIS. ANTI-PATTERN - using a reactive stream for TextField state
private val _username = MutableStateFlow("")
val username = _username.asStateFlow()
fun updateUsername(input: String) {
viewModelScope.launch {
// async operation
val isUsernameAvailable = userRepository.isUsernameAvailable(input)
// ...
if (!isUsernameAvailable) {
// modify error state
}
// DO NOT DO THIS. ANTI-PATTERN - updating after an async op
_username.value = input
}
}
}
问题
我们已经完成了用户名字段的实现。如果现在运行应用程序,我们应该能够进行测试:
当我们输入时,我们很快发现不正确的行为:在我们输入时,有些字母会被跳过,有些字母按错误的顺序添加到输入中,整个位被重复,光标来回跳动。所有编辑操作均失败,包括删除和选择要替换的文本。显然存在错误。
发生了什么,我们该如何解决呢?
TextField的内部实现
在撰写本文(使用Compose UI 1.3.0-beta01)时,TextField的实现包括持有3个状态的副本:
- 输入法编辑器(IME):为了能够执行智能操作,例如建议替换一个单词的下一个词或表情符号,键盘需要拥有当前显示的文本的副本。
- 由用户定义并更新的状态持有者,在上面的示例中,它是一个MutableStateFlow变量。
- 内部状态充当控制器,使其他两个状态保持同步,因此您无需手动与IME交互。
即使在每个TextField的全部时间内都有3个状态的副本在发挥作用,开发人员只管理其中一个(状态持有者),而其他副本则是内部的。
这三种状态如何在幕后相互作用?为了简化,从键盘键入或添加的每个字符执行一系列步骤,构成一个反馈循环,如下所示:
- 从键盘输入事件(输入单词“hello”)并被转发到内部控制器。
- 内部控制器接收到此更新“hello”,并将其转发给状态持有者。
- 状态持有者更新为“hello”内容,这将更新UI并通知内部控制器已接收到更新。
- 内部控制器通知键盘。
- 键盘被通知,因此它可以为下一个键入事件做准备,例如建议下一个单词。
只要这些状态的副本保持同步,TextField就能按预期运行。
然而,通过引入异步行为和竞态条件到打字的过程中,这些拷贝就可能不同步,且无法恢复。这些错误的严重程度取决于各种因素,如引入的延迟量、键盘语言、文本内容和长度以及输入法实现。
即使只是使用响应式流来表示状态(例如StateFlow)而没有延迟,也可能会出现问题,因为如果您使用默认调度程序,则更新事件的分派不是立即的。
让我们尝试看一下在这种情况下会发生什么,当您开始输入时。来自键盘的新事件“hello”到来,然后在我们更新状态和UI之前,我们生成一个异步调用。然后另一个事件“world”从键盘上来了。
第一个异步事件恢复,循环完成。当TextField内部状态接收到异步“hello”时,它会丢弃之前收到的最新的“hello world”。
但是在某个时候,“hello world” 异步事件也将恢复。此时 TextField 保持无效状态,其中 3 个状态不匹配。
TextField 的内部状态被覆盖为 ‘hello’,而不是 ‘hello world’。
但是在某个时候,“hello world” 异步事件也将恢复。此时 TextField 保持无效状态,其中 3 个状态不匹配。
TextField存在不一致性。这些意外的异步调用与IME的处理、快速输入、时序条件以及替换整个文本块的删除等操作相结合,缺陷变得更加明显。
既然我们对其中的动态有了一些了解,让我们看看如何修复和避免这些问题。
处理TextField状态的最佳实践
避免延迟更新状态
当onValueChange被调用时,立即同步更新您的TextField。
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpViewModel.kt
class SignUpViewModel(private val userRepository: UserRepository) : ViewModel() {
fun updateUsername(input: String) {
- viewModelScope.launch {
- // async operation
- val isUsernameAvailable = userRepository.isUsernameAvailable(input)
- // ...
-
- if (!isUsernameAvailable) {
- // modify error state
- }
username.value = input
}
}
}
// SignUpScreen.kt
@Composable
fun SignUpScreen(...) {
OutlinedTextField(
value = viewModel.username,
onValueChange = { username -> viewModel.updateUsername(username) }
…
)
}
你可能仍然需要对文本进行过滤或修剪。同步操作可以进行。例如,如果你的同步操作将输入转换为不同的字符集,请考虑使用 visualTransformation
。你应该避免使用异步操作,因为这会导致上述问题。
使用 MutableState 表示 TextField 状态
避免使用响应式流(例如 StateFlow)来表示 TextField 状态,因为这些结构引入了异步延迟。而应该使用 MutableState:
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
class SignUpViewModel : ViewModel() {
var username by mutableStateOf("")
private set
// ...
}
如果您仍然更喜欢使用 StateFlow 来存储状态,请确保使用立即调度程序而不是默认调度程序来从流中收集。
这种解决方案需要更深入的协程知识,并可能导致以下问题:
- 由于收集是同步的,因此当它发生时,UI 可能处于不可操作状态。
- 会干扰 Compose 的线程和渲染阶段,因为它假设重组发生在主线程上。
在哪里定义状态
如果您的TextField state需要在键入时进行业务逻辑验证,则将状态提升到ViewModel中是正确的。如果不需要,您可以使用Composables或状态持有类作为真正的数据源。
一般的规则是,您应该将状态放在尽可能低的位置,同时仍然被正确地拥有,这通常意味着更接近它被使用的地方。有关Compose中状态的更多信息,请查看我们的指南。
在解决此问题时,重要的不是将TextField state提升到哪里,而是如何存储它。
在您的应用程序中应用最佳实践
考虑到这些最佳实践,让我们同时实现异步和同步验证到我们的TextField state中。
从异步验证开始,如果要使用的用户名无效,则我们想要在TextField下方显示错误消息,并在服务器端执行此验证。在我们的UI中,它将如下所示:
当调用onValueChange
时,我们将立即调用更新方法来更新TextField
,然后,ViewModel将根据刚刚更改的值安排异步检查。
在ViewModel中,我们定义了两个状态变量:一个用于TextField状态的username
变量作为MutableState
,一个userNameHasError
作为StateFlow
,它会在用户名更新时进行反应性计算。
snapshotFlow
API将Compose State转换为flow
,以便我们可以对每个值执行异步(挂起)操作。
因为输入速度可能比获取异步调用结果更快,所以我们按顺序处理事件,并使用mapLatest
(实验性)在出现新事件时取消未完成的调用,以避免浪费资源或显示不正确的状态。出于同样的原因,我们还可以添加一个防抖方法(异步调用之间的延迟)。
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpViewModel.kt
@OptIn(ExperimentalCoroutinesApi::class)
class SignUpViewModel(private val signUpRepository: SignUpRepository) : ViewModel() {
var username by mutableStateOf("")
private set
val userNameHasError: StateFlow<Boolean> =
snapshotFlow { username }
.mapLatest { signUpRepository.isUsernameAvailable(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false
)
fun updateUsername(input: String) {
username = input
}
}
// SignUpScreen.kt
@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
fun SignUpScreen(...)
OutlinedTextField(
value = viewModel.username,
onValueChange = { newValue ->
viewModel.updateUsername(newValue)
}
)
val userNameHasError by viewModel.userNameHasError.collectAsStateWithLifecycle()
if (userNameHasError) {
Text(
text = "Username not available. Please choose a different one.",
color = Color(ColorError)
)
}
...
}
请注意,我们正在使用实验性的collectAsStateWithLifecycle API收集错误验证流,这是在Android中收集流的推荐方式。要了解有关此API的更多信息,您可以查看Jetpack Compose博客文章中的“安全地消费流”部分。
https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3
现在,我们想添加同步验证以检查输入是否包含无效字符。我们可以使用synchronous的derivedStateOf() API,每当用户名更改时将触发lambda验证。
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
// SignUpViewModel.kt
class SignUpViewModel(private val signUpRepository: SignUpRepository) : ViewModel() {
var username by mutableStateOf("")
private set
val userNameHasLocalError by derivedStateOf {
// synchronous call
signUpRepository.isUsernameCorrect(username)
}
...
}
derivedStateOf()
创建新的State,读取userNameHasLocalError
的组件将在该值在true和false之间更改时重新组合。
我们完整的带验证的用户名实现如下:
考虑 TextField 的实现
目前,我们正在改进 TextField API,并将其视为我们的优先事项之一。
Compose 路线图反映了团队在多个方面开展的工作,这种情况下文本编辑和键盘输入的改进都与这些 API 相关。因此,请注意未来的 Compose 发布版本以及发布说明。