Flutter 表单页在 iOS 上输入正常,上线后却频繁被投诉难用

Flutter 表单页在 iOS 上输入正常,上线后却频繁被投诉难用

Flutter 表单页有一种问题,开发环境里不一定稳定复现,但用户一旦遇到,投诉会非常集中:

  • 输入框焦点乱跳
  • 中文输入被打断
  • 校验提示一闪一闪
  • 点“下一项”键盘行为不符合预期

这种问题最烦的地方在于,代码通常“看起来没错”。真正的问题出在:页面把输入、校验、格式化和重建绑得太紧了。

一个真实的业务场景

这是一个实名认证表单页,包含:

  • 姓名
  • 身份证号
  • 银行卡号
  • 手机号
  • 短信验证码

需求也很真实:

  • 每个字段边输边校验
  • 银行卡号自动分组
  • 错误提示实时显示
  • 点键盘“下一项”自动切换焦点

初版实现很常见:

TextField(
  controller: cardController,
  onChanged: (value) {
    setState(() {
      form.cardNo = formatCardNo(value);
      errors.cardNo = validateCardNo(form.cardNo);
    });
  },
)

上线后 iOS 用户反馈非常集中:

  • 拼音输入时光标乱跳
  • 银行卡号输入中途会回退
  • 某些字段输得越快越卡

为什么会这样

原因 1:在 onChanged 里改 controller.text

很多格式化需求会写成这样:

onChanged: (value) {
  final formatted = formatCardNo(value);
  controller.text = formatted;
}

这在 iOS 中文输入场景里很容易出问题,因为系统输入法在拼音组合阶段会维护一段“组合文本”。你在这个阶段强改文本,相当于打断了输入法状态机。

结果就是:

  • 光标位置丢失
  • 候选词上屏异常
  • 拼音组合被提前提交

Android 上有时候问题不明显,iOS 上会更敏感。

原因 2:整个页面跟着输入一起重建

如果你的表单页每输一个字符就 setState() 整页刷新,问题会被继续放大。

重建本身不是原罪,但复杂表单通常还带着:

  • 错误提示
  • 提交按钮禁用态
  • 身份校验异步请求
  • 埋点

这些都挂在同一个 setState() 上,输入体验会明显变差。

原因 3:同步校验和异步校验混在一个输入节奏里

比如手机号输入到第 11 位时,立刻去请求服务端判断是否已注册:

onChanged: (value) async {
  setState(() => loading = true);
  final exists = await api.checkPhone(value);
  setState(() => loading = false);
}

如果用户继续输入、删除、重输,请求和 UI 状态会交错得非常难看。

先分清三类逻辑

我后来处理 Flutter 表单页,先强制拆开三类职责:

  • 输入控制
  • 本地同步校验
  • 服务端异步校验

这三件事如果混在一起,输入框一定抖。

解决方式 1:格式化放进 TextInputFormatter

只要是输入格式约束,比如银行卡空格分组、身份证大写 X、手机号去空格,优先用 TextInputFormatter,不要在 onChanged 里强改 controller.text

示例:

class BankCardFormatter extends TextInputFormatter {
  
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final text = newValue.text.replaceAll(' ', '');
    final buffer = StringBuffer();

    for (int i = 0; i < text.length; i++) {
      if (i > 0 && i % 4 == 0) {
        buffer.write(' ');
      }
      buffer.write(text[i]);
    }

    final formatted = buffer.toString();
    return TextEditingValue(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }
}

挂到输入框:

TextField(
  controller: cardController,
  inputFormatters: [BankCardFormatter()],
)

这类逻辑放在 formatter 里,至少不会在页面层乱改输入状态。

解决方式 2:用局部状态,不要整页 setState

大表单页我现在基本会拆成字段级组件,每个字段只管理自己的错误和焦点,表单页只汇总最终结果。

伪代码:

class CardInputField extends StatefulWidget {
  final ValueChanged<String> onValueChanged;
  const CardInputField({super.key, required this.onValueChanged});

  
  State<CardInputField> createState() => _CardInputFieldState();
}

class _CardInputFieldState extends State<CardInputField> {
  final controller = TextEditingController();
  String? errorText;

  
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
      inputFormatters: [BankCardFormatter()],
      onChanged: (value) {
        final pure = value.replaceAll(' ', '');
        setState(() {
          errorText = validateCardNo(pure);
        });
        widget.onValueChanged(pure);
      },
      decoration: InputDecoration(errorText: errorText),
    );
  }
}

这样输入银行卡时,只重建当前字段,不会把整页跟着拖起来。

解决方式 3:异步校验必须去抖,而且要废弃旧结果

手机号是否已注册、身份证是否命中过风控,这类校验不能跟每次按键一一对应。

我一般会这么做:

Timer? _debounce;
int _requestId = 0;

void onPhoneChanged(String value) {
  _debounce?.cancel();
  _debounce = Timer(const Duration(milliseconds: 300), () async {
    final currentId = ++_requestId;
    final result = await api.checkPhone(value);
    if (!mounted || currentId != _requestId) return;

    setState(() {
      phoneExists = result.exists;
    });
  });
}

这里有两个关键点:

  • 防抖,避免每个字符都打接口
  • 请求编号校验,避免旧结果覆盖新状态

只做防抖不够,因为弱网下旧请求还是可能晚回来。

解决方式 4:明确焦点流转,不要指望系统自动猜

复杂表单页里,焦点切换如果不明确控制,体验会很差。

我一般显式维护:

final nameFocus = FocusNode();
final idFocus = FocusNode();
final cardFocus = FocusNode();

TextField(
  focusNode: nameFocus,
  textInputAction: TextInputAction.next,
  onSubmitted: (_) => FocusScope.of(context).requestFocus(idFocus),
)

如果字段是动态显示的,比如企业认证和个人认证共用一个页面,焦点切换更要显式处理,不然一旦某个字段隐藏,焦点很容易丢到不可见节点上。

一个很容易被忽略的坑:不要频繁创建 Controller

如果你把 TextEditingController 写在 build() 里,或者依赖父组件每次重建重新传一个新 controller,输入行为也会出各种诡异问题。

控制器和 FocusNode 都应该稳定存在于字段组件生命周期里,页面销毁时再统一释放。


void dispose() {
  controller.dispose();
  focusNode.dispose();
  super.dispose();
}

这事不高级,但经常有人图省事偷懒。

后来我给表单页定了几条硬规则

这几条在移动端表单里非常值钱:

  1. 文本格式化只进 TextInputFormatter,不在 onChanged 强改文本。
  2. 同步校验和异步校验分层处理。
  3. 异步校验必须去抖,并且丢弃旧结果。
  4. TextEditingControllerFocusNode 生命周期稳定管理。
  5. 大表单拆成字段级组件,避免整页跟着输入频繁重建。
  6. 焦点流转显式指定,不靠默认行为碰运气。

结果

这轮改完之后,最直观的变化不是“代码更规范”,而是用户反馈明显降了:

  • iOS 中文输入不再被打断
  • 银行卡号输入不会跳光标
  • 异步校验不再闪烁覆盖
  • 整个表单页输入流畅度稳定很多

Flutter 表单体验差,很多时候不是框架本身有问题,而是页面把输入法、校验、状态重建三件事拧在了一起。只要把它们拆开,问题通常就收得住。

最后更新 3/31/2026, 11:42:15 PM