做 Flutter for OpenHarmony 的实战项目最怕的不是环境有多折腾而是页面做了一半发现某个控件在鸿蒙适配版上跑不起来。剧本杀组队App到这个阶段首页列表和详情页已经有了接下来要让用户真正把一局剧本杀发起起来核心就是发起组队表单。这篇实战04我会把发起组队这块完整拆开从字段怎么定、校验怎么写、时间地点怎么选到提交后怎么跳转逐步讲明白。老规矩先把整条链路理清楚用户进入“发起组队”页面填写标题、选剧本类型、挑人数和开始时间、补充地点和备注点提交之后走一次接口校验成功就跳到新生成的组队详情页。表单页面本身不复杂但它承担了从“草稿”到“可提交数据”再到“后端模型”的转变任何一个字段的边界条件没想清楚后面联调都会被打回来重改。1. 整体思路与表单设计1.1 先别急着写UI把字段边界定下来很多同学做表单的习惯是一上来就摆TextFormField这个毛病我一开始也有。等到联调接口的时候才发现标题没做长度限制、玩家人数和实际剧本规则对不上、时间传成了本地字符串没转UTC。所以在写发起组队表单之前我先把字段和校验规则列成了一张表。我这边定的字段包含下面这些字段类型是否必填约束条件组队标题String是去掉首尾空格后长度 4~20不能全空剧本类型String是从预设列表选择不能自由输入需要人数int是2~12 人默认值给 6开始时间DateTime是必须晚于当前时间地点String是去掉首尾空格后至少 2 个字符标签ListString否最多选 3 个方便筛选备注String否最多 200 字把这张表定下来之后后面的 UI 和校验逻辑基本就是照着表翻译。剧本杀组队跟普通拼车不一样人数是有场景语义的6 人本是经典配置但有些恐怖本需要 7 到 8 人硬核本也可能两人开。所以我不让用户手输数字而是用一个下拉框约束在 2~12 之间减少脏数据。再有一个关键点表单页面是典型的一次性录入场景我选择用一个专门的TeamDraft模型来承载用户输入而不是让后端接口直接接收一堆散落的 controller 值。这样提交逻辑写起来干净后面如果要加“草稿箱”或者“编辑组队”复用起来也方便。1.2 表单交互节奏要照顾真实输入场景发起组队这个页面真正会让用户感到烦躁的往往是校验时机。用户还没填完就满屏飘红体验很糟。我的做法是把autovalidateMode设置为AutovalidateMode.disabled等用户点提交时才触发整表校验。用户填完一个字段后如果确有错误再通过TextFormField的validator显示单条错误。这种交互节奏更适合“发起组队”这种偏长表单的场景用户需要先把想法组织好再来处理错误。反而是那种只填一两个信息的小弹窗才适合边输入边校验。还有一个人机交互的小细节时间选择不是放在表单里让用户手输而是用一个只读输入框点击后弹出系统日期选择器和时间选择器。这能避免用户被键盘和日期格式折腾也更符合移动端习惯。2. 数据模型和状态管理2.1 定义一个轻量的 TeamDraft 草稿模型写表单之前先把TeamDraft定义好。它负责把页面的临时输入变成结构化的业务对象。class TeamDraft { TeamDraft({ required this.title, required this.scriptType, required this.playerCount, required this.startTime, required this.place, this.tags const [], this.note , }); final String title; final String scriptType; final int playerCount; final DateTime startTime; final String place; final ListString tags; final String note; MapString, Object toJson() { return { title: title.trim(), scriptType: scriptType, playerCount: playerCount, startTime: startTime.toIso8601String(), place: place.trim(), tags: tags, note: note.trim(), }; } }这里有个小讲究startTime在发送给接口时用toIso8601String()而不是手动格式化成yyyy-MM-dd HH:mm:ss。因为 ISO 8601 自带时区信息后端解析不容易因为时区不一致而搞出“提前一小时”的坑。Flutter 的DateTime本身不带时区概念用 ISO 格式至少能把语义传清楚。toJson里我还做了trim。标题和地点如果在最后提交时才处理是有风险的。你永远不知道用户会不会在标题前后多敲一个空格尤其移动端输入法偶尔会自动补空格。把清洗动作放在模型层比放在验证器里更统一。2.2 状态管理够用就行不必一上来就上状态库发起组队表单的状态我选择直接用StatefulWidget里的setState管理不引入Provider、Riverpod这类状态库。原因有两个第一这个页面的状态范围非常清晰无非就是几个 controller、当前选中的剧本类型、人数、时间、标签。它不跨页面共享也不存在复杂的竞态。第二OpenHarmony 的 Flutter 生态里纯 UI 第三方库相对安全但状态库虽然也是纯 Dart引入越多版本冲突的风险越大。能少一个依赖就少一个依赖。页面里我维护的核心状态如下class _CreateTeamPageState extends StateCreateTeamPage { final GlobalKeyFormState _formKey GlobalKeyFormState(); final TextEditingController _titleController TextEditingController(); final TextEditingController _placeController TextEditingController(); final TextEditingController _noteController TextEditingController(); final ListString _scriptTypes [ 欢乐本, 恐怖本, 情感本, 硬核本, 机制本, 阵营本, 还原本 ]; final ListString _tagOptions [ 新手友好, 可反串, 需要DM, 高配玩家, 禁止跳车, 来搭子 ]; String? _scriptType; int? _playerCount; DateTime? _startTime; ListString _tags []; bool _submitting false; ... }有人会问为什么不直接把所有值都塞进TeamDraft然后 setState因为表单的校验是渐进式的用户在输入过程中Draft 可能是残缺状态。把 controller 的值直接映射到模型是提交前最后一步要做的事而不是每个onChanged都去更新模型。3. 表单UI与校验逻辑实现3.1 页面骨架Form 包 ListView发起组队页面的骨架很简单Scaffold里放一个FormForm里放ListView页面滚动交给列表自己处理。这样软键盘弹起来的时候输入框不会被直接顶出可视区域。Scaffold( appBar: AppBar(title: const Text(发起组队)), body: SafeArea( child: Form( key: _formKey, autovalidateMode: AutovalidateMode.disabled, child: ListView( padding: const EdgeInsets.all(16), children: [ _buildTitleField(), const SizedBox(height: 16), _buildScriptTypeField(), const SizedBox(height: 16), _buildPlayerCountField(), const SizedBox(height: 16), _buildStartTimeField(), const SizedBox(height: 16), _buildPlaceField(), const SizedBox(height: 16), _buildTagField(), const SizedBox(height: 16), _buildNoteField(), const SizedBox(height: 24), _buildSubmitButton(), ], ), ), ), )有一点要注意Form和ListView配合时如果列表项特别多建议给ListView加上keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag这样用户往下滚动时键盘会自动收起不用非得点空白处才能收键盘。3.2 文本输入校验trim 之后再判断标题输入框是最典型的必填校验。我的 validator 长这样String? _validateTitle(String? value) { final text value?.trim() ?? ; if (text.isEmpty) { return 组队标题不能为空; } if (text.length 4) { return 标题至少写 4 个字方便队友一眼看懂; } if (text.length 20) { return 标题太长了20 个字以内更清晰; } return null; }这里必须先trim再判断空。因为 这种纯空格字符串长度不为 0但如果不清洗就会被判定为合法标题。标题是用户对一局剧本杀的第一印象写得模糊会导致组队效率很低。地点输入框的校验类似但最低长度我放宽到 2 个字。因为剧本杀门店可能是“XX桌游馆”也可能只是某个商圈里的一个房间名两个字符足够表达。备注字段不设 validator但要在onChanged里做长度截断。200 字以内超过就直接丢弃输入这样用户不会满字之后还一直敲。3.3 下拉选择和人数DropdownButtonFormField 的注意点剧本类型和需要人数我都用DropdownButtonFormField。有人纠结为什么不用自定义弹窗原因是系统组件在 OpenHarmony 的 Flutter 适配版里兼容性相对稳定而且自带语义屏幕阅读器也能正确播报。DropdownButtonFormFieldString( initialValue: _scriptType, decoration: const InputDecoration( labelText: 剧本类型, border: OutlineInputBorder(), ), items: _scriptTypes .map((type) DropdownMenuItem(value: type, child: Text(type))) .toList(), onChanged: (value) setState(() _scriptType value), validator: (value) { if (value null || value.isEmpty) { return 请选择剧本类型; } return null; }, )人数我默认选中 6 人因为剧本杀最常见的配置就是 6 人本。但要注意不要用value参数用新版本的initialValue。旧参数虽然还能跑但新版 Flutter 已经在相关场景里产生了不少弃用警告。onChanged里一定要用setState把选择值存下来否则用户选完 UI 显示变了但内部变量还是 null最后提交校验直接报“请选择剧本类型”非常割裂。3.4 时间选择日期和时间要分开选开始时间我拆成两个步骤先选日期再选时间。用户不可能一次性在一个控件里完成“日期 时间 时区”的选择移动端也不适合这么干。Futurevoid _pickStartTime() async { final now DateTime.now(); final pickedDate await showDatePicker( context: context, initialDate: now.add(const Duration(days: 1)), firstDate: now, lastDate: DateTime(now.year 1), helpText: 选择剧本日期, cancelText: 取消, confirmText: 确定, ); if (pickedDate null || !mounted) return; final pickedTime await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(now), helpText: 选择开场时间, cancelText: 取消, confirmText: 确定, ); if (pickedTime null) return; final combined DateTime( pickedDate.year, pickedDate.month, pickedDate.day, pickedTime.hour, pickedTime.minute, ); if (combined.isBefore(now)) { ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text(开始时间不能早于当前时间))); return; } setState(() _startTime combined); }我在这里卡过两次。第一次是忽略了pickedDate null就继续取时间结果用户取消日期选择后直接弹出了时间选择器体验很怪。第二次是没判断mounted异步结束后用了一个已经销毁的 context 去弹 SnackBar直接报错。这两个问题都是从模拟器上一步步踩出来的。还有一点firstDate不要设置成当天零点而是DateTime.now()。用户如果选了今天那么时间选择器里还能选一个比现在早的时间但我们会用combined.isBefore(now)拦截。如果 firstDate 是当天零点校验逻辑本身没问题但会把“今天已经过去的时间段”也放进来提示不够友好。4. 提交流程与接口对接4.1 提交入口先收键盘再触发全表校验提交按钮在页面底部点击之后第一步不是直接 setState而是把键盘收起来避免键盘把错误提示挡住。Futurevoid _submit() async { FocusScope.of(context).unfocus(); if (!(_formKey.currentState?.validate() ?? false)) { return; } if (_startTime null) { return; } final draft TeamDraft( title: _titleController.text, scriptType: _scriptType!, playerCount: _playerCount ?? 6, startTime: _startTime!, place: _placeController.text, tags: _tags, note: _noteController.text, ); setState(() _submitting true); try { final createdTeam await _teamApi.create(draft); if (!mounted) return; setState(() _submitting false); Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) TeamDetailPage(teamId: createdTeam.id), ), ); } catch (e) { if (!mounted) return; setState(() _submitting false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text(创建失败请稍后重试)), ); } }提交按钮在_submitting true时应该禁用并且显示一个转圈指示器。不然用户连续点两次提交就会创建两条一模一样的组队这个错误非常常见而且很难用接口幂等性兜底。关于导航方式我选择pushReplacement。因为用户发起组队成功后他当前最关心的是刚创建的这个队伍详情。如果把发起页留在栈底用户按返回键又会回到一个已经填完的表单页这显然不合理。如果产品设计是“提交成功后回到组队列表并高亮新队伍”那就应该用pop加回调这个看产品来定。4.2 OpenHarmony 适配下的网络层选型Flutter 本身的网络请求库大多都是纯 Dart 实现比如http和dio所以只要依赖锁版本没问题在 OpenHarmony 上跑通常不会出大乱子。真正出问题的是那些依赖原生插件的库比如某些混合栈通信插件、某些内部走了 Android API 的推送 SDK。在发起组队表单这个场景我不需要推送、不需要定位、不需要相册选择所以网络层用最简单的http就够。接口地址我用环境变量方式注入const apiBaseUrl String.fromEnvironment( API_BASE_URL, defaultValue: https://api.example.com, );联调时用--dart-defineAPI_BASE_URL...传参不同环境不用改代码也不容易把测试环境地址带到生产包。网络请求也别直接把 DTO 透传后端。创建接口返回的createdTeam至少要包含id和title这样详情页才能第一时间用真实数据渲染而不是拿着本地草稿再拉一次接口。4.3 提交过程中的 loading 和错误兜底_submitting状态不只是为了按钮转圈它还在语义上提醒整个页面“当前正在提交不要随意改动”。我建议在提交期间用IgnorePointer把整个表单包起来或者直接让按钮禁用。因为用户在提交过程中如果又把标题改了可能造成请求体和页面显示不一致。错误兜底要分场景网络超时是一类后端返回业务错误是一类。我这边暂时统一弹一个 SnackBar但更好的做法是区分提示“网络异常请检查网络”和“该时段已被预约请重新选择”。等接口联调稳定后可以再加业务错误码处理。表单走到这一步已经不只是前端事至少要和后端约定好错误响应格式再继续扩展。5. 踩坑记录与排查思路5.1 日期选择器中文不生效的问题这是我在 OpenHarmony 模拟器上第一次跑时间选择时遇到的。弹出来全是英文按钮文字也默认。原因很简单工程没有配置flutter_localizations。showDatePicker和showTimePicker依赖MaterialLocalizations根MaterialApp不配置中文 locale系统就会回落到英文。解决方案是在MaterialApp里加三件事localizationsDelegates、supportedLocales和locale: const Locale(zh)。如果你的项目只用了showDatePicker而没引flutter_localizations编译时不会立刻报错但运行时会出现本地化资源缺失的异常排查起来反而更隐蔽。5.2 mounted 和异步 context 的坑表单里所有异步回调都要考虑页面可能已经销毁。用户选完日期后立刻点返回此时_pickStartTime的后续代码还在执行如果不判断mounted直接使用context会引发运行时异常。我现在的习惯是异步操作前先记录需要的数据异步完成后第一时间判断mounted再操作context和setState。这不是严谨不严谨的问题这是移动端异步的常态尤其表单页面用户随时可能返回。5.3 软键盘弹出后表单被遮住发起组队页面字段比较多软键盘弹出后如果只靠Scaffold默认的resizeToAvoidBottomInset有时还是会被挡住。解决办法是确保Form的滚动容器是ListView而不是Column。Column会把整个布局撑起来键盘弹出来后内容没地方滚动输入框直接被顶到键盘下面。我在真机上还碰到过一个情况某些输入法弹出会导致AppBar也跟着偏移。这个一般需要检查Scaffold是否被外层包了奇怪的SafeArea或者固定高度。如果布局够简单问题不会出现。5.4 常见问题速查表现象可能原因解决办法日期选择器按钮是英文缺少flutter_localizations配置根MaterialApp的 localizations 和locale: Locale(zh)选完下拉框后点提交仍提示未选忘记在onChanged里 setState保存值的回调必须更新状态变量软键盘弹出后按钮消失页面用Column而不是可滚动容器改成FormListView必要时加keyboardDismissBehavior提交按钮连点两次创建多条没有提交中的禁用状态用_submitting控制按钮禁用并显示 loading标题有空格但校验通过了validator 没用trim所有文本先trim再判断空和长度时间选择后立即返回页面报错未判断mounted异步回调用完后先if (!mounted) return;这些坑都不是什么高大上的东西但实测下来它们几乎都会在第一次联调或者第一次演示的时候冒出来。6. 关于这个表单的几点个人体会最后说点实际感受。发起组队表单在整个剧本杀组队App里不算特别复杂的页面但它非常值得拆出来单独做。因为它是用户从“看别人组队”转变为“自己主动发起”的关键一步交互上的任何一个卡顿都会直接影响组队成功率。我自己的体会是写这种表单页面一定要先想清楚字段边界和页面流程再动手写 UI。不要让人家填到一半才被“剧本类型不能为空”这种东西打断也不要等接口联调了才发现playerCount可能传成字符串。如果你也在做 Flutter for OpenHarmony 的类似功能我建议提交失败时先用最简单的 SnackBar 兜底把接口返回的原始错误先打印到日志里。等整个流程跑通了再回来优化错误提示的颗粒度。表单页面本来就是这样先保证链路完整再去抠体验细节。