Flutter 基础体系 · 第 50/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 表单校验:Form、Controller、异步规则、错误和提交

表单校验不是“给输入框加一个正则表达式”这么简单。一个可提交的表单通常同时包含:

  • 输入控件如何保存和读取值;
  • 哪些控件属于同一个表单;
  • 同步规则何时执行;
  • 服务器或数据库参与的异步规则如何接入;
  • 错误显示在哪里、由谁清除;
  • 提交过程中如何处理重复点击、过期响应和生命周期;
  • Android、iOS、桌面与 Web 在输入行为上的差异。

Flutter 将这些职责分散在 FormFormStateFormFieldTextFormFieldTextEditingController 和业务状态中。理解它们之间的边界,比记住某个校验 API 更重要。

一、先建立表单的状态模型

设表单有 nn 个字段。第 ii 个字段的值为 xix_i,同步校验函数为:

Vi(xi)eiV_i(x_i) \rightarrow e_i

其中:

  • ei=nulle_i = null:字段通过同步校验;
  • eie_i 是字符串:字段不通过校验,字符串是用户可见的错误信息。

表单同步校验通过的条件是:

Validsync=i=1n(Vi(xi)=null)\text{Valid}_{sync} = \bigwedge_{i=1}^{n}(V_i(x_i) = null)

也就是说,只要有一个字段返回错误,整个表单的同步校验就失败。

如果还需要异步规则,例如“用户名是否已被占用”,可以表示为:

A(x1,,xn)(ok,e)A(x_1,\ldots,x_n) \rightarrow (ok, e)

最终允许提交的条件不是简单的 FormState.validate() 返回 true,而是:

CanSubmit=ValidsyncValidasync¬Submitting\text{CanSubmit} = \text{Valid}_{sync} \land \text{Valid}_{async} \land \neg \text{Submitting}

这里的 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;
  },
)

这里的流程是:

  1. 用户输入文本;
  2. TextEditingController 更新文本;
  3. 触发校验时,Flutter 将当前字段值传给 validator
  4. validator 返回错误字符串或 null
  5. 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;
},

执行顺序决定显示哪个错误。上面的实现采用“第一个失败规则优先”的策略:

  1. 空字符串先报告“请输入密码”;
  2. 非空但长度不足,报告长度错误;
  3. 长度合格但没有大写字母,报告字符要求;
  4. 全部满足时返回 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”,不是新的校验规则。

一个常见用户体验流程是:

  1. 页面首次打开时不显示“不能为空”;
  2. 用户点击提交;
  3. FormState.validate() 发现错误并显示;
  4. 用户继续修改时,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 的语义。

即使强行绕过类型问题,也会遇到更多问题:

  1. validate() 无法同步知道异步请求是否完成;
  2. 用户可能在请求返回前再次修改输入;
  3. 多个请求可能乱序返回;
  4. 页面可能已经销毁;
  5. 加载状态和错误状态没有自然归属。

因此,异步校验应拆为两个阶段:

同步校验通过
    ↓
发起异步检查
    ↓
等待结果
    ↓
将结果写入页面状态
    ↓
显示字段级或表单级错误
    ↓
允许或阻止提交

八、完整示例:同步规则、异步用户名检查和提交

下面是一个可运行的 Flutter 示例。它演示:

  • FormGlobalKey<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 时:

  1. validator 返回“用户名至少需要 3 个字符”;
  2. _usernameAsyncError 通常为空;
  3. 不应发起用户名占用检查,因为同步条件尚未满足。

用户输入 alice 并点击提交时:

  1. FormState.validate() 返回 true
  2. 页面进入 _checkingUsername = true
  3. 发起异步检查;
  4. 服务器返回不可用;
  5. _usernameAsyncError 变为“用户名已被占用”;
  6. forceErrorText 让用户名字段显示错误;
  7. 不执行真正提交。

用户随后把 alice 改成 bob

  1. _usernameRequestVersion 增加;
  2. 清除旧的异步错误;
  3. 旧响应即使晚到,也因版本号不匹配而被丢弃;
  4. 新的值不会被旧请求覆盖。

九、异步并发:为什么必须处理过期响应

假设用户连续输入并发起两次请求:

请求 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 客户端,也可以在新请求开始时取消旧请求。但“取消请求”和“忽略过期结果”是两个独立防线:取消未必能阻止服务器已经返回的响应进入客户端,因此结果校验仍然有价值。

十、异步校验不等于最终服务器校验

“用户名是否可用”存在典型的竞态:

  1. 客户端检查 bob,服务器回答可用;
  2. 另一个用户抢先注册 bob
  3. 当前客户端提交注册请求;
  4. 服务器发现 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,
)

不要同时设置 controllerinitialValue。这两个参数表达的是同一件事的不同来源,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

十五、平台差异:校验机制相同,输入行为不同

FormFormStatevalidator 是 Flutter 层机制,Android、iOS、桌面和 Web 的同步校验语义基本一致。但输入行为存在平台差异。

Android 和 iOS

  • 软键盘会影响可用布局高度;
  • TextInputAction.nextdone 会映射到系统键盘按钮;
  • autofillHints 可能触发系统自动填充;
  • 密码字段可能调用系统密码管理器;
  • 输入法组合输入期间,Controller 文本变化不一定对应“用户完成了一个词”。

因此,不应在每次 Controller 变化时立即执行昂贵的远程检查,除非有去抖、长度门槛和过期保护。

桌面平台

  • 通常没有软键盘;
  • Enter、Tab、快捷键的行为更重要;
  • Tab 导航依赖焦点遍历;
  • 鼠标点击可能让用户跳过某些预期的焦点顺序。

不能假设“按键盘完成键”是所有平台都一致的提交入口。按钮和明确的快捷键处理仍然需要存在。

Web

Flutter Web 的 Form 是 Flutter Widget,不等同于浏览器原生 HTML <form>。因此:

  • 不会自动获得浏览器原生表单提交语义;
  • 浏览器原生约束验证不会自动替代 Flutter validator;
  • 浏览器自动填充、密码管理和输入法行为由浏览器控制;
  • 网络请求还会受到浏览器同源策略、代理和 CORS 配置影响。

如果 Web 端需要浏览器级无障碍或自动填充行为,应同时正确设置标签、语义、autofillHints 和可访问性信息,而不能只依赖 Flutter 的错误文本。

十六、诊断表单问题的顺序

遇到“提交没有反应”或“错误没有显示”时,可以按数据流排查:

  1. 输入控件是不是 TextFormField 或其他 FormField
  2. FormGlobalKey<FormState> 是否属于同一个 Widget 树;
  3. validate() 是否真的被调用;
  4. validator 是否返回了 null 或错误字符串;
  5. 是否因为 AutovalidateMode 设置而误判为“没有校验”;
  6. 是否存在 forceErrorText 覆盖了同步错误;
  7. 异步结果是否仍对应当前输入;
  8. setState 时页面是否仍然 mounted;
  9. 提交按钮是否被 _checking_submitting 禁用;
  10. 服务器错误是字段级还是表单级,是否显示在正确位置。

可以临时记录关键节点:

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 官方文档重新梳理;正文与示例由 WR BLOG 编写。