《HarmonyOS技术精讲-UI开发》第7篇:表单与交互实战

HarmonyOS NEXT 开发里,TextFieldButton 的组合是构建表单最基础、也最容易出问题的场景。官方文档虽然提供了组件的基本用法,但一旦涉及到实时校验、密码一致性检测、以及提交时的二次验证,很多开发者在实际开发中都会碰到状态刷新不及时、控制器取值与界面显示不一致的问题。这篇文章不绕弯子,直接通过一个注册表单的完整实现,把 TextField的controller获取输入、正则校验、onChange实时验证、Button提交时校验逻辑 这几个关键点一次性讲清楚。

它解决什么问题

任何需要用户输入信息的场景几乎都离不开表单。比如注册、登录、个人信息编辑等。这些场景中,数据校验是核心功能之一。

为什么需要实时校验?

早先很多表单只在点击"提交"按钮后才进行校验,如果用户输入的字段较多,提交后一次性弹出多个错误提示,体验很差。实时校验(输入时立即给出反馈)能显著提升用户满意度。

ArkUI 里怎么做?

  • 通过 TextField 组件的 onChange 回调,可以在输入字符时同步执行校验逻辑。
  • 通过 TextInputController 获取或设置输入框的内容(例如提交前强制获取最新值)。
  • 通过 ButtononClick 事件,执行最终一次"全量校验"并决定是否提交。

环境说明

text 复制代码
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机

核心实现:注册表单

我们构建一个包含三个字段的注册表单:用户名、密码、确认密码。校验规则如下:

字段 校验规则 实时校验? 错误提示
用户名 非空,4-16位字符 "用户名长度为4-16个字符"
密码 非空,6-18位字符 "密码长度为6-18个字符"
确认密码 非空,与密码一致 "密码不一致"

第一步:定义状态与控制器

这一段代码用于定义组件的状态变量和控制器。

typescript 复制代码
// src/main/ets/pages/RegisterForm.ets
import promptAction from '@ohos.promptAction'

@Entry
@Component
struct RegisterForm {
  // 状态变量:存储当前输入的文本
  @State username: string = ''
  @State password: string = ''
  @State confirmPassword: string = ''
  
  // 状态变量:存储实时校验的错误信息
  @State usernameError: string = ''
  @State passwordError: string = ''
  @State confirmPasswordError: string = ''
  
  // 控制器:可用于获取或设置输入框的值
  private usernameController: TextInputController = new TextInputController()
  private passwordController: TextInputController = new TextInputController()
  private confirmPasswordController: TextInputController = new TextInputController()
  
  // 提交状态标记
  @State isSubmitting: boolean = false
  
  build() {
    // 构建UI...
  }
}

注意事项:

  • @State 修饰的变量会触发 UI 自动刷新,适合用于表单数据绑定。
  • TextInputController 用于程序化控制输入框,例如清空或设置焦点。这里用作提交前深度获取内容。
  • 错误信息也用 @State 管理,因为需要实时显示在 UI 上。

第二步:实时校验逻辑

这一段代码实现了每个输入框的实时校验函数。

typescript 复制代码
// 校验用户名
private validateUsername(value: string): void {
  if (value.length === 0) {
    this.usernameError = '用户名不能为空'
  } else if (value.length < 4 || value.length > 16) {
    this.usernameError = '用户名长度为4-16个字符'
  } else {
    this.usernameError = '' // 校验通过
  }
}

// 校验密码
private validatePassword(value: string): void {
  if (value.length === 0) {
    this.passwordError = '密码不能为空'
  } else if (value.length < 6 || value.length > 18) {
    this.passwordError = '密码长度为6-18个字符'
  } else {
    this.passwordError = ''
  }
  // 每次密码变化时,也要重新校验确认密码的一致性
  this.validateConfirmPassword(this.confirmPassword)
}

// 校验确认密码
private validateConfirmPassword(value: string): void {
  if (value.length === 0) {
    this.confirmPasswordError = '请再次输入密码'
  } else if (value !== this.password) {
    this.confirmPasswordError = '密码不一致'
  } else {
    this.confirmPasswordError = ''
  }
}

// 提交时全量校验
private submitForm(): void {
  // 使用控制器强制获取最新输入值(处理可能的 async 延迟问题)
  let rawUsername = this.usernameController.text
  let rawPassword = this.passwordController.text
  let rawConfirm = this.confirmPasswordController.text
  
  // 更新状态后再执行校验(但实际同步时已经绑定了 onChnage, 状态值应该是最新的)
  // 这里为了更可靠,直接使用 controller 拿最新的值
  this.username = rawUsername
  this.password = rawPassword
  this.confirmPassword = rawConfirm
  
  this.validateUsername(rawUsername)
  this.validatePassword(rawPassword)
  this.validateConfirmPassword(rawConfirm)
  
  // 全量校验通过后提
  if (this.usernameError === '' &&
      this.passwordError === '' &&
      this.confirmPasswordError === '') {
    this.isSubmitting = true
    // 模拟网络请求
    promptAction.showToast({ message: '注册成功!', duration: 2000 })
  } else {
    promptAction.showToast({ message: '请检查表单错误', duration: 2000 })
  }
}

关键点说明:

  • validatePassword 中,密码变化时要手动调用 validateConfirmPassword,因为确认密码的校验依赖于密码的状态。
  • 提交时,通过 controller.text 获取输入的最终值。这是为了防止在极短的时间内用户输入完毕但 onChange 还未完成状态更新的问题。(实际开发中很少出现,但写成这样更健壮)

第三步:构建 UI

这一段代码用于组装界面。

typescript 复制代码
build() {
  Column({ space: 16 }) {
    // 用户名输入
    TextInput({
      placeholder: '请输入用户名',
      controller: this.usernameController
    })
      .onChange((value: string) => {
        this.username = value
        this.validateUsername(value)
      })
      .width('90%')
      .borderRadius(8)
    
    if (this.usernameError !== '') {
      Text(this.usernameError)
        .fontColor(Color.Red)
        .fontSize(12)
        .width('90%')
        .textAlign(TextAlign.Start)
    }
    
    // 密码输入
    TextInput({
      placeholder: '请输入密码',
      type: InputType.Password,
      controller: this.passwordController
    })
      .onChange((value: string) => {
        this.password = value
        this.validatePassword(value)
      })
      .width('90%')
      .borderRadius(8)
    
    if (this.passwordError !== '') {
      Text(this.passwordError)
        .fontColor(Color.Red)
        .fontSize(12)
        .width('90%')
        .textAlign(TextAlign.Start)
    }
    
    // 确认密码输入
    TextInput({
      placeholder: '请再次输入密码',
      type: InputType.Password,
      controller: this.confirmPasswordController
    })
      .onChange((value: string) => {
        this.confirmPassword = value
        this.validateConfirmPassword(value)
      })
      .width('90%')
      .borderRadius(8)
    
    if (this.confirmPasswordError !== '') {
      Text(this.confirmPasswordError)
        .fontColor(Color.Red)
        .fontSize(12)
        .width('90%')
        .textAlign(TextAlign.Start)
    }
    
    // 提交按钮
    Button('注册')
      .width('90%')
      .height(48)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .backgroundColor('#007AFF')
      .onClick(() => {
        this.submitForm()
      })
      .enabled(!this.isSubmitting) // 提交后禁用按钮,防止重复提交
    
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
}

注意事项:

  • 密码输入框需要设置 type: InputType.Password 以隐藏输入的字符。
  • 错误提示信息使用条件渲染,只有在有错误时才显示 Text 组件。
  • 提交按钮在 isSubmitting 为 true 时通过 .enabled(false) 禁用,防止重复点击。

常见问题与踩坑记录

问题 1:TextField onChange 触发后,状态更新不及时

现象:

在使用 @State 绑定的变量进行校验时,如果校验逻辑依赖于先前输入的值(比如密码变化需要同步校验确认密码),有时发现界面上的错误提示不是最新的。

原因:

onChange 回调是异步的,@State 变量更新的渲染触发是批量的。在极短时间内连续输入时,状态值可能不是你预期的"当前最新值"。尤其是在一个回调里修改了多个 @State 变量时。

解决方案:

在检验逻辑中,直接使用回调参数 value 作为当前输入值,而不是依赖 this.passwordthis.username。例如,在 validatePassword(value) 中,传入 value 参数,而内部拿 valuethis.confirmPassword 比较。这样可以确保获取到的是当前输入的字符序列,而不是可能延迟的状态值。

问题 2:密码输入框的 TextInputController.text 读取不到最新值

现象:

Button 的点击回调中,通过 this.passwordController.text 拿到的值比界面上实际显示的值缺少最后输入的字符。

原因:

controller.text 在输入完毕后才会更新,而 onChange 回调执行的频率和 controller.text 的更新时机不完全同步。官方文档提到过 controller 主要用于外部设置值,获取当前值则应优先使用 @State 绑定的变量。

解决方案:

  • 提交逻辑中,优先使用 @State 变量(this.password),而不是 controller.text
  • 如果必须使用 controlle,可以在 onChange 中同步写入 controller 的值到 @State
  • 推荐做法:保留 @State 变量作为单一数据源,controller 仅用于清空或设置初始值等操作。

最佳实践

1. 单一数据源原则

始终将 @State 变量作为文本框的"真实"状态来源。controller 只用于程序化操作(例如清空、聚焦),不要作为输入值的主要获取途径。

2. 校验逻辑单元化

一个字段的多个校验规则(如非空、长度、格式)应合并到同一个函数中,并返回统一的错误信息字符串。这样 UI 层只需要判断一个状态变量。

3. 提交前二次校验

为了让用户有更流畅的体验,实时校验可以作为"提示用途",但提交时必须进行一次全量、严格的校验 。这是因为用户在填写完所有字段后,可能并未触发所有 onChange(比如她没有修改最后一个字段)。

4. 避免在 build() 中构建闭包

onChange 中如果使用了闭包并且闭包捕获了大量外部对象,可能会影响性能。保持校验函数独立且参数化,不依赖 build() 内的临时变量。

FAQ

Q:为什么我的 TextField 在模拟器上输入中文会卡顿?

A:这通常是因为 onChange 触发频率过高,并且你在回调中执行了复杂操作(如正则匹配、状态多次赋值)。模拟器的渲染性能有限。建议将校验逻辑精简,并避免在回调中更新多个 @State。如果校验逻辑很重,考虑使用 debouncethrottle 节流。

Q:ConfirmPassword 校验在密码修改后为何没有立刻更新?

A:这是因为你需要在 validatePassword 中显式调用 this.validateConfirmPassword(this.confirmPassword)@State 变化不会自动触发其他相关字段的校验。确保所有关联校验在入口函数中被串联调用。

Q:submitForm 中我怎么获取所有输入框的值?

A:使用 @State 变量(this.username 等)获取。如果担心异步延迟,可以同时调用 controller.text 对比,但通常无需担心。在实际开发中,绝大多数情况 @State 已经是最新的。

如果你在实现类似表单交互时遇到状态同步或校验逻辑的问题,可以重点检查 onChange 中是否使用了正确的参数,以及提交时是否对关联字段做了二次验证。官方文档对 TextInputController 的描述比较有限,建议结合真机测试验证其行为。