系列更新到第四篇,先说清楚这期做完能看到什么:你从组队列表页点一下“发起组队”,会进入一个真正的表单页,填好剧本、时间、人数、店铺位置和车头备注,点发布,表单校验通过后能回到列表页,并且新组队已经出现在列表里。这期的核心工作就是把 Flutter for OpenHarmony 上的一个标准复杂表单跑通,过程中顺手解决了一批平台适配的怪问题。如果你正在跟着做这个剧本杀组队App,或者你单纯对 Flutter 跑在 OpenHarmony 上的真实手感感兴趣,这篇都值得往后看。
说实话,表单页在任何项目里都是“看起来简单、做起来全是细节”的活。组队表单尤其如此——它不是一个通讯录登记页,它要承担的是信息收集、规则校验、发布反馈这三件事。做得顺不顺,直接影响用户愿不愿意把一次组队发出去。这篇文章会把产品逻辑、状态管理选型、UI落地、校验设计、发布链路、真机排坑挨个拆开讲,保证你照着写能直接落地。
1. 这期要做的事:把“发起组队”从按钮变成可用表单
1.1 剧本杀组队表单的产品逻辑
先别急着写代码。我在做这个页面之前,会先想一个问题:车头(也就是发起组队的人)把信息填出来给谁看?答案是给一群完全不认识的人看。对方凭什么愿意报名?凭的是这条组队信息能让他们低成本判断“这车合不合适、时间够不够、店在哪、还缺几个人”。
所以表单字段不能拍脑袋。我的首版字段表是这样的:
| 字段 | 控件类型 | 说明 |
|---|---|---|
| 剧本名 | 文本输入框 | 必填,支持模糊搜索 |
| 开本时间 | 日期+时间选择 | 必填,不能早于当前时间 |
| 人数上限 | 下拉选择 | 默认6人,范围2-10 |
| 店铺/地点 | 文本输入框 | 必填,建议填写具体店名和分店 |
| 角色要求 | 多选标签组 | 非必填,但选了能提高成车率 |
| 车头备注 | 多行文本框 | 选填,补充说明用 |
| 联系方式 | 文本输入框 | 必填,支持手机号或微信号 |
你可能注意到,我没有在首版做“剧本封面上传”。这个我后面单独说。在 Flutter for OpenHarmony 这个组合下,图片选择、裁剪、上传链路会引入文件管理和权限相关的插件,这些三方库在鸿蒙适配上的状态还不算稳定,我首版直接砍掉。产品上少一个封面不影响发车,技术上少一个大坑,这个取舍我觉得很值。
1.2 表单页在整条业务链路里的位置
从整个App的导航来看,表单页的入口在组队列表页右上角的“发起组队”按钮。用户填完内容、点发布,成功之后返回列表页,列表需要刷新出这条新组队。这个联动看起来简单,但决定了我后面提交逻辑怎么写、页面间怎么传参。
从我前几篇搭好的工程来看,路由用的是手写的页面路由管理,没有额外引路由库。所以这期提交成功后返回刷新,我会采用Navigator.pop(context, true)的方式,把“是否发布成功”作为返回值传给上一页,列表页拿到 true 再刷新数据。这样做比事件总线、全局状态广播简单得多,也足够稳。
这一篇是系列第4篇,前提是你已经有前3篇的工程骨架。如果还没搭过,建议先把路由和基础页面跑通,再来看这篇,不然你会卡在环境问题上,反而浪费了表单本身的细节。
2. 为什么不堆三方状态管理库:OpenHarmony环境下的选型取舍
2.1 先说说我试过的状态管理方案
做 Flutter 开发的人,一上来就会习惯性问:要不要直接上 Provider、Riverpod、Bloc?我的回答是:这个页面不要,整包项目暂时也不加。
原因很直接:我是在 Flutter for OpenHarmony 这个分支上做开发,它不是标准的 Flutter SDK,三方插件能不能跑完全看社区适配进度。Provider 这种库虽然是纯 Dart 实现,理论上能跑,但我实测跑起来之后热重载偶尔会出现状态重置的诡异现象,排查成本比它节省的代码量要高得多。Riverpod 我观望了一下,在鸿蒙分支上的 issue 讨论还不算充分,不太想在这种核心业务页面上赌运气。
这不是说状态管理库不好。我的选择逻辑是:团队现在这个项目复杂度,表单页的状态只涉及一组输入框控制器、一个时间、一个下拉值、一组多选标签、一个提交中标记,这些用StatefulWidget+setState完全能管理得清清楚楚。等以后真的出现跨页面共享数据、需要全局缓存、复杂异步协作时,再引 Riverpod 也不迟。选型要跟着业务复杂度走,不能跟着技术潮流走。
2.2 Form组件是Material体系的老牌能力
表单界面的基础设施,我直接用的是 Flutter 自带的Form+TextFormField+DropdownButtonFormField。这几个组件属于 Material 库,不依赖任何外部包。在 Flutter for OpenHarmony 的适配中,Material 组件是官方适配的重点,跑起来比奇奇怪怪的三方表单库要稳得多。
Form组件提供的核心能力就三个:validate()统一触发表单校验、save()让每个字段把值写回模型、reset()清空表单。我这次主要用的是前两个。值得强调的一点是,FormState的校验不是原子性的,它会把每个FormField的validator返回值收集起来,只要有任何一个非空,validate()就返回 false。这个特性对我后面做整体校验提示很重要。
2.3 控制器和DTO的组织方式
在实际开发里,我不会在 build 方法里直接TextFormField(controller: ...)然后到处散落控制器。这个页面我用了一个私有数据状态类,加上四个控制器:
class _CreateTeamPageState extends State<CreateTeamPage> { final _formKey = GlobalKey<FormState>(); final _dramaController = TextEditingController(); final _storeController = TextEditingController(); final _contactController = TextEditingController(); final _remarkController = TextEditingController(); DateTime _startTime = DateTime.now().add(const Duration(hours: 2)); int _maxPlayers = 6; final List<String> _selectedRoleTags = []; bool _submitting = false; @override void dispose() { _dramaController.dispose(); _storeController.dispose(); _contactController.dispose(); _remarkController.dispose(); super.dispose(); } }控制器记得在dispose()里释放,这是老生常谈,但很多人真会漏。在 OpenHarmony 上漏了之后不会立刻崩,但页面反复进出内存会缓慢涨,这种问题最难查。
DTO 也就是提交时的数据结构,我单独定义了一个模型:
class TeamSheetDto { final String dramaName; final DateTime startTime; final int maxPlayers; final String storeName; final List<String> roleTags; final String remark; final String contact; TeamSheetDto({ required this.dramaName, required this.startTime, required this.maxPlayers, required this.storeName, required this.roleTags, required this.remark, required this.contact, }); }模型和页面解耦的好处是,后面如果接真实后端,或者本地存储,直接复用这个 DTO,不用重构页面代码。
3. 逐项落地:组队表单页面的编码实现
3.1 页面整体骨架
表单页我采用的布局是:顶部AppBar放标题和返回按钮,中间是一棵ListView,底部用一个SafeArea包住发布按钮。为什么不用Column+Expanded?因为表单内容在窄屏上很可能超出可视区域,一旦键盘弹起来,底部按钮会被挤得很难看。ListView自带滚动,再配合padding,是目前最稳的方案。
Scaffold( appBar: AppBar(title: const Text('发起组队')), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ _buildDramaNameField(), _buildStartTimePicker(), _buildMaxPlayersDropdown(), _buildStoreField(), _buildRoleTagSelector(), _buildRemarkField(), _buildContactField(), ], ), bottomNavigationBar: _buildSubmitBar(), )底部的发布按钮放在bottomNavigationBar而不是 ListView 里,有两个好处:一是键盘弹出时按钮不会被顶走,二是用户滚动到页面底部时按钮始终可见,操作路径更短。
3.2 关键表单项的实现细节
剧本名输入没啥特别的,直接上TextFormField,重点在validator:
TextFormField( controller: _dramaController, maxLength: 20, decoration: InputDecoration(labelText: '剧本名称'), validator: (value) { if (value == null || value.trim().isEmpty) { return '请填写剧本名称'; } return null; }, )这里maxLength除了限制长度,还会在右下角显示计数器。有人觉得丑,我倒是觉得这个计数器能提醒车头别写超长,好几回省了后端接口的报错。
开本时间是本页最复杂的字段。我没有用原生的showDatePicker,原因放到最后一部分专门讲。这里我用的是一个自定义的选择入口:
ListTile( title: const Text('开本时间'), subtitle: Text(_formatTime(_startTime)), trailing: const Icon(Icons.calendar_month), onTap: _pickStartTime, )点击之后,我会弹一个底部选择面板,里面用滚轮分别选择日期和时间。这个交互在鸿蒙上很稳,后面细说。
人数上限我用了DropdownButtonFormField<int>:
DropdownButtonFormField<int>( initialValue: _maxPlayers, decoration: const InputDecoration(labelText: '人数上限'), items: [ for (int i = 2; i <= 10; i++) DropdownMenuItem(value: i, child: Text('$i人')), ], onChanged: (value) { setState(() => _maxPlayers = value ?? 6); }, )角色要求是多选标签组,这个场景用Wrap包FilterChip最舒服:
Wrap( spacing: 8, children: [ for (final tag in _roleTags) FilterChip( label: Text(tag), selected: _selectedRoleTags.contains(tag), onSelected: (selected) { setState(() { if (selected) { _selectedRoleTags.add(tag); } else { _selectedRoleTags.remove(tag); } }); }, ), ], )标签预置几项就够了:“全车熟人”、“不挑角色”、“进阶玩家”、“新手友好”、“可跳车(需提前说)”、“需提前到店”。这些标签是我看过一些组队群里的常见需求提炼出来的,先固化在代码里,后续再考虑后台配置化。
3.3 校验反馈与滚动定位
validate()触发的校验错误是逐个字段显示的,但如果用户点了提交、底部按钮又在屏幕外,错误信息藏在屏幕上半段,用户根本不知道哪儿错了。我加了一个自动滚动定位的逻辑:给 ListView 的每一项外面包一层带GlobalKey的容器,校验失败时,通过Scrollable.ensureVisible滚到第一个错误字段上方。
这里不贴全部代码,核心思路是每个字段区域维护一个 key 列表,validateForm()里逐个校验并记录第一个失败项的 key。
这个体验细节很多团队会忽略。但在组队这种信息密度高的表单上,用户填到一半发现点不了发布,又被校验提示卡住,很容易直接放弃。滚动到错误处 + 顶部 SnackBar 提示“还有几项没填好”,能明显降低跳出率。
4. 校验规则的颗粒度:剧本杀场景下的必填与联动约束
4.1 基础校验之外的业务校验
如果只是“非空、非null、格式对”,那就太辜负这个表单了。组队场景真正值钱的校验是业务约束,我做了这么几条:
第一,开本时间不能早于当前时间。我允许用户选今天,但日期如果是今天,时间必须大于当前时间 + 至少30分钟。为什么是30分钟?因为从发布到玩家看到需要时间,车头自己也需要提前到店。这个约束我从产品角度定的,不是拍脑袋。
第二,人数上限必须大于等于2。单人不能叫“组队”,所以下拉直接从2开始,这个由控件本身保证了,但校验函数里还是会兜底。
第三,角色要求和备注至少填一个。这是我做需求时的一个发现:如果车头一个标签都不选、也没有任何备注,这条组队信息看起来很像一个空壳,陌生人很难判断要不要加入。所以我把这两个字段设计成“二选一必填”,比单纯“非必填”更有业务意义。
第四,联系方式支持手机号或微信号,但必须得填。我提供了两种格式的正则判断:
String? _validateContact(String? value) { if (value == null || value.trim().isEmpty) { return '请留下手机号或微信号'; } final isPhone = RegExp(r'^1[3-9]\d{9}$').hasMatch(value.trim()); final isWechat = RegExp(r'^[a-zA-Z][a-zA-Z0-9_-]{5,19}$').hasMatch(value.trim()); if (!isPhone && !isWechat) { return '请输入正确的手机号或微信号'; } return null; }这个规则并不是手机号正则的尽头,但在这个版本里够用。真要在生产环境上线,可以考虑接入验证码逻辑,但这就不属于表单页的范畴了。
4.2 把validator方法从build方法里抽出来
我见过太多人把所有validator直接写在build方法里的匿名函数里。短字段没无所谓,像联系方式这种逻辑一复杂,build 里就会变得又长又难测。我的做法是把每个校验器抽成私有方法,比如_validateDramaName、_validateContact。这样每个 validator 的输入输出都很清晰,后续单元测试也好写。
还有一点很容易被忽略:TextFormField的validator返回的字符串会显示在输入框下面,所以文案要短、直接、指向明确。“请输入正确的手机号或微信号”比“联系方式格式有误”好一百倍,用户不需要思考该怎么改。
4.3 表单整体校验的UI反馈
前面说的自动滚动只解决了一部分问题。真正的完整链路是:点击发布 → 先调_formKey.currentState?.validate()→ 如果 false,滚动到第一个错误项 + SnackBar 提示 → 如果 true,进入提交状态。
我建议 SnackBar 只显示一次,不要重复弹出。验证过很多次,用户连续点发布,如果每次都弹 SnackBar,会被当成 bug。我的处理是同一个提交周期内只出现一次。
5. 提交发布:从校验到列表刷新的完整链路
5.1 DTO组装与仓库层设计
校验通过后,第一步是组装 DTO。注意这时候要读各个控制器的值,同时处理 trim。角色标签和备注至少有一项,但两者都可以是空字符串,我在组装 DTO 时先做了一次合并判断,保证最终提交的数据满足后端约束。
我习惯把这层网络逻辑单独抽象成仓库(Repository),页面不直接关心接口地址、请求头这些细节。我这期的仓库层长这样:
class TeamRepository { Future<void> createTeam(TeamSheetDto dto) async { // 由于后端接口尚未完全就绪,当前接入 mock,后续替换为真实 HTTP 即可。 await Future.delayed(const Duration(milliseconds: 800)); return; } }有人会问:mock 数据和真实接口怎么平滑切换?我用的方案是接口类里判断一个环境开关。真机联调时切到真实接口,没有后端时自动走 mock,页面代码一行不动。
5.2 提交中的状态处理
防重复点击是必须的。我用一个_submitting布尔值,进入提交逻辑时先判断:
Future<void> _handleSubmit() async { if (_submitting) return; if (!_formKey.currentState!.validate()) { // 滚动与提示逻辑 return; } setState(() => _submitting = true); try { await _repository.createTeam(_buildDto()); if (!mounted) return; Navigator.pop(context, true); } catch (e) { if (!mounted) return; setState(() => _submitting = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('发布失败:$e,请重试')), ); } }发布按钮在_submitting为 true 时显示一个CircularProgressIndicator,并且onPressed设为 null,这样点击事件直接被屏蔽,比单独判断更干净。失败时把_submitting复位,表单所有字段保持原样,用户改完可以再点一次,不用重新填一遍。
5.3 返回列表的数据刷新
提交成功后,我用Navigator.pop(context, true)把结果带回去。列表页的入口代码这么写:
final needRefresh = await Navigator.push<bool>( context, MaterialPageRoute(builder: (context) => const CreateTeamPage()), ); if (needRefresh == true) { _loadTeamList(); }这个模式非常直接。比起用全局状态“广播”一条新数据,这种返回值传递方式更符合页面栈的直觉,也更容易理解和排查。等后面引入本地数据库或后端推送,再考虑更实时化的刷新策略不迟。
整套链路走完,用户感知就是:发布中按钮转圈几秒,然后自动回到列表,新组队出现在最上面。
6. 真机排坑:OpenHarmony上这套表单遇到的适配问题
6.1 日期时间选择器:原生的不一定最稳
这是我这期踩得最深的一个坑。标准 Flutter 里,showDatePicker弹出来是一个 Material 日历面板,用起来很方便。但在 Flutter for OpenHarmony 的适配初期版本上,我实测发现日历面板某些月份显示会出现渲染空白,而且这种问题在模拟器上还复现不出来,只有真机上偶尔出现,极其恶心。
我的处理方案是放弃showDatePicker,改用showModalBottomSheet加自研的简单滚轮选择器。底部弹层本身是 Material 组件,在鸿蒙上渲染很稳定。滚轮用ListWheelScrollView实现,日期、小时、分钟三个滚轮联动,整体代码量不大但体验完全够用。
这里要给你一个实战建议:在 Flutter for OpenHarmony 上,遇到任何“标准 Flutter 没问题、鸿蒙真机有渲染问题”的组件,优先不要死磕兼容,用更基础的 Material 组件组合代替,通常能快速找到稳定解。适配期的第一原则是稳定优先,不是组件复用优先。
6.2 输入法遮挡与安全区
真机测试时还遇到一个现象:键盘弹出后,底部的发布按钮有时候会整个被输入法盖住。理论上Scaffold的resizeToAvoidBottomInset默认是 true,会重新布局内容。但我在部分鸿蒙系统版本上发现,某些输入法是全屏模式,不走 resize,导致按钮确实会被遮住。
我的解决办法分两层:第一,在Scaffold上保持resizeToAvoidBottomInset: true;第二,在ListView的padding底部额外加一个滚动缓冲,比如EdgeInsets.only(bottom: 120)。这样即使键盘不走 resize,用户也能手动滑动到输入框和按钮。这不算完美方案,但在无法控制系统输入法行为的情况下,比干瞪眼强得多。
6.3 中文字体与文本溢出的细节
鸿蒙系统的中文字体渲染本身没问题,但我在角色标签多选这一块遇到过一次溢出:某个标签在窄屏上把Wrap的正常换行打乱了,导致标签文字被截断。排查后发现不是字体问题,是我给FilterChip的label没有限制最大宽度。
给标签容器加constraints或者label里包一层ConstrainedBox可以解决。这个小问题提醒我:在新平台上做适配,不能只盯着大组件,一些基础组件的默认约束也可能跟标准 Flutter 有细微区别。遇到怪异表现,先怀疑布局约束,再怀疑字体、再怀疑渲染。
如果你按这篇做到了这里,恭喜你,你已经拥有一个可以在 OpenHarmony 真机上跑通的组队表单页。我个人在使用中最大的体会是:表单页的复杂度不在代码量,而在于你愿不愿意在交互细节上打磨。多花一点时间做滚动定位、防重复点击、失败保留现场,用户的反馈会非常明显。最后再分享一个小技巧:把整个表单所有字段的validator文案收集起来过一遍,你会发现文案一致性对表单完成率的影响,远大于任何动画效果。下篇我打算聊组队列表页的数据加载、空态设计和详情页基础框架,到时候见。