Flutter 基础体系 · 第 50/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 表单校验:Form、Controller、异步规则、错误和提交
表单校验不是“给输入框加一个正则表达式”这么简单。一个可提交的表单通常同时包含:
- 输入控件如何保存和读取值;
- 哪些控件属于同一个表单;
- 同步规则何时执行;
- 服务器或数据库参与的异步规则如何接入;
- 错误显示在哪里、由谁清除;
- 提交过程中如何处理重复点击、过期响应和生命周期;
- Android、iOS、桌面与 Web 在输入行为上的差异。
Flutter 将这些职责分散在 Form、FormState、FormField、TextFormField、TextEditingController 和业务状态中。理解它们之间的边界,比记住某个校验 API 更重要。
一、先建立表单的状态模型
设表单有 个字段。第 个字段的值为 ,同步校验函数为:
其中:
- :字段通过同步校验;
- 是字符串:字段不通过校验,字符串是用户可见的错误信息。
表单同步校验通过的条件是:
也就是说,只要有一个字段返回错误,整个表单的同步校验就失败。
如果还需要异步规则,例如“用户名是否已被占用”,可以表示为:
最终允许提交的条件不是简单的 FormState.validate() 返回 true,而是:
这里的 Submitting 表示当前是否已经在向服务器提交。它不是校验结果,但必须参与提交条件,否则用户可能重复创建订单、重复注册账号或重复发送请求。
Flutter 的 Form 主要负责同步的 FormField 校验和状态协调;异步状态、提交状态以及服务器错误,通常由页面状态或状态管理层负责。
二、Form、FormField 和 TextFormField 分别做什么
1. Form 是字段的协调容器
Form 本身不读取每个输入框的文本,也不自动理解业务规则。它提供一个上下文,使下面的 FormField 可以被统一操作。
最常见的写法是:
final formKey = GlobalKey<FormState>();
Form(
key: formKey,
child: Column(
children: [
TextFormField(
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入用户名';
}
return null;
},
),
],
),
)
提交时:
final isValid = formKey.currentState!.validate();
if (!isValid) {
return;
}
// 所有 FormField 的同步 validator 都通过
GlobalKey<FormState> 提供了从页面逻辑访问 FormState 的方式。FormState 常用方法有:
validate():运行后代FormField的校验,并触发错误显示;save():调用后代字段的onSaved;reset():恢复字段初始值并重置字段状态。
save() 不等于 validate()。如果代码需要“校验通过后保存”,应显式写出:
if (formKey.currentState!.validate()) {
formKey.currentState!.save();
}
不能假设 save() 会自动阻止无效值。
2. FormField<T> 是通用字段机制
FormField<T> 管理一个类型为 T 的字段状态。它不限定字段必须是文本,也可以表示:
- 下拉选择;
- 单选或多选;
- 日期;
- 文件选择;
- 自定义输入组件。
它的核心属性包括:
FormField<String>(
validator: (value) {
if (value == null || value.isEmpty) {
return '不能为空';
}
return null;
},
builder: (fieldState) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('自定义字段'),
if (fieldState.hasError)
Text(
fieldState.errorText!,
style: const TextStyle(color: Colors.red),
),
],
);
},
)
FormFieldState 保存字段级状态,例如当前错误文本、是否被用户交互过,以及用于触发重建的方法。
3. TextFormField 是文本输入和 FormField 的组合
TextFormField 可以理解为:
TextField的文本编辑能力 +FormField<String>的表单校验能力。
因此,普通的 TextField 不会自动参与 FormState.validate():
Form(
child: Column(
children: [
TextField(), // 不属于 FormField,不会被 Form.validate() 校验
TextFormField(
validator: (value) => '错误',
),
],
),
)
如果一个输入框需要参与表单校验,应使用 TextFormField,或者自己实现 FormField。
三、Controller 保存什么,FormState 保存什么
这两个对象经常被混淆,但它们解决的是不同问题。
TextEditingController:管理文本编辑值
TextEditingController 保存文本、选区和组合输入状态:
final usernameController = TextEditingController();
@override
void dispose() {
usernameController.dispose();
super.dispose();
}
读取当前文本:
final username = usernameController.text;
修改文本:
usernameController.text = 'alice';
监听文本变化:
late final VoidCallback listener;
@override
void initState() {
super.initState();
listener = () {
debugPrint(usernameController.text);
};
usernameController.addListener(listener);
}
@override
void dispose() {
usernameController.removeListener(listener);
usernameController.dispose();
super.dispose();
}
如果 Controller 是页面状态对象创建的,通常也应该由该对象销毁。否则可能造成监听器、资源或编辑状态残留。
FormFieldState:管理字段的表单状态
FormFieldState 负责:
- 当前字段的校验错误;
- 调用
validator; - 响应
FormState.validate()、reset(); - 让表单知道这个字段是否通过校验。
Controller 不会自动调用 validator,validator 也不会自动修改 Controller。
例如:
final controller = TextEditingController();
TextFormField(
controller: controller,
validator: (value) {
if (value == null || value.length < 8) {
return '至少输入 8 个字符';
}
return null;
},
)
这里的流程是:
- 用户输入文本;
TextEditingController更新文本;- 触发校验时,Flutter 将当前字段值传给
validator; validator返回错误字符串或null;TextFormField根据字段状态显示错误。
如果 validator 需要读取同一个字段的值,优先使用传入的 value,而不是再次读取 Controller。这样可以避免形成两个看似不同的数据来源:
validator: (value) {
final text = value?.trim() ?? '';
return text.isEmpty ? '请输入内容' : null;
}
四、同步校验规则:validator 的契约
TextFormField.validator 是同步函数,典型签名可以写成:
String? validator(String? value)
它有明确契约:
- 返回
null表示通过; - 返回非空字符串表示失败;
- 不应在其中执行异步操作;
- 不应在其中调用
setState; - 不应在其中修改输入框内容。
一个包含多个规则的密码校验:
validator: (value) {
final password = value ?? '';
if (password.isEmpty) {
return '请输入密码';
}
if (password.length < 8) {
return '密码至少需要 8 个字符';
}
if (!RegExp(r'[A-Z]').hasMatch(password)) {
return '密码至少包含一个大写字母';
}
return null;
},
执行顺序决定显示哪个错误。上面的实现采用“第一个失败规则优先”的策略:
- 空字符串先报告“请输入密码”;
- 非空但长度不足,报告长度错误;
- 长度合格但没有大写字母,报告字符要求;
- 全部满足时返回
null。
如果改成多个规则同时收集错误,就需要额外设计错误模型。validator 本身只能返回一个字符串,因此不适合直接表达多个并列错误。
常用同步规则
必填和空白
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '该字段不能为空';
}
return null;
},
只判断 isEmpty 会把 " " 当作有效输入,这通常不符合用户预期。
数字
validator: (value) {
final text = value?.trim() ?? '';
final number = int.tryParse(text);
if (number == null) {
return '请输入整数';
}
if (number < 1) {
return '数量必须大于 0';
}
return null;
},
int.parse 在非法输入时会抛出异常。校验阶段更适合使用 tryParse,因为非法用户输入是正常分支,不应被当作程序异常。
邮箱
validator: (value) {
final email = value?.trim() ?? '';
if (email.isEmpty) {
return '请输入邮箱';
}
final pattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!pattern.hasMatch(email)) {
return '邮箱格式不正确';
}
return null;
},
正则表达式只能做格式筛选,不能证明邮箱存在,也不能证明邮箱属于当前用户。后两者必须由外部流程验证。
五、何时执行校验:AutovalidateMode
TextFormField 可以通过 autovalidateMode 控制自动校验时机:
TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (value) {
if (value == null || value.isEmpty) {
return '请输入内容';
}
return null;
},
)
常见模式包括:
AutovalidateMode.disabled:不自动校验,通常只在提交时调用validate();AutovalidateMode.always:字段重建时持续校验;AutovalidateMode.onUserInteraction:用户与字段交互后开始自动校验。
自动校验只是“何时调用 validator”,不是新的校验规则。
一个常见用户体验流程是:
- 页面首次打开时不显示“不能为空”;
- 用户点击提交;
FormState.validate()发现错误并显示;- 用户继续修改时,
onUserInteraction让错误及时更新。
注意,validate() 会触发相关字段重建。因此不要在 build() 中无条件调用:
// 错误示例
@override
Widget build(BuildContext context) {
formKey.currentState?.validate();
return ...;
}
这会导致构建期间改变状态,可能造成重复构建、异常或糟糕的交互。校验应发生在提交、焦点变化或明确的用户操作中。
六、错误显示:字段错误、异步错误和表单错误
表单至少有三种不同层次的错误。
1. 字段同步错误
由 validator 返回,通常显示在对应输入框下方:
TextFormField(
decoration: const InputDecoration(
labelText: '用户名',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入用户名';
}
return null;
},
)
TextFormField 会把字段错误状态和 InputDecoration 连接起来。通常不需要手动在外部再复制显示一遍。
2. 服务器返回的字段错误
例如服务器返回:
{
"field": "username",
"message": "用户名已被占用"
}
这不是本地同步规则的结果。当前 Flutter API 可以使用 forceErrorText 将外部错误注入字段:
TextFormField(
forceErrorText: usernameServerError,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请输入用户名';
}
return null;
},
)
forceErrorText 非空时会强制字段处于错误状态,并优先表现外部错误。它适合显示服务器返回的字段错误,但必须在用户修改字段后清除,否则即使新值已经改变,旧错误仍可能继续显示。
onChanged: (_) {
if (usernameServerError != null) {
setState(() {
usernameServerError = null;
});
}
},
3. 表单级错误
有些错误不属于单个字段,例如:
- 用户名和邮箱的组合已存在;
- 库存不足;
- 订单状态已经改变;
- 服务器暂时不可用。
这类错误可以显示在提交按钮上方:
if (formError != null)
Text(
formError!,
style: const TextStyle(color: Colors.red),
),
不要把“服务器不可用”伪装成某个字段格式错误。错误归属正确,用户才知道应该修改输入,还是稍后重试。
七、异步校验为什么不能直接写进 validator
下面的写法在类型上就是错误的:
// 错误:validator 需要 String?,而 async 函数返回 Future<String?>
validator: (value) async {
final exists = await checkUsername(value);
return exists ? '用户名已存在' : null;
},
validator 需要在当前调用栈中立即返回 String?,而 async 函数返回的是 Future<String?>。Flutter 的 FormState.validate() 也没有等待异步 validator 的语义。
即使强行绕过类型问题,也会遇到更多问题:
validate()无法同步知道异步请求是否完成;- 用户可能在请求返回前再次修改输入;
- 多个请求可能乱序返回;
- 页面可能已经销毁;
- 加载状态和错误状态没有自然归属。
因此,异步校验应拆为两个阶段:
同步校验通过
↓
发起异步检查
↓
等待结果
↓
将结果写入页面状态
↓
显示字段级或表单级错误
↓
允许或阻止提交
八、完整示例:同步规则、异步用户名检查和提交
下面是一个可运行的 Flutter 示例。它演示:
Form与GlobalKey<FormState>;TextEditingController的读取和销毁;- 同步
validator; forceErrorText显示异步字段错误;- 请求过期保护;
- 提交按钮防重复点击;
mounted生命周期保护;- 字段错误和表单错误的区分。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Form Validation Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const RegistrationPage(),
);
}
}
class RegistrationPage extends StatefulWidget {
const RegistrationPage({super.key});
@override
State<RegistrationPage> createState() => _RegistrationPageState();
}
class _RegistrationPageState extends State<RegistrationPage> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
String? _usernameAsyncError;
String? _formError;
bool _checkingUsername = false;
bool _submitting = false;
// 每次输入变化都递增。旧请求返回时,如果编号不匹配,就丢弃结果。
int _usernameRequestVersion = 0;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
void _onUsernameChanged(String _) {
_usernameRequestVersion++;
// 输入改变后,旧的“用户名已占用”不再适用于新值。
if (_usernameAsyncError != null || _formError != null) {
setState(() {
_usernameAsyncError = null;
_formError = null;
});
}
}
Future<bool> _checkUsernameOnServer(String username) async {
// 示例用延迟模拟网络请求。
await Future<void>.delayed(const Duration(milliseconds: 800));
// 示例规则:alice 被视为已经占用。
return username.toLowerCase() != 'alice';
}
Future<void> _submit() async {
FocusManager.instance.primaryFocus?.unfocus();
// 第一阶段:同步校验。
final isSyncValid = _formKey.currentState?.validate() ?? false;
if (!isSyncValid) {
return;
}
if (_submitting || _checkingUsername) {
return;
}
final username = _usernameController.text.trim();
final password = _passwordController.text;
final requestVersion = ++_usernameRequestVersion;
setState(() {
_checkingUsername = true;
_usernameAsyncError = null;
_formError = null;
});
try {
// 第二阶段:异步校验。
final available = await _checkUsernameOnServer(username);
// 页面销毁,或用户在请求期间改过用户名:丢弃本次响应。
if (!mounted || requestVersion != _usernameRequestVersion) {
return;
}
if (!available) {
setState(() {
_usernameAsyncError = '用户名已被占用';
});
return;
}
// 第三阶段:执行真正提交。
setState(() {
_checkingUsername = false;
_submitting = true;
});
try {
await Future<void>.delayed(const Duration(milliseconds: 600));
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('注册成功')),
);
} catch (_) {
if (!mounted) {
return;
}
setState(() {
_formError = '提交失败,请稍后重试';
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
} catch (_) {
if (!mounted || requestVersion != _usernameRequestVersion) {
return;
}
setState(() {
_formError = '无法检查用户名,请稍后重试';
});
} finally {
if (mounted && requestVersion == _usernameRequestVersion) {
setState(() {
_checkingUsername = false;
});
}
}
// 变量在示例中明确表示提交流程已经读取了它们。
// 真实项目中应将 username/password 传给 repository 或 API client。
assert(username.isNotEmpty);
assert(password.isNotEmpty);
}
@override
Widget build(BuildContext context) {
final busy = _checkingUsername || _submitting;
return Scaffold(
appBar: AppBar(title: const Text('注册')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _usernameController,
enabled: !busy,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration: const InputDecoration(
labelText: '用户名',
border: OutlineInputBorder(),
),
onChanged: _onUsernameChanged,
validator: (value) {
final username = value?.trim() ?? '';
if (username.isEmpty) {
return '请输入用户名';
}
if (username.length < 3) {
return '用户名至少需要 3 个字符';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
enabled: !busy,
obscureText: true,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.newPassword],
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration: const InputDecoration(
labelText: '密码',
border: OutlineInputBorder(),
),
validator: (value) {
final password = value ?? '';
if (password.isEmpty) {
return '请输入密码';
}
if (password.length < 8) {
return '密码至少需要 8 个字符';
}
return null;
},
onFieldSubmitted: (_) {
if (!busy) {
_submit();
}
},
),
if (_usernameAsyncError != null) ...[
// usernameAsyncError 已经通过 forceErrorText 注入时,
// 不需要在这里再次显示;此处仅用于说明表单级错误的位置。
const SizedBox.shrink(),
],
if (_formError != null) ...[
const SizedBox(height: 12),
Text(
_formError!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: busy ? null : _submit,
child: busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('提交'),
),
],
),
),
);
}
}
上面的用户名字段还需要将异步错误传给 forceErrorText。完整字段应改成:
TextFormField(
controller: _usernameController,
enabled: !busy,
forceErrorText: _usernameAsyncError,
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration: const InputDecoration(
labelText: '用户名',
border: OutlineInputBorder(),
),
onChanged: _onUsernameChanged,
validator: (value) {
final username = value?.trim() ?? '';
if (username.isEmpty) {
return '请输入用户名';
}
if (username.length < 3) {
return '用户名至少需要 3 个字符';
}
return null;
},
)
也就是说,前面代码中的用户名字段应使用这个版本。这里特意分开说明,是因为同步错误和异步错误的来源不同:
validator负责“当前文本格式是否正确”;_usernameAsyncError负责“服务器是否接受这个用户名”。
这个示例的实际流程
用户输入 al 时:
validator返回“用户名至少需要 3 个字符”;_usernameAsyncError通常为空;- 不应发起用户名占用检查,因为同步条件尚未满足。
用户输入 alice 并点击提交时:
FormState.validate()返回true;- 页面进入
_checkingUsername = true; - 发起异步检查;
- 服务器返回不可用;
_usernameAsyncError变为“用户名已被占用”;forceErrorText让用户名字段显示错误;- 不执行真正提交。
用户随后把 alice 改成 bob:
_usernameRequestVersion增加;- 清除旧的异步错误;
- 旧响应即使晚到,也因版本号不匹配而被丢弃;
- 新的值不会被旧请求覆盖。
九、异步并发:为什么必须处理过期响应
假设用户连续输入并发起两次请求:
请求 A:检查 alice
请求 B:检查 bob
理想返回顺序是:
请求 A 发出 → 请求 A 返回
请求 B 发出 → 请求 B 返回
但网络实际可能是:
请求 A 发出
请求 B 发出
请求 B 返回:bob 可用
请求 A 返回:alice 已占用
如果代码不检查请求是否过期,最后返回的 A 会把当前输入 bob 标记成错误,形成错误 UI。
可以使用递增序号:
int version = 0;
Future<void> check(String value) async {
final currentVersion = ++version;
final result = await remoteCheck(value);
if (!mounted || currentVersion != version) {
return;
}
setState(() {
// 只有最新请求可以更新 UI
});
}
这个方法的因果关系是:
- 每次输入变化都使旧版本失效;
- 异步响应只携带发起时的版本;
- 响应版本不是当前版本时,说明用户已经产生了更新输入;
- 丢弃旧响应,避免状态回退。
如果使用的是支持取消的 HTTP 客户端,也可以在新请求开始时取消旧请求。但“取消请求”和“忽略过期结果”是两个独立防线:取消未必能阻止服务器已经返回的响应进入客户端,因此结果校验仍然有价值。
十、异步校验不等于最终服务器校验
“用户名是否可用”存在典型的竞态:
- 客户端检查
bob,服务器回答可用; - 另一个用户抢先注册
bob; - 当前客户端提交注册请求;
- 服务器发现
bob已被占用。
因此,客户端异步检查只能改善交互,不能成为数据一致性的最终保证。最终提交接口仍必须重新校验,并以服务器返回为准。
客户端检查的正确定位是:
本地 validator:快速发现明显错误
异步预检查:提前反馈可能的业务错误
最终提交接口:权威校验和事务保证
如果最终提交返回字段错误,应将其重新映射到字段状态:
setState(() {
_usernameAsyncError = '用户名已被占用';
});
如果返回的是全局业务错误,则写入 _formError,而不是硬塞入某个字段。
十一、提交事件:按钮、键盘和焦点
提交可能来自三种入口:
- 点击按钮;
- 键盘的完成或下一步动作;
- 页面外部调用,例如扫码结果填入后自动提交。
这些入口应尽量复用同一个 _submit(),否则不同入口可能执行不同的校验流程。
焦点和键盘
提交前可以移除焦点:
FocusManager.instance.primaryFocus?.unfocus();
它通常会关闭移动端软键盘,但具体键盘动画和焦点表现由平台输入系统决定。
字段之间可以使用:
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) {
FocusScope.of(context).nextFocus();
},
最后一个字段可以使用:
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
_submit();
},
onFieldSubmitted 是文本输入动作回调,不是表单校验回调。它可能在用户按键盘完成键时触发,因此仍然必须调用统一的同步校验和提交逻辑。
防止重复提交
提交按钮禁用只是第一层保护:
onPressed: _submitting ? null : _submit,
真正的提交函数也应再次检查:
if (_submitting) {
return;
}
因为提交函数可能被键盘回调、按钮回调或其他代码路径同时调用。UI 禁用用于改善交互,状态检查用于保证逻辑安全。
十二、重置、初始值和 Controller 的边界
如果使用 Controller,TextFormField 的初始文本应通过 Controller 设置:
final controller = TextEditingController(text: '初始值');
TextFormField(
controller: controller,
)
不要同时设置 controller 和 initialValue。这两个参数表达的是同一件事的不同来源,Flutter API 将它们视为互斥配置。
不使用 Controller 时,可以使用:
TextFormField(
initialValue: '初始值',
)
FormState.reset() 会让字段恢复其初始状态。使用 Controller 时,应注意“表单字段初始值”和“Controller 当前文本”必须保持一致,否则重置行为可能与业务状态预期不一致。
例如编辑页面加载服务器数据:
@override
void initState() {
super.initState();
_usernameController.text = loadedUser.username;
}
如果服务器数据是异步加载的,不要在每次 build() 中反复赋值,否则会:
- 覆盖用户已经输入的内容;
- 重置光标位置;
- 触发不必要的监听;
- 造成输入闪烁。
应只在数据首次加载完成或明确切换编辑对象时更新 Controller。
十三、常见错误及其失败表现
错误一:把 TextField 放进 Form,期待它自动校验
Form(
child: TextField(),
)
失败表现是 formKey.currentState!.validate() 不会检查该字段。原因是 TextField 不是 FormField。
修复方式是改用 TextFormField,或为自定义控件包裹 FormField。
错误二:validator 返回空字符串
validator: (value) {
return '';
},
空字符串通常会让字段进入错误状态,但用户看不到有意义的错误内容。错误消息必须告诉用户如何修正。
错误三:在 validator 中调用异步 API
这会导致类型不匹配,或者产生无法等待、过期响应、重复请求等问题。异步流程应由页面状态或业务层管理。
错误四:异步请求完成后直接 setState
final result = await request();
setState(() {
error = result.error;
});
如果用户已经离开页面,可能出现:
setState() called after dispose()
应先判断:
if (!mounted) {
return;
}
setState(() {
error = result.error;
});
如果还存在输入变化竞态,则同时检查请求版本。
错误五:修改输入后不清除服务器错误
用户把 alice 改成 bob,但界面仍然显示“用户名已被占用”,说明外部错误状态没有随着输入失效而清除。
异步错误通常绑定到“某次具体输入值”,而不是永久绑定到字段:
class FieldError {
final String value;
final String message;
const FieldError(this.value, this.message);
}
或者使用请求版本、输入快照进行判断。核心是不能把旧值的结果套在新值上。
错误六:只依赖客户端校验
客户端校验可以被绕过,也无法保证并发一致性。权限、唯一性、库存、价格和状态转换必须在服务器重新验证。
十四、何时使用自定义 FormField
如果组件不是文本输入,但希望参与 Form 统一校验,可以使用 FormField<T>。
例如一个必选下拉框:
FormField<String>(
validator: (value) {
if (value == null) {
return '请选择城市';
}
return null;
},
builder: (field) {
return DropdownButtonFormField<String>(
value: field.value,
decoration: InputDecoration(
labelText: '城市',
errorText: field.errorText,
),
items: const [
DropdownMenuItem(value: 'beijing', child: Text('北京')),
DropdownMenuItem(value: 'shanghai', child: Text('上海')),
],
onChanged: field.didChange,
);
},
)
这里 field.didChange 很关键。自定义控件更新值时,应通知 FormFieldState,否则 Form 不知道字段值已经变化。
不过在当前 Flutter API 中,某些 Material 组件已经提供了带表单能力的变体,例如 DropdownButtonFormField。如果现成组件满足需求,优先使用它;只有在组件状态或 UI 逻辑确实特殊时,才需要直接组合 FormField。
十五、平台差异:校验机制相同,输入行为不同
Form、FormState 和 validator 是 Flutter 层机制,Android、iOS、桌面和 Web 的同步校验语义基本一致。但输入行为存在平台差异。
Android 和 iOS
- 软键盘会影响可用布局高度;
TextInputAction.next、done会映射到系统键盘按钮;autofillHints可能触发系统自动填充;- 密码字段可能调用系统密码管理器;
- 输入法组合输入期间,Controller 文本变化不一定对应“用户完成了一个词”。
因此,不应在每次 Controller 变化时立即执行昂贵的远程检查,除非有去抖、长度门槛和过期保护。
桌面平台
- 通常没有软键盘;
- Enter、Tab、快捷键的行为更重要;
- Tab 导航依赖焦点遍历;
- 鼠标点击可能让用户跳过某些预期的焦点顺序。
不能假设“按键盘完成键”是所有平台都一致的提交入口。按钮和明确的快捷键处理仍然需要存在。
Web
Flutter Web 的 Form 是 Flutter Widget,不等同于浏览器原生 HTML <form>。因此:
- 不会自动获得浏览器原生表单提交语义;
- 浏览器原生约束验证不会自动替代 Flutter validator;
- 浏览器自动填充、密码管理和输入法行为由浏览器控制;
- 网络请求还会受到浏览器同源策略、代理和 CORS 配置影响。
如果 Web 端需要浏览器级无障碍或自动填充行为,应同时正确设置标签、语义、autofillHints 和可访问性信息,而不能只依赖 Flutter 的错误文本。
十六、诊断表单问题的顺序
遇到“提交没有反应”或“错误没有显示”时,可以按数据流排查:
- 输入控件是不是
TextFormField或其他FormField; Form和GlobalKey<FormState>是否属于同一个 Widget 树;validate()是否真的被调用;validator是否返回了null或错误字符串;- 是否因为
AutovalidateMode设置而误判为“没有校验”; - 是否存在
forceErrorText覆盖了同步错误; - 异步结果是否仍对应当前输入;
setState时页面是否仍然 mounted;- 提交按钮是否被
_checking或_submitting禁用; - 服务器错误是字段级还是表单级,是否显示在正确位置。
可以临时记录关键节点:
debugPrint('validate start');
final valid = _formKey.currentState?.validate() ?? false;
debugPrint('sync valid: $valid');
debugPrint('username: ${_usernameController.text}');
不要只在按钮回调中打印日志。表单问题往往发生在“输入值没有同步”“字段不属于 Form”“异步响应覆盖新状态”等中间环节。
十七、测试时应验证状态转移,而不是只验证文字
表单测试至少应覆盖以下路径:
初始页面
├─ 空输入提交 → 显示同步错误
├─ 格式错误提交 → 不发起异步请求
├─ 格式正确 → 发起异步检查
├─ 异步失败 → 显示字段或表单错误
├─ 修改字段 → 清除旧异步错误
├─ 重复点击 → 只有一个提交请求
├─ 请求返回前离开页面 → 不调用已销毁 State 的 setState
└─ 服务器最终拒绝 → 显示权威错误
例如在 Widget Test 中,可以验证提交按钮状态和错误文本:
await tester.tap(find.text('提交'));
await tester.pump();
expect(find.text('请输入用户名'), findsOneWidget);
对于异步流程,需要推进时间:
await tester.pump(const Duration(milliseconds: 800));
实际项目中还应将网络请求抽象为 repository 或 mock service,这样测试可以明确控制:
- 请求何时返回;
- 返回成功还是失败;
- 两个请求以什么顺序返回;
- 页面销毁后是否还有回调。
十八、一个可维护的职责划分
一个表单页面可以按如下方式分工:
TextEditingController
保存文本、光标和编辑状态
TextFormField.validator
只做立即可判断的同步规则
页面状态 / 状态管理层
管理异步检查、加载、提交和错误
Repository / API Client
发起请求并解析服务器结果
FormState
统一触发字段同步校验、重置和保存回调
服务器
执行最终权威校验和数据写入
这种划分的核心不是类越多越好,而是避免互相越界:
- Controller 不承担业务校验;
- validator 不承担异步请求;
- 页面不假设客户端检查等于服务器保证;
- 服务器错误不被伪装成本地格式错误;
- 提交函数不绕过统一校验入口。
最终,Flutter 表单的可靠提交应当遵循一条清晰的数据流:
用户输入
→ Controller / FormField 状态更新
→ validator 执行同步校验
→ FormState.validate()
→ 异步规则检查
→ 防过期响应与生命周期检查
→ 提交请求
→ 映射服务器错误
→ 成功反馈或恢复可编辑状态
只要每一步的状态来源、触发时机和错误归属明确,Form、Controller、异步规则、错误显示和提交就不会互相纠缠。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter Deep Link 与 Universal Link:配置、解析、登录和安全
- 下一篇:Flutter 焦点与键盘:FocusNode、快捷键、遍历和输入法
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论