上一篇文章我们把发现页的布局定了下来,但坦白讲,光有发现页是跑不通业务闭环的。有朋友私下问我,什么时候写“发起组队”的逻辑,其实我比你们还急,因为只有把发起组队的表单真正落地,后续的组队列表、详情页、成员加入才有数据可以流转。今天这篇就把这个核心入口彻底讲明白。
先说清楚这篇要解决什么问题:让用户在一个表单页面里,把剧本名称、约玩时间、人数上限、玩法标签、补充说明这些信息填完,点击提交之后,数据能正确保存下来,并且在发现页的列表里能立刻看到新创建的组队信息。这个过程涉及表单控件选型、字段校验、状态同步、数据持久化,以及在 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 _maxPlayers = 6;
DateTime _startTime = DateTime.now().add(const Duration(hours: 1));
final List<String> _selectedTags = [];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('发起组队'),
elevation: 0,
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildPlayNameField(),
const SizedBox(height: 20),
_buildTimeSelector(),
const SizedBox(height: 20),
_buildPlayerCountSelector(),
const SizedBox(height: 20),
_buildTagSelector(),
const SizedBox(height: 20),
_buildRolePreferenceField(),
const SizedBox(height: 20),
_buildDescriptionField(),
const SizedBox(height: 80),
],
),
),
bottomNavigationBar: _buildSubmitBar(),
);
}
}
为什么用 Form 包裹,而不是手动判断字段内容?因为 Flutter 的 Form 配合 TextFormField 自带的 validator,可以在提交时统一触发表单内所有校验项,不用自己维护一堆布尔状态。这个机制对表单页来说太重要了,后面校验部分会详细展开。
底部按钮放到 bottomNavigationBar,而不是列表最后一项,原因很直接:用户填完信息以后,无论滚到哪一屏,提交按钮永远在底部可见,不需要滑回去再找按钮。这个体验细节在长表单上尤其明显。
2.2 文本输入类字段实现
先写剧本名称输入框,这个字段是整个表单的“门面”,我用 TextFormField 加一些装饰性配置:
dart复制Widget _buildPlayNameField() {
return TextFormField(
controller: _playNameController,
maxLength: 30,
decoration: InputDecoration(
labelText: '剧本名称',
hintText: '如:漓川怪谈簿 / 一座城 / 持斧奥夫',
prefixIcon: const Icon(Icons.menu_book_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
counterText: '${_playNameController.text.length}/30',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请填写剧本名称';
}
return null;
},
);
}
这里有个容易踩的小坑:maxLength 默认会在输入框右下角显示一个计数器,样式比较丑。我通过 counterText 覆盖成自定义计数,让文案跟整体排版风格更协调。validator 里的 trim() 很重要,不然用户输入一堆空格也能通过校验。
补充说明字段我用多行文本,允许用户表达更具体的内容,比如门店地址、车队要求、上车票价等等。为了控制数据库存储量,设置 maxLines: 4 加 maxLength: 200:
dart复制Widget _buildDescriptionField() {
return TextFormField(
controller: _descriptionController,
maxLines: 4,
maxLength: 200,
decoration: InputDecoration(
labelText: '补充说明',
hintText: '门市地址 / 车队要求 / 费用说明...',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
}
角色偏好字段我单独放了一个文本输入框,而不是做成选择项。为什么?因为用户对“角色”的理解千差万别,有的人想表达“我拿侦探位”,有的人想说“我不要凶手本”,与其给一堆预设选项让用户挑,不如给一个自由输入的输入框。预设选项是产品自己猜的,自由输入才是用户真实需求的第一手来源。初期版本把选择做成输入框,等数据积累到一定量,再把高频回答转成候选标签,这才是合理的迭代路径。
2.3 日期时间与人数选择实现
剧本杀组队必须有个开本时间,否则其他人没法判断能不能赶上。日期选择我调用 Material 自带的选择器:
dart复制Widget _buildTimeSelector() {
return InkWell(
onTap: _selectStartTime,
borderRadius: BorderRadius.circular(12),
child: InputDecorator(
decoration: InputDecoration(
labelText: '开始时间',
prefixIcon: const Icon(Icons.schedule),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'${_startTime.year}-${_startTime.month.toString().padLeft(2, '0')}-${_startTime.day.toString().padLeft(2, '0')} '
'${_startTime.hour.toString().padLeft(2, '0')}:${_startTime.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 16),
),
),
);
}
Future<void> _selectStartTime() async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
initialDate: _startTime,
firstDate: now,
lastDate: now.add(const Duration(days: 60)),
);
if (date == null) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_startTime),
);
if (time == null) return;
setState(() {
_startTime = DateTime(date.year, date.month, date.day, time.hour, time.minute);
});
}
我把日期和时间拆成了两个系统对话框。有人可能觉得这一步一步弹框很繁琐,不如用第三方库一次选完。但实测下来,系统自带的 showDatePicker 在 OpenHarmony 上的稳定性比第三方日期库要好,少一个依赖就少一个兼容隐患。记住 firstDate 不能是过去时间,否则用户选了昨天的时间,后端要校验拦截,体验反而更差。
人数选择器是一个自定义步进器,拆成减号和加号两个按钮,中间显示当前人数。这里加了一个边界判断:最少 1 人,最多 12 人,超过边界以后对应按钮置灰,避免用户反复点但没反应而产生困惑:
dart复制Widget _buildPlayerCountSelector() {
return Row(
children: [
const Text('人数上限', style: TextStyle(fontSize: 16)),
const Spacer(),
IconButton(
onPressed: _maxPlayers > 1
? () => setState(() => _maxPlayers--)
: null,
icon: const Icon(Icons.remove_circle_outline),
),
Text(
'$_maxPlayers 人',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
IconButton(
onPressed: _maxPlayers < 12
? () => setState(() => _maxPlayers++)
: null,
icon: const Icon(Icons.add_circle_outline),
),
],
);
}
人数最好不要用下拉框。剧本杀的人数通常就是 4 到 12 之间的某个值,用步进器点两下就能选到,比从列表里一个个找要快得多。这种高频小额交互,步进器永远是首选。
2.4 标签多选实现
标签选择我用 Wrap 加 FilterChip,为什么不用 DropdownButton?因为用户对玩法标签的诉求是“可以多选”,下拉框天然不适合多选场景。用标签流式的展示方式,用户扫一眼就能看到全部选项,点一下选中、再点一下取消,交互反馈非常直观:
dart复制Widget _buildTagSelector() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('玩法标签', style: TextStyle(fontSize: 16)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: TeamTags.gameTags.map((tag) {
final selected = _selectedTags.contains(tag);
return FilterChip(
label: Text(tag),
selected: selected,
onSelected: (value) {
setState(() {
if (value) {
_selectedTags.add(tag);
} else {
_selectedTags.remove(tag);
}
});
},
);
}).toList(),
),
],
);
}
FilterChip 本身会自带一个选中打勾的状态,用户视觉上很清楚哪些选了、哪些没选。我不建议自己用 GestureDetector 包 Container 手搓一个标签选择器,选中颜色、取消逻辑、无障碍支持都要你重新实现,纯属吃力不讨好。
3. 校验逻辑与提交流程
UI 只是表象,表单页真正的复杂度在于提交时的数据聚合和校验。这里我单独拿出来一节说,因为大多数新手写表单,UI 写完就以为万事大吉,结果提交数据时各种 null 崩溃、字段缺失、状态异常,傻眼了。
3.1 表单校验规则定制
我在 _formKey.currentState!.validate() 这一层做整体校验,每个 TextFormField 的 validator 会在这一步统一触发。除了基础的非空校验,我还加了两个自定义规则:
- 开始时间必须晚于当前时间至少 30 分钟,防止用户创建一场马上就开始又赶不上的局。
- 玩法标签至少选一个,保证组队信息在列表页有足够的可辨识度。
时间这个校验,我在点击提交按钮的时候做:
dart复制void _submit() {
if (!_formKey.currentState!.validate()) return;
final now = DateTime.now();
if (_startTime.difference(now).inMinutes < 30) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('开始时间至少需要晚于当前时间 30 分钟')),
);
return;
}
if (_selectedTags.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请至少选择一个玩法标签')),
);
return;
}
// 构建实体
final team = TeamEntity(
id: 'team_${DateTime.now().microsecondsSinceEpoch}',
playName: _playNameController.text.trim(),
maxPlayers: _maxPlayers,
startTime: _startTime,
tags: List.from(_selectedTags),
rolePreference: _rolePreferenceController.text.trim().isEmpty
? null
: _rolePreferenceController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
ownerName: '我',
createTime: DateTime.now(),
);
TeamStorage.instance.saveTeam(team);
Navigator.pop(context, team);
}
有的读者可能会问:为什么校验不全部放进 validator,而是手动写在 _submit 里?因为 validator 是 Flutter 表单自带的回调,它更适合校验跟某个 TextFormField 绑定的字段;而时间、标签这种跨控件、跨组件的数据,直接写在提交逻辑里反而更直观。两种方式各有适用场景,不要为了统一而统一。
3.2 提交按钮的交互状态管理
提交按钮我做了一个防重复提交的处理,这在所有表单页里都属于刚需。用户在弱网环境下连续点提交,如果不加锁,理论上会触发两条相同数据的插入:
dart复制Widget _buildSubmitBar() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: FilledButton(
onPressed: _isSubmitting ? null : _submit,
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
),
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('创建组队', style: TextStyle(fontSize: 16)),
),
),
);
}
_isSubmitting 这个状态我一开始是没有的,后来在模拟器上快速双击提交按钮,发现本地存储里出现了两条完全一样的记录,才意识到需要加一层“提交锁”。注意,用 onPressed: null 来禁用按钮,比用 if (_isSubmitting) return; 的判断多了一层视觉反馈:按钮置灰 + 转圈,用户能感知到“正在提交”,不会傻等。
3.3 使用回调返回结果给上一页
提交完成以后,新创建的 TeamEntity 需要通过 Navigator.pop(context, team) 返回给发现页。这是 Flutter 页面之间数据传递的标准姿势:上一页用 Navigator.push 打开表单页时,await 这个操作,拿到返回值后再局部刷新列表数据。
这里有个细节值得注意:不要在表单页里直接拿一个全局的 Provider 去改列表数据,也不要用 EventBus 去广播。直接用路由返回值,数据流最清晰,页面之间的耦合度最低。真项目里等页面层级复杂了,再考虑上全局状态管理也不迟,表单页这种单层页面跳转用原生的返回机制就够了。
4. OpenHarmony 适配实战:那些文档里没写的坑
这一节是整个项目里最让我头疼,但也是收获最大的部分。Flutter 跑在 OpenHarmony 上,跟跑在 Android/iOS 上,不少细节是有差异的。如果你的项目未来要兼容 OpenHarmony,下面这些点值得你保存下来。
4.1 Flutter SDK 版本与依赖兼容性
OpenHarmony 的 Flutter 分支目前还谈不上完全成熟,官方主分支的更新节奏和大版本差异,都会直接影响你依赖的第三方库能不能正常编译运行。我建议在项目启动前就锁定一套经过验证的版本组合,不要一股脑追最新版。
在我这个项目里,日期选择用的是 showDatePicker 这种系统组件,它在 OpenHarmony 上能跑,但弹出样式跟 Android 上不太一样,会在底部以类似半屏弹层的形式出现。如果你的 UI 设计稿对日历样式有非常强的定制要求,需要做好适配的心理准备。另外,某些常用的第三方表单库,比如 flutter_form_builder,在 OpenHarmony 上会有部分控件渲染异常,我测下来最稳定的做法就是少用第三方控件,尽量用 Foundation 库里的系统组件。
4.2 中文字体与渲染问题
OpenHarmony 的默认中文字体是 HarmonyOS Sans,但 Flutter 引擎在 OpenHarmony 上加载字体资源的机制和 Android 不同。如果你在 Android 上设置了 fontFamily: 'PingFang SC' 这类 iOS 字体名称,到 OpenHarmony 上就找不到这个字体,系统会回退到默认字体,造成行高和字重显示不一致。
我的处理方式是在 MaterialApp 的 theme 里统一配置字体回退链:
dart复制theme: ThemeData(
fontFamilyFallback: const ['HarmonyOS Sans', 'Roboto', 'system-ui'],
),
这样在 OpenHarmony 设备上,系统会优先尝试 HarmonyOS Sans,找不到再回退到 Roboto。实测下来中英文混排的显示效果正常,不会出现方块字或者字体大小不一致的问题。
4.3 系统键盘遮挡输入框
表单页最经典的坑就是软键盘弹起来以后,底部的提交按钮和输入框被遮住。正常情况下 Flutter 的 Scaffold 里设置 resizeToAvoidBottomInset: true(默认就是 true),键盘弹起来后页面会自动上推。但在 OpenHarmony 的部分版本上,这个默认行为有些失效,键盘会直接覆盖在页面上。
我的解决方案是给 Scaffold 显式加一行配置:
dart复制Scaffold(
resizeToAvoidBottomInset: true,
...
)
然后确保内容区域是 ListView 而不是 Column,这样即使键盘顶起内容,用户也可以通过滚动来看到被遮挡的部分。如果某些场景下仍然被遮挡严重,可以考虑在提交按钮外层再包一个 SafeArea,进一步保证按钮可见性。
4.4 本地持久化的兼容选择
表单提交后要把 TeamEntity 保存下来,这就涉及到本地存储选型。在 OpenHarmony 上,社区适配比较成熟的方案没有 Android 那么多。我实测了几种方案,最终选用的是基于文件存储的 JSON 序列化,而不是直接依赖 SQLite。
原因很简单,sqflite 在 OpenHarmony 上的原生插件还不太稳定,需要额外配置 ohos 平台的 runner,配置成本和踩坑成本都不小。我的场景是保存“我发起的组队”和“我加入的组队”,数据量不会很大,用 JSON 文件存储完全足够。直接落盘到应用私有目录,读出来以后用 jsonDecode 解析成 List<TeamEntity>,简单高效:
dart复制class TeamStorage {
TeamStorage._();
static final TeamStorage instance = TeamStorage._();
static const _fileName = 'teams.json';
Future<File> _getFile() async {
final dir = await getApplicationDocumentsDirectory();
return File('${dir.path}/$_fileName');
}
Future<List<TeamEntity>> loadTeams() async {
try {
final file = await _getFile();
if (!await file.exists()) return [];
final content = await file.readAsString();
if (content.isEmpty) return [];
final list = jsonDecode(content) as List<dynamic>;
return list
.map((e) => TeamEntity.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
return [];
}
}
Future<void> saveTeam(TeamEntity team) async {
final teams = await loadTeams();
teams.add(team);
final file = await _getFile();
final content = jsonEncode(teams.map((e) => e.toJson()).toList());
await file.writeAsString(content);
}
}
getApplicationDocumentsDirectory() 来自 path_provider,这个插件在 OpenHarmony 上已经有社区适配版本了。如果你的项目对数据安全性要求更高,后续可以再迁移到更底层的原生数据库,初期用文件存储保证功能快速跑通是最务实的做法。
5. 表单页功能自测与常见问题排查
很多开发者的习惯是写完代码能编译通过就提交了,结果自测的时候一堆问题。我建议表单页这种重交互的页面,一定要逐个字段、逐个场景走一遍测试清单,否则到了集成测试阶段才暴露问题,定位成本翻好几倍。
5.1 自测清单
以下是我跑这个表单页时整理的测试用例,可以直接抄:
- 剧本名称为空时点击提交,校验提示是否正常出现。
- 剧本名称超过 30 字,是否还能继续输入。
- 人数减到 1 之后,减号按钮是否置灰。
- 人数加到 12 之后,加号按钮是否置灰。
- 选择一个标签后,标签样式是否有选中态变化。
- 不选任何标签点击提交,是否有提示。
- 选择日期时间后,时间显示是否与所选一致。
- 提交成功后返回上一页,列表是否出现新数据。
- 重复点击提交按钮,是否只有一条数据写入。
这类测试用例写一遍不费时间,但能帮你把表单页的边界状态都覆盖到位。我经常说,表单页写代码可能只花 30% 的时间,剩下的 70% 都在处理边界条件。
5.2 常见问题速查表
| 问题 | 可能原因 | 解决方案 |
|---|---|---|
| 点击提交按钮无任何反应 | validator 校验未通过,但错误提示未正常展示 | 检查 TextFormField 是否包裹在 Form 内部;查看 SnackBar 是否被遮挡 |
| 日期选择器弹出后白屏 | OpenHarmony 对 showDatePicker 支持不完整 | 降级使用自实现底部弹层,或用老版本的 Flutter SDK |
| 键盘弹出后提交按钮不可见 | resizeToAvoidBottomInset 未生效 | 显式设置 true,提交按钮放 bottomNavigationBar |
| 表单数据丢失 | 页面重建导致 State 被回收 | 用 PageStorageKey 或把状态提升到 controller 层 |
| 重复提交产生多条数据 | 缺少提交防重锁 | 加 _isSubmitting 状态控制按钮点击 |
| 中文输入法候选词遮挡输入框 | OpenHarmony 输入法适配问题 | 滚动视图兜底,必要时监听键盘高度自适应 |
| 列表页刷新后没有新数据 | 存储写入失败或数据未重新加载 | 检查文件路径是否正确,json 序列化是否有异常 |
5.3 一个值得说的细节:软键盘顶起后的背景颜色
这个问题不查资料绝对想不到。OpenHarmony 上软键盘弹起时,页面上推的区域默认是黑色的,跟 App 整体白色的主题格格不入。解决办法是在 Scaffold 外层包一个 ColoredBox,或者给 MaterialApp 的 theme 设置 scaffoldBackgroundColor:
dart复制theme: ThemeData(
scaffoldBackgroundColor: Colors.white,
...
),
设置了以后,键盘弹起时上推区域的背景色就会跟随主题色,视觉上自然很多。这类问题只有真机调试才能发现,模拟器上通常看不出来。
6. 表单页的性能与体验优化
功能能跑通,不代表体验过关。我拿到一台配置不怎么样的测试机跑了几天,发现表单页如果优化不到位,就很容易给人“卡卡的”感觉。这里分享几个我实际验证过的优化点。
6.1 避免多余的 setState
FilterChip 的选中回调里我直接调用了 setState,这本身没问题,但如果整个表单页的 StatefulWidget 有大量子控件,每次 setState 会触发整棵子树的重建,在低端机上就能感觉到掉帧。一个轻量级的优化是把每个表单项拆成独立的 StatefulWidget,让状态变化只影响对应的控件。
不过,老实说,对于一个字段数量固定的页面,拆分的收益并不明显。真正应该避免的是在 build 方法里做耗时操作,比如 jsonEncode、文件读写、正则匹配,这些都应该放到提交回调或者异步函数里。
6.2 输入框的文本变化监听
如果需要在输入过程中实时展示字数、实时校验格式,可以用 TextEditingController 加 listener。但要注意,listener 在每次键盘输入时都会触发,如果里面有耗时的计算,就会直接影响打字流畅度。
我的经验是:能提交时再校验的,绝不在输入过程中校验。只有在有明确产品诉求(比如注册页的密码强度实时提示)时才用 listener,否则默认行为就够了。
6.3 防止页面重复打开
用户从发现页点“发起组队”,如果因为网络慢或者页面卡顿,他多次点击入口按钮,就会在 Navigator 栈里 push 多个表单页。这个问题不属于表单页本身的逻辑,但很影响体验。在入口按钮的 onPressed 里加一个跳转防抖即可:
dart复制if (_navigating) return;
_navigating = true;
await Navigator.push(...);
_navigating = false;
这些优化细节看着不起眼,但恰恰是区分“能用”和“好用”的分界线。
写在最后的个人体会
发起组队表单这个页面,从需求到落地,我前后花了大概三天时间。第一天设计字段和数据结构,第二天写 UI 和校验逻辑,第三天主要耗在 OpenHarmony 的兼容适配和真机测试上。说实话,表单页本身的 Flutter 代码写起来很快,真正折磨人的是那些“看起来能用、但换个平台就出问题”的细节。
我个人最大的体会是:做跨平台项目,一定要给平台适配留出单独的排期。尤其是 Flutter 跑在 OpenHarmony 上这个组合,还处在快速演进期,千万不要把 Android 上跑通了就当全部跑通了。每次在 OpenHarmony 真机上验证一遍,你能发现的隐性 Bug 远比想象的多。
另外,如果你也在做类似的原生能力调用,我建议你提前确认目标平台对 path_provider、图片选择、地理位置这些高频插件支持到什么程度。能用系统组件解决的,就不要优先考虑第三方库;能不引入原生代码的,就不要自己写 Platform Channel。这个原则,能帮你少走很多弯路。
下一篇我打算写发现页列表和组队详情页的联动,包括本地数据加载、卡片式布局、以及加入组队时的状态流转。到时候见。
