1. 发起组队的表单需求与设计
做到剧本杀组队App的实战第四篇,终于要碰表单了。前面几篇我们把项目骨架搭好,首页列表能刷出店家发布的剧本局,用户点击“发起组队”之后,需要填写一整套组队信息。这个页面就是所有组队功能的数据入口,搞不好,后面所有业务逻辑都得跟着返工。
先说说我为啥把这一篇单独拎出来写。剧本杀的组队表单,看起来就是几个输入框加按钮,实际做起来相当碎:剧本类型要下拉选择,人数档位要可调整,时间要支持日期和时间双选,还可能附带“老带新”“硬核推理”这类标签。每多一个字段,校验逻辑、状态管理、错误提示都要跟着膨胀。尤其我们跑在OpenHarmony上,有些iOS和安卓那边丝滑的控件,在这里会有奇奇怪怪的适配问题。所以这篇不仅讲怎么写表单,更想讲清楚为什么这么写、哪些地方容易踩坑。
这篇内容适合两类人:第一类是刚开始用Flutter做业务页面、想照着完整抄一个表单页的开发者;第二类是把Flutter工程往OpenHarmony上迁移,卡在控件适配和状态管理上的人。我会把从数据模型到UI实现、从Provider状态管理到真机调试的完整过程都过一遍,保证你跟着敲完,能跑起来一个健壮的发组队页面。
1.1 需求拆解:剧本杀组队的典型表单字段
动手写代码前,我习惯先列字段清单。表单这种东西,最容易出现“交互设计稿不明不白,后端接口和前端字段对不上”的情况。我按照剧本杀组队App的常见场景,把“发起组队”拆成了下面这些核心字段:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| 队长昵称 | 文本 | 是 | 简单校验,2-12个字符 |
| 组队标题 | 文本 | 是 | 比如“周四晚硬核推理来4人” |
| 剧本类型 | 下拉选择 | 是 | 硬核、情感、机制、欢乐、恐怖等 |
| 人数档位 | 选择器 | 是 | 4人、6人、8人、12人 |
| 开局时间 | 日期时间 | 是 | 格式化为“MM月dd日 HH:mm” |
| 剧本店/地点 | 文本 | 是 | 方便队友知道在哪集合 |
| 车型/难度偏好 | 标签多选 | 否 | 新手友好、老带新、禁剧透、可复盘 |
| 补充描述 | 多行文本 | 否 | 组队要求、角色分配备注 |
| 联系方式 | 文本 | 是 | 微信号或手机号,压力别给太死 |
这里最有意思是“车型/难度偏好”这种多选标签。最初设计我只放了输入框,后来被玩家吐槽“不说清楚偏好在群里聊半天”。后来改成一组可多选的标签,体验好了很多,数据上也好存,后端只需要传一个逗号分隔的字符串就行。
另一个容易被忽略的字段是“开局时间”。剧本杀组队对时间精度要求很高,玩家得知道几点到店。Flutter原生日期选择器在OpenHarmony上的表现并不完全一致,这一点我在后文会展开讲,但需求阶段就要定好:是精确到分钟,还是只选日期?我最终选择日期和时间分开选,避免一个复杂弹窗在兼容层出问题。
1.2 为什么用Flutter做表单,在OpenHarmony上要怎么取舍
每次写业务页面,我都会先纠结一下:要不要引第三方表单库?说实话,网上有不少响应式表单方案,但我不建议在这个App里用。
原因很简单:第一,剧本杀组队表单的字段量不算大,Flutter自带的Form、TextFormField、DropdownButtonFormField已经能覆盖绝大多数场景;第二,我们跑在OpenHarmony上,第三方库的兼容性是个未知数,与其为了省事引一堆依赖,最后发现某个组件在鸿蒙内核上崩了再回头排查,不如直接从官方组件出发,把不可控因素降到最低;第三,表单逻辑和状态管理是我们自己业务的一部分,未来要联动地图选店、角色分配这些功能,自己掌握核心逻辑比被框架禁锢要灵活得多。
状态管理这里,我选了Provider。没选Riverpod和Bloc,不是它们不好,而是对我们这个量级的项目来说太重。Provider足够简单,而且整个App目前也就几个页面共享状态,后面就算要升级,也是局部替换,不会伤筋动骨。
在OpenHarmony平台做具体取舍时,要记住一个核心原则:能用纯Dart实现的功能,尽量不要依赖平台通道和原生插件。比如日期格式化,直接依赖intl包就好,这是纯Dart的,两边行为一致。再比如下拉选择,如果系统样式有问题,就自己用showModalBottomSheet包一个选择面板。这不是倒退,这是在多端适配下最务实的做法。
2. 表单基础搭建与校验策略
有了需求清单,下一步不是急着写UI,而是先把数据模型定下来。很多新手喜欢先在页面上拖控件,等到写提交逻辑时才发现数据字段对不上,再来回改。我的习惯是先建Model,再写Form,最后接这个页面的状态管理。这样,页面控件和提交接口中间有一层稳定的“契约”。
2.1 从Model到Form:先定义数据结构
这里我把发起组队需要用到的数据定义成一个GroupMatchRequest类,它既是表单输入输出的载体,也是未来提交给后台接口的请求体。先把代码贴出来:
class GroupMatchRequest { String captainName; String title; String scriptType; int playerCount; DateTime startTime; String location; List<String> preferences; String description; String contact; GroupMatchRequest({ this.captainName = '', this.title = '', this.scriptType = '', this.playerCount = 4, required this.startTime, this.location = '', this.preferences = const [], this.description = '', this.contact = '', }); Map<String, dynamic> toJson() { return { 'captain_name': captainName, 'title': title, 'script_type': scriptType, 'player_count': playerCount, 'start_time': startTime.millisecondsSinceEpoch, 'location': location, 'preferences': preferences, 'description': description, 'contact': contact, }; } }注意几个细节。
第一,playerCount默认值是4,这样当玩家还没做任何选择时,页面上展示的默认档位是合理的,不会出现空数据。第二,startTime我直接放DateTime对象,而不是在Model里塞一个String,因为时间在UI、业务逻辑、网络请求三个层面需要不同格式,存对象最安全。第三,preferences是个List<String>,方便前端用Wrap+FilterChip做多选标签,提交到后端时序列化成JSON数组。
这里说个经验之谈:不要为了图省事,把preferences拼成字符串存在Model里,也不要让表单页面直接依赖JSON的Map。只要有了这个清晰的数据模型,后面无论是写校验、做重置,还是调接口,都能少犯很多低级错误。
2.2 校验规则怎么写才不让人抓狂
Flutter的TextFormField自带一个validator回调,返回值为null代表校验通过,返回字符串就代表错误提示。这个设计很方便,但很多人会越写越乱。我见过一个表单页的校验函数堆了一百多行,全是if判断。其实校验规则可以拆成小组件,让每个字段的校验逻辑独立可测试。
我将常用规则抽成了一个校验工具类:
class FormValidators { static String? required(String? value, String fieldName) { if (value == null || value.trim().isEmpty) { return '$fieldName不能为空'; } return null; } static String? lengthRange(String? value, int min, int max, String fieldName) { if (value == null || value.trim().isEmpty) { return '$fieldName不能为空'; } if (value.trim().length < min || value.trim().length > max) { return '$fieldName长度需要在$min-$max个字符之间'; } return null; } static String? contact(String? value, {String? fieldName = '联系方式'}) { if (value == null || value.trim().isEmpty) { return '$fieldName不能为空'; } final v = value.trim(); final isPhone = RegExp(r'^1[3-9]\d{9}$').hasMatch(v); final isWechat = RegExp(r'^[a-zA-Z][a-zA-Z0-9_-]{5,19}$').hasMatch(v); if (!isPhone && !isWechat) { return '请输入正确的手机号或微信号'; } return null; } }为什么我要单独写一个contact校验,而不是简单判断非空?因为剧本杀组队场景里,玩家留微信的概率比留手机号大得多。微信号规则和手机号规则完全不同,用一个正则都要小心翼翼。这里我先把两种合法格式都放行了,免得玩家压根发不出组队信息。实际做产品时,如果后台需要区分手机号或微信号,可以在提交成功后再让用户绑定手机号,表单这层没必要卡得太死。
另外,别忘了给TextFormField设置autovalidateMode。我一般建议第一次先不要自动校验,等玩家点了提交、触发过一次校验后,再使用AutovalidateMode.onUserInteraction,这样后续填写的字段能实时反馈,体验会好很多。
3. 关键控件的实现细节
接下来是页面实现最重的部分:各种输入控件和选择控件的真实落地。这里我不会把每个TextField都贴一遍,重点讲几个容易出问题、又最影响体验的控件:下拉选择、日期时间选择、人数档位、多选标签。
3.1 下拉选择、日期时间、人数区间这几个控件的适配
先说下拉选择。Flutter原生有DropdownButtonFormField,在常规移动端上表现还行,但在OpenHarmony上我第一次真机测试就看到了问题:点击下拉项时,高亮背景遮不住整个选项行,而且在某些分辨率下弹层会偏移。虽然不影响核心功能,但看起来确实粗糙。
我的解决方案是用一个可复用的“底部弹出选择器”替代原生下拉。按下选择框时,通过showModalBottomSheet弹出一个滑动选择面板,里面列一个Radio列表选项。这样不仅视觉统一,而且完全绕开了原生下拉弹层在OpenHarmony上的适配风险。
Future<String?> showOptionPicker({ required BuildContext context, required String title, required List<String> options, }) { return showModalBottomSheet<String>( context: context, builder: (context) { return SafeArea( child: ListView( shrinkWrap: true, children: [ Padding( padding: const EdgeInsets.all(16), child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), ), ...options.map((option) => ListTile( title: Text(option), onTap: () => Navigator.pop(context, option), )), ], ), ); }, ); }这里要注意,showModalBottomSheet在OpenHarmony上有些版本需要包一层SafeArea,否则底部会被系统导航条遮住,尤其用全面屏手势时很明显。
再讲日期时间选择。我用的是showDatePicker和showTimePicker,它们虽然来自Material库,但我测试下来在OpenHarmony上的表现整体还行。唯一的问题是中文语言环境下的格式化。官方示例默认会把日期显示为英文日期格式,看着很不靠谱。我在这里用了intl的DateFormat来统一格式化:
Future<void> _pickStartTime(BuildContext context) async { final now = DateTime.now(); final date = await showDatePicker( context: context, initialDate: _request.startTime, firstDate: DateTime(now.year, now.month, now.day), lastDate: DateTime(now.year + 2), ); if (date == null) return; final time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_request.startTime), ); if (time == null) return; setState(() { _request.startTime = DateTime(date.year, date.month, date.day, time.hour, time.minute); }); }有人可能问,为什么firstDate是从今天开始,而不是过去的任意日期?剧本杀组队本质上约的是未来局,过去的时间没有意义。把起始日期卡在当天能天然规避一些脏数据,比如玩家手滑选了昨天。
人数档位的实现更简单,我用了一排ChoiceChip,选项写死为4、6、8、12。这个字段本来就不需要自由输入,给离散选项比Slider直观得多。代码如下:
Wrap( spacing: 8, children: [4, 6, 8, 12].map((count) { final selected = _request.playerCount == count; return ChoiceChip( label: Text('$count人'), selected: selected, onSelected: (_) => setState(() => _request.playerCount = count), ); }).toList(), )注意,在OpenHarmony真机上,ChoiceChip的点击反馈可能不如其他平台灵敏,我在onSelected里加了setState之后,立刻就能看到选中状态切换,目前没有发现实质性问题。
3.2 动态添加快捷选项:把“剧本杀经验段位”做成标签选择
接下来是最受玩家欢迎的“偏好标签”选择。这里我用FilterChip包在Wrap里,实现多选。
final preferenceOptions = ['新手友好', '老带新', '禁剧透', '可复盘', '硬核推土机', '情感沉浸']; Wrap( spacing: 8, runSpacing: 4, children: preferenceOptions.map((option) { final selected = _request.preferences.contains(option); return FilterChip( label: Text(option), selected: selected, onSelected: (isSelected) { setState(() { if (isSelected) { _request.preferences.add(option); } else { _request.preferences.remove(option); } }); }, ); }).toList(), )这里有个小坑:如果直接用_request.preferences.contains(option)来判断选中态,然后又在onSelected里直接改写原List,当setState触发Widget重建时,Wrap里的FilterChip会依赖这个List重建。逻辑上没问题,但要注意不要在原List上既读又写,否则会出现同一个标签选中状态闪烁。
保险的做法是把_request.preferences的引用在setState前先复制一份:
onSelected: (isSelected) { setState(() { final current = List<String>.from(_request.preferences); if (isSelected) { current.add(option); } else { current.remove(option); } _request.preferences = current; }); }道理和函数式编程里的不可变数据是相通的。尤其以后如果接入了状态管理库,这种原List就地修改很容易触发不可预期的UI刷新。做表单页面,时刻记着一句话:修改状态前,先复制。
4. 状态管理与提交逻辑
表单页面除了输入控件,还有一坨“业务状态”:当前请求数据、是否正在提交、校验错误是否显示、提交后的成功状态。这些如果全用setState堆在页面里,页面会越来越臃肿。这个项目本来就在用Provider,我干脆建了一个GroupMatchProvider统一管理发起组队的场景状态。
4.1 用Provider管理表单状态
先定义Provider:
class GroupMatchProvider extends ChangeNotifier { GroupMatchRequest _request = GroupMatchRequest(startTime: DateTime.now()); bool _submitting = false; GroupMatchRequest get request => _request; bool get submitting => _submitting; void updateRequest(GroupMatchRequest newRequest) { _request = newRequest; notifyListeners(); } void updateField<T>(T Function(GroupMatchRequest request) getter, void Function(GroupMatchRequest request, T value) setter, T value) { setter(_request, value); notifyListeners(); } Future<bool> submit() async { _submitting = true; notifyListeners(); try { // 模拟提交接口,真实场景在这里替换为HTTP调用 await Future.delayed(const Duration(milliseconds: 800)); return true; } catch (e) { return false; } finally { _submitting = false; notifyListeners(); } } void reset() { _request = GroupMatchRequest(startTime: DateTime.now()); _submitting = false; notifyListeners(); } }这个Provider里,我没有把所有逻辑一股脑塞进去,而是把updateField设计得比较通用。但实际在表单页面里,我大部分时间还是逐个字段更新,因为每个控件需要拿不同的值类型。比如选择器返回String,ChoiceChip返回int,直接为一个字段写一个方法会很啰嗦,用通用updateField反而能让UI层代码更清晰。
在页面里,要在build方法外层通过context.watch<GroupMatchProvider>()来读取状态,这样只要Provider里任何字段变化,整个页面就会刷新。如果页面比较大,可以在局部用context.select只监听某几个字段,避免整页重绘。我们这个表单页控件密度不高,暂时没必要钻这个性能优化,但你要知道有这个选项。
4.2 发起组队的提交流程与错误处理
提交按钮的逻辑大概是:
void _handleSubmit(BuildContext context) async { final provider = context.read<GroupMatchProvider>(); final form = _formKey.currentState; if (form == null || !form.validate()) return; final success = await provider.submit(); if (!context.mounted) return; if (success) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('组队发布成功')), ); provider.reset(); Navigator.of(context).pop(true); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('发布失败,请稍后重试')), ); } }这里有两个细节值得单独说。
第一个细节是form.validate()返回false时,Flutter会自动把每个不符合条件的TextFormField的errorText展示出来,但这只在AutovalidateMode.disabled时生效。如果前面已经设了AutovalidateMode.onUserInteraction,那么点提交时再校验一次也没问题,只是错误信息可能已经提前显示了。
第二个细节是提交期间一定要禁用按钮,并且显示一个loading状态。不然玩家连续点三四次提交,就会给后台发三四条一样的组队请求。我的做法是在FilledButton上根据submitting属性切换子组件:
FilledButton( onPressed: provider.submitting ? null : () => _handleSubmit(context), child: provider.submitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('发起组队'), )这里还要克服一个新手很容易犯的错:context在异步方法里跨越了await后继续使用,需要先判断context.mounted,否则会有内存泄漏警告,在某些Flutter版本里还直接抛异常。我在前面代码里写了if (!context.mounted) return;,这个是从Flutter 3.7之后官方强烈推荐的写法。
5. 常见问题与排查技巧实录
到这一节,我想把真机上遇到的坑和排查思路集中记录下来。这些内容一般不会写进官方文档,但在做OpenHarmony适配时却能救你半条命。
5.1 我在OpenHarmony真机上踩过的表单坑
第一个坑:键盘弹出后,表单页面的底部按钮被完全遮挡。常规的解决办法是在Scaffold外面套SingleChildScrollView,然后给最外层加一个MediaQuery.viewInsets.bottom的padding。我实现完后,在OpenHarmony模拟器上看着没问题,一上真机,发现输入框虽然能滚上来,但右下角的提交按钮还是偶尔被系统输入法面板挡住。
后来排查出原因:OpenHarmony的输入法面板在一些真机上走的是“可变大小”模式,Flutter的resizeToAvoidBottomInset无法及时获取正确的高度。我最后是手动监听键盘高度,在Scaffold的bottomNavigationBar位置放一个AnimatedPadding来适配:
AnimatedPadding( duration: const Duration(milliseconds: 150), padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: submitButton, )这个方案在Android上表现正常,在OpenHarmony上也稳住了。代价是需要在提交按钮外面包AnimatedPadding,但收益很明显。
第二个坑:showDatePicker在中文状态下,确定和取消按钮的文字显示为英文“OK”“CANCEL”。这个问题并不是OpenHarmony专有,但如果你没有在App入口配置localizationsDelegates,就会出现。解决方法是给MaterialApp加上以下这段:
localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: const [Locale('zh', 'CN')], locale: const Locale('zh', 'CN'),加上之后,日期选择器的按钮文字、默认的标题都会变成中文。不要小看这个配置,一个表单页里如果日期选择器是中英混杂的,玩家第一眼就会觉得这个应用很糙。
第三个坑:DropdownButtonFormField在OpenHarmony的某些版本上,点击时如果有FormField包裹,校验错误信息和控件本身的间距会被吃掉,甚至errorText会把按钮高度撑开,布局上下抖动。我实际测试下来,不如直接自己封装一个“选择框+底部弹层”的组件可控。这也是我在前面选择底部弹出选择器的原因,不只是为了视觉统一,更是为了绕开适配坑。
5.2 键盘遮挡、空值校验与表单重置的优化
关于键盘遮挡,我再补充一个和输入法类型有关的细节:联系方式输入框,需要设置keyboardType: TextInputType.number或者TextInputType.text?如果留手机号,最好设置TextInputType.phone,但如果是微信号,数字键盘不好输入字母。所以我在联系方式字段没有使用专门的数字键盘,而是用默认键盘,然后在校验时同时兼容手机号和微信号。打字体验上稍微差一点,但换来的是输入逻辑简单、不会出现“切大小写”的难题。
空值校验这块,我前面已经写了工具类。但还有一个容易出问题的场景:玩家在“补充描述”里填写了纯空格。虽然字段允许多行文本,但我不希望在描述里出现一堆无意义空格。所以校验器里对所有文本型字段,我都先trim()再判断长度。如果你只在提交时trim,会发现在TextFormField里显示的内容和实际提交的内容不一致,玩家会觉得困惑。所以我更建议在编辑文本时就用inputFormatters拦截首尾空格,或者至少在校验时统一处理。
表单重置这里,我不能只清空Provider里的数据。因为Form表单控件有自己的内部状态,必须同时调用_formKey.currentState?.reset(),才能把错误提示、输入内容一起清掉。只重置数据,界面上的输入文字和错误提示还在,体验很糟糕。我写了一个resetForm方法:
void resetForm() { _formKey.currentState?.reset(); context.read<GroupMatchProvider>().reset(); }这个方法用在两个地方:发布成功后清空页面;以及页面右上角提供“重新填写”按钮,方便玩家一键清空。
还有一个经验是:尽可能把表单拆成小组件。不要让_buildPrefTags、_buildDateTimePicker这种bundle方法都写在一个build里,否则代码动辄几百行,排查问题时你光滚动就烦了。我的习惯是每个字段抽一个私有方法,参数里传入控件状态值,返回一个Widget。这样键盘遮挡、阴影、边框等问题都能单独定位。
6. 写在后面的一点个人体会
做表单页是所有业务系统里最考验耐心的活儿。功能上不复杂,但适配细节极多,尤其是放在OpenHarmony这种还在快速迭代的平台上。我自己在写“发起组队”这个表单页时,最大的体会不是怎么写控件,而是怎么控制“需求蔓延”。产品经理可能随口就说“加一个地区选择”“加一个店铺搜索”,如果你在表单设计阶段没有守住边界,字段一多,校验逻辑、页面布局、状态管理都会跟着复杂。
我现在的原则是:能用默认控件解决的,绝不上第三方;能用一个枚举解决的,绝不用自由输入;能在数据模型层约束的,绝不放到UI层靠正则硬压。这套规则让整个H5页面在OpenHarmony上的适配工作少了很多无谓的返工。
另外一个很推荐的做法是:写完表单后,拿一台低端OpenHarmony真机做一次全流程走查,重点看键盘弹出、日期选择、下拉弹层这三个场景,因为它们是表单适配的重灾区。如果这三个场景不出问题,其他字段基本都不会有什么大问题。
这一篇先讲到这里。表单页面已经能完整跑通,下一步就是把它接上真实的后台接口,处理加载、回显和错误重试逻辑。等实际接完接口,我再单独写一篇把网络层和异常兜底的经验补上。