这个系列写到第4篇,Flutter for OpenHarmony 的剧本杀组队App已经从“能跑起来”走到了“能填东西”。前几篇我们把项目初始化、首页骨架、剧本列表都搭好了,今天这篇要落地一个真正有业务味的模块:发起组队的表单。也就是说,玩家进到App里点“发起组队”,填完相关信息,点击提交,这条组队记录就能被存下来、出现在列表页里。这是整个项目第一个完整的数据录入闭环,涉及Flutter表单控件的组织方式、校验逻辑、日期时间选择,以及OpenHarmony环境下的细节适配,内容非常扎实。
这篇我尽量按实战顺序讲:先梳理业务上到底要哪些字段,再讲Flutter表单方案怎么选,然后贴核心实现代码,接着聊校验和提交闭环,最后把我在OpenHarmony真机上调试踩过的坑都列出来。无论你是刚开始接触Flutter表单,还是已经在鸿蒙设备上跑Flutter应用,这篇都能给你一些能直接拿去用的东西。
1. 发起组队这个场景,到底要沉淀哪些数据
写代码之前先把业务想清楚,这是我一直坚持的习惯。剧本杀组队这件事情,玩家需要的不是一个花哨的界面,而是能在几分钟内把“什么本、几个人、什么时候、在哪开”说清楚,让别人看到信息后愿意加入。所以表单字段的设计,决定了列表页的展示质量,也决定了后续匹配逻辑能不能跑起来。
1.1 业务字段与表单交互设定
站在玩家视角,我发起一个组队,最关心的是这几件事:
- 组队的标题或描述,让别人一眼看懂这是什么局。
- 剧本名称,尤其是热门本,名字写错了玩家根本搜不到。
- 参与人数,这里包含“已有几个人”和“还需要几个人”,但表单里通常只填“总人数”或“缺几人”就够了,具体状态由后台维护。
- 开始时间,定了时间才能约人,而且时间校验很关键,不能选过去的时间。
- 地点或线上房间号,线下本需要门店位置,线上本则需要房间号。
- 补充说明,比如“新手友好”“机制本”“已有一个坦克”这类个性化信息。
这么多字段如果一次性全塞进页面,用户会烦。但发起组队毕竟不是高频操作,它是目的明确的单次录入,所以信息可以适当多,重点是分组清晰、输入尽量省力。我把字段分成三层:标题和剧本名称是最基础的必填项,时间和人数是关键结构化信息,地点和说明是补充项。这样表单的校验规则就很清晰了,基础项必填,结构化信息做业务校验,补充项可填可不填。
另外还要考虑一个现实问题:在这个场景里,是否需要上传封面图。很多剧本杀组队App会让发起人传一张剧本封面或现场图,但图片上传会涉及权限申请、文件选择、上传进度、图片压缩等一堆事,而且OpenHarmony上的文件选择器和Android、iOS不完全一样,硬塞进这一篇会把表单主线冲淡。我的做法是先在模型里预留一个 coverUrl 字段,表单页暂时不放上传入口,等后面单独开一篇讲鸿蒙下的图片选择与上传时再接上。
1.2 用数据模型框定边界
字段梳理完之后,我通常会直接写出对应的Dart模型。这个模型既是表单提交的结果,也是列表页刷新的数据来源,甚至后续做本地缓存、接口对接也要靠它。先给一个精简版本:
dart复制class TeamModel {
final String title; // 组队标题
final String scenario; // 剧本名称
final int totalPlayers; // 总人数
final int currentPlayers; // 当前已有成员数,默认1
final DateTime startTime; // 开始时间
final String location; // 地点或房间号
final String description; // 补充说明
const TeamModel({
required this.title,
required this.scenario,
required this.totalPlayers,
required this.currentPlayers,
required this.startTime,
required this.location,
this.description = '',
});
Map<String, dynamic> toJson() {
return {
'title': title,
'scenario': scenario,
'total_players': totalPlayers,
'current_players': currentPlayers,
'start_time': startTime.toIso8601String(),
'location': location,
'description': description,
};
}
}
这里有个细节值得展开说一下:为什么 currentPlayers 默认值是1?因为发起人点“发起组队”那一刻,他自己就是这个队的第一名成员,所以表单里不需要让用户填“当前已有几人”,默认1个人是合理的。以后如果做“帮朋友代发”之类的功能,这个字段才需要开放编辑。这样设计既减少了表单负担,也给后台留了扩展空间。
从表单控件的角度看,title 和 scenario 用 TextFormField,totalPlayers 用步进器或下拉框,startTime 用日期时间选择器,description 用多行文本框。这套组合基本就是所有组队类表单的通用模板。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Flutter表单实现的两条路线,我为什么选Form
Flutter里面做表单,路数其实不少,但核心就两种:自己维护状态,或者用 Form + TextFormField 这套官方方案。第三方库如 reactive_forms、flutter_form_builder 也很强,但在OpenHarmony适配这个前提下,我会优先选生态兼容性更稳的方案,这一点后面细说。
2.1 三套方案的对比
先把我考虑过的方案列个表,这是我在项目开始前做的选型功课:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
纯手写 TextEditingController + setState |
灵活、无额外依赖 | 字段多时代码量暴涨,校验逻辑散落 | 1-2个字段的极简表单 |
官方 Form + TextFormField |
校验集中、支持自动保存、社区资料多 | 复杂联动需要额外处理 | 中小型表单,适合本项目 |
| 第三方表单库 | 功能全、代码量少 | 对OpenHarmony分支的兼容性需要验证 | 大型复杂表单或团队有统一规范 |
这个项目是维护在Flutter for OpenHarmony分支上的,第三方包能不能顺利编译、运行时会不会有兼容性差异,都需要额外踩坑验证。官方 Form 这套API是Flutter框架自带的,不依赖任何第三方原生插件,从适配风险的角度讲是最低的。另外,组队表单的性能要求不高,但交互细节多,比如错误提示的显示时机、日期选择器点击后的回填等,官方方案足够覆盖。
所以我最终选择了官方 Form + TextFormField,状态管理则用无状态页面加局部 StatefulWidget,没有额外引入 provider 或 riverpod。原因很简单:在首个版本里,所有表单数据只在当前页面范围内使用,提交时才组装成 TeamModel,没有跨页共享需求,引入状态管理库属于额外复杂度。
2.2 Form核心机制,一篇文章讲透
这里必须把 Form 的工作机制拆开说,因为你理解了它,后面查问题会轻松很多。
Form 本身不存数据,它只是一个容器,真正的魔法在 GlobalKey<FormState> 上。每个 TextFormField 在注册到 Form 之后,FormState 就能通过 _formKey.currentState 拿到所有子表单字段的状态,然后统一调用 validate() 触发校验、调用 save() 把 onSaved 中的数据收集起来。这就是为什么我能在一个按钮的 onPressed 里完成“先校验、再取值”的闭环。
dart复制final _formKey = GlobalKey<FormState>();
void _submit() {
if (_formKey.currentState?.validate() == true) {
_formKey.currentState?.save();
// 组装 TeamModel 并提交
}
}
我见过不少新手在提交按钮里用 TextEditingController.text 一个字段一个字段地取,这也是可行的,但问题在于:如果某个字段校验不通过,你没办法在统一的地方停下来。用 Form 之后,校验逻辑被收拢在每个字段的 validator 里,页面层只关心整体结果,代码会干净一大截。
校验触发方式也值得一提。现在是 validate() 被调用时一次性校验所有字段,所以用户点提交之后,哪个字段有问题会全部标红。但如果希望用户从输入框移开后马上看到错误提示,可以把 TextFormField 的 autovalidateMode 设为 AutovalidateMode.onUserInteraction。我建议首版用默认的提交时校验,因为用户填写过程中频繁飘红很打扰人,体验并不好。
3. 表单页面UI搭建:从静态布局到可交互
设计好模型、确定了方案,接下来就是页面实现。表单页面我采用 Scaffold + AppBar + Form + ListView 的经典结构。在这里,ListView 的选择是有讲究的:键盘弹起时,ListView 内容可以滚动,避免被键盘遮挡;字段多了以后能顺滑滚动;而且底部可以天然顶起按钮。
3.1 页面整体布局与样式细节
页面的代码骨架如下:
dart复制class CreateTeamPage extends StatefulWidget {
const CreateTeamPage({super.key});
@override
State<CreateTeamPage> createState() => _CreateTeamPageState();
}
class _CreateTeamPageState extends State<CreateTeamPage> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _scenarioController = TextEditingController();
final _descriptionController = TextEditingController();
int _totalPlayers = 6;
DateTime? _startTime;
String _location = '';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('发起组队')),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _titleController,
maxLength: 30,
decoration: const InputDecoration(
labelText: '组队标题',
hintText: '比如:周末来一车《病娇男孩的精分日记》',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请填写组队标题';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _scenarioController,
maxLength: 50,
decoration: const InputDecoration(
labelText: '剧本名称',
hintText: '请填写剧本全名',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请填写剧本名称';
}
return null;
},
),
// 人数、时间、地点、说明等字段
],
),
),
);
}
}
这里有个容易被忽略的点:maxLength 加上之后,输入框右下角会出现数字统计,如果你的设计稿里不想要这个统计,需要配 counterText: '' 把它隐藏。我用 maxLength 主要是为了限制字符长度,避免用户把标题填成小作文。
所有输入框的边框我用的是 OutlineInputBorder,它比下划线边框在视觉上更重,适合表单页这种需要用户专注填写的场景。Material 3 模式下,InputDecoration 的默认样式和 Material 2 差别不小,如果追求统一视觉效果,建议在项目主题里配好 InputDecorationTheme,而不是在每个输入框上重复设置。
3.2 日期、时间、人数这些特殊控件的处理
普通文本字段上面已经写了,真正需要动脑的是日期时间选择器和人数选择器。
人数这块我一开始想用 DropdownButtonFormField,但试了下发现移动端上“先点开再找数字”的操作还是偏重,改成 Stepper 加减按钮之后,操作成本低很多。用步进器还有一个好处,可以很自然地限制范围:
dart复制Row(
children: [
const Text('需要人数'),
const Spacer(),
IconButton.outlined(
onPressed: _totalPlayers > 2 ? () {
setState(() => _totalPlayers--);
} : null,
icon: const Icon(Icons.remove),
),
Text('$_totalPlayers 人', style: Theme.of(context).textTheme.titleMedium),
IconButton.outlined(
onPressed: _totalPlayers < 10 ? () {
setState(() => _totalPlayers++);
} : null,
icon: const Icon(Icons.add),
),
],
)
人数下限设为2,是因为剧本杀至少得两个人才能开局;上限设为10,是因为绝大多数剧本杀本子最多也就10人,个别“12人阵营本”属于少数派,等真需要时再放开。这个上下限不是硬编码拍脑袋,是结合市面上剧本的常见配置定的,后面做二次封装时也可以把这些值变成常量。
日期时间的交互,我用的是 showDatePicker + showTimePicker 的组合。为什么不用一个现成的日期时间库?因为 showDatePicker 是官方内置,主题适配是现成的,而且不需要额外依赖。在OpenHarmony的Flutter运行环境里,尽量少依赖原生实现是铁律,因为很多第三方库在鸿蒙分支上还没适配。
dart复制Future<void> _pickStartTime() async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
initialDate: now.add(const Duration(days: 1)),
firstDate: now,
lastDate: now.add(const Duration(days: 30)),
);
if (date == null) return;
final time = await showTimePicker(
context: context,
initialTime: const TimeOfDay(hour: 19, minute: 0),
);
if (time == null) return;
setState(() {
_startTime = DateTime(date.year, date.month, date.day, time.hour, time.minute);
});
}
这里我特意先选日期再选时间,而不是用一个组合控件。原因很简单:两个步骤分开,用户心智负担小,而且代码逻辑清晰。这里还有一个容易被忽略的业务细节,firstDate 设置成 now 只能拦截“选过去的日期”,但用户完全可以选择今天早上9点这种已经过去的时间。所以在提交校验里,我会再判断一次具体时间点,这个放到第4节详细讲。
选择完时间后,页面上需要一个地方回显结果。我习惯用一个 InkWell 包着 InputDecorator,点击整个区域都能触发选择,体验比只点一个小图标好得多:
dart复制InkWell(
onTap: _pickStartTime,
child: InputDecorator(
decoration: const InputDecoration(
labelText: '开始时间',
border: OutlineInputBorder(),
suffixIcon: Icon(Icons.calendar_month),
),
child: Text(
_startTime == null
? '请选择开始时间'
: _formatTime(_startTime!),
style: TextStyle(
color: _startTime == null ? Colors.grey : null,
),
),
),
)
_formatTime 这个格式化函数我直接用了 intl 包,但这里要提醒一句:如果你项目里还没引过 intl,或者不想为此引入新依赖,也可以手写一个简单的格式化方法,比如 '${dt.month}月${dt.day}日 ${dt.hour}:${dt.minute.toString().padLeft(2, '0')}'。在OpenHarmony分支上,intl这种纯Dart包一般都能正常用,因为不涉及原生代码,但能少一个依赖就少一个。
4. 校验、提交与反馈:表单的最后一个环节
表单页做完,最核心的反而是最后一个环节:把数据用好。这里的“好”包含三层意思:校验要挡得住脏数据,提交过程要有状态反馈,提交成功后要正确跳转并通知列表页刷新。如果这一步没做好,前面做得再漂亮,数据不对也白搭。
4.1 从“必填校验”到“业务规则校验”
表单校验分两个层次。第一层是“有没有填”,也就是非空判断,这个已经在字段的 validator 里写过了。第二层是“填得对不对”,这才是真正拦住脏数据的关键。
拿时间字段举例,我最开始只做了非空校验,结果测试的时候发现,用户可以先选“今天”,再选“早上8点”,这个时间在当前时刻之前,发出去的组队根本没人能参加。所以后面加了业务规则校验:
dart复制bool get _isFutureTime {
final now = DateTime.now();
if (_startTime == null) return false;
return _startTime!.isAfter(now.add(const Duration(hours: 1)));
}
这个“提前至少1小时”的规则是有讲究的。剧本杀组队不像外卖下单,发起人需要预留时间等人凑齐,如果允许提前10分钟发起,那这局基本约不到人。从产品角度讲,组队App的价值是“提前攒局”,所以时间门槛不能太低。但如果把它设成“提前24小时”,又会让临时约本的用户流失。1小时是一个折中值,也是我看了多个同类App后对比出来的比较合理的阈值。
人数校验同理,不能只靠步进器限范围,提交时还要再兜底一次。因为字段状态可能在UI上被绕过(比如以后接接口回填数据),提交时统一校验才是保险的:
dart复制void _submit() {
FocusScope.of(context).unfocus(); // 收起键盘
if (!_formKey.currentState!.validate()) return;
if (_startTime == null || !_isFutureTime) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请选择正确的开始时间')),
);
return;
}
_formKey.currentState!.save();
final model = TeamModel(
title: _titleController.text.trim(),
scenario: _scenarioController.text.trim(),
totalPlayers: _totalPlayers,
currentPlayers: 1,
startTime: _startTime!,
location: _location,
description: _descriptionController.text.trim(),
);
// 提交逻辑
}
这里我用 FocusScope.of(context).unfocus() 先把键盘收起来,一是避免点击提交时键盘遮挡提示,二是让页面进入一个更“干净”的状态。SnackBar 和 Form 自带的字段级错误提示是可以共存的:字段级错误提示负责“哪里错了”,SnackBar 负责“为什么不能提交”,两者配合才不会让用户懵。
4.2 提交状态的统一管理与路由闭环
真正的提交动作,在OpenHarmony上需要接后端接口。但很多Flutter开发者在做Demo时,后端还没就绪,我的做法是抽象一个 TeamRepository,把提交逻辑隔离在页面之外。这样后续后端接口好了,只改仓库层,页面不用动。
dart复制class TeamRepository {
Future<bool> createTeam(TeamModel team) async {
// TODO: 替换为真实接口调用
await Future.delayed(const Duration(milliseconds: 500));
return true;
}
}
在页面里,提交时用一个 _isSubmitting 状态来控制按钮的加载动画,防止用户重复点击:
dart复制bool _isSubmitting = false;
void _submit() async {
// ... 校验逻辑
setState(() => _isSubmitting = true);
final success = await _repository.createTeam(model);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (success) {
Navigator.pop(context, model);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('发起失败,请稍后重试')),
);
}
}
这里有两个细节是实践中踩出来的。第一,网络请求返回后必须判断 mounted,因为用户可能在请求过程中退出页面,这时再 setState 会直接报错。第二,Navigator.pop 时把 model 作为返回值传回去,这样上一页通过 await Navigator.push() 就能拿到新创建的组队数据,然后刷新列表,形成完整的路由闭环。
5. 真机调试与踩坑记录:OpenHarmony环境下的表单细节
如果说前三节是教科书内容,那这一节就是真正的实战现场。在OpenHarmony的Flutter环境下做表单,最大的感受是:框架层的开发体验和Android上差别不大,但和“原生能力”沾边的事情都要多留个心眼。
5.1 依赖版本不一致,代码再怎么优雅也白搭
这个系列项目依赖的是适配OpenHarmony的Flutter SDK分支,和官方Flutter SDK在版本号上并不同步。我遇到过最典型的问题就是 pubspec.yaml 里某个依赖的版本范围和当前Flutter版本对不上,导致 flutter pub get 时依赖解析失败,或者依赖下载下来了但编译报错。
处理这个问题的经验有三条。第一条,尽量把依赖锁定到“确定可用的版本”,不要用 ^ 范围匹配,而是直接指定版本号,比如把 provider: ^6.0.5 改成 provider: 6.0.5。这样可以避免解析Fork分支版本时出现冲突。第二条,优先使用纯Dart包,不带原生代码的那种,因为纯Dart包几乎不会受OpenHarmony原生适配的影响。第三条,如果某个包必须依赖原生实现,务必先查一下它是否适配了OpenHarmony,很多Flutter插件在鸿蒙分支上还没有对应的实现,用 flutter build hap 或安装到真机时才会发现问题。
我在这个表单项目里一共就只依赖了 intl 一个包,其余全部用Flutter内置能力实现,就是为了降低适配风险。
5.2 键盘遮挡、日期选择器样式、热重载失效的小毛病
先说说键盘遮挡问题。Flutter官方有 resizeToAvoidBottomInset 这个属性,默认是 true,理论上Scaffold会随键盘弹出而调整高度。但在OpenHarmony设备上,某些输入法弹起时 Scaffold 的避让并不可靠,尤其是多层嵌套布局时更容易出现遮挡。我的页面本身就是 ListView,能滚动,但还是出现过一个现象:聚焦最底部的补充说明输入框时,键盘直接把输入框盖住,列表也推不上去。
排查了一圈,根因是焦点输入框不在视口内时,Scrollable.ensureVisible 没有被自动触发。解决方案是给文本输入框加一个 onTap,主动请求焦点后延迟一帧滚动到可见位置。另外,用 SingleChildScrollView 会比 ListView 更容易出现这类问题,因为 ListView 会自动管理焦点项的可视性,而 SingleChildScrollView 不会。所以建议表单页优先用 ListView。
再说主题样式。有很多人问过showLicensePage 这类系统页面的主题颜色问题,其实根子在 MaterialApp 的 theme 配置上。如果设置了 ColorScheme.fromSeed(seedColor: xxx),showDatePicker、showLicensePage、showAboutDialog 这些系统级页面都会跟随seedColor生成一套Material 3配色,个别组件你单独设置的颜色反而会被覆盖。如果你发现日期选择器选中的高亮色和你预期不一样,去检查seedColor,大概率是这里的问题。
还有一个我差点忽略的坑:热重载后页面没有更新。在OpenHarmony分支上,热重载偶尔会失灵,代码改了但UI不变。遇到这种情况不要急着怀疑代码,先试一次完整的热重启(R键),绝大多数情况下能恢复。如果连热重启都不生效,就冷启动一下。这个和代码逻辑没关系,纯粹是Fork分支的工具链还不够稳定。
5.3 验收清单:表单功能做完,怎么算真正做完
这个表单从“能跑”到“稳”,我列了一个自查清单,分享给大家做参考:
| 检查项 | 判定标准 |
|---|---|
| 字段完整性 | 标题、剧本名、人数、时间、地点、说明均可录入 |
| 必填校验 | 空值提交时,对应字段标红并提示 |
| 业务校验 | 选择过去时间时,提交被拦截并给出明确提示 |
| 边界限制 | 人数范围2-10,标题和说明长度受限 |
| 键盘交互 | 输入框不被键盘遮挡,提交时自动收起 |
| 提交状态 | 提交过程中按钮防重复点击,有加载反馈 |
| 跳转闭环 | 提交成功后返回列表页,新数据被正确接收 |
做完清单不代表结束了,你还可以在这个基础上去扩展。比如把 TeamRepository 的假接口换成真实后端;给表单页加一个“默认跳转到本周六晚上7点”的快捷时间选项;或者把 TeamModel 接入本地数据库存草稿。每一步都是这个系列后续内容里值得单独讲一次的话题。
表单是任何App里都绕不开的基础能力,但表单做得好不好,直接决定了用户愿不愿意继续用你这个App。我在这个项目里最大的体会是:先花30分钟把业务字段想清楚,再动手写代码,比闷头写两个小时然后反复改要快得多。尤其是OpenHarmony这种还在快速迭代的适配环境,技术方案更倾向于“保守选型、少依赖原生”,反而会让你把更多精力花在业务本身。这个思路,这套代码,不管你的项目是剧本杀还是密室逃脱还是单纯的签到报名,都可以直接搬过去用。
