Flutter for OpenHarmony 这个剧本杀组队 App 写到第 04 篇,我终于把发起组队的表单页完整实现了。前面几篇我们主要是搭脚手架、处理路由和列表页,App 能看能逛,但一直缺一个最关键的动作:让用户自己创建一个队伍。发起组队是这个产品从“资讯浏览”变成“工具”的分水岭,而承接这个动作的页面,就是这次要啃的硬骨头。
先说个背景,这个系列不是简单的 Flutter UI 教学,而是切切实实跑在 OpenHarmony 设备上的跨端应用实践。如果你之前只在 Android/iOS 上写过 Flutter 表单,第一次切到 OpenHarmony 平台时,多少会感受到一些微妙的差异:输入法弹起时机、键盘遮挡、原生权限申请、图片选择器等等,都不是“复制粘贴再编译”就能百分百顺畅跑起来的。这次实战会把这些差异点全部暴露出来,我也会顺带给出化解方案。如果你是刚接触 Flutter 的新手,这篇也可以当一篇完整的“表单页实现参考”;如果你已经在 OpenHarmony 上踩过不少坑,那可以重点看第 4 部分和第 6 部分,那些才是干货比较密集的地方。
项目到目前为止的代码结构并不复杂:入口是一个标准的 Flutter 工程,外层用 MaterialApp 包了一层主题,OpenHarmony 侧单独维护了一个 ohos 工程目录,用来做原生能力注册和设备打包。列表页已经能展示剧本和队伍的模拟数据,那个页面是从一个公开的接口拉的数据,为了不阻塞 UI 进度,当时接入了本地 mock。这次做完表单以后,发布的数据要能回流到列表列表,所以我在写表单提交逻辑的时候,特意把回调刷新和状态同步设计进去了。
1. 项目背景与本节目标
1.1 组队场景中表单的特殊性
剧本杀组队 App 里的“发起组队”,和普通社区发帖有很大区别。普通发帖只需要标题加正文,但组队天然带有结构化的需求:玩什么本、几个人、什么时候、在哪里、是否接受新人、是整车还是拼车。这些字段如果不结构化,全塞进一段文字里,后端的匹配和筛选就无从谈起。
举一个真实的例子:我之前在模拟数据里放了一条“本周六《年轮》拼车,缺2,新手可带”,看起来信息很全,但如果用户想按剧本名索引、按日期排序、按是否新手友好过滤,这条信息根本没法解析。所以在表单设计阶段就要把它拆成字段,而不是让用户填一段“人话”。这也是这一节要从产品逻辑讲起的原因,代码反而是水到渠成的事情。
在实际约局场景中还有一个隐性需求:发起人经常是临时起意,手机屏幕可能就亮了一分钟。如果表单超过 8 个输入项,页面跳出率会明显上升。所以我们不能一股脑把所有信息都摊在用户面前,要设计合理的默认值、分组展示、动态显隐,尽量把用户的输入负担降到最低。
1.2 这一篇完成后的功能清单与验收标准
我把这次发起组队表单的功能拆成了四块,每一块都有明确的验收标准:
- 页面能承载创建队伍所需的全部字段,包含标题、剧本名称、玩法类型、人数、时间、地点、备注、联系方式。
- 所有关键字段有即时校验,非法输入在点击提交前就给出友好提示。
- 表单支持从“组队招募”和“车队报名”两种意图切换,下拉选人和标签选人两种模式切换后相关字段会联动。
- 提交过程有 loading 态,防重复点击;数据组装后能通过仓库层提交,成功后返回列表并触发刷新。
这样拆的原因很简单:发起组队不只是“把十个输入框塞进一个页面”,它关系到后续消息通知、人数变化、发车状态流转等一连串逻辑。如果数据模型一开始设计得不好,等后端接口联调时再返工,代价是成倍的。项目走到第 04 篇,我们追求的已经不是“能跑通”,而是“各个功能模块在后续迭代中不会塌方”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 表单整体设计与字段规划
2.1 为什么不是“十个输入框”直接铺开
很多新手写表单会把内容全部塞进 ListView,每个输入框之间加一点间距就算完事。这样写出来的页面有两个问题:操作压力大,用户看到一整屏输入框会本能地烦躁;业务上也不好扩展,比如后面想加“只接受女生”这种筛选项,又要往下继续堆。
我在设计这个页面时定了三条原则:第一,必要信息一眼可见;第二,可选项分步展开;第三,能选择的不要输入。具体到页面结构上,最顶部是标题和剧本名称,这是用户发起组队时最先想到的两件事。中间是时间和人数,这两个字段决定了队伍能不能组起来,因此放得比较靠前。地点和备注属于补充信息,被放在后面,如果用户没填也不影响发布。联系方式则放在最后,并且和隐私提示放在一起,降低用户的填写顾虑。
这样的布局还有一个好处:用户从上往下填写的顺序,正好是逻辑上从抽象到具体的顺序。先确定玩什么,再确定什么时候,然后确定几个人,最后留下联系渠道,比较符合自然思维习惯。
2.2 核心字段的数据建模与默认值策略
先看一张我在实现前整理的字段规划表,这张表基本等同于“表结构”的前置草稿:
| 字段 | 控件形式 | 是否必填 | 数据类型 | 默认值/占位文案 |
|---|---|---|---|---|
| 队伍标题 | TextFormField | 是 | String | “周六下午《窗边的女人》高分车队” |
| 剧本名称 | TextFormField | 是 | String | 自动带出最近玩过的剧本 |
| 玩法类型 | ChoiceChip 单选 | 否 | String(枚举) | 本格推理 |
| 总人数 | 自定义步进器 | 是 | int | 6 |
| 已占车位/招募人数 | 自定义步进器 | 是 | int | 1 |
| 开本时间 | 日期时间选择器 | 是 | DateTime | 未来第七天的 14:00 |
| 线下地点 | Dropdown 记忆选择 | 否 | String | 上次使用过的地点 |
| 备注 | 多行 TextFormField | 否 | String | “纯新手也可,不跳车” |
| 联系方式 | TextFormField | 是 | String | 空,提示“手机号或微信号” |
这里有几个细节需要展开说明,因为它们直接决定了代码怎么写。
第一,队伍标题和剧本名称在语义上是不同的。标题是给列表页扫一眼用的,比如“周六下午高分车队”;剧本名称是结构化检索用的,比如“年轮”。我当时纠结过要不要让用户只填剧本名称、标题自动生成。后来否掉了这个方案,因为自动生成容易显得生硬,而且不同玩家的表达习惯差异很大。但校验规则会做区分:标题允许最多 30 个字,剧本名称建议不超过 20 个字。
第二,“人数”这个字段不要天真地只放一个“总人数”。剧本杀组队有三种情况:整车(人齐了缺一个补位)、拼车(有几个散人再加几个)、带新(老带新)。如果产品只有总人数,后面处理“还差几个”的时候必然要再写分支逻辑。我的做法是把总人数和招募人数分开,总人数代表这个本的实际满员数量,招募人数代表发起方还能接受几个人。需求变化时,招募人数这个字段随时可以成为独立的筛选项,不用返工。
第三,时间字段的默认值不是“当前时间”,而是未来第七天的下午。剧本杀约局一般需要提前一两天甚至一周组织,默认“此时此刻”会让用户点击时产生警觉,总觉得自己选错了。我直接把默认值推到一周后,一方面符合真实使用场景,一方面也减少了日期选择器的打开率。
2.3 减少输入负担的三个实操细节
表单页面的用户流失多半发生在“填到一半放弃了”。原因很多时候不是功能复杂,而是页面本身给用户带来的心理压力太大。我在这版实现里做了三个很实际的设计,你们后面写别的表单也能复用。
第一个是地点记忆。用户在同一个城市、同一个桌游吧反复组队的概率很高,所以我用一个简单的 SharedPreferences 存取最近使用的地点,页面上用 DropdownButtonFormField 展示“最近使用”选项,同时保留“手动输入新地点”的入口。如果这个 App 后续接入账号系统,这个记忆甚至可以移到服务端,换设备也能同步。
第二个是草稿自动暂存。组队信息的填写往往会被微信消息打断,用户切出去回个消息再回来,如果发现输入内容全没了,大概率会直接关闭页面。我在每次表单内容变化时用防抖把草稿写入本地缓存,重新进入页面时自动恢复。这里有个细节需要注意:不是所有字段都要恢复,时间如果已经过期了就不要恢复,要避免用户提交了“上周六”的队伍。
第三个是发布成功后的“再来一局”。进入详情页或者回到列表页后,再次点击发起组队时,我的代码会从前一个成功数据里回填时间之外的全部字段。这个功能做起来很简单,就是把 Release 时的数据对象在内存或本地缓存里保留一份,但对用户来说非常贴心:上周末刚组过的剧本配置可以直接复用,连重新选择玩法类型都省了。
3. 表单核心实现:状态管理、校验与动态交互
3.1 页面骨架与状态管理选型
到实际写代码这步,绕不开的一个决策是“表单状态怎么管”。Flutter 里常用的是 setState + TextEditingController、Provider、Bloc 三种思路;如果用了 GetX 或 Riverpod 那就是另一套玩法。考虑到这个项目不是纯 UI Demo,后面还要接登录态、列表页刷新、用户信息缓存,我在项目初始化时引入了 Riverpod。不过本篇的表单是一个相对独立的功能模块,没有全局状态需要同步,所以我把表单状态局部管理放在 StatefulWidget 里,只对外暴露一个“提交结果回调”的接口。
为什么这么选?我当时也犹豫过要不要把页面上十几个状态全部塞进 Riverpod 的 Notifier,这样看起来更“架构正确”。但仔细分析后发现,表单里的所有状态生命周期都跟随页面本身,没有任何跨页共享的需求。如果强行用全局状态管理,还要照顾页面销毁时状态清理、已发布列表的监听断开等问题,代码反而复杂。
页面骨架我这里直接给出核心代码,你们可以感受下结构:
dart复制class CreateTeamPage extends StatefulWidget {
const CreateTeamPage({super.key, this.draftData});
final CreateTeamDraftData? draftData;
@override
State<CreateTeamPage> createState() => _CreateTeamPageState();
}
class _CreateTeamPageState extends State<CreateTeamPage> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _scriptController = TextEditingController();
final _remarkController = TextEditingController();
final _contactController = TextEditingController();
String _playType = '本格推理';
int _totalSeats = 6;
int _recruitCount = 1;
DateTime? _startTime;
String _location = '';
bool _isSubmitting = false;
@override
void initState() {
super.initState();
if (widget.draftData != null) {
_loadDraftData(widget.draftData!);
}
}
@override
void dispose() {
_titleController.dispose();
_scriptController.dispose();
_remarkController.dispose();
_contactController.dispose();
super.dispose();
}
}
draftData 参数是给“再来一局”和“编辑未发布草稿”这两个场景复用的,以后即使要加“编辑已上架队伍”的能力,也只改外层调用,不用动这个页面内部逻辑。方法 _loadDraftData 里会对传入的草稿做一个时间有效性判断,这个后面会再说。
3.2 表单校验:不止是“非空判断”
如果只是判断“输入是否为空”,Flutter 自带的 TextFormField validator 已经够用了,但实际做下来,项目需要的校验规则远不止这么简单。我在这页里定义了几条规则,分别对应不同字段的“业务语义”。
标题字段的校验最基础:不能为空、去掉首尾空格后至少 2 个字、不能超过 30 字。文案我写得比较具体,因为校验文案本身也是 UI 的一部分,直接决定用户是否能快速修复错误。我见过很多项目只显示“请输入标题”,用户根本不知道自己是没输入还是输入太长。
剧本名称字段除了非空,还有一个逻辑要处理:如果用户输入了“年轮”,但玩法类型仍然默认是“本格推理”,可能没问题;可如果用户输入的是《病娇男孩的精分日记》,默认的“本格”就不太合适了。这里面存在一个隐性的匹配问题,完全靠代码去判断剧本名称属于本格还是变格并不现实,因为剧本库不是无限的。我的做法是放下一个“修改后自动把玩法类型重置为未选择”的标记,用户确认玩法类型时,系统会弹出一个提示,防止以前选择的类型被误带过去。这个逻辑在草稿回填时也要处理,不然很容易出现“剧本填的是豪门惊情,类型还停留在欢乐机制”这种明显 bug。
时间字段的校验比较有意思。我用一个自定义 FormField 来包日期时间选择器,校验规则检查两点:不能为空、不能早于当前时间 30 分钟。为什么要放宽到 30 分钟而不是直接禁止过去时间?因为在页面上切到“明天”再切回“今天”的时候,用户也许只是想重新看下日历,不希望因为选错一个已经过去的时间而被硬生生拦下来。但也绝不能允许“一小时前”的时间被提交,否则会出现一个已经发车时间的队伍挂在列表里,造成用户困惑。
联系方式我采用了宽松校验。手机号用 1 开头的 11 位数字正则,但同时也允许用户填微信号或者 QQ 号,所以正则不是死板的“只接受手机号”。这在本地组织活动的场景里很常见,有些玩家不愿意给手机号,却愿意加微信。如果这里强制手机号,反而会挡掉一部分用户。
3.3 ChoiceChip 与步进器的联动实现
玩法类型和人数是两个天然适合“点选”而不是“输入”的字段,所以我用了 ChoiceChip 组成一个横向滚动的标签组。代码结构很简单,核心就是一个 Wrap 包着若干 ChoiceChip,选中状态由 _playType 字符串控制。
dart复制Wrap(
spacing: 8,
children: _playTypeOptions.map((type) {
return ChoiceChip(
label: Text(type),
selected: _playType == type,
onSelected: (selected) {
setState(() => _playType = selected ? type : '');
},
);
}).toList(),
)
之所以用 Wrap 而不是 SingleChildScrollView 里的 Row,是因为剧本杀类型标签数量不固定,今天可能加一个“机制还原本”,明天可能加一个“pve 探索本”,Wrap 在宽度不够时能自动换行,不会把标签挤出屏幕。数据类型上我用的是普通 String 而不是 int 枚举索引,因为后面要提交给后端的就是中文标签或固定的英文字段名,直接用 String 更直观,也方便和 mock 数据保持一致性。
人数步进器则是一个自绘的小组件,内部用两个 IconButton 控制加减,中间显示数字。这里有一个细节:总人数改变时,招募人数不能超过总人数。比如当前选的总人数是 7,招募人数已经加到 3,当用户把总人数减到 5 时,招募人数要自动被钳制到不超过 4。这个逻辑必须在 setState 里同步处理,否则会出现“全组 5 个人、还要再招 4 个”的矛盾数据,后端虽然也能校验,但前端就犯了低级的交互错误。
3.4 日期时间联动:日期与时间分开选择后的组装
Flutter 默认的 showDatePicker 和 showTimePicker 都是独立的,没有直接提供一个完整的“日期时间选择器”。我在页面里只放置一个“选择开本时间”的按钮,点击后依次弹出日期选择器和时间选择器,用户确认后再把两个结果拼成一个 DateTime。
这里有一个比较隐蔽的问题:如果用户先选了日期“2026年3月21日”,又弹出了时间选择器,但时间选择器里用户突然不想选了,点了取消,那么日期选择的结果应该被丢弃。很多新手会把日期选择结果直接 setState 进去,导致用户取消时间选择以后,日期却已经改变了。我的处理方式是先存到临时变量里,等时间选择器也返回非空结果后,统一 setState 提交:
dart复制Future<void> _pickDateTime() async {
final now = DateTime.now();
final pickedDate = await showDatePicker(
context: context,
initialDate: _startTime ?? DateTime(now.year, now.month, now.day + 7),
firstDate: DateTime(now.year, now.month - 1, now.day),
lastDate: DateTime(now.year + 1, 12, 31),
);
if (pickedDate == null || !mounted) return;
final pickedTime = await showTimePicker(
context: context,
initialTime: _startTime != null
? TimeOfDay.fromDateTime(_startTime!)
: const TimeOfDay(hour: 14, minute: 0),
);
if (pickedTime == null || !mounted) return;
setState(() {
_startTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
});
}
initialDate 这里如果直接传 now 的第七天,表达式很长,但效果稳定。需要注意的是 firstDate 我设成了上个月的同一天,而不是严格限制到今天,因为用户不需要看到一堆灰掉的日期,只需要确保最终选择结果在业务校验里通过。
3.5 提交按钮的防重复与加载态
表单页最后一步是提交。如果在校验通过后直接发起网络请求,用户快速点击两次“发布”,就可能创建两条重复队伍,这是很典型的低级 bug。我用一个 _isSubmitting 布尔值控制按钮状态:点击后立即置为 true,按钮显示 CircularProgressIndicator 并禁用。
在组装请求数据前,我先调用 FocusScope.of(context).unfocus() 收起键盘,避免提交过程中键盘遮挡提交结果提示。这个步骤看起来不起眼,但实际体验差异很大。如果键盘依然弹着,Navigator.pop 返回列表页时,经常会出现返回动画期间键盘闪一下的问题,观感很差。
防重复这里我还加了一层“守卫”,不是只靠按钮禁用。为什么?因为程序里除了按钮点击,还可能有物理返回键触发提交、外部 deep link 唤起提交等路径。在 _submit 方法入口直接判断:
dart复制if (_isSubmitting) return;
setState(() => _isSubmitting = true);
然后整个方法用 try/finally 包裹,finally 里把 _isSubmitting 复位。这样即使网络请求报错,按钮也能恢复可点击状态,不会出现一次失败后整个页面“永久禁用”的尴尬。
4. OpenHarmony 平台差异:键盘遮挡、日期选择与图片选择
4.1 输入法弹起与键盘遮挡问题
如果你只在 Android Studio 模拟器上跑过 Flutter,大概率不会遇到键盘把底部按钮顶出屏幕的情况,因为 Flutter 的 Scaffold 默认 resizeToAvoidBottomInset 为 true,键盘弹起时整个页面高度会自动收缩。但到了 OpenHarmony 真机上,这个默认行为不一定总是生效,尤其是在 RK3568 这类开发板上,输入法是一个独立窗口,部分定制 ROM 键盘弹起的动画持续时间和 Flutter 的 viewInsets 通知并不同步,于是出现一个现象:键盘已经弹起来了,但页面没有收缩,等几秒后突然跳一下。
我的处理方案是在页面根部套上两层保险。第一层是 Scaffold 的 resizeToAvoidBottomInset 保持默认开启,同时给最外层内容的 ListView 加上 padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 80)。注意这里不能用 MediaQuery.of(context).viewInsets.bottom 直接作为底部 padding,因为当键盘关闭时这个值会变成 0,会导致列表内容跳动。我额外加了一个 80 的逻辑像素,确保即使键盘状态异常,最后一个字段也能被滚动到可见范围内。
第二层保险是监听焦点变化,在当前获得焦点的输入框滚动到可视区域时使用 Scrollable.ensureVisible。这层逻辑手动写起来有点繁琐,但却是 OpenHarmony 上体验最稳定的方案。
4.2 日期选择器与主题色的适配
showDatePicker 在 Flutter 里走的是 Material 组件,OpenHarmony 上只要 Flutter 引擎能正常渲染,这个组件通常可以直接用。但我遇到过一个比较奇怪的问题:在某些 OpenHarmony 版本上,日期选择器弹出的对话框字体颜色和背景色都正常,但“确定”“取消”按钮的文本颜色跟主题色不一致,看起来像是 UI 样式被强制覆盖了。
排查后发现,问题出在 Flutter 应用的主题色没有贯穿到 Dialog 的 action 按钮,因为 showDatePicker 内部使用的是 Theme.of(context).colorScheme.primary,而我将 MaterialApp 的 colorScheme 设置成了自定义颜色,但部分组件用的是 colorScheme.secondary。这不是 OpenHarmony 的问题,但在这个平台上被放大了,因为默认 Material 主题在非 Google 设备上对颜色偏色的容忍度很低。
解决办法很简单:调用 showDatePicker 时,传入一个 builder,用 Theme 包一层自定义主题,显式指定对话框里按钮的颜色,并把日期选择器的背景改成与应用统一的浅色。
4.3 “调用鸿蒙图库”的完整降级方案
这是很多人真正头疼的地方:纯 Flutter 的 image_picker 插件默认不支持 OpenHarmony。如果表单里需要上传封面图,直接 pub get 安装 image_picker 可能在编译 ohos 工程时报找不到平台实现。我查过社区方案,目前 OpenHarmony 适配 Flutter 生态还处于早期,部分插件有社区 fork 版本,但不是标准插件都能跑。所以在这篇文章的项目里,我没有让表单首版强依赖图片上传,因为可控性和优先级都不划算。但这不代表我对“选图”这个需求没有准备,我留了一条稳定的降级链路。
如果后面确实需要在表单里加封面图,不建议自己从零写一个完整的图片选择器。合理的做法是:Flutter 侧通过 MethodChannel 调用 OpenHarmony 原生侧的 PhotoAccessHelper 拉起系统相册,拿到图片的 URI 后,再由原生侧拷贝到应用沙箱临时目录,返回给 Flutter 一个本地路径。OpenHarmony 的权限模型和 Android 不一样,需要在 module.json5 里声明类似 ohos.permission.READ_IMAGEVIDEO 的权限,运行时也要动态向用户申请。具体 API 在不同 SDK 版本里变化比较大,我没办法给出一份永远不过时的代码,但这条链路的骨架是通用的。
我特别想提醒的是,如果你在 OpenHarmony 表单里加了图片选择但一直编译不过,先不要怀疑 Flutter 代码写错了,九成问题出在原生侧权限配置和 MethodChannel 注册时机。我在接入测试时,一度反复编译同一个错误,最后发现是 EntryAbility 的 onCreate 里注册代码被热重载覆盖掉了,Flutter 侧报“MissingPluginException”,但原生侧完全没有日志输出。把注册逻辑移到 aboutToAppear 生命周期里后,问题立刻消失。
5. 数据组装与提交:从表单到业务状态
5.1 数据模型与本地校验的一致性
表单提交前,我会把所有控制器里的字符串全部 trim,并把可空字段统一转成空字符串或 null,避免后端收到“ ”这种脏数据。Flutter 侧我定义了一个 CreateTeamRequest 的不可变数据类,携带全部字段。这里有一个项目约定:所有从 UI 层传出去的数据模型,都必须经过 copyWith 或构造器创建,不允许在 UI 层直接修改 Repository 内部的可变对象。
dart复制class CreateTeamRequest {
final String title;
final String scriptName;
final String playType;
final int totalSeats;
final int recruitCount;
final DateTime startTime;
final String location;
final String remark;
final String contact;
const CreateTeamRequest({
required this.title,
required this.scriptName,
required this.playType,
required this.totalSeats,
required this.recruitCount,
required this.startTime,
required this.location,
required this.remark,
required this.contact,
});
}
使用不可变数据类的好处是在跨层传递时不会出现某个字段被中间逻辑悄悄篡改的情况。尤其在多人协作的项目里,UI 层只管组装,Repository 层只管提交,这种边界划分能让后续接后端接口时省去大量调试时间。
5.2 Repository 层与网络权限的坑
在我们这个模拟项目中,Repository 层暂时没有真正对接后端,因为后端服务还在开发中。我在接口定义上使用抽象类,这样以后后端 ready,只需要换一个实现类即可:
dart复制abstract class TeamRepository {
Future<String> createTeam(CreateTeamRequest request);
}
class MockTeamRepository implements TeamRepository {
@override
Future<String> createTeam(CreateTeamRequest request) async {
await Future.delayed(const Duration(milliseconds: 800));
return 'mock_team_id_${DateTime.now().millisecondsSinceEpoch}';
}
}
不过这里必须提醒一个 OpenHarmony 特有的大坑:如果你的 App 后续要访问真实网络,需要在 ohos 工程的 module.json5 里声明 INTERNET 权限。这个权限不像 Android 那样默认放行,OpenHarmony 对网络权限管控比较严格,不加权限时网络请求会直接抛异常且没有明确报错。我在最初跑通 mock 的时候没有遇到这个问题,因为 mock 不发起真实请求,但等第一次打算请求本地服务端接口时,直接卡了半小时。如果你们在 OpenHarmony 上写网络层,第一时间先把权限清单核对一遍。
另外,如果你的后端接口是 http 明文请求而不是 https,还需要处理 OpenHarmony 网络安全配置的允许明文传输选项,一般是在工程的配置文件里加入 cleartextTrafficPermitted 之类的开关。具体字段名因 SDK 版本而异,碰到时可以查一下对应版本的 API 文档,别在自己代码里反复找。
5.3 提交成功后的页面跳转与数据刷新
调完 createTeam 方法后,只有在拿到服务端返回的 teamId 时才算真正成功。Success 分支里,我使用 Navigator.pop 并把创建结果传回上一页:
dart复制if (mounted) {
Navigator.of(context).pop(teamId);
}
上一页的列表组件在 push 页面时通过 await 等待返回值,只要返回值不为空,就触发一次队伍列表刷新。这种做法比“发布成功后直接跳详情页”更克制:用户可能发布完还想看看别的内容,直接强制跳详情页反而打断操作流。列表页的刷新逻辑是后续要讲的另一个主题,目前的 mock 实现只是在列表头部插入一条新队伍。
失败分支的处理也需要注意 UI 反馈。如果只是弹一个 SnackBar 提示“发布失败”,用户不得不重新填一遍所有数据,这种体验是灾难级的。我在失败分支中保留了当前页面所有状态,并通过 try/catch 捕获到具体错误信息后展示在顶部横幅,同时给出“重试”按钮。重试时直接复用 _submit 方法里的全部数据,不会因为网络抖动丢失用户已经填写的内容。
5.4 后台返回之后的草稿清理
当发布成功并且列表页已经拿到 teamId,当前表单页从导航栈里被移除。如果用户重新进入创建页,系统不应该再恢复上一次已发布的草稿,否则会出现重复发布的困惑。所以我在成功发布的回调里会 clear 掉本地草稿缓存。这里可以用 SharedPreferences 或简单的单例缓存,看项目整体取舍。
草稿清理还有一个边角情况:如果用户发布了 A 队伍的草稿,又创建了 B 队伍,然后返回到列表页,缓存应该清的是 A 而不是 B。如果只是用“固定 key 缓存一个草稿”,清 A 时可能误伤 B。我的做法是每次创建草稿时生成一个唯一的 draftId,和队伍模板数据一起缓存,发布成功后只删除对应 draftId 的缓存,既解决了误伤问题,也给后续“保存草稿列表”的功能留了扩展空间。
6. 真机调试踩坑与性能优化备忘
6.1 常见问题速查表
把这一段时间在 OpenHarmony 真机上调试表单页遇到的典型问题整理成了一张表,供参考。这些问题不是教程里能学到的,基本都是硬件、固件、Flutter 引擎三者相互摩擦出来的:
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 键盘弹起后页面不收缩 | OpenHarmony 输入法窗口与 Flutter viewInsets 通知不同步 | 开启 resizeToAvoidBottomInset 并在 ListView 底部手动加 padding |
| 输入框获得焦点后光标错位 | 部分 RK 系列设备开启了屏幕自动旋转或显示缩放 | 关闭显示缩放,固定页面方向后重测 |
| 日期选择器“确定”按钮颜色异常 | 主题色没有正确传递到 Dialog action | 在 showDatePicker 的 builder 中用 Theme 包裹 |
| 调用图库返回 MissingPluginException | 原生侧 MethodChannel 注册时机不对 | 把注册逻辑放到 Ability 的 aboutToAppear 生命周期中 |
| 提交网络请求超时或失败 | 未声明 INTERNET 权限 | 检查 module.json5 中的权限配置 |
| debug 模式下表单输入卡顿明显 | OpenHarmony debug 引擎性能损耗高于 Android | 使用 flutter run --profile 或 release 包验证性能 |
| 中文输入法联想词把页面顶出可视区 | 输入法候选词窗口计算异常 | 在输入框获得焦点后手动滚动到可视区域 |
6.2 减少重建与提升滚动流畅度
表单页输入过程中,点击人数步进器会触发 setState,这是很常见的操作。但要注意一个细节:如果 setState 的粒度太大,整个页面的 TextFormField 都会被重建,用户正在输入的内容虽然不会丢,但正在进行的输入法联想词状态可能被打断。我处理这个问题的办法是尽量把步进器和标签组拆成独立 StatefulWidget,让它们只在自己内部刷新:
dart复制StepperInput(
value: _recruitCount,
min: 1,
max: _totalSeats - 1,
onChanged: (value) {
_recruitCount = value;
},
)
父级并不需要因为 _recruitCount 的变化而 rebuild 整个列表,只有在提交时才会读取最终值。这样操作步进器时,输入标题的 TextFormField 完全没有被重建,输入法状态也就稳定了。
文本输入字段自身是 TextFormField 的 Controller 模式,这本来就是 Flutter 推荐的局部更新方案。实际测试下来,在 RK3588 设备上,页面基本能稳定在 60 帧附近;在 RK3568 这种性能稍弱的设备上,release 模式尚可,debug 模式输入时能感觉到明显掉帧。所以如果你们的产品要大规模铺到低端开发板,建议把性能验收直接放在 release 包上进行,不要拿 debug 包的数据当真。
6.3 RK3568 设备选型与设备树问题的提示
项目最初在 RK3568 开发板上验证时,遇到过开机直接黑屏的问题。排查到最后不是 Flutter 工程的问题,而是设备树选得不对。同一块 RK3568 芯片会有多个开发板变体,有的用 HDMI 输出,有的用 MIPI DSI 接屏幕,还有的通过 LVDS 转接板。如果你用的固件默认设备树是针对 MIPI 屏的,但实际接的是 HDMI 显示器,启动阶段会找不到显示接口,出现“代码看着没问题、但屏幕上就是什么都没有”的诡异现象。
这一点和 Flutter 本身无关,但是是 OpenHarmony 开发绕不开的环境问题。如果你也遇到烧录完系统后屏幕无输出,先花十分钟确认设备树到底选的是哪个变体,再考虑是不是应用层的问题。我自己在这个环节浪费过很长时间,所以在这里记一笔。
6.4 热点模块中“主题色修改”的一个延伸提醒
在做这个页面时,我顺手把应用的主题色整理了一遍,因为这个表单页用到了大量的输入框、标签、日期选择器,不同组件的激活颜色不统一会显得很乱。如果你们的项目也出现了“某个组件颜色跟整体不搭”的情况,先检查 MaterialApp 的 theme 是否设置了 colorSchemeSeed,并尽量用 ThemeData 里的扩展字段统一定义。我实际中见过一个比较隐蔽的情况:输入框的下划线颜色跟随 colorScheme.primary,而 ChoiceChip 的选中背景色却跟随 colorScheme.secondary,结果整个表单看起来像两套主题拼在一起。将这两个属性都统一到同一个 colorScheme 色板后,视觉上立刻就干净了。
7. 关于这个页面后续迭代的几点经验
表单页面做到这里,基础的“发起组队”闭环已经完整了。从我个人的经验看,这块后续大概率会碰到的不是 UI 问题,而是产品逻辑和数据口径问题,所以分享几个视角给同在做这类组队工具的朋友。
如果你后面要接真实后端,建议把“总人数”“招募人数”“已加入人数”这三个数字的约束一次性定义清楚,不要让前端单独维护一套逻辑。比如服务端最终要以总人数和已有成员数量为准,而不是信任前端的招募人数。我在模拟数据阶段用本地变量直接计算,感觉没问题,但多人协作时这些字段稍不留神就会产生歧义,最稳妥的做法是接口层面的入参和返回都带上明确的字段说明。
另一个建议是给创建成功的队伍设置一个“可撤销时间窗”。剧本杀临时跳车很常见,用户发错时间或地点后需要一个纠错机会。在这篇文章的实现里,我只处理了发布成功即返回的逻辑,还没做“发布后编辑”的入口,但如果团队产品规划里有这个需求,未来把 CreateTeamPage 扩展成编辑页时,会发现当初数据模型通过 draftData 回填的设计可以直接复用,要改的地方很少。这算是一点前瞻性设计带来的隐性收益。
最后再分享一个更细的技巧:表单页面的错误提示文案,一定要在真机上多看几遍,不要只在模拟器里验证。部分 OpenHarmony 开发板使用的中文字体渲染宽度和标准 Android 不一样,同样的文案在模拟器上只占一行,在真机上可能换行,甚至会把按钮挤变形。我在“联系方式”的校验提示里曾经写了一条长长的错误文案,模拟器里好好的,切到设备上直接溢出屏幕。后来把所有提示文案都控制在 18 个字以内,同时配合 Expanded 包裹,彻底解决了这个问题。
表单是组队 App 的起点,也是一整条业务链路最容易出低级错误的地方。把这块打磨扎实以后,再去实现加入队伍、席位变更、发车提醒这些功能时,就不需要再回头补数据模型的坑了。
