上一期把组队大厅的列表和详情页打通了,这一期我们来做整个组队流程里最关键的入口:发起组队表单。很多人觉得表单嘛,不就是几个输入框加个提交按钮,有什么好讲的。但剧本杀组队这件事,信息维度比一般社交发帖要复杂得多,人数上限、剧本类型、偏好标签、时间地点全是强关联的,表单设计得顺不顺,直接决定了玩家愿不愿意把你的组队信息发出去。再加上我们跑在OpenHarmony环境上,一些Flutter常规控件在这里的表现和Android不完全一样,这一期我把整个表单从字段设计到校验再到适配的完整过程拆开讲,适合正在做Flutter跨端应用、或者准备在OpenHarmony上做业务开发的同学参考。
1. 表单需求拆解:剧本杀组队到底要收集哪些信息
1.1 字段设计不是拍脑袋,是跟着业务流程走的
发起组队这个功能,背后对应的真实场景是:一个玩家不想当独狼,想把同城或者同好的人凑到一起,在一个确定的时间去某个剧本店打一局。那么在打开表单之前,玩家脑子里已经有一套自己的计划,表单要做的,就是把这套计划用最低的成本填进去。
我一开始列了一个很长的清单,什么是否接受跳车、是否允许带新手、房主性别偏好、语音沟通平台,列了十几个字段。后来实推了一遍流程就砍掉了好几个,原因很简单,字段越少,填写成本越低,组队发起成功率越高。最终保留下来的几项,每一项都对应一个明确的业务判断:
- 剧本名称:核心信息,玩家组队首看这个,必须填。
- 剧本类型:让路人快速判断与自己口味是否匹配,多选用标签实现。
- 剧本难度:影响队伍门槛,决定要不要在描述里做额外提示。
- 游戏时长:分档位选择,避免玩家时间对不上。
- 发起时间与场次时间:区分创建时间与实际开局时间,开局时间不能选过去。
- 所在城市与具体门店:位置是组队的核心筛选条件,城市做下拉,门店做文本输入。
- 人数上限:组队要留坑位,不是必填项也要给默认值。
- 队伍描述:用来补充说明其他信息,但不强制太长。
这个字段清单最终落到了九个组件的表单上。我在设计第一版的时候其实犯过一个错误,就是把“剧本名称”和“剧本类型”放到了一行,界面好看是好看,但在小屏设备上输入体验变差了。后来改成上下布局,每个字段占一行,换行逻辑更清楚,误触率也降下来了。
1.2 状态管理选型:这次我不上全局Store
很多用Flutter写过中型项目的同学,一说到表单就会想上Provider、Riverpod或者Bloc,把每个输入框都绑定到全局状态上。我的建议是,表单页面真没必要把状态放到全局Store里。
原因很简单,发起组队表单是一个典型的“一次性输入场景”:页面打开、填写、提交、销毁。状态只有这个页面自己用,没有跨页面的共享需求。你把它放到全局Store里,反而带来几个问题:每次输入都要走全局状态的通知链路,性能上多损耗;页面销毁后状态残留,第二次进入时需要手动重置,容易出现旧数据;代码多了一层model和provider映射,维护成本增加。
所以这个页面的状态管理,我用的是StatefulWidget加TextEditingController的经典组合,配合一个简单的表单数据模型来收集最终结果。只有极少数组件,比如标签选择数组,单独用了StatefulWidget内部状态,因为这些组件会被复用到详情页和筛选页。
表单状态管理的边界,我一般是这样判断的:数据离开页面之后还需要被其他页面读,才考虑放全局;如果只是页面内部自产自销,老老实实写在页面里就好。
2. 表单控件封装:从“能用”到“好用”差在细节
2.1 常规输入项与校验
先说三个最基础的输入组件:剧本名称、门店地址、队伍描述,对应的是单行文本、单行文本和多行文本。
剧本名称这个字段,我加了一个建议输入法改成textInputAction.next,也就是键盘右下角显示“下一步”,配合FocusNode链式传递,玩家填完剧本名直接跳到下一个输入项,而不需要点了“完成”再去点下一个框。这个细节很小,但实际使用中当表单超过六个输入项时,连续填写的顺畅感差别非常明显。
门店地址这一栏,我没有用下拉选择,因为OpenHarmony上城市数据联动门店列表的下拉组件,在后端接口没有就绪之前,先做成自由输入最为稳妥。等后续接口完成,我会把城市字段改成联动选项,门店做成搜索选择器,但当前版本文本输入完全够用。
队伍描述我用的是约束输入,maxLines给到4行,maxLength限定在100个字符。这里要注意的是Flutter的maxLength默认会在右下角显示一个计数器,样式不是很好看,我会把它关掉,自己做一个长度提示。还有一个坑是TextFormField在部分输入法下,中文输入法的组合态拼音会被计入maxLength,导致玩家还没上屏就被截断,做计数逻辑时忽略组合态才是正确做法。
2.2 时间选择、人数步进器、标签多选封装思路
这三个控件是这个表单里最定制化的部分,也是我第一次在OpenHarmony上使用时踩坑最多的部分,分开来说。
时间选择这里,我用了showDatePicker弹出日历选择开局日期,再用showTimePicker弹出时间选择。组合时要注意返回的DateTime默认格式带时区,表单模型里存的是原始DateTime对象,展示时格式化成年月日时分,提交给后端时再统一转成时间戳。这个链路里最容易出问题的点是时区偏移,尤其是通过时间选择器创建了一个本地时间对象,在转UTC时间戳时差8个小时,排了很久才发现。
人数上限我用的是Stepper步进器,最小值5,最大值10,这是基于剧本杀经典配置设计的。实际测试时我遇到了一个经典问题:Stepper自带的加号和减号按钮在OpenHarmony上点击区域偏小,连点时会跳到2步,手感不跟手。我最后没有纠结这个,直接把自定义的加减按钮传进去,外面套了一层GestureDetector,把点击热区扩大到40像素以上,这个问题就解决了。
标签多选是表单里交互最重的一个组件。剧本杀标签包括古风、硬核、机制、情感、恐怖、欢乐、还原、阵营。每个标签是一个Chip组件,选中用高亮色,未选中用灰色描边。这里用Wrap布局包起来,自动换行,不限制标签个数,但建议最多选三个。选中逻辑上,我保留了一个去重的Set集合,每点击一个标签就toggle一次,同时更新状态。唯一要记住的是一定要给每个Chip加唯一的Key,不然标签列表更新后,Flutter复用自己的组件时选中状态就会错乱。
2.3 提交按钮的加载态与防连点
表单底部就是一个大的提交按钮,文案从“发布组队”切换成“发布中”再切回“发布组队”,这是基础操作。但真正需要注意的是提交按钮在整个提交过程里必须禁用,否则玩家双击就会发出两个同样的请求,生成两条重复组队,这在数据库层面可能没有唯一约束兜底,会变成脏数据。
我在实现里没有用按钮的enabled属性来控制,因为禁用态会改变视觉透明度,闪烁一下用户体验不好。我是用一个bool值记录isSubmitting,按钮ontap里先判断这个值,如果true就直接return,同时按钮内部显示一个小圈圈转着。这套方案在Android上没问题,在OpenHarmony上我也实测过,点击事件的响应频率没有异常,不会存在连点穿透的情况。
3. 校验逻辑与提交状态协同
3.1 校验时机:是边输边报,还是提交再报
关于表单校验,业界的两个派别:一是change时实时校验,二是submit时统一校验。我自己的习惯是两段式:初次进入表单不做任何校验,玩家点击提交时做全量校验,校验失败的字段标记为错误状态;提交过一次之后,再切换输入项时才对单个字段做实时校验,清掉旧的错误提示。
这么做的原因是,第一次进入就实时校验会非常烦人,玩家刚打了剧本名的一个字就弹“不能为空”,体验像被监控一样。但提交过后,整个表单进入了“用户已经尝试过提交”的状态,这时候实时反馈是帮忙,能让玩家立刻看到错误在消失。
Flutter里实现两段式校验,用GlobalKey加FormState的validate方法是最直接的方式。关键点是每个TextFormField的validator只做单字段校验,不要把跨字段的逻辑写进去。像“选择了硬核标签,队伍描述必须写清楚难度要求”这种联动校验,我会放到Form的validate回调外层做,而不是写在某个validator里。
3.2 整体校验通过后的数据组装与服务层调用
校验通过,接下来就是把表单数据组装成请求模型。我专门建了一个TeamCreationRequest模型类,字段对应前面说的九项,用fromFormData方法把表单状态转成不可变对象。这么设计的好处是,测试时可以直接构造不同的TeamCreationRequest来测服务层,不需要再去填一遍表单。
组装完成后,调服务层的createTeam方法。这个方法是异步的,返回值为成功或失败两种结果。这里要注意的是发起组队的接口和普通接口不太一样,组队创建成功之后,服务端一般会返回一个队伍ID,前端要用这个ID做两件事:第一,把当前页面替换成组队详情页,而不是压栈一个新页面,不然返回键会回到一个已经提交过的表单;第二,emit一个事件通知大厅列表页刷新数据,不然玩家看到的新队伍要重新下拉才出现。
3.3 提交失败的表单回滚处理
服务端接口、网络、超时这些环节,总会有失败的情况。提交失败时,最愚蠢的做法是把表单清空了让玩家重填。正确的做法是保留所有已填数据,只把按钮状态复位、错误提示用一个SnackBar展示出来。玩家只需要点击重试即可。
这个逻辑说起来简单,但我在实际开发里见过很多版本都做错了,原因在于Controller的生命周期管理太粗暴。玩家填到一半,网络断了,提交抛异常,此时如果controller被dispose了,第二次提交时TextFormField虽然在界面上显示正常,内部绑定的controller已经失效了,一调用text属性就报错。
我的处理方案是Controller只创建不销毁,页面生命周期内复用同一套Controller,只有页面销毁时才统一释放。页面消失重建时,再重新绑定表单数据。这些代码要做好注释,不然下次维护的人很容易在页面生命周期回调里把Controller给dispose掉。
4. OpenHarmony适配实录:表单之外的坑才最花时间
4.1 日期选择器在OpenHarmony上的适配
这个适配问题我调试了一整个下午,最后定位到是Flutter的showDatePicker在OpenHarmony上的默认文案资源缺失,弹出的选择器直接崩溃或者显示空白。当时我还以为是代码写错了,把showDatePicker的参数每行都核对了一遍,最后还是通过替换日期选择器的locale参数解决的。
具体来说,我在调用showDatePicker的时候,强制指定了localizationsDelegates和supportedLocales,这样Flutter的日期选择器就不再依赖系统默认的locale资源,而是直接用应用内置的本地化资源。这个问题在Android上不会出现,因为Android系统集成了完整的当地语言资源,但OpenHarmony的Flutter适配版本目前还没有把date picker相关的system locale完全填充好。
时间选择器showTimePicker也有类似问题,弹出的面板上“确定”和“取消”按钮文案可能显示为英文,这是因为MaterialLocalizations没有正确加载中文语言包。我的做法是在MaterialApp上配置locale为zh_CN,并把flutter_localizations的delegates全部加上。这样全局都能拿到中文本地化资源,其他控件遇到类似问题也能一并解决。
4.2 输入键盘弹起遮挡输入框
表单页在手机上最容易出问题的场景就是键盘弹起后,底部按钮和输入框被遮挡。Android上Flutter的默认行为是resizeToAvoidBottomInset为true,页面会自动缩上去。但在OpenHarmony上,我实测发现这个属性部分生效:输入框可以自动滚动到可见区域,但左下角的发布按钮会被键盘完全盖住。
排查了一阵子,发现根因是OpenHarmony的窗口键盘模式在一定版本下没有按预期的“调整大小”模式工作。我的解决办法是绕开Flutter自动resize,改用一个滚动视图包住整个表单,然后在Scaffold的bottomNavigationBar位置放提交按钮。配合一个键盘高度监听器,在键盘弹出时给底部导航栏做一个等于键盘高度的padding,这样按钮就直接顶到键盘上方,不管什么平台都不会被遮住。
这个方案虽然比默认写法多几十行代码,但它有一个额外的好处:键盘弹出收起时按钮的动画过渡更顺滑,不会因为页面整体resize导致Tab页面抖一下。实测下来,真机键盘弹出速度比较快时,这个方案没有明显卡顿。
4.3 真机与模拟器的字体渲染差异
表单里我用了TextFormField的labelText和errorText,在模拟器上显示完全正常,但第一次上到真机上的时候,发现中文字体在部分小字号情况下渲染偏细,笔画有点发虚,尤其红色字号的errorText,在真机上观看时边缘不够锐利。排查下来是字体族渲染差异,即OpenHarmony下的Flutter对默认字体的fallback机制和Android不同。
我的处理方式很粗暴:页面根节点指定fontFamily为系统默认中文字体,同时给TextStyle的fontWeight提升一档,从w400调到w500,让文字渲染更饱满一些。这套改动生效后,错误提示和标签文字在真机上的可读性好了很多。这个问题的实质是字体渲染引擎差异,后续如果OpenHarmony的Flutter适配版本更新了字体fallback机制,可以再去掉这个强制的字体设置。
5. 常见问题排查与避坑清单
5.1 表单相关高频问题速查
开发这个页面时,我把遇到过的所有问题整理成了表格,方便大家对照排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 日期选择器弹出空白或崩溃 | 本地化资源缺失 | 配置MaterialApp的localizationsDelegates并指定locale |
| 时间选择器按钮显示英文 | 中文本地化未加载 | 引入flutter_localizations并设置为zh_CN |
| 键盘弹起时按钮被遮挡 | OpenHarmony窗口模式未自动resize | 底部按钮监听键盘高度做padding |
| 标签选中状态错乱 | Chip未设置唯一Key | 每个标签设置ValueKey |
| 连点提交生成重复队伍 | 未禁用提交按钮 | 用isSubmitting变量拦截重复点击 |
| 表单提交失败后二次点击报错 | Controller被提前dispose | 页面销毁前不释放Controller |
| 中文输入法拼音计入字数 | maxLength在组合阶段统计 | 自定义字数统计忽略组合输入态 |
| 时间选择后提交时间差8小时 | 本地时间转UTC偏移 | 明确转换时区,不使用隐含默认时区 |
5.2 我踩过最深的三个坑
第一个坑是Form的autovalidateMode配置。我一开始设置成AutovalidateMode.disabled,后来改成onUserInteraction,结果发现每次输入时都触发整个表单的validator列表,页面卡了一下。我排查完才意识到,onUserInteraction本意是好的,但如果每个validator都做了正则匹配或网络请求,就会频繁执行。最终方案还是回到自定义的两段式校验,只在提交时全量校验,提交过一次后实时校验单字段,性能和体验都兼顾到了。
第二个坑是日期选择器返回的值不可变性。Flutter的DateTime对象本身是不可变的,但如果复用了上次选择的DateTime实例作为initialDate,当玩家修改了时间后再次打开选择器,你不小心就会把修改过的实例继续传给下一次的initialDate,导致第二次打开时默认值跑到昨天去了。这个逻辑一定要在页面状态里保存“表单填写的场次时间”和“选择器的初始时间”两个变量,不能混用。
第三个坑是自定义标签组件之前我还试着用ListView来代替Wrap,每个标签放进一条横线。结果横向空间浪费特别严重,而且标签数量一多,表单的滚动长度直接翻倍,观感很差。换成Wrap后,整个标签区域只占三行左右,表单高度缩短了差不多一半。这套布局策略后来也应用到了详情页和筛选页,组件复用性很高。
表单这块功能做到这里,核心的发起流程已经跑通了:从填写到校验,到组装数据调服务层,再到创建成功跳转详情页,期间把OpenHarmony上的几个平台差异也全部摸透了。下一期的内容,我打算把详情页里面的加入组队和退出组队流程讲透,尤其是并发情况下坑位的扣减和恢复逻辑,那块的并发问题比表单有意思得多。