Flutter for OpenHarmony 的剧本杀组队App写到第四篇,发起组队表单实现是我觉得最有嚼头的一环。前几篇铺好了工程骨架、剧本列表和局详情页,到这一篇,页面开始真正跟用户交互了:填剧本名、选开局时间、定招募人数、挂上标签、写两句备注,最后点发布。窄看是一张表单,背后其实是"数据模型怎么定、验证规则怎么组织、特殊控件怎么嵌进来、提交前怎么兜底、OpenHarmony真机上要避开哪些坑"这一整条链路。这篇就按我实做的顺序,把这张发起组队表单从零到跑通拆开讲,适合已经把Flutter基础工程跑起来、想让业务表单更扎实的开发者参考。
1. 先定数据模型再写控件:组队表单的字段拆解
1.1 一张约局表单到底要收集什么
做表单,我习惯先别碰控件,先把"一局剧本杀"这个业务对象拆干净。一个组队局最少需要的信息,按使用场景可以分成三组:
- 展示信息:剧本名称、类型标签、难度。这些是列表页和局详情页直接要渲染的。
- 约局信息:开局时间、地点/门店、招募人数上限。这三个撑着"组队"的核心逻辑,缺一不可。
- 附加信息:DM要求、备注、私局或公开。DM要求是剧本杀特有的,有人对带队DM有偏好,比如"要女生DM""要主持过10场以上";备注则留给"新手勿扰""可拼车"这类灵活内容。
三组信息的使用场景完全不同。组队发的"局"不是剧本库条目,它带有明确的时间与社交属性,所以时间、地点、人数上限是刚需;标签决定这个局会被哪些人刷到,做起来的价值比想象中大。以我的经验,字段宁可少一点,初始版本必填项别超过8个,否则用户发起一局的成本太高,流失会非常明显。
我在模拟项目X里最终定下的字段清单是这样的:
| 字段 | 控件类型 | 必填 | 校验规则 |
|---|---|---|---|
| 剧本名称 | 文本输入 | 是 | 非空,2~30字符 |
| 开局时间 | 日期+时间选择 | 是 | 必须晚于当前时间 |
| 门店/地点 | 文本输入 | 是 | 非空,最多50字符 |
| 招募人数上限 | 步进器 | 是 | 3~12人 |
| 剧本标签 | 多选标签 | 否 | 最多选3个 |
| DM备注 | 文本输入 | 否 | 最多50字符 |
| 其他备注 | 多行文本 | 否 | 最多200字符 |
这版字段跟UI、产品对过一遍,基本没返工。很多项目表单返工,不是因为控件不好看,而是因为一开始没约定好"这个局到底长什么样"。
1.2 Dart 数据模型先行,表单不会跑偏
很多新手写表单时,直接开撸TextFormField,写到一半发现要传的字段不够,又回头加,非常被动。我的做法是先定义发布请求的数据类,用Dart的不可变风格:
class CreateSessionRequest { final String scenarioName; final DateTime startTime; final String venue; final int maxPlayers; final List<String> tags; final String dmNote; final String remark; const CreateSessionRequest({ required this.scenarioName, required this.startTime, required this.venue, required this.maxPlayers, this.tags = const [], this.dmNote = '', this.remark = '', }); Map<String, dynamic> toJson() => { 'scenario_name': scenarioName, 'start_time': startTime.toIso8601String(), 'venue': venue, 'max_players': maxPlayers, 'tags': tags, 'dm_note': dmNote, 'remark': remark, }; }定义好这个DTO之后,表单里每个控件的值、类型、默认值就都清楚了。字段类型本身就是一层约束:maxPlayers是int,就不会有人往里面传字符串;startTime是DateTime,时间选择器的返回值必须转成这个类型。后面接后端时,toJson直接灌进去,不用再做一次字段映射。
这一层的另一个好处,是校验逻辑可以跟DTO配套。等下讲validator时会看到,检查的东西本质上都是在为这个DTO的安全创建兜底。没有数据契约直接堆UI,后期加字段、改类型会牵连一片。
2. Form 骨架与验证逻辑:从 TextFormField 到可复用的 validator
2.1 Form、GlobalKey、TextFormField 怎么配合
Flutter做表单,题眼在于FormState的集中管理机制。每个TextFormField在构建时会自动把自己注册到FormState内部的一个登记表里;Form的key用GlobalKey,是为了让外层代码不依赖BuildContext也能拿到FormState实例。
骨架代码长这样:
final _formKey = GlobalKey<FormState>(); Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: _scenarioController, focusNode: _scenarioFocusNode, textInputAction: TextInputAction.next, decoration: const InputDecoration( labelText: '剧本名称', hintText: '例如:年轮', prefixIcon: Icon(Icons.theater_comedy), ), validator: (value) { final text = value?.trim() ?? ''; if (text.isEmpty) { return '剧本名称不能为空'; } if (text.length < 2) { return '至少输入2个字符'; } return null; }, ), // 其他字段... ], ), )validator返回字符串表示错误信息,返回null表示通过。它在几种情况下会被调用:主动调用_formKey.currentState!.validate()时,会触发所有字段的验证;autovalidateMode设为always或onUserInteraction时,则会在输入过程中或失焦时触发。OpenHarmony上TextField的行为跟标准Flutter引擎大体一致,但字体和键盘行为有差异,这个放到第5节专门讲。
FormState内部维护的是一张"报名表":每个TextFormField都会携带自己占的格子(字段)和打分的老师(validator)去登记。你要检查全班同学是否及格,只需要对着这张表喊一声validate(),所有人当场交卷。这是我见过Flutter表单机制里最优雅的设计。
2.2 validator 的组织方式,别写了一坨
小表单把validator写在widget里没什么问题,字段一多,闭包套闭包非常难维护。我见过三种常见写法:
- 匿名闭包直接写在validator参数里。简单直观,适合规则不超过一行的场景。
- 抽成私有方法,比如
String? _validateScenarioName(String? value)。可读性好,逻辑能复用。 - 把校验规则定义成独立类或扩展函数,供多个页面共享。适合同一字段在创建、编辑、导入等多处出现的情况。
我在这个项目里推荐第二种,规则上到5条以上再考虑第三种。另一个容易被忽略的小技巧:凡是文本校验,先value?.trim()再判断长度。否则用户多打一个空格,前端校验通过了,接口那边却报空值错误,这种问题排查起来很恶心。
如果字段特别多,还可以把validator做成一个私有函数表:
Map<String, String? Function(String?)> validators = { 'scenarioName': _validateScenarioName, 'venue': _validateVenue, 'dmNote': _validateDmNote, };这样每个字段的校验规则单独一个方法,新增字段时只加一行映射,不会动主体代码。表结构在维护上比一长串if else舒服得多。
2.3 中文输入法下的一个真实坑:inputFormatter 与预组合字符
这是OpenHarmony上特别值得警惕的点。LengthLimitingTextInputFormatter(30)限制的30是"编码点数量",底层对输入的字符挨个计数。日常英文没问题,但中文输入法在拼音阶段,会先把拼音字母作为临时文本塞进编辑区,再整体替换成汉字。如果打得快一点,临时文本可能触顶上限,导致之后的汉字被拦截。用户会感觉"我明明没输入满,字怎么打不进去"。
遇到这个问题的处理方式,我的建议是输入框不用inputFormatters限长,改为在validator里检查长度,并在onChanged里做字符裁剪兜底。注意别直接用substring截取,中文可能遇到代理对分裂,Dart的characters包更安全:
onChanged: (value) { if (value.characters.length > 30) { final cut = value.characters.take(30).toString(); _scenarioController.value = TextEditingValue( text: cut, selection: TextSelection.collapsed(offset: cut.length), ); } },这个兜底对复制粘贴的超长文本同样适用。实测下来,中文拼音输入在OpenHarmony设备上没有出现再被拦截的问题。类似的坑还会出现在maxLength计数上,它也是按编码点算的,中文场景下很容易让用户困惑,所以我在正式业务表单里基本不用maxLength属性。
3. 特殊控件接入:时间、人数步进器与标签多选
3.1 showDatePicker 在 OpenHarmony 引擎上的兼容性实测
开局时间是约局表单的关键字段。Flutter自带的showDatePicker和showTimePicker在Android/iOS上表现稳定。但Flutter for OpenHarmony是社区引擎,我在实际项目里测到:日期弹窗能弹出来,布局和手势也正常,但弹窗样式会跟设备系统主题混搭,个别版本上确认按钮无响应。
我的应对策略分两层。第一层是优先用标准showDatePicker,但把参数写全,顺便把中文文案配好:
Future<void> _pickTime() async { final now = DateTime.now(); final date = await showDatePicker( context: context, initialDate: _startTime.isAfter(now) ? _startTime : now, firstDate: DateTime(now.year, now.month, now.day), lastDate: DateTime(now.year + 1, now.month, now.day), helpText: '选择开局日期', cancelText: '取消', confirmText: '确定', ); if (date == null) return; final time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_startTime), helpText: '选择开局时间', ); if (time == null) return; setState(() { _startTime = DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); }这里有个细节:要让日期选择器显示中文,MaterialApp需要配localizationsDelegates和supportedLocales,否则helpText、confirmText这些参数即使配了中文也不生效,按钮文本还是英文。这是很多移植项目到OpenHarmony后在日期选择器上翻车的第一原因。
第二层是如果发现弹窗在目标设备上依然有问题,就降级成自定义底部弹层:用showModalBottomSheet放滚轮选择器或日历GridView,完全不依赖系统弹窗。我在一款低配开发板上就是走了这条兜底路线,因为那个版本的OpenHarmony Flutter引擎对showTimePicker的渲染存在已知问题。判断标准很简单:真机上点一次没响应就换方案,不要跟框架死磕。
3.2 人数步进器:边界控制比控件外观更重要
人数上限我用了步进器而不是输入框,因为这步是"从3到12"的离散选择,步进器最直观。减号按钮要处理边界状态,避免用户无脑连点导致数值溢出:
int _maxPlayers = 6; void _changePlayerCount(int delta) { setState(() { _maxPlayers = (_maxPlayers + delta).clamp(3, 12); }); } Row( children: [ IconButton( icon: const Icon(Icons.remove_circle_outline), onPressed: _maxPlayers <= 3 ? null : () => _changePlayerCount(-1), ), Text( '$_maxPlayers 人', style: Theme.of(context).textTheme.titleMedium, ), IconButton( icon: const Icon(Icons.add_circle_outline), onPressed: _maxPlayers >= 12 ? null : () => _changePlayerCount(1), ), ], )用clamp直接限制边界,比在加减回调里各写一个if判断更不容易出错。Flutter里onPressed为null时,按钮的ink响应和视觉会同步变灰,这是很多初学者不知道的特性——不用去手动改颜色。
要注意,步进器数值属于表单的一部分,但FormState只管理TextFormField,步进器不会自动参与validate()。所以提交时要把_maxPlayers单拎出来校验一次,放进一个统一的校验方法里。这类"游离于Form之外的控件"在复杂表单里很多,比如开关、滑块、自定义选择器,大家记得专门为它们补一次状态校验。
3.3 FilterChip 标签多选:数量上限比看起来重要
剧本标签我用Wrap加一排FilterChip。标签是剧本杀局比较典型的社交筛选维度:推理、情感、恐怖、阵营、机制、欢乐。多选状态用一个Set维护:
final Set<String> _selectedTags = {}; Widget _buildTagSection() { return Wrap( spacing: 8, runSpacing: 8, children: _tagOptions.map((tag) { final isSelected = _selectedTags.contains(tag); final isFull = _selectedTags.length >= 3 && !isSelected; return FilterChip( label: Text(tag), selected: isSelected, onSelected: isFull ? null : (selected) { setState(() { if (selected) { _selectedTags.add(tag); } else { _selectedTags.remove(tag); } }); }, ); }).toList(), ); }标签是"非必填但有上限"的选项。当已选3个时,其余未选中chip的onSelected要置null,视觉上置灰,用户能直观知道"不能加了"。如果只是弹个Toast提示"最多选3个",体验会差一截。这个细节做到后,反馈即时且无打扰。
有些产品还会做"允许自定义标签",那就是在Wrap末尾加一个带加号的InputChip,点击后弹输入框新增标签。这个扩展不复杂,但要注意去重和总数上限,否则标签库会被用户玩成欢乐的海洋。我这个版本先不做,数据量大了再考虑。
4. 提交前的最后一道关:全量校验与数据组装
4.1 validate() 的触发时机与 autovalidateMode 的选择
表单有一个值得斟酌的问题:什么时候校验?我最初把autovalidateMode设成AutovalidateMode.always,结果用户还没开始填,满屏红色错误就蹦出来了,非常劝退。后来统一改成默认的disabled(不自动校验),只在用户点提交按钮时全量验证;如果已经提交过一次且失败,再切换成AutovalidateMode.onUserInteraction,用户修改字段时对应报错会即时消失,体验比较自然。
提交按钮的回调:
void _submit() { if (_isSubmitting) return; bool pass = _formKey.currentState?.validate() ?? false; pass = _validateSummary() && pass; if (!pass) { _focusFirstError(); return; } _doPublish(); }_validateSummary()负责校验FormState管不到的那些字段,比如时间、人数:
bool _validateSummary() { if (_startTime.isBefore(DateTime.now())) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('开局时间需要晚于当前时间')), ); return false; } if (_maxPlayers < 3) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('招募人数至少3人')), ); return false; } return true; }把非TextField字段放进这个方法,与FormState的validate()并联,所有问题一目了然。这里我还加了一个_isSubmitting状态,用于防重复提交。用户手快连点两次发布按钮,会发出两条重复的组队局,这在真实项目里是事故级别的问题。
4.2 校验失败后,怎么让用户看到第一个错误
validate()返回false时,报错文本会显示在对应输入框下方,但页面如果很长,用户可能根本看不见最上面的错误。要主动把滚动视图和焦点挪到第一个错误项上。
一是给每个文本字段配FocusNode:
final _scenarioFocusNode = FocusNode(); final _venueFocusNode = FocusNode(); final _dmFocusNode = FocusNode();然后写一个按业务顺序返回第一个未通过字段焦点的方法:
void _focusFirstError() { final checks = [ (_scenarioFocusNode, _scenarioController.text.trim().isEmpty), (_venueFocusNode, _venueController.text.trim().isEmpty), ]; for (final item in checks) { if (item.$2) { FocusScope.of(context).requestFocus(item.$1); return; } } }这个方法不用太复杂,能定位到第一个空字段即可。聚焦之后,配合Scrollable.ensureVisible不一定每次都生效,因为输入框聚焦后键盘弹起本身会触发滚动。更稳妥的做法是:当所有TextFormField都包在ListView里时,先Future.delayed(Duration(milliseconds: 100)),再滚动到聚焦节点的context。实测这个时序在OpenHarmony上更稳,直接滚有时会被键盘动画打断。
4.3 组装请求对象与提交反馈
校验通过之后,代码就清爽了,从controller和状态变量里取值,构造DTO:
void _doPublish() { setState(() { _isSubmitting = true; }); final request = CreateSessionRequest( scenarioName: _scenarioController.text.trim(), startTime: _startTime, venue: _venueController.text.trim(), maxPlayers: _maxPlayers, tags: _selectedTags.toList(), dmNote: _dmNoteController.text.trim(), remark: _remarkController.text.trim(), ); // 后端接口就绪前,先打日志走通闭环 debugPrint('publish request: ${request.toJson()}'); Future.delayed(const Duration(seconds: 1), () { if (!mounted) return; setState(() { _isSubmitting = false; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('组队局已创建')), ); Navigator.of(context).pop(true); }); }后端没就绪之前,先打日志、弹SnackBar、返回上一页并传回true——这个闭环把表单模块和外部依赖解耦了,后续接接口只需替换中间那一行。返回的true还可以让列表页知道"有新局了,要不要刷新",这是个很实用的约定。如果你更讲究,发布按钮在_isSubmitting期间可以换成一个小号CircularProgressIndicator,同时禁用按钮,给用户明确的等待反馈。
5. OpenHarmony 真机适配笔记:键盘遮挡、字体与滚动容器
5.1 软键盘弹起把表单顶没了的处理
表单页面最常见的问题就是键盘遮挡。标准Flutter里Scaffold默认resizeToAvoidBottomInset: true,键盘弹起后body高度会被压扁,配合ListView就能滚动到可见区域。但OpenHarmony的Flutter for OpenHarmony引擎在某些版本上默认表现不一样——我在A同学那台开发板上实测,键盘弹起后页面会整体上移但高度不收缩,底部的提交按钮被顶到屏幕外,而且怎么划都划不回来。
处理方案是显式声明,别依赖默认值:
Scaffold( resizeToAvoidBottomInset: true, body: SafeArea( child: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), children: [ /* 表单字段 */ ], ), ), )同时给ListView底部留出120左右的padding,给键盘和按钮之间留足空间。这个120看起来是"经验值",实际对应的是键盘高度和按钮高度的和,在不同真机上可以微调。还有一个进阶技巧:监听MediaQuery.of(context).viewInsets.bottom,动态给底部的ButtonBar加padding,这样键盘弹起时按钮会浮在键盘正上方,效果最舒服:
final bottomInset = MediaQuery.of(context).viewInsets.bottom; bottomNavigationBar: Padding( padding: EdgeInsets.only(bottom: bottomInset), child: _buildPublishBar(), ),这个方法比固定padding通用,OpenHarmony横竖屏切换时也能自适应。我建议表单页面优先用这个方案。
5.2 中文字体与 Material 主题的差异
OpenHarmony设备默认中文字体是系统自带的HarmonyOS Sans,Flutter对全局字体的fallback一般会走系统字体链,英文、数字显示正常。但部分低版本OpenHarmony的渲染引擎对中文加权粗体支持不完整,具体表现是:TextStyle里fontWeight: FontWeight.w600或w700的中文,在某些开发板上渲染出来跟普通体几乎没差别,标题不够醒目。
我的解决方式是在theme里统一指定字体的fallback链:
theme: ThemeData( fontFamilyFallback: ['HarmonyOS Sans', 'Noto Sans SC', 'sans-serif'], // ... ),如果你用的是自定义字体资源,一定要把ttf打进去并用FontLoader注册,不能只依赖系统字体,否则发布到不同OpenHarmony设备上,字体表现可能不一致。这属于典型"本地好好的,装机就翻车"的场景——我们项目里就栽过一次,某款开发板中文字体回退到一个很老的衬线字体,整个界面显得很陈旧。
5.3 构建hap包与真机运行的一点点提示
Flutter for OpenHarmony的工程构建产物是.hap安装包。日常工作流是在flutter工程里,通过OpenHarmony平台插件提供的构建命令产出hap,再通过设备调试工具安装到开发板上。有几个匹配点值得注意:
- Flutter SDK与OpenHarmony工具链版本要配套。社区引擎更新快,版本错位最常见的问题是运行时崩溃找不到符号,或者布局引擎行为不一致。
- 真机调试确认设备在线后再执行run命令,启动阶段尽量别拔线。
- 状态栏和安全区:OpenHarmony设备的刘海/挖孔区域高度跟Android并不完全一致,页面顶部一定要用SafeArea包一层,否则表单标题会被状态栏吃掉。
我在这个模块里的实际体会是,OpenHarmony上跑Flutter表单类界面,真正花时间的不是Dart业务代码,而是平台适配的收尾工作。表单骨架、校验、提交这些逻辑一次写好能反复用,真机上的键盘、字体、弹窗问题才是需要逐个验证的坑。把这一套跑通之后,发起组队这个入口才算真正立起来了,后面再往上加网络层和实时组队状态,就有了稳定的地基。