上一篇文章我们把发现页的布局定了下来,但坦白讲,光有发现页是跑不通业务闭环的。有朋友私下问我,什么时候写“发起组队”的逻辑,其实我比你们还急,因为只有把发起组队的表单真正落地,后续的组队列表、详情页、成员加入才有数据可以流转。今天这篇就把这个核心入口彻底讲明白。
先说清楚这篇要解决什么问题:让用户在一个表单页面里,把剧本名称、约玩时间、人数上限、玩法标签、补充说明这些信息填完,点击提交之后,数据能正确保存下来,并且在发现页的列表里能立刻看到新创建的组队信息。这个过程涉及表单控件选型、字段校验、状态同步、数据持久化,以及在 OpenHarmony 上运行 Flutter 时不那么顺滑的几个兼容问题。
这篇内容适合谁看?如果你正在用 Flutter 做 OpenHarmony 应用开发,或者你只是想在任意 Flutter 项目里把「发单/创建/发布」这类表单模块做得扎实一点,这篇都值得你从头到尾过一遍。我会把从字段设计到代码实现、再到平台适配踩坑的全过程都写出来,基本上是拿着代码说人话。
1. 需求梳理与字段设计
动手写代码之前,先别急着堆 Widget。我见过太多人一上来就撸 TextField,撸到一半发现字段之间各种联动关系理不清,改布局改到怀疑人生。表单页最忌讳的就是边写边想「要不要加个字段」,这种摇摆只会让代码越来越乱。
1.1 组队的核心信息拆解
剧本杀组队这个场景,用户想表达的核心诉求就一句话:我在某个时间,想玩某个本,还缺几个人,一起来。根据这个诉求,我把发单页的字段拆成了四个维度:
| 维度 | 字段 | 类型 | 是否必填 |
|---|---|---|---|
| 玩什么 | 剧本/主题名称 | 文本输入 | 是 |
| 找谁玩 | 所需总人数 | 步进器选择 | 是 |
| 什么时候 | 开始时间 | 日期+时间选择 | 是 |
| 补充信息 | 玩法标签、角色偏好、备注说明 | 多选标签+多行文本 | 否 |
这里有一个很重要的产品判断:用户填写成本要尽量低,字段能省则省。剧本杀组队不像职场 OA 流程,用户不会愿意在手机上填一个十几项的表单,每多一个必填项,发单转化率就掉一截。所以我把门店地址、费用说明这类信息合并到备注里,而不是单独做成必填字段,等用户量起来之后,再根据真实需求决定要不要把某个字段提升为独立项。
1.2 数据结构设计
围绕表单字段,我先定义一个数据模型,这个模型后续会贯穿整个 App 的列表页和详情页。在 Flutter 项目里新建 model/team_entity.dart:
dart复制class TeamEntity {
final String id;
final String playName;
final int maxPlayers;
final DateTime startTime;
final List<String> tags;
final String? rolePreference;
final String? description;
final String ownerName;
final DateTime createTime;
TeamEntity({
required this.id,
required this.playName,
required this.maxPlayers,
required this.startTime,
required this.tags,
this.rolePreference,
this.description,
required this.ownerName,
required this.createTime,
});
factory TeamEntity.fromJson(Map<String, dynamic> json) {
return TeamEntity(
id: json['id'] as String,
playName: json['playName'] as String,
maxPlayers: json['maxPlayers'] as int,
startTime: DateTime.fromMillisecondsSinceEpoch(json['startTime']),
tags: List<String>.from(json['tags'] ?? []),
rolePreference: json['rolePreference'] as String?,
description: json['description'] as String?,
ownerName: json['ownerName'] as String,
createTime: DateTime.fromMillisecondsSinceEpoch(json['createTime']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'playName': playName,
'maxPlayers': maxPlayers,
'startTime': startTime.millisecondsSinceEpoch,
'tags': tags,
'rolePreference': rolePreference,
'description': description,
'ownerName': ownerName,
'createTime': createTime.millisecondsSinceEpoch,
};
}
}
关于标签,我单独维护一个常量列表,方便后续做兴趣推荐的时候直接复用:
dart复制class TeamTags {
static const List<String> gameTags = ['硬核推理', '欢乐机制', '情感沉浸', '恐怖惊悚', '阵营对抗'];
static const List<String> roleTags = ['侦探位', '凶手位', '边路位', '新手友好'];
}
设计这个模型的时候,有两点值得说一下。第一,id 我没有用自增主键,而是用 DateTime.now().microsecondsSinceEpoch.toString() 加前缀生成,好处是本地生成、全局唯一,不需要依赖数据库返回。第二,时间字段存的是毫秒时间戳,不是格式化字符串。字符串看起来直观,但排序、比较大小都要额外处理,时间戳才是正道。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 表单 UI 层实现
模型定好了,接下来就是真正写界面。表单页我的整体布局思路是:AppBar 给标题,底部固定提交按钮,中间内容区用 ListView 承载所有表单项。这样结构清晰,又能天然规避软键盘弹起时溢出渲染的问题。
2.1 页面骨架与表单控件选型
先看一下页面的基础结构:
dart复制class CreateTeamPage extends StatefulWidget {
const CreateTeamPage({super.key});
@override
State<CreateTeamPage> createState() => _CreateTeamPageState();
}
class _CreateTeamPageState extends State<CreateTeamPage> {
final _formKey = GlobalKey<FormState>();
final _playNameController = TextEditingController();
final _descriptionController = TextEditingController();
final _rolePreferenceController = TextEditingController();
int _
