
系列更新到第四篇先说清楚这期做完能看到什么你从组队列表页点一下“发起组队”会进入一个真正的表单页填好剧本、时间、人数、店铺位置和车头备注点发布表单校验通过后能回到列表页并且新组队已经出现在列表里。这期的核心工作就是把 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 讨论还不算充分不太想在这种核心业务页面上赌运气。这不是说状态管理库不好。我的选择逻辑是团队现在这个项目复杂度表单页的状态只涉及一组输入框控制器、一个时间、一个下拉值、一组多选标签、一个提交中标记这些用StatefulWidgetsetState完全能管理得清清楚楚。等以后真的出现跨页面共享数据、需要全局缓存、复杂异步协作时再引 Riverpod 也不迟。选型要跟着业务复杂度走不能跟着技术潮流走。2.2 Form组件是Material体系的老牌能力表单界面的基础设施我直接用的是 Flutter 自带的FormTextFormFieldDropdownButtonFormField。这几个组件属于 Material 库不依赖任何外部包。在 Flutter for OpenHarmony 的适配中Material 组件是官方适配的重点跑起来比奇奇怪怪的三方表单库要稳得多。Form组件提供的核心能力就三个validate()统一触发表单校验、save()让每个字段把值写回模型、reset()清空表单。我这次主要用的是前两个。值得强调的一点是FormState的校验不是原子性的它会把每个FormField的validator返回值收集起来只要有任何一个非空validate()就返回 false。这个特性对我后面做整体校验提示很重要。2.3 控制器和DTO的组织方式在实际开发里我不会在 build 方法里直接TextFormField(controller: ...)然后到处散落控制器。这个页面我用了一个私有数据状态类加上四个控制器class _CreateTeamPageState extends StateCreateTeamPage { final _formKey GlobalKeyFormState(); 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 ListString _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 ListString 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包住发布按钮。为什么不用ColumnExpanded因为表单内容在窄屏上很可能超出可视区域一旦键盘弹起来底部按钮会被挤得很难看。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重点在validatorTextFormField( 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, )点击之后我会弹一个底部选择面板里面用滚轮分别选择日期和时间。这个交互在鸿蒙上很稳后面细说。人数上限我用了DropdownButtonFormFieldintDropdownButtonFormFieldint( 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 { Futurevoid createTeam(TeamSheetDto dto) async { // 由于后端接口尚未完全就绪当前接入 mock后续替换为真实 HTTP 即可。 await Future.delayed(const Duration(milliseconds: 800)); return; } }有人会问mock 数据和真实接口怎么平滑切换我用的方案是接口类里判断一个环境开关。真机联调时切到真实接口没有后端时自动走 mock页面代码一行不动。5.2 提交中的状态处理防重复点击是必须的。我用一个_submitting布尔值进入提交逻辑时先判断Futurevoid _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.pushbool( 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文案收集起来过一遍你会发现文案一致性对表单完成率的影响远大于任何动画效果。下篇我打算聊组队列表页的数据加载、空态设计和详情页基础框架到时候见。