做剧本杀组队App做到第4个实战篇,前面几篇我们解决了框架搭建、首页信息流、剧本库列表和房间详情,这些场景本质都在解决“找局”的问题。而“发起组队”这一步,才是用户从被动浏览转向主动建局的关键动作。在Flutter for OpenHarmony这套开发组合下,很多人以为表单不过是一堆TextFormField配上按钮,实际动手才知道,剧本选择、人数区间、日期时间这类偏业务逻辑的控件,加上OpenHarmony设备上输入法弹起、焦点切换、校验时机这些环境差异,任何一个点都能卡住大半天。
这篇我就把发起组队表单从需求拆解、Form基座搭建、特色控件实现,到最后的提交链路完整过一遍。适合已经跑通Flutter for OpenHarmony基础环境、准备开始写业务模块的开发者,也适合正在纠结“表单到底应该拆成几个部分”的Flutter初学者。文章里所有代码都是我自己在项目中验证过的,可以直接抄。
1. 发起组队表单的需求拆解:先定字段,再谈控件
1.1 剧本杀组队的核心信息结构
很多人一上来就铺输入框,这是表单设计里最容易踩的坑。在动手写任何代码之前,我得先想清楚一个事:发起组队这个动作,用户到底需要提供哪些信息?
剧本杀组队本质上是在创建一个“游戏局”。这个局的信息可以分成三类:局本身的信息,包括玩什么剧本、多少人、什么时候开;参与者的筛选条件,比如是否需要推理担当、是否接受新手;线下履约的信息,比如在哪个店、有没有额外备注。这个分类直接决定了表单的视觉分组和字段排序,而不是让所有输入框平铺在页面上。
| 字段 | 录入控件 | 校验规则 | 设计原因 |
|---|---|---|---|
| 剧本名称 | 只读输入框 + 选择弹层 | 必填,1-20字 | 组队的核心信息,优先从剧本库选择 |
| 开始时间 | 日期选择器 + 时间选择器 | 必填,不早于当前时间 | 玩家决策的关键条件 |
| 人数区间 | Stepper 步进器 | 必填,3-12人,下限不超过上限 | 剧本杀有人数硬性要求 |
| 角色需求 | FilterChip 多选 | 选填,最多4个 | 辅助组人时的角色匹配 |
| 集合地点 | 普通输入框 | 选填,最多30字 | 线下履约的场所信息 |
| 备注 | 多行输入框 | 选填,最多100字 | 补充说明,比如“新手友好” |
逐个解释一下为什么这么定。剧本名称用了只读输入框加选择弹层,而不是让用户直接打字,是因为当用户从剧本库选出来时,我们可以顺势带上剧本的封面图、默认人数上下限、难度标签等关联数据,这几个数据在创建房间后的详情页会用到,能少让用户填一次。开始时间拆成日期和时间两个Picker组合,比用一个长的DateTime输入框直观得多,尤其OpenHarmony上中英文输入法切换时,纯文本时间输入很容易出格式问题。人数区间这个设计比较特殊,剧本杀不是固定人数,而是“下限多少人能开,上限多少人封车”,所以单独一个数字满足不了,必须用“下限+上限”两个值。
1.2 哪些信息不该出现在创建表单里
确定完该有的字段,还要明确哪些信息不该放在这个表单里。产品经理或者作为开发者的我们自己,很容易把表单越做越长:“要不要加一个是否接受新手的开关?”“要不要加组局宣言?”“要不要加付费方式?”每多一个字段,用户的心理门槛就高一截,从“随便填一下”变成“要慎重考虑”,转化率肉眼可见往下掉。
我的取舍原则很简单:创建时必须要有、并且影响别人是否加入的信息,才放进这个表单。像“是否接受新手”这种锦上添花的筛选条件,放在房间列表页的筛选器里,或者创建成功后进详情页再编辑;付费方式这种涉及资金的内容,更是要等局组起来之后单独走结算流程,放进组队表单里反而会引发信任问题。这也意味着表单要支持后续编辑,所以数据模型设计时,接口层就要预留PATCH能力。
1.3 校验规则按业务定义,而不是按控件定义
校验逻辑看起来是技术活,实际上是业务逻辑的翻译。必填、长度限制只是最基础的抽屉,真正的业务校验是这几条:人数下限必须小于等于上限,且下限不低于3、上限不超过12,这来自剧本杀行业的普遍规则——绝大多数剧本的最低开本人数是3-4人,最大上限是12人;开始时间不能早于当前时间,这是为了避免创建一个“昨天就开”的无效局;时间范围限制在未来7天内,防止用户把时间排到遥遥无期,局永远组不起来。
这些规则写在validator里只是前端第一道拦截,服务端必须做同样的校验,因为客户端的校验可以被绕过。我习惯在提交时把校验拆成两层:一层是表单字段本身的校验,用Form的validator机制;另一层是业务逻辑校验,比如“开始时间不能再今天之前”,这个会单独写一个方法,在点击提交按钮时统一检查。这样好处是代码职责清晰,validator只负责字段格式,业务规则逻辑不会被拆散到各个控件里。
2. Form基座搭建:Form、Field与Controller的正确协作
2.1 一个局一个Key:GlobalKey 的使用边界
Flutter的表单核心是Form组件。它本身不提供任何UI,只是一个状态容器,通过GlobalKey 让我们能够触发表单内所有字段的校验、重置等操作。一个页面用一个自己的GlobalKey就够了,不需要全局静态变量,也不需要每个输入框单独配一个FormState。
class CreateTeamPage extends StatefulWidget { const CreateTeamPage({super.key}); @override State<CreateTeamPage> createState() => _CreateTeamPageState(); } class _CreateTeamPageState extends State<CreateTeamPage> { final _formKey = GlobalKey<FormState>(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('发起组队')), body: Form( key: _formKey, child: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ _buildScriptField(), _buildTimeField(), _buildPlayerCountField(), _buildRoleTagsField(), _buildLocationField(), _buildNoteField(), _buildSubmitButton(), ], ), ), ); } }这里有几个细节。第一,Form包住的是ListView而不是Column,因为表单页在OpenHarmony设备上一定会遇到软键盘弹起的情况,ListView天然支持滚动,键盘弹起时输入框能滚到可视区域;第二,字段构建方法拆成独立方法,可读性更好,也方便后面用条件来控制字段显隐;第三,每个TextFormField之间用SizedBox或间距组件隔开,不靠Form帮你做布局,布局是页面自己的责任。
GlobalKey 最常用的操作有三个:validate()触发所有字段校验,reset()清空所有字段到初始状态,save()触发每个FormField的onSaved回调。在发起组队这个场景里,提交时我们只用validate(),因为数据是手动从controller里取的,不依赖onSaved机制。
2.2 控制器的生命周期管理
TextEditingController是连接文本输入框和数据源的桥梁。它不能在build方法里创建,每次build都会创建新实例会导致输入框反复失去焦点、输入内容丢失,这种问题在Flutter社区里被问过无数次。正确做法是在State的字段上声明,在dispose里释放。
class _CreateTeamPageState extends State<CreateTeamPage> { final _formKey = GlobalKey<FormState>(); final _scriptNameController = TextEditingController(); final _locationController = TextEditingController(); final _noteController = TextEditingController(); @override void dispose() { _scriptNameController.dispose(); _locationController.dispose(); _noteController.dispose(); super.dispose(); } }注意,所有用了controller的输入框,在widget销毁时必须释放controller,否则在开发模式控制台会看到“A TextEditingController was used after being disposed”之类的报错,严重情况下会导致内存泄漏。另外,如果页面里使用了FocusNode来管理焦点切换,FocusNode同样必须在dispose里释放。
这里有个容易被忽略的点:读only的TextField也需要controller。剧本名称那个输入框,用户不能直接打字,但需要点击后弹层选择,选择完把结果赋值到controller里。如果你图省事用Text来展示,提交的时候还得额外维护一个字段去取当前选中的剧本,反而多此一举。
2.3 Validator和autovalidateMode的配合策略
TextFormField的validator是个返回String?的函数,返回null代表校验通过,返回错误文本代表校验失败。一个字段的校验规则可能不止一条,比如剧本名称既要判空,又要判长度,我习惯用提前return的方式逐条检查,而不是嵌套多层if。
String? _validateScriptName(String? value) { if (value == null || value.trim().isEmpty) { return '请选择或输入剧本名称'; } if (value.trim().length > 20) { return '剧本名称不能超过20个字'; } return null; }校验时机是另一个值得琢磨的点。Flutter的TextFormField默认不在输入过程中校验,只在validate()调用时或者autovalidateMode触发时才校验。如果表单一开始就设成AutovalidateMode.always,用户还没开始填呢,整页就飘红,体验非常劝退。我习惯初始设为AutovalidateMode.disabled,当用户点击过一次提交且校验失败后,再通过setState把模式切换成AutovalidateMode.onUserInteraction,这样用户修改字段时能实时看到错误消失,又不会一进页面就被红色包围。
2.4 为什么没有直接上三方表单库
Flutter生态里其实有不少表单库,比如flutter_form_builder、reactive_forms之类的,封装了校验、状态管理、动态字段等能力。我在这个项目里选择全部手写,原因有两点。
第一,这个表单只有6个字段,规则并不复杂,引入一个三方库意味着要学习它的API,后续还要跟着升级,维护成本反而高于收益。第二,也是更重要的,OpenHarmony上的Flutter生态和标准Flutter生态有一些兼容性差异,三方库如果依赖了平台特定插件,在OpenHarmony上可能直接跑不起来。比如有些表单库内部依赖url_launcher这类插件,虽然表单库本身不用url_launcher,但项目里间接引入后,就需要验证OpenHarmony版本的插件支持情况。在这个阶段,稳定比省事重要,表单这种核心业务逻辑,自己掌控最放心。
3. 剧本杀特色控件的落地:从日期时间到人数选择
3.1 剧本选择:只读输入框 + 底部弹层搜索
剧本名称字段如果做成普通TextFormField,用户随便打个“恐怖本”也能提交,但“恐怖本”不是一个具体剧本,组队信息就是无效的。所以这里采用只读输入框加底部弹层的交互:点击输入框,弹出一个全屏或半屏的搜索列表,里面展示剧本库的剧本,用户选择后把剧本名回传到controller里。
TextFormField( controller: _scriptNameController, readOnly: true, decoration: const InputDecoration( labelText: '剧本名称', hintText: '点击选择剧本', suffixIcon: Icon(Icons.arrow_drop_down), ), validator: _validateScriptName, onTap: () async { final selected = await Navigator.of(context).push<Map<String, String>>( MaterialPageRoute(builder: (_) => const ScriptSearchPage()), ); if (selected != null && mounted) { setState(() { _scriptNameController.text = selected['name'] ?? ''; _selectedScriptId = selected['id'] ?? ''; }); } }, )readOnly: true这个属性很关键。它让TextField不弹出软键盘,但onTap仍然有效,从而把交互拦截到我们的弹层上。这里顺手把选中的剧本ID也存一下,因为提交接口需要传脚本ID,而不是仅传一个展示用的名称字符串。
搜索弹层本身不需要太复杂,一个搜索框加一个ListView。搜索框用TextFormField,监听onChanged,对剧本库列表做模糊过滤。这里有个性能考量:如果剧本库数据量大,每次输入都实时过滤可能会卡顿,可以加个简单的防抖,比如300毫秒间隔,但就当前项目的剧本库规模来说,几百个剧本直接内存过滤完全没问题。
3.2 日期和时间两个Picker的组合
开始时间是由日期加时间两个部分组成的,用两个Picker组合起来是Flutter最主流的做法。showDatePicker和showTimePicker都是Flutter原生能力,OpenHarmony版本的Flutter SDK对这两个组件做了兼容,我实测可以直接使用。
Future<void> _pickStartTime() async { final now = DateTime.now(); final initial = _startTime ?? now.add(const Duration(hours: 2)); final date = await showDatePicker( context: context, initialDate: initial, firstDate: now, lastDate: now.add(const Duration(days: 30)), ); if (date == null) return; final time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(initial), ); if (time == null) return; setState(() { _startTime = DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); }这个实现有几个细节要注意。第一个是firstDate和lastDate的设定。firstDate设为当前时间,这样日历控件里过去的日期全灰不可点,这比validator里再判断日期是否合法直观得多。lastDate设为30天以后,避免用户选一个过于遥远的时间。第二个是initialTime的计算,如果用户已经选择过时间,打开Picker的初始值应该是之前的选择,而不是重新回到当前时间的后两小时,不然用户想微调时间时,Picker每次跳回默认位置会非常恼火。
第三个细节在用户取消操作的处理上。showDatePicker返回null表示用户取消,直接return;showTimePicker也同理。这里需要注意,如果用户选完日期但在时间选择器里取消了,那么这次操作的结果应该是“未改变时间”,而不是“日期已改但时间没改”。我的做法是先把日期和时间都选完再一次性赋值setState,避免中间状态。
3.3 人数区间:Stepper比Slider更好用
人数选择在剧本杀场景里必须是离散值,不存在3.5个人这种说法。Flutter里做离散选择有Slider、DropdownButton、Stepper几种思路。Slider在触屏上拖着选,看起来方便,但“精准命中某个值”并不容易,尤其在OpenHarmony的触控采样率不同的设备上,拖到5松手变6是家常便饭。DropdownButton倒是精准,但比起Stepper多了一步展开选择的操作。
我这里直接用两个Stepper,一个管下限,一个管上限,配合联动逻辑。下限加一不能超过上限,上限减一不能低于下限。
Widget _buildPlayerCountField() { return Row( children: [ Expanded( child: _buildStepperLabel('下限', _minPlayers, () { if (_minPlayers > 3) setState(() => _minPlayers--); }, () { if (_minPlayers < _maxPlayers) setState(() => _minPlayers++); }), ), const Text('—'), Expanded( child: _buildStepperLabel('上限', _maxPlayers, () { if (_maxPlayers > _minPlayers) setState(() => _maxPlayers--); }, () { if (_maxPlayers < 12) setState(() => _maxPlayers++); }), ), ], ); }Flutter自带一个Stepper组件,但它主要用于“分步向导”,不是“数值增减器”,硬套到这个场景还得改样式,不如直接用IconButton自己搭一个加号减号的组合,代码量没多多少,样式完全可控。按钮在临界点自动禁用,比如下限已经等于上限时,下限的加号是灰色不可点的,用户不需要靠报错来知道规则,交互上就给了暗示。
3.4 角色需求标签:FilterChip的多选逻辑
剧本杀组队有个很常见的需求:房主希望招特定类型的玩家,比如“推理担当”“气氛组”“情感本玩家”。这些角色需求用标签形式展示比下拉框友好得多,用户一眼扫过心里就有数,点几下就完成选择。
Flutter的FilterChip就是干这个的。它是一种可切换的标签,选中状态通过selected参数控制,点击回调里维护一个List 。
final _selectedRoleTags = <String>[]; static const _roleTags = ['推理担当', '气氛组', '情感本玩家', '恐怖本坦克', '新手友好']; Widget _buildRoleTagsField() { return Wrap( spacing: 8, runSpacing: 8, children: _roleTags.map((tag) { return FilterChip( label: Text(tag), selected: _selectedRoleTags.contains(tag), onSelected: (selected) { setState(() { if (selected) { if (_selectedRoleTags.length < 4) _selectedRoleTags.add(tag); } else { _selectedRoleTags.remove(tag); } }); }, ); }).toList(), ); }限制最多4个标签是为了防止用户选太多导致信息失去重点。这里有个细节:当用户尝试选第5个标签时,onSelected回调里传入的selected参数已经是true了,但我们的逻辑会拦截掉这个添加,这会导致这个FilterChip没有被选中的视觉反馈,而其他的标签都被选中了。这是我实际开发中遇到的交互卡点,后来在界面上加了个提示,当达到上限时用SnackBar轻提示“最多选择4个角色需求”,就不再困惑了。
这个字段本身不是必填,所以不需要validator。但提交数据时要注意,为空时传给后端一个空的List,而不是null,后端处理空列表比处理null更省事。
4. OpenHarmony上表单的适配与踩坑:键盘、焦点与输入法
4.1 软键盘顶起布局:resizeToAvoidBottomInset不是万能的
跨端开发最怕的就是环境差异。同样的Flutter代码,在Android模拟器上跑得好好的,到OpenHarmony真机上软键盘一弹起来就把提交按钮顶到键盘后面,或者整个页面被压缩得变形。
Flutter默认的Scaffold有个resizeToAvoidBottomInset属性,默认值是true,意思是当软键盘弹起时,Scaffold的body区域自动缩小到键盘以上的可视区域。这个机制在标准Flutter上工作正常,但在OpenHarmony的某些输入法实现上,缩小的时机和输入法动画不一定同步,体感上会出现“键盘弹起来了页面还没缩,键盘收起时页面又跳了一下”的问题。
我的处理方案是:保持resizeToAvoidBottomInset为true,但把页面的根布局从Column换成ListView,同时给ListView设置一个足够的底部padding。这样即使键盘把可视区域顶小,ListView也能滚动到底部,用户不会找不到提交按钮。实测下来,这个方案在OpenHarmony 4.0和5.0的几个版本上表现稳定。
另外提一个细节:TextField的textInputAction设成TextInputAction.next,键盘右下角会变成“下一步”按钮,用户点击后焦点自动跳到下一个输入框,这在表单页能明显减少键盘来回弹出收起的干扰。
4.2 焦点切换与失焦校验的时序问题
表单里如果有多个输入框,焦点的切换顺序和校验时机是需要协调的。Flutter中焦点的默认行为是:用户点击哪个输入框,焦点就跳到哪个。但如果是通过“下一步”按钮跳转,我们需要显式地控制下一个焦点。
final _noteFocusNode = FocusNode(); TextField( controller: _noteController, focusNode: _noteFocusNode, textInputAction: TextInputAction.done, maxLines: 3, maxLength: 100, onSubmitted: (_) { _noteFocusNode.unfocus(); _submit(); }, )这里有个失焦校验的坑:TextFormField的validator默认在validate()时统一触发,不会因为失焦就自己校验。如果你希望“用户一离开这个输入框就校验”,需要把autovalidateMode设为AutovalidateMode.onUserInteraction,但那样又会变成“每输入一个字符就校验”,比较打扰。我的中间方案是:提交前统一校验,校验失败后切到自动校验模式,这个在前面2.3节已经说过了。
FocusNode同样要在dispose里释放。多行备注这个输入框我单独建了一个FocusNode,是为了在键盘的“完成”按钮点击时主动收起键盘并触发提交。如果只有一个输入框需要这样处理,单独建FocusNode是最清晰的;如果表单里多个输入框要连续切换,建议用一个FocusNode列表来管理。
4.3 中文输入法与多行文本的显示差异
中文输入法是OpenHarmony设备上绕不开的痛点,尤其对多行文本输入框。之前遇到过两个问题。
第一个是候选词遮挡。输入备注时,中文输入法的候选词条会悬浮在软键盘上方,如果输入框刚好在屏幕中下部,候选词条可能把输入框本身盖住。这个很难从Flutter侧完全控制,因为候选词条是输入法应用自己渲染的,但可以缓解:把多行输入框放在页面上方区域,或者保证页面支持滚动,让用户能手动把输入框滚到不被遮挡的位置。
第二个是字符统计的差异。TextField的maxLength统计的是Dart字符串的length属性,对中文来说基本一个汉字算一个字符,但某些特殊字符比如emoji会被按两个字符算。如果用户输入了emoji表情,可能还没到100个字就提示超长了。因为组队备注本来就是短文本,这个影响不大,我查了下确实存在这个现象,后面如果要做严格计数,得用characters包来按用户感知的字素簇统计,这里先不做过多纠结。
5. 提交链路的最后一公里:数据模型、校验与防重复提交
5.1 用不可变数据模型承载表单数据
表单控件负责收集数据,提交之前需要把这些散落在各个controller里的数据组装成一个请求对象。我习惯为创建接口单独定义一个请求模型类,而不是直接用Map传参,这样有类型检查,字段名也更安全。
class CreateTeamRequest { final String scriptId; final String scriptName; final int minPlayers; final int maxPlayers; final DateTime startTime; final List<String> roleTags; final String location; final String note; const CreateTeamRequest({ required this.scriptId, required this.scriptName, required this.minPlayers, required this.maxPlayers, required this.startTime, required this.roleTags, required this.location, required this.note, }); Map<String, dynamic> toJson() { return { 'scriptId': scriptId, 'minPlayers': minPlayers, 'maxPlayers': maxPlayers, 'startTime': startTime.toIso8601String(), 'roleTags': roleTags, 'location': location, 'note': note, }; } }字段全部声明为final并在构造函数里required,从源头避免“创建一个空对象,然后慢慢塞数据”这种容易漏字段的写法。startTime转成ISO8601字符串传给后端是跨端开发的标准做法,比传毫秒时间戳可读性好很多,后端解析也不容易踩时区坑。
这里我特意同时传了scriptId和scriptName。scriptId是后端用来关联剧本库真正数据的,scriptName只是为了服务端回显方便,因为服务端可能没接剧本库数据或者剧本库数据同步不及时。当然,这只是一种折中设计,如果后端能通过ID查到名称,不传scriptName也完全可以。
5.2 提交触发的完整状态流
提交不是一个“点击按钮就发请求”的简单动作,它要经历一整个状态流:校验、组装数据、请求中、成功、失败。
bool _submitting = false; Future<void> _submit() async { if (_submitting) return; if (!_formKey.currentState!.validate()) { return; } final gameTime = _startTime; if (gameTime == null || gameTime.isBefore(DateTime.now())) { _showErrorToast('请选择有效的开始时间'); return; } final request = CreateTeamRequest( scriptId: _selectedScriptId ?? '', scriptName: _scriptNameController.text.trim(), minPlayers: _minPlayers, maxPlayers: _maxPlayers, startTime: gameTime, roleTags: List.unmodifiable(_selectedRoleTags), location: _locationController.text.trim(), note: _noteController.text.trim(), ); setState(() => _submitting = true); try { final teamId = await widget.apiService.createTeam(request); if (!mounted) return; Navigator.of(context).pop(teamId); } catch (e) { if (!mounted) return; setState(() => _submitting = false); _showErrorToast('创建失败,请检查网络后重试'); } }注意流程的顺序。第一步先通过_formKey.currentState!.validate()触发所有表单字段的validator校验,这一步拦的是“空值、格式错误”这类基础问题。第二步再检查业务字段_startTime是否为空、是否为过去时间。这两个拆开来,是因为validator里存的是字段级的错误信息,而_startTime不是输入框,没有对应的validator,所以必须单独判断。
这里还隐藏了一个细节:validate()返回true不代表数据一定合法,因为某些业务规则没有绑到validator上。比如人数下限3、上限12,这个在Stepper的联动里已经保证了,但未来如果有人改成那种下滑选择器,记得要在提交时加上这道检查。
5.3 防重复提交与错误反馈
防重复提交在表单页是刚需。用户连点两下提交按钮,如果接口没有幂等处理,就会出现两个重复的组队房间,这是很尴尬的事故。
防重复的关键是_submitting这个状态标志位。在请求发出之前先判断,如果已经在提交中就return;只有请求结束并已经处理完成功或失败分支后,才把_submitting重置为false。按钮组件也要根据_submitting显示加载态,比如禁用点击、显示一个转圈指示器,从视觉上切断用户第二次点击的念头。
这里有个容易写错的地方:把_submitting置回false的时机。有的代码喜欢在成功分支里也重置,失败分支里也重置,看似没问题,但一旦网络请求超时被catch住,可能漏掉某个分支导致按钮永远卡在加载态。更稳妥的做法是像上面代码一样,只在catch分支里重置,成功分支直接pop页面离开,页面都关了,状态重置已经没有意义。
错误反馈我用的是SnackBar,弹出在页面底部。相比对话框,SnackBar不打断用户操作流,而且SnackBar支持自定义背景色和时长。如果是网络错误,文案统一提示“创建失败,请检查网络后重试”,不把后端原始错误信息直接抛给用户,那里面可能带着SQL语法或者堆栈信息,既不安全也不友好。
最后分享一个我个人的小技巧。这段提交逻辑里我用了try-catch而不是then-catch,是因为async/await的可读性更好,尤其后续如果想添加日志上报,只需要在catch分支里加一行代码就行。表单页是整个组队流程的起点,数据质量好不好,就看这一层把关严不严,所以多花点心思在提交链路上,后面做列表筛选、详情展示都会轻松很多。