Compose 中 TextField 的有效状态管理

Compose 中 TextField 的有效状态管理

compose TextField

为了防止同步问题和意外行为:

  • 避免在输入和更新TextField状态之间出现延迟/异步行为。
  • 避免使用响应式流收集StateFlow的数据来保存TextField状态,例如使用默认调度程序。
  • 使用Compose API,例如MutableState<String>,定义TextField状态变量。 需要时,将TextField状态提升到ViewModel,例如将业务验证应用于TextField内容。

假设我们必须在Jetpack Compose应用中实现注册页面,并收到以下设计:
使用两个文本输入框实现注册界面

我们有两个文本输入框和一个按钮。
让我们从顶部的文本输入框开始,它是用户名字段。
为了在Compose中实现一个文本输入框,我们需要定义一个状态变量:

  1. 存储当前显示的值,并将其传递给TextField的值参数。
  2. 每当用户在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
       }
   }
}

问题

我们已经完成了用户名字段的实现。如果现在运行应用程序,我们应该能够进行测试:
当我们尝试将用户名jane@mail.com更改为jane.surname@mail.com时,TextField出现不正确的行为
当我们输入时,我们很快发现不正确的行为:在我们输入时,有些字母会被跳过,有些字母按错误的顺序添加到输入中,整个位被重复,光标来回跳动。所有编辑操作均失败,包括删除和选择要替换的文本。显然存在错误。

发生了什么,我们该如何解决呢?

TextField的内部实现

在撰写本文(使用Compose UI 1.3.0-beta01)时,TextField的实现包括持有3个状态的副本:

  1. 输入法编辑器(IME):为了能够执行智能操作,例如建议替换一个单词的下一个词或表情符号,键盘需要拥有当前显示的文本的副本。
  2. 由用户定义并更新的状态持有者,在上面的示例中,它是一个MutableStateFlow变量。
  3. 内部状态充当控制器,使其他两个状态保持同步,因此您无需手动与IME交互。

即使在每个TextField的全部时间内都有3个状态的副本在发挥作用,开发人员只管理其中一个(状态持有者),而其他副本则是内部的。

这三种状态如何在幕后相互作用?为了简化,从键盘键入或添加的每个字符执行一系列步骤,构成一个反馈循环,如下所示:

TextField状态之间的交互

  1. 从键盘输入事件(输入单词“hello”)并被转发到内部控制器。
  2. 内部控制器接收到此更新“hello”,并将其转发给状态持有者。
  3. 状态持有者更新为“hello”内容,这将更新UI并通知内部控制器已接收到更新。
  4. 内部控制器通知键盘。
  5. 键盘被通知,因此它可以为下一个键入事件做准备,例如建议下一个单词。

只要这些状态的副本保持同步,TextField就能按预期运行。

然而,通过引入异步行为和竞态条件到打字的过程中,这些拷贝就可能不同步,且无法恢复。这些错误的严重程度取决于各种因素,如引入的延迟量、键盘语言、文本内容和长度以及输入法实现。

即使只是使用响应式流来表示状态(例如StateFlow)而没有延迟,也可能会出现问题,因为如果您使用默认调度程序,则更新事件的分派不是立即的。

让我们尝试看一下在这种情况下会发生什么,当您开始输入时。来自键盘的新事件“hello”到来,然后在我们更新状态和UI之前,我们生成一个异步调用。然后另一个事件“world”从键盘上来了。

第一个异步事件恢复,循环完成。当TextField内部状态接收到异步“hello”时,它会丢弃之前收到的最新的“hello world”。
TextField 的内部状态被覆盖为 'hello',而不是 'hello world'
但是在某个时候,“hello world” 异步事件也将恢复。此时 TextField 保持无效状态,其中 3 个状态不匹配。

TextField 的内部状态被覆盖为 ‘hello’,而不是 ‘hello world’。
但是在某个时候,“hello world” 异步事件也将恢复。此时 TextField 保持无效状态,其中 3 个状态不匹配。
每次异步处理恢复后,TextField存在不一致性
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中,它将如下所示:

显示错误,因为“username1”已经被使用了

当调用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 发布版本以及发布说明。

猜你喜欢

转载自blog.csdn.net/u011897062/article/details/131006927
今日推荐