
说句实话“发起组队”这个页面是整个剧本杀组队App里我花心思最多的地方。不是因为它用到了多高深的技术而是因为它处在产品路径上最关键的转折点——用户从“看看有什么局”变成“我要开一局”一切转化都压在这个表单上。前几篇把工程骨架、UX通用组件和列表页搭完之后这篇就专门拆解这个表单从需求分析到代码落地再到OpenHarmony分支适配的全过程。如果你是正在用Flutter做跨端业务、又想往开源鸿蒙上落地的开发者这篇文章里涉及的选型思路和踩坑记录应该能帮你少走不少弯路。1. 先想清楚再动手发起组队表单的字段设计与交互规划1.1 从用户视角倒推字段而不是想到什么填什么很多开发拿到“做个发起组队表单”这个需求第一反应是罗列字段剧本名、城市、门店、时间、人数、性别比例、剧本类型、难度、时长、是否允许新手、备注……列完发现三屏都放不下。这种表单用户看到就想退出尤其是手机端。我当时的做法是先问一个问题用户发起一局剧本杀最少的有效信息是什么答案是四件事玩什么本、几个人、什么时候、去哪打。对应的字段就是剧本名称、人数、开本时间、地点/车店。其他信息比如剧本类型、难度、备注都属于“有了更好但不是必须”的字段可以放进来但必须保证不增加用户的思考负担。最后我敲定的字段结构是这样的字段控件类型是否必填说明剧本名称TextFormField是支持手动输入后续可扩展为搜索选择剧本类型DropdownButtonFormField否阵营/硬核/情感/恐怖/机制/其他人数自定义Stepper是默认4人范围1-12开本时间日期时间选择器是默认今天2小时后地点/车店TextFormField是可填写店名或线上房间号一句话描述TextFormField否最多50字这里有个产品层面的判断联系方式、拼车群号这类隐私字段我没有放进表单而是在提交成功后引导用户在“队伍详情页”再补充。原因是表单每多一个字段用户放弃的概率就会明显上升尤其是隐私相关字段最容易劝退。详情页的容错率高得多用户可以慢慢填。1.2 页面层级与回填逻辑决定表单代码怎么组织在动手写代码之前还要把表单页在整个App里的位置理清楚。这个表单页至少有两个入口从首页/发现页的“发起组队”按钮进入此时表单为空提交后返回列表页列表需刷新出新条目。从已有队伍的详情页点“再来一局”此时表单应该把上一局的部分信息剧本名、类型、地点自动带出用户改一下时间人数就能快速发起。这两个入口意味着表单页面不能是“一次性”的它得支持种子数据回填。我在代码里定义了这样一个页面参数class CreateGroupPage extends StatefulWidget { final GroupSeedData? seedData; const CreateGroupPage({super.key, this.seedData}); }GroupSeedData是一个轻量模型只包含可以回填的字段不包含状态类数据。这样设计的好处是页面本身的逻辑保持单一不管从哪个入口进来都是“接收seedData初始化表单提交时回调结果”数据从哪来的问题被完全隔离在路由层。2. 控件选型能组合出符合直觉的输入体验的几个方案2.1 Form GlobalKey 的组合是Flutter表单的基底Flutter里面表单控件不少但大多数复杂表单的基底都是这套组合Form作为容器TextFormField作为输入控件GlobalKeyFormState作为验证和提交的控制器。class _CreateGroupPageState extends StateCreateGroupPage { final _formKey GlobalKeyFormState(); final _nameController TextEditingController(); final _locationController TextEditingController(); final _descController TextEditingController(); override void dispose() { _nameController.dispose(); _locationController.dispose(); _descController.dispose(); super.dispose(); } }这里有一个容易被新手忽略的点TextEditingController必须在dispose里释放否则会有内存泄漏提示在OpenHarmony分支上甚至会导致页面频繁进出后出现输入卡顿。我一开始写了三四个输入框图省事直接在build里TextEditingController()结果来回跳了几次页面之后输入明显变慢排查了半天才找到是这个原因。Form的验证逻辑是通过_formKey.currentState!.validate()触发的它会遍历所有TextFormField的validator返回整体是否通过。这个机制的好处是校验逻辑被收敛在每个控件内部新增字段只需要加控件和validator提交代码不用动。2.2 三大特殊控件剧本类型、人数加减、日期时间剧本类型我用了DropdownButtonFormFieldString。它在Material组件里是下拉单选的标准实现有默认的边框和对齐方式和TextFormField放在一起视觉上是一致的。不过后面会说到这个控件在OpenHarmony分支上有一个弹层样式的小坑这里先按下不表。人数加减我没有用Material自带的Stepper而是自己写了一个组合控件左侧减号、中间数字、右侧加号外面包一层FormFieldint来接入表单验证。FormFieldint( initialValue: _playerCount, validator: (value) { if (value null || value 1 || value 12) { return 人数需要在1到12人之间; } return null; }, builder: (field) { return Row( children: [ IconButton( onPressed: field.value! 1 ? () { field.didChange(field.value! - 1); setState(() _playerCount field.value! - 1); } : null, icon: const Icon(Icons.remove_circle_outline), ), Text(${field.value}人), IconButton( onPressed: field.value! 12 ? () { field.didChange(field.value! 1); setState(() _playerCount field.value! 1); } : null, icon: const Icon(Icons.add_circle_outline), ), ], ); }, )不用原生Stepper的原因有三点一是原生Stepper横屏布局太占空间在一行里塞下“人数”标签和步进器会很挤二是它的可定制性比较差点击范围小、视觉偏重跟这个App的轻量风格不搭三是FormField的封装方式能直接复用表单验证通道提交时统一判断不用单独写一个人数越界的逻辑。日期时间选择器用的是showDatePicker和showTimePicker。在OpenHarmony分支上这两个组件的交互表现和标准Flutter基本一致但有几个细节需要适配详见第4章。2.3 一个容易被忽略的选择键盘类型与输入过滤器表单体验好坏往往不在控件样式上而在键盘弹出的那一瞬间。用户真正接触的是键盘类型不是界面设计。剧本名称是文本输入textInputAction我设成了TextInputAction.next这样键盘右下角是“下一项”点击直接跳到地点输入框减少一次点击。地点输入同理。一句话描述是多行文本maxLines设成3textInputAction不再有意义因为软键盘换行键本来就是换行。这里要特别注意的是键盘弹出后会遮挡表单下方的输入框。标准做法是在Scaffold里加resizeToAvoidBottomInset: true默认就是true配合ListView滚动。但在OpenHarmony分支上个别版本的默认滚动行为有差异我会在第6章专门记录这个坑。3. 让用户一次就填对校验规则的设计思路与落地3.1 校验规则不是越多越好聚焦四个必填和一个拦路虎表单校验最忌讳的是“什么都想拦”结果用户填了五次都提交不上去然后直接放弃。我制定校验规则的原则是优先保证数据完整性其次才是格式合法性。对这四个必填字段我的规则如下字段校验规则错误提示剧本名称非空且去除首尾空格后长度≥1“请填写剧本名称”人数1-12之间的整数“人数需要在1到12人之间”开本时间不能早于当前时间“请选择晚于当前的时间”地点/车店非空“请填写集合地点或房间号”“拦路虎”是时间校验。因为开本时间用日期时间两个选择器分别设置很容易出现“选的日期是明天时间是上午9点”看起来没问题但如果用户选了“今天上午9点”这个时间其实已经过去了。所以我把时间校验放在程序员层面预判日期等于今天时时间必须晚于当前时刻至少30分钟日期为明天或更晚时不限制时间。这个逻辑放在validator里validator: (value) { if (value null) return 请选择开本时间; final now DateTime.now(); final selected value; final isToday selected.year now.year selected.month now.month selected.day now.day; if (isToday selected.difference(now).inMinutes 30) { return 开本时间需要晚于当前时间至少30分钟; } return null; }设置30分钟而不是0分钟是考虑到用户填完表单、提交、队友看到消息、响应整个过程需要时间留出缓冲能避免很多“刚发起就过期”的尴尬。3.2 校验时机autoValidateMode与提交时校验的平衡Flutter的Form有一个autovalidateMode属性它控制validator在什么时候执行。选项有三个disabled只在提交时校验、always每次build都校验、onUserInteraction用户结束编辑某个字段后校验。我的选择是一开始设为disabled即只在点击“立即发起”时整体校验一旦用户点过一次提交且校验有错误就切换为onUserInteraction。AutovalidateMode _autoValidateMode AutovalidateMode.disabled; void _submit() { if (_formKey.currentState!.validate()) { // 提交逻辑 } else { if (_autoValidateMode AutovalidateMode.disabled) { setState(() _autoValidateMode AutovalidateMode.onUserInteraction); } } }这样做的好处是第一次提交前页面很干净用户不会被“红字追着跑”提交失败后改一个字段就即时反馈一个字段不用再反复点提交才能看到哪个字段有问题。体验上非常接近原生App的表单交互而且代码代价极小。3.3 校验通过之后提交前的二次确认这一步容易被忽略。用户填完点“立即发起”表单校验全部通过这时候直接Navigator.pop把数据带回去我建议先弹一个轻量的确认对话框汇总展示这次填写的内容。原因是“发起组队”是一个发布动作发布出去就会有人看到并加入用户一旦点错撤销成本比填错高得多。Futurevoid _confirmAndSubmit() async { final confirmed await showDialogbool( context: context, builder: (ctx) AlertDialog( title: const Text(确认发起组队), content: Text(剧本$_nameController.text\n 类型$_selectedType\n 人数$_playerCount人\n 时间${_formatTime(_selectedTime)}\n 地点$_locationController.text), actions: [ TextButton( onPressed: () Navigator.pop(ctx, false), child: const Text(再想想), ), FilledButton( onPressed: () Navigator.pop(ctx, true), child: const Text(确认发起), ), ], ), ); if (confirmed true) { Navigator.pop(context, _buildResultData()); } }这个对话框的文案我调了好几版。最初按钮写的是“取消”和“确定”后来改成“再想想”和“确认发起”是通过几个朋友真机测试后确定的——纯粹的“取消”会让用户潜意识觉得“点这个按钮是不是会丢掉已填内容”而“再想想”传递的是“你还有退路但内容不会丢”。实际测试中改成“再想想”后用户误触返回的比例明显下降。4. 日期时间与人数上限特殊控件在鸿蒙落地时的适配细节4.1 时间选择器在OpenHarmony分支上的表现差异showDatePicker和showTimePicker是Material库提供的标准弹窗按理说跨平台表现应该一致。但在OpenHarmony的Flutter分支上我实测有两个差异第一日期选择器的initialDatePickerMode参数如果设为DatePickerMode.year弹窗的年份列表滚动会出现偶发卡顿尤其是在低配RK3568开发板上。这不是代码问题更像是框架内部的绘制性能问题。我的应对是默认使用DatePickerMode.day让用户先选日期、再进年份视图尽量避免直接落到年份列表模式。第二时间选择器的选择确认按钮位置与Android标准版存在差异。标准Material的时间选择器通过showTimePicker弹出一个圆形时钟界面底部有“OK”“Cancel”按钮OpenHarmony分支上按钮文字可能显示不全。我用了一个取巧的办法不直接使用showTimePicker而是自定义一个简单的CupertinoDatePicker风格的底部弹窗同时承载日期和时间的选择。FutureDateTime? _showDateTimePicker() { return showModalBottomSheetDateTime( context: context, builder: (ctx) { DateTime temp DateTime.now().add(const Duration(hours: 2)); return SizedBox( height: 300, child: Column( children: [ Padding( padding: const EdgeInsets.all(16), child: Text(选择开本时间, style: Theme.of(ctx).textTheme.titleMedium), ), Expanded( child: CupertinoDatePicker( mode: CupertinoDatePickerMode.dateAndTime, minimumDate: DateTime.now(), initialDateTime: temp, onDateTimeChanged: (value) temp value, ), ), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () Navigator.pop(ctx, null), child: const Text(取消), ), TextButton( onPressed: () Navigator.pop(ctx, temp), child: const Text(确定), ), ], ), ], ), ); }, ); }用CupertinoDatePicker替代的原因很简单它在一个滚动列表里同时解决日期和时间交互路径比“先日期弹窗再时间弹窗”少一步。而在这类强调效率的表单里少一步就是少一次流失。虽然它是iOS风格但配合深色主题和圆角卡片在鸿蒙上看起来并不违和。4.2 人数上限从哪来根据剧本类型动态变化人数这个字段看起来就是个1到12的步进器但实际产品逻辑比“固定范围”更复杂。硬核本通常6人起情感本上限可能到9人部分恐怖本4人就能开。如果把人数上限写死成12用户选了个硬核本却只能组到4个人后面还要在备注里补充“还缺2人”这就是把产品逻辑的锅甩给用户了。我的处理方案是当剧本类型选择为“硬核”时人数下限自动变为5选择“情感”或“恐怖”时人数上限自动变为9。这个逻辑封装在_getPlayerRangeByType方法里步进器的加减按钮根据当前动态范围置灰。(int, int) _getPlayerRangeByType(String? type) { switch (type) { case 硬核: return (5, 12); case 情感: case 恐怖: return (4, 9); default: return (1, 12); } }这样做表面上增加了代码量实际上减少了很多后续的沟通成本。玩家的线下组队效率提高了“差两个人”这种事后打补丁的情况也少了很多。4.3 页面弹出输入法时的滚动避让OpenHarmony上键盘弹出后的避让逻辑在大多数场景是正常的但我在测试中发现一个场景很顽固把页面放在横屏平板上时键盘弹出后即使resizeToAvoidBottomInset为true身高不足时ListView底部仍然会被遮挡。原因是OpenHarmony分支的Flutter对MediaQuery.viewInsets的更新时机比Android标准版慢小半拍如果ListView已经滚动到某个位置键盘弹出时视口收缩滚动位置来不及同步修正。我的解决思路是曲线救国给TextFormField的onTap回调里主动加一个延迟滚动onTap: () { Future.delayed(const Duration(milliseconds: 300), () { if (!mounted) return; Scrollable.ensureVisible( _locationFocusNode.context!, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); }); },这个方案不算完美但实测在RK3568开发板和手机上都能让输入框正确滚入可视区域。更复杂的ScrollController偏移量计算反而容易引入位置抖动的副作用。5. 提交数据的前后衔接结果如何带回列表页并触发刷新5.1 用Navigator.pop返回结果而不是全局状态表单页的提交流程走到最后一步所有的数据都在一个CreateGroupResult对象里。这个对象要怎么交还给列表页我见过不少项目在提交后直接Provider.ofGroupStore(context, listen: false).addGroup(...)把数据塞进全局store然后Navigator.pop。这种方式不是不行但它违背了“数据流向清晰”的原则。表单页其实不需要知道数据去了哪里它只需要“把结果交还回去”。如果以后这个表单被用在别的入口比如管理员代开组队它是不是还得依赖那个全局store所以我把提交流程设计为void _doSubmit() { final formData _buildResultData(); Navigator.pop(context, formData); }表单页不负责写库、不负责调接口、不负责刷新列表。它的唯一职责是校验用户输入组装合法数据返回给调用方。这样的页面是“干净”的可测试性、可复用性都更好。5.2 列表页如何接住新数据await返回后的刷新策略列表页通过Navigator.push启动表单页然后await它的返回结果Futurevoid _openCreatePage() async { final result await Navigator.pushCreateGroupResult( context, MaterialPageRoute(builder: (_) const CreateGroupPage()), ); if (result ! null) { _groups.insert(0, result.toGroupModel()); setState(() {}); } }这里我特意用了“插入到列表第0位”再setState而不是重新从数据源拉全量列表。因为当前阶段App还没有接入后端本地数据源就是一串内存中的模型对象重新拉取反而绕了一圈。后面接入服务端后这里会换成fetchGroups()然后整体替换但现在保持简单。另一个细节是列表页需要监听这个await的返回值但表单页可能因为中途退出而返回null。所以if (result ! null)这个判断是必须的。很多新手在这一步容易直接_groups.insert(0, result...)一旦用户在表单页按了系统返回键result就是null当场空指针崩溃。5.3 为后续接入服务端预留的数据模型组队表单的数据结构虽然现在写死在内存里但迟早要联调后端。所以我在设计CreateGroupResult时直接采用了和未来接口字段对齐的命名而不是图省事用name、time这种通用名称class CreateGroupResult { final String scriptName; final String? scriptType; final int playerCount; final DateTime startTime; final String location; final String? description; }字段名看起来比通用版长但好处是后面接接口时jsonEncode几乎不需要做字段映射直接序列化就能对应后端的入参。我在前面几个项目里吃过“表单字段和服务端字段对不上到处做map”的亏所以这次一开始就统一了命名。如果你们后端字段风格是snake_case可以在模型里加toJson()方法时再转一次但无论如何也别在UI层直接用MapString, dynamic传参那样后续维护会非常痛苦。6. 我在这个表单上踩过的几个坑OpenHarmony分支适配与输入体验6.1 焦点切换与软键盘遮挡实测中最影响体验的两件事这个页面上线到开发板实测后前三个反馈里有两个都和“键盘”有关。一个是剧本名输入完后点“下一项”焦点跳到地点输入框键盘没有跟着切换另一个是输入最后一行描述时键盘弹起来把“确认发起”按钮挡住了。焦点切换的问题根因出在TextInputAction.next配合FocusScope的使用上。标准Flutter要求你在onFieldSubmitted里主动把焦点移动到下一个FocusNode不然它只是收起键盘并不会自动跳到下一个输入框FocusNode _nameFocus FocusNode(); FocusNode _locationFocus FocusNode(); _onNameSubmitted(String value) { FocusScope.of(context).requestFocus(_locationFocus); }而键盘遮挡问题除了第4.3节提到的延迟滚动外还有一个更彻底的做法把底部提交按钮从Scaffold的bottomNavigationBar里移出来放进ListView的最后一个item。这样键盘弹出后列表可以整体向上滚动按钮永远不会被盖住。这个改动的体验提升是立竿见影的。6.2 DropdownButtonFormField在鸿蒙上的弹层问题这是我这个表单里踩得最深的一个坑。DropdownButtonFormField在标准Flutter上点击后弹出的选项列表是一个悬浮弹层默认锚定在控件下方。在OpenHarmony分支上这个弹层的overlay样式偶尔会错位——选项列表出现在屏幕左上角而不是紧贴着下拉框。排查过程比较曲折。先怀疑是自己的布局问题把页面改成最简单的Column还是错位然后怀疑是主题的inputDecorationTheme影响去掉边框依然错位最后发现是分支版本的DropdownButton在计算弹层位置时对RenderBox的全局坐标转换和标准版有差异在嵌套了SafeArea和MediaQuery的页面里尤其明显。我的绕行方案是不再使用DropdownButtonFormField改成InkWell包着一个仿下拉框的容器点击后弹showModalBottomSheet在底部弹窗里用RadioListTile列出剧本类型。InkWell( onTap: _showTypePicker, child: InputDecorator( decoration: const InputDecoration( labelText: 剧本类型, border: OutlineInputBorder(), ), child: Text(_selectedType ?? 请选择), ), )底部弹窗是自绘的showModalBottomSheet不依赖DropdownButton的弹层计算所以在鸿蒙上没有错位问题。而且从交互角度看移动端下拉选择改成底部弹窗选择手指点击路径更短用户并不觉得是降级反而觉得更顺手。这个改动让我意识到有时候不必死守标准组件平台有坑就换一个交互范式用户其实不关心你用了什么控件只关心好不好用。6.3 一个细节中文输入法下的拼音残留拦截还有一个很影响观感的细节在英文输入场景下完全发现不了中文输入法一用就暴露。场景是这样的用户在剧本名称输入框打出“漓川怪谈簿”拼音还没上屏的时候如果直接点击“立即发起”表单校验会读取到输入框的value但它既包含拼音字母、又包含部分汉字于是校验通过后提交出去的数据就是残缺的。解决方案是在提交校验前先调用FocusManager.instance.primaryFocus?.unfocus()强制输入法提交当前未上屏的拼音然后再读取controller的valuevoid _submit() { FocusManager.instance.primaryFocus?.unfocus(); Future.delayed(const Duration(milliseconds: 100), () { if (!mounted) return; if (_formKey.currentState!.validate()) { _confirmAndSubmit(); } }); }这个100毫秒的延迟很关键。直接同步调用validate拼音可能还没来得及上屏延迟100毫秒后输入法已经把候选词提交到输入框了此时再校验拿到的就是完整文本。这个经验也算是在中文输入法环境下做Flutter表单的一个通用技巧了不管是不是OpenHarmony都用得上。6.4 最后关于这套实现的整体评价到这里一个可用于真机的“发起组队”表单就算完整落地了。它没有用到任何黑科技没有引入额外的状态管理库就是FormTextFormField 几个弹窗选择器的标准组合。但真正让它跑起来的是那些围绕用户决策路径和平台差异做的细节适配——从字段取舍到校验时机从控件替换到键盘滚动没有一个环节是“照着默认样式写完就完事”的。如果你也在做类似的功能我的建议是先把字段列出来逐条问“这个字段能不能删”再把控件按“用户最少点击次数”重新选一遍最后集中精力处理键盘和弹层这两个最容易翻车的点。这套流程走下来就算不踩我踩过的坑也能稳稳交付。