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();
}
这事不高级,但经常有人图省事偷懒。
后来我给表单页定了几条硬规则
这几条在移动端表单里非常值钱:
- 文本格式化只进
TextInputFormatter,不在onChanged强改文本。 - 同步校验和异步校验分层处理。
- 异步校验必须去抖,并且丢弃旧结果。
TextEditingController和FocusNode生命周期稳定管理。- 大表单拆成字段级组件,避免整页跟着输入频繁重建。
- 焦点流转显式指定,不靠默认行为碰运气。
结果
这轮改完之后,最直观的变化不是“代码更规范”,而是用户反馈明显降了:
- iOS 中文输入不再被打断
- 银行卡号输入不会跳光标
- 异步校验不再闪烁覆盖
- 整个表单页输入流畅度稳定很多
Flutter 表单体验差,很多时候不是框架本身有问题,而是页面把输入法、校验、状态重建三件事拧在了一起。只要把它们拆开,问题通常就收得住。
