1. 发起组队功能的定位与设计思路
Flutter for OpenHarmony 的剧本杀组队App已经跑通了大框架。前面几篇我们把开发环境、路由骨架、首页信息流和队伍列表都搭好了,今天这篇要解决的是整个业务链里交互密度最高的入口:发起组队表单。
这个表单承担的任务很直接——用户把组队意图变成结构化数据,发给后端,后续的匹配、通知、房间管理全都依赖这条链路。所以表单字段怎么定、校验怎么设计、提交后的反馈怎么做,都不只是写几个输入框那么简单。
有人会觉得“表单嘛,TextFormField 堆一排不就完了”。真做起来完全不是那么回事。剧本杀组队有一套自己的业务约束:人数有范围、时间有门槛、角色要求有偏好、费用模式有潜规则。字段之间还有联动,比如选了某个类型的剧本,人数上限就得跟着收窄。这篇我把发起组队从业务拆解到控件实现完整过一遍,重点放在为什么这么做,以及哪些地方容易踩坑。
1.1 表单字段:跟着业务流程走,而不是想起什么加什么
先花时间把字段定清楚。这个阶段多纠结几分钟,后面少改十次接口。剧本杀组队表单最终的字段是这批:
- 剧本名称:必填。用户要写清楚玩什么本,纯文本输入,后续可以接模糊搜索。
- 玩法类型:必填。线下本、线上本、实景搜证、阵营本、情感本,这些归类直接用下拉选择,比自由输入好清洗。
- 难度星级:二选一或者下拉。1到5星,默认给3星,避免用户不填导致数据没法排序。
- 组队人数:必填。3到12人,联动玩法类型做动态范围。
- 开局时间:必填。日期 + 时刻组合,默认偏移两小时,减少手动调整次数。
- 预计时长:选填。给个一小时到六小时的几个档位,方便用户估算。
- 费用说明:必填。免费、AA制、包场固定价、由商家报价,四种模式,下拉选择。
- 补充描述:选填。多行文本,限制200字以内,防止有人把这里当聊天区。
- 联系方式:选填。手机号或微信号,正则校验格式,但不强制填写。如果没填,显示App内会话入口。
为什么没有把发起人昵称、头像放进表单?因为这两项走的是登录态数据。表单里每多一个字段,填写成功率和数据质量都会下降。我自己见过不少团队把“性别”“年龄”也塞进发起组队表单里,结果就是用户填到一半就放弃了。组队信息里通过率最高的做法是:把人员维度的数据下沉到用户资料,把队伍维度的数据放上表单。
1.2 技术选型:Form 方案与状态管理怎么定
表单本身当然用Flutter官方的 Form + TextFormField 组合。它自带校验生命周期,提交时统一触发 validator,不用自己维护一堆校验状态。
另一个考虑是状态管理。这个项目的模块不大,发起组队页面用 StatefulWidget + setState 完全够,外加几个 TextEditingController 和 ValueNotifier 管理动态值。有的团队一上来就迁移 Riverpod、Bloc,结果表单页面里套了七八个状态类,排查问题都费劲。我建议是:单页面前期用原生 setState,等出现跨页面共享状态、数据回填写需求时,再引入 Riverpod。这个节奏是经过项目验证的,模块边界清晰,后面拆重构也方便。
还有一个底层选型问题:为什么用 Flutter for OpenHarmony 而不是直接用原生ArkUI?团队原因其实很实际。Flutter 的布局模型、组件生态、热重载调试链路大家都熟了,从标准Flutter迁移到 Flutter for OpenHarmony,大部分 Widget 语法是兼容的,能把 Android 和 OpenHarmony 两端的开发成本压缩到同一套代码里。特别适合这种工具型App,页面逻辑重心在表单和列表,对平台深度能力的依赖反而没那么多。
2. 表单核心细节拆解:控件要逐个抠
表单页面最容易翻车的地方往往是细节。下面的内容来自我实际敲代码时的经验,不是抄文档能看出来的。
2.1 文本输入与校验:把体验做细
剧本名称的输入框,我用 TextFormField 包了一层。关键点有三个:decoration 的提示文案、validator 的校验时机、以及 autovalidateMode 的选择。
TextFormField( controller: _nameController, maxLength: 30, autovalidateMode: AutovalidateMode.onUserInteraction, decoration: InputDecoration( labelText: '剧本名称', hintText: '比如:窗边的女人', prefixIcon: Icon(Icons.theater_comedy_outlined), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), validator: (value) { final v = value?.trim() ?? ''; if (v.isEmpty) return '请填写剧本名称'; if (v.length < 2) return '剧本名称至少两个字'; return null; }, )maxLength 设置 30,既是防呆,也是给后端省存储空间。hintText 给示例文本,比干巴巴写“请输入”强得多。
validator 为什么用 trim 之后再判断?因为用户很可能在输入法打字时多出空格。不处理的话,“ ” 这种纯空格会通过非空校验,到了后端就变成一条脏数据。
autovalidateMode 我有自己的偏好:名字这种即时反馈的字段用 onUserInteraction,用户一改马上提示;但是描述这种长文本,等提交时统一校验就好,避免一边打字一边满屏红字,看起来压力特别大。
补充描述框的细节更琐碎,但价值很高:
TextFormField( controller: _descController, maxLines: 4, maxLength: 200, decoration: InputDecoration( labelText: '补充描述', alignLabelWithHint: true, hintText: '对队友的需求、扮演角色、剧本难度说明……', ), validator: (value) { if (value != null && value.trim().length > 200) { return '描述最多200字'; } return null; }, )maxLines: 4 是为了多行输入不被单行输入框压扁。alignLabelWithHint: true 这个属性很关键,不然多行 TextFormField 的 label 会贴在左上角,跟 hint 挤在一起。
校验时的中文提示一定要给到位,不要用英文或“格式错误”这种笼统话术。用户看到“请填写剧本名称”知道去哪修,看到“Invalid format”只会懵。
2.2 下拉选择与日期时间:交互要贴近用户习惯
人数选择用 DropdownButtonFormField 实现,初始值 4,因为剧本杀最常见的就是 4 到 8 人局。
DropdownButtonFormField<int>( initialValue: 4, hint: Text('选择人数'), decoration: InputDecoration(labelText: '组队人数'), items: List.generate(9, (i) { final n = i + 4; return DropdownMenuItem(value: n, child: Text('$n人')); }), onChanged: (val) { setState(() => _playerCount = val ?? 4); }, validator: (value) { if (value == null) return '请选择人数'; return null; }, )注意 Flutter 新版废弃了 DropdownButtonFormField 的 value 参数,推荐用 initialValue。旧代码直接迁移会报错,这个细节很容易踩。
日期时间选择是表单里最绕的地方。系统自带的 showDatePicker 在 OpenHarmony 的 Flutter 分支上存在兼容性问题,表现为日历文本溢出、按钮显示不全。我在这篇项目里直接放弃系统选择器,改用 showModalBottomSheet 配自定义滚轮选择器,代码量不多,但视觉和交互完全可控。
自定义选择器的核心结构:
Future<DateTime?> _pickDateTime(BuildContext context) async { final now = DateTime.now(); final picked = await showModalBottomSheet<DateTime>( context: context, builder: (ctx) { return _DateWheelPicker( initialDate: _targetTime ?? now.add(Duration(hours: 2)), minDate: now, ); }, ); return picked; }滚轮用 ListWheelScrollView 实现日期轴和时间轴,选完以后把值回填到触发按钮的文案上。这一步的体验比系统弹窗好很多——用户可以连续滑动,不用跨两个弹窗选日期和时刻。
默认值偏移两小时这个细节,是运营数据告诉我的:发起组队的人通常希望马上成局,给一个“当前时间 + 2小时”作为起点,用户修改幅度小,成功率明显提升。
下面是回填展示的代码:
TextFormField( controller: _timeController, readOnly: true, onTap: () async { final picked = await _pickDateTime(context); if (picked != null) { setState(() { _targetTime = picked; _timeController.text = _formatDateTime(picked); }); } }, decoration: InputDecoration( labelText: '开局时间', suffixIcon: Icon(Icons.calendar_month), ), validator: (_) => _targetTime == null ? '请选择开局时间' : null, )readOnly 加 onTap 的组合,是为了让这个输入框变成“触发器”而不是“真正的输入框”,避免用户手动敲出乱格式的时间字符串。
2.3 布局与键盘避让:别让键盘把表单顶飞
表单页面最典型的问题就是键盘弹出来后,底部按钮被顶出屏幕,或者输入框被遮挡,用户连自己刚打的字都看不到。
我采用的布局方案是 Scaffold 里放一个 SafeArea 包裹的 ListView,Form 作为 ListView 的孩子,而不是反过来。为什么用 ListView?因为当键盘弹出时,Flutter 会自动收缩视图port,ListView 提供了滚动能力,用户可以滚动到正在编辑的输入框位置。如果直接用 Column,字段多了必然 overflow,键盘一弹更乱。
Scaffold( resizeToAvoidBottomInset: true, body: SafeArea( child: Form( key: _formKey, child: ListView( padding: EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ _buildNameField(), SizedBox(height: 16), _buildTypeAndDifficulty(), // 其余字段... _buildContactField(), SizedBox(height: 24), _buildSubmitButton(), ], ), ), ), )resizeToAvoidBottomInset: true 是默认值,但我仍然显式写出来,因为 Flutter for OpenHarmony 的某些分支版本对键盘避让的处理跟标准Flutter不一致,显式声明之后行为可预期。
真正的坑在底部提交按钮。最省事的设计是把它固定在页面底部,用 bottomNavigationBar 放按钮,这样键盘弹出时按钮会被顶到键盘上方,不会遮挡输入内容。但这个方案有个副作用——屏幕小的时候,固定按钮会侵占输入框的展示空间。
我来回改了两版,最终选择让按钮跟着表单内容滚动,也就是放在 ListView 最后一个位置。用户填写完上一个字段,自然看到按钮,点击之前键盘还开着,但按钮就在手指头附近,点击无阻碍。
UI 上还有一个被忽视的细节:加载中状态要吞掉点击事件。防重复提交是老生常谈,但很多新手实现不对。我写的是:
Widget _buildSubmitButton() { return SizedBox( height: 48, child: ElevatedButton( onPressed: _isSubmitting ? null : _submitForm, child: _isSubmitting ? Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 12), Text('提交中…'), ], ) : Text('发起组队'), ), ); }_onPressed 设为 null 之后再配合 CircularProgressIndicator,用户一看按钮灰了,就知道动作已经触发。只改文案不改点击状态的坑,我见过太多次。
3. 发起组队表单的完整实现
这一节把整个页面从数据模型到提交流程串起来,给你一条可以直接抄的路线。
3.1 数据模型与状态管理:先定模型,再写UI
我习惯先写模型类,再写页面。模型是所有字段校验、提交、回显的基准,UI 只是它的投影。
class TeamRequest { final String name; final String gameType; final int difficulty; final int playerCount; final DateTime startTime; final int durationHours; final String feeType; final String description; final String contact; TeamRequest({ required this.name, required this.gameType, required this.difficulty, required this.playerCount, required this.startTime, required this.durationHours, required this.feeType, required this.description, required this.contact, }); Map<String, dynamic> toJson() { return { 'name': name.trim(), 'gameType': gameType, 'difficulty': difficulty, 'playerCount': playerCount, 'startTime': startTime.toUtc().millisecondsSinceEpoch, 'durationHours': durationHours, 'feeType': feeType, 'description': description.trim(), 'contact': contact.trim(), }; } }时间字段我转成了 UTC 毫秒时间戳而不是 ISO 字符串。原因是后端同学对接时明确表示:时间戳由服务端统一转时区,避免客户端各自格式不一致导致排期错乱。这个约定要在接口文档里写清楚,别让客户端随意发挥。
状态管理上,整个页面有三个 TextEditingController、一个 DropdownButtonFormField 对应的游戏类型、一个难度值、一个目标时间、一个费用类型。这些散值如果全部用 setState 维护,代码会显得杂乱。我在这个页面里把“可变值”收敛成 5 个状态变量:
String _gameType = '线下本'; int _difficulty = 3; int _playerCount = 4; DateTime? _targetTime; String _feeType = 'AA制';文本类输入全部走 controller。为什么这样分?因为下拉选择、日期选择的数值需要参与逻辑联动(比如玩法类型影响人数范围),而文本输入只是单纯收集字符串。把逻辑联动值抽出来单独管理,提交和校验都清晰。
3.2 表单提交流程:从校验到接口返回
提交逻辑是整个页面的核心,我写成了四个步骤:校验、组装、发请求、处理反馈。没写进一个超长方法里,方便单独调试。
Future<void> _submitForm() async { if (_isSubmitting) return; FocusScope.of(context).unfocus(); if (!(_formKey.currentState?.validate() ?? false)) return; setState(() => _isSubmitting = true); final request = TeamRequest( name: _nameController.text, gameType: _gameType, difficulty: _difficulty, playerCount: _playerCount, startTime: _targetTime ?? DateTime.now(), durationHours: _durationHours, feeType: _feeType, description: _descController.text, contact: _contactController.text, ); try { final ok = await _api.createTeam(request); if (ok) { _showSuccessTip(); Navigator.of(context).pop(true); } } catch (e) { _showErrorTip(e.toString()); } finally { if (mounted) setState(() => _isSubmitting = false); } }先说 FocusScope.of(context).unfocus(),这行是防呆的。用户键盘还开着,如果直接把请求发出去,键盘动画中间发出的响应时机容易出怪问题。先收起键盘,视觉上也干净。
再强调 mounted 判断:异步请求返回后页面可能已经销毁,直接 setState 会报“setState called after dispose”。这个坑在提交表单这个场景高发,因为用户提交完通常马上 pop 页面。
接口层我用的是 dio 的封装。在 Flutter for OpenHarmony 环境里,dio 的兼容性表现不错,底层 socket 走的是 OpenHarmony 的网络栈,不需要额外适配。请求超时时间我设了 10 秒,组队创建属于轻量写操作,超过 10 秒大概率是网络环境问题,直接提示比无限等强。
校验失败时的处理也讲究。我只在错误相关输入项旁边显式红字,不做整页弹窗。但如果多个字段都为空,用户到底改哪里?所以我在校验失败后会调一个 _focusFirstError() 方法,找到第一个 validator 出错的 FormFieldState,给它 focus 并滚动到可视区域。
void _focusFirstError() { final state = _formKey.currentState; if (state == null) return; for (final field in state.fields.values) { final fieldState = field as FormFieldState?; if (fieldState != null && fieldState.hasError) { fieldState.focusNode?.requestFocus(); break; } } }这个细节对长表单太重要了。用户填了十项,提交时报错,如果不自动滚动,他得自己摸索到底哪里有问题。自动聚焦到第一处错误,配合滚动,两步就解决了。
3.3 与OpenHarmony能力的联动:不止是渲染到屏幕上
表单本身只是 UI 层,但它在 OpenHarmony 设备上运行,有些地方必须主动适配。
第一个是存储。如果用户上次发起组队时填过常用剧本,下次进来最好预填。这个需求我用 OpenHarmony 的偏好存储实现。Flutter for OpenHarmony 提供了对应的平台通道能力,但封装程度不一。有的项目里直接用起来会缺实现,需要自己写一个 MethodChannel 调 OHOS 的 Preferences。
const MethodChannel prefsChannel = MethodChannel('com.teamapp/ohos_prefs'); Future<String?> getPrefsString(String key) async { try { return await prefsChannel.invokeMethod('getString', {'key': key}); } on MissingPluginException { return null; } }注意 MissingPluginException 一定要捕获。OpenHarmony 生态里插件适配参差不齐,很多插件在 Android 上有实现,换到 OpenHarmony 上就是空。真正的工程判断是:这个插件有没有 ohos/ 目录下的实现,没有就只能靠通道自己写。
第二个是权限。当前表单没有涉及图片上传。但如果后面加“上传剧本封面”需求,就要在发起组队页面上触发选择图片,那就必须先处理权限申请。OpenHarmony 的权限模型跟 Android 不完全一致,它把权限分成了两类:“系统授权”和“用户授权”。相册访问属于用户授权,需要在 config 里声明 ohos.permission.READ_IMAGEVIDEO,还要在运行时拉起授权弹窗。
我先不展开写权限申请代码,因为这个功能还在迭代中。建议各位在做跨端功能前,先确认所依赖的插件是否已经适配 OHOS,再决定是等适配还是自己写通道。
第三个是系统分享。发起组队成功后,常见动作是把组队信息分享到聊天软件。Flutter for OpenHarmony 本身没有统一的 share 插件,但可以走 Intent 通道拉起分享面板。不过目前我只做了站内通知,跨端分享还在评估,所以这里不贴代码了。
4. 踩坑实录与排查技巧
表单这种东西,写的时候觉得简单,跑起来全是诡异问题。我把自己在 OpenHarmony 真机上踩过的问题整理成了速查表,还有几个排查思路,各位直接对照着用。
4.1 高频问题速查表
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
| 点击日期输入框没反应 | readOnly 输入框不触发 onTap,需要包 GestureDetector | 去掉 readOnly 改用 IgnorePointer,或确保 readOnly + onTap 组合正确 |
| 键盘弹出后按钮看不到 | 用 Column 而不是 ListView | 改成 ListView + resizeToAvoidBottomInset |
| 校验不触发 | 忘了把 TextFormField 放 Form 里 | 全页包 Form,加 GlobalKey |
| 输入框自动获取英文键盘 | 数值类字段没设 keyboardType | 根据需要设 TextInputType.number 或 phone |
| 下拉默认值不显示 | value 参数在旧版 Flutter 已废弃 | 用 initialValue,并确保 items 里有对应值 |
| 中文输入法把点击onChanged触发两次 | 输入法联想组合逻辑,偶发 | 在 _submit 里统一去重状态,不在 onChanged 里提交 |
| 日期选择器文字溢出 | 系统默认 Material 组件适配不佳 | 换自定义 BottomSheet 滚轮 |
| hot reload 失效 | OpenHarmony 分支与 Android 不同 | 用命令行 flutter attach 重连,避免频繁全量重启 |
这里我想特别说下日期选择器文字溢出这个。标准 Flutter 的 showDatePicker 在像素密度较低或字体缩放较大的设备上,日历格子会被挤成乱码。这个问题在 Flutter for OpenHarmony 上出现的概率更高,因为默认字体和渲染参数跟标准分支有差异。与其修系统组件的内部样式,不如自己封装一个选择器,一劳永逸。
4.2 OpenHarmony 适配中的几个特殊点
先讲包名与工程结构。Flutter for OpenHarmony 项目的包结构依然保留 android 目录,但会多出 ohos 目录,里面是 HarmonyOS 的 Ability 和 Module 配置。在跑表单这种纯 UI 页面时,ohos 目录里的配置一般不用改,但一旦涉及平台通道就一定要进去加权限声明或者注册自定义插件。
接着是字体和字号。OpenHarmony 系统默认字体不是思源黑体之类的标准 Android 字体,行高表现略有不同。表单里中文字符多、hint 文案长,我在 labelText 和 hintText 并发时发现过文字截断。解决办法是给 InputDecoration 设置 isDense: true,并且给 TextFormField 增加样式 fontSize 和 height,不要全依赖主题。
还有滚动惯性差异。OpenHarmony 设备上 ListView 的物理滚动效果与 Android 存在细微差别,主要体现在快速滑动后停止的位置不一致。这个影响不大,但如果你做了下拉刷新加表单项的组合,要确认刷新手势不会被输入框的滚动手势抢走。解决办法是在 Form 的滚动视图上设置 physics: BouncingScrollPhysics(),开启动态弹性,体验上更接近预期。
4.3 提高表单开发效率的小习惯
最后分享几个让我受益颇多的工程习惯,不算高深,但每一步都能减少返工。
第一,表单字段和 UI 组件之间建立表格映射。我把字段名、控件类型、是否必填、校验规则、提示文案全部列在同一个表格里,直接映射到代码。新同学接手时,对着表格就能看懂页面,不会出现改了半天不知道字段干嘛用的情况。
第二,用 debugPrint 统一打印提交报文。在后端联调阶段,这个习惯救过我很多次。 request 组装完、发出去之前,先打印一份完整的 toJson() 字符串。后端说没收到时,我能马上确认到底是发送失败,还是参数错误。
第三,表单组件写成一个通用包裹器。我把 labelText、错误提示、必填标记、间距这些布局逻辑抽成 FormItemWrapper,业务页面只传字段和校验函数。这样所有表单页面的视觉风格统一,而且后续要改布局时只需要改一个文件。
class FormItemWrapper extends StatelessWidget { final String label; final bool required; final Widget child; const FormItemWrapper({ Key? key, required this.label, this.required = false, required this.child, }) : super(key: key); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(bottom: 6), child: Row( children: [ Text(label, style: TextStyle(fontWeight: FontWeight.w600)), if (required) Padding( padding: EdgeInsets.only(left: 2), child: Text('*', style: TextStyle(color: Colors.red)), ), ], ), ), child, SizedBox(height: 12), ], ); } }第四,在提交按钮的文案里带上当前页面时间,比如“发起组队(14:30)”。这个灵感来自活动报名页,作用是提示用户表单数据实时对应的时间点。代码实现就是拿 DateTime.now() 格式化一下,成本极低,但能明显降低用户对“提交后什么时候生效”的困惑。
个人经验的一点补充
发起组队表单这个页面上线后,我看过一组数据:用户从进入页面到点击提交,平均停留时间是47秒。大多数人在名字和描述两个字段上犹豫,时间选择反而是习惯性顺手一滑。这说明表单字段越少、提示越具体,决策越顺畅。如果当初多塞两个非必要字段,停留时间估计会往上涨不少。
另一点是表单的失败反馈。最初我只做了 SnackBar 提示“提交失败请重试”,后来发现用户失败后往往反复点提交,以为是按钮坏了。改成把失败原因详情放到错误段落里展示,比如“服务端超时,请检查网络后重试”,点击率很快就恢复了。
最后想单独聊一句 Flutter for OpenHarmony 的工程心态。很多开发者拿到这套框架后,习惯性把问题归因于“分支不成熟”。但实际上,表单这种页面涉及的 Core Widget 和布局系统,分支上已经相当完整,真正按文档写、按规范写,很少会遇到框架本身的阻碍。真出了问题,先看自己的代码有没有依赖 Android 专属插件,再看有没有越权使用 Platform Channel。把这两个排查顺序做对了,表单开发在 OpenHarmony 上并没有传说中那么辛苦。
这篇先写到这里。下一步可以做的方向是把发起组队后的详情页和队伍房间状态串起来,或者在表单里接一个图片上传封面组件,到时候再单独开一篇聊。