这个系列写到“Flutter for OpenHarmony 剧本杀组队App实战”第4篇,总算轮到了一个用户每天都会碰到、也最能看出产品功力的页面——发起组队。前几篇我们把工程骨架搭起来、页面路由跑通,也验证了OpenHarmony设备上Flutter的渲染与交互能力,这次要做的,就是把剧本杀里最核心的“开车建队”做出来,也就是一堆字段的收集与校验。
发起组队这件事,看起来就是一个表单,但实际操作中比普通注册登录要麻烦得多。原因很简单:用户填的信息跨越了“玩什么剧本、什么时候玩、几个人玩、在哪玩、怎么联系”,每一步都可能阻止用户按下发布按钮。我在OpenHarmony开发板上把整条链路完整跑了一遍,从字段规划、Form状态管理、校验器设计,到联动控件封装、日期快捷选择、数据提交回传,今天这篇文章全部拆开讲,相关代码你拿到手就能直接抄到自己的Flutter for OpenHarmony工程里。
1. 发起组队表单的整体设计:先想清楚要用户交什么
1.1 组队页的功能定位与业务背景
聊代码之前,必须先聊产品逻辑。剧本杀的组队行为,本质是“在指定时间、指定地点,凑满指定人数玩同一个本”,所以这次表单要完成的核心目标只有一个:用最低成本,让一个想玩剧本杀的人把所有组队条件表达清楚。
传统App里的表单,往往按照“填写注册信息”的思路来做,一行一个输入框,用户填得越多越好。但放到剧本杀项目里,这套逻辑会带来很高的流失率,原因在于用户来这里的情绪是“想快点发车”,不是“回答平台问题”。因此,我在表单设计阶段就定了一个原则:能点选的不让用户打字、能校验的绝不放任错误提交、能自动补充的不让用户手动输入。
围绕这个原则,实际需要收集的数据大致拆分成了三块:
- 基础信息区:剧本名称、剧本类型。解决“玩什么”的问题。
- 组队信息区:需要几个人、准备几号几点开场、指定地点还是线上平台。解决“怎么凑”的问题。
- 联系/备注区:联系人微信(选填)、补充说明。解决“找不到人”和“特殊要求”的问题。
这个分组在后面页面区域划分上被直接复用,就变成了上下三个视觉区块。每个区块在Table里定位非常清楚:
| 区块 | 解决的核心问题 | 建议控件类型 | 是否必填 |
|---|---|---|---|
| 剧本名称 | 用户和路人一眼知道玩什么 | 文本框(限长) | 必填 |
| 剧本类型 | 判断自己感不感兴趣 | 下拉框或标签选择 | 必填 |
| 组队人数 | 这个车是否容易拼齐 | 步进器/加减控件 | 必填 |
| 开始时间 | 自己那个时间段能不能来 | 日期选择+时间选择 | 必填 |
| 地点/线上平台 | 判断距离和参与成本 | 标签+补充文本框 | 必填 |
| 联系方式 | 确认后联系发起人 | 文本框(正则校验) | 选填 |
| 备注 | 讲清楚特殊要求 | 多行文本框 | 选填 |
1.2 字段模型与技术方案的选型打底
在设计好字段后,不要急着堆代码。我习惯先把页面表单字段对应到一张数据模型上,再做UI。当时定义了一个“组队创建请求”模型,字段大概是:
dart复制class TeamCreateRequest {
final String title; // 剧本名称
final String category; // 剧本类型
final int memberCount; // 组队人数
final DateTime startTime; // 开始时间
final String locationLabel; // 地点标签,如"xx门店"/"腾讯会议"
final String locationDetail; // 补充地址或线上房间号
final String? contactWechat; // 绑定微信,防止触发审核敏感信息
final String description; // 组队补充说明
const TeamCreateRequest({
required this.title,
required this.category,
required this.memberCount,
required this.startTime,
required this.locationLabel,
required this.locationDetail,
this.contactWechat,
this.description = '',
});
}
定义模型的时候一直比较克制,没有把“最多人数/最少人数/当前人数”全部塞进来。原因是,组队发布阶段根本不关心当前已经进了几个人,那是加入组队之后要考虑的事情。发布表单只需要明确几个名额、什么时间段,剩下的状态计算全部扔给服务端。
Flutter侧的表单技术方案,无脑使用的是 Form + TextFormField 这套官方组合,不做自研状态管理。自研状态管理意味着要自己维护焦点、校验时机、错误提示、值同步,这套复杂度加上OpenHarmony侧渲染的坑排查起来非常难受。官方的Form体系本质上是把这些状态给你封装好了,业务代码只需要集中精力在 validator 和 onSaved 上,反而更适合注意力稀缺的移动端页面。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 表单基础框架:Form、TextFormField与Controller的配合
2.1 先把页面壳子搭稳:滚动、安全区与防遮挡
做任何移动端表单页面,第一件事就是避免内容溢出。录制竖屏时会发现,在键盘弹出的瞬间如果页面不可滚动,底部输入框大概率会被遮挡。Flutter默认在焦点切换到输入框时会通过 resizeToAvoidBottomInset 调整页面高度,但我依然建议Form外面包一个 SingleChildScrollView。
整个页面的基础结构当时是这样搭的:
dart复制class CreateTeamPage extends StatefulWidget {
const CreateTeamPage({super.key});
@override
State<CreateTeamPage> createState() => _CreateTeamPageState();
}
class _CreateTeamPageState extends State<CreateTeamPage> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _wechatController = TextEditingController();
final _descriptionController = TextEditingController();
final _locationDetailController = TextEditingController();
String _category = '硬核推理';
int _memberCount = 5;
DateTime _startTime = DateTime.now().add(const Duration(hours: 2));
String _locationLabel = '到店';
@override
void dispose() {
_titleController.dispose();
_wechatController.dispose();
_descriptionController.dispose();
_locationDetailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('发起组队')),
body: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 32),
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.disabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 分区块内容...
],
),
),
),
);
}
}
这里有一个小细节:每个 TextEditingController 必须在 dispose() 里手动释放。由于我们当前项目普遍用 flutter_ohos 在OpenHarmony上跑Flutter,如果忘记释放Controller,日志层面不会立刻报错,但连续创建销毁页面会在低配的rk3566/rk3568板子上出现内存抖动。
2.2 TextFormField的配置不是越多越好
表单字段中文本域占了很大比重,但并不是每个输入框都需要完整的高级配置。以剧本名称输入框为例,最终还是选择了最朴素的写法:
dart复制TextFormField(
controller: _titleController,
maxLength: 20,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: '剧本名称',
hintText: '例如:病娇男孩的精分日记',
prefixIcon: Icon(Icons.movie_filter_outlined),
border: OutlineInputBorder(),
),
validator: (value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return '请告诉队友玩什么本';
}
return null;
},
onSaved: (value) {
title = value?.trim() ?? '';
},
)
加了 maxLength 之后,后台存储和列表展示都不会因为超长文本爆掉。要注意的是,maxLength 在部分Flutter版本会和中文输入法产生光标跳动问题,尤其在OpenHarmony上接入微信输入法的时候感受很明显。如果踩到雷,一个临时方案是不要使用 maxLength,改在 validator 里检查长度,并用字符串截断来兜底。
微信选填框的校验字段比较特殊,为了不给用户手机号带来骚扰风险,目前强制走微信号这个渠道。校验规则使用正则处理,不能太严格也不能太宽松:
dart复制validator: (value) {
final text = value?.trim() ?? '';
if (text.isEmpty) return null; // 选填,允许为空
final isWechat = RegExp(r'^[a-zA-Z][-_a-zA-Z0-9]{5,19}$').hasMatch(text);
final isPhone = RegExp(r'^1[3-9]\d{9}$').hasMatch(text);
if (!isWechat && !isPhone) {
return '请输入正确的微信号或手机号';
}
return null;
}
2.3 validate、save与autovalidate的调用时机
很多新手会纠结一个问题:到底什么时候触发表单校验?点击提交按钮的时候统一校验?还是用户离开某输入框立即校验?我建议做成混合策略:全局在点“发布”时统一检查,单项输入则在用户交互后异步给出反馈。
具体实现上,将Form的 autovalidateMode 先设置为 AutovalidateMode.disabled,用户点过提交后再把值改为 AutovalidateMode.onUserInteraction:
dart复制bool _submitted = false;
void _submit() {
setState(() => _submitted = true);
final form = _formKey.currentState;
if (form == null || !form.validate()) return;
form.save();
// 走后面的提交逻辑
}
这种做法在实际产品里体验最稳定——第一次进来不会满屏飘红把用户吓跑,点了提交有了错误标记之后,改一个字段消一个错。整套做法跟Web端“touched之后显示错误”是一个道理。
3. 联动控件封装:下拉框与人数步进器该怎么接进表单
3.1 DropdownButtonFormField在剧本类型上的使用策略
剧本杀类型用下拉框其实是最快的实现。先造一个类型列表:
dart复制const List<String> scriptCategories = [
'硬核推理', '欢乐机制', '情感沉浸',
'恐怖惊悚', '阵营本', '还原本', '其他',
];
把 DropdownButtonFormField<String> 放进去,配合定义好的默认值:
dart复制DropdownButtonFormField<String>(
initialValue: _category,
decoration: const InputDecoration(
labelText: '剧本类型',
prefixIcon: Icon(Icons.category_outlined),
border: OutlineInputBorder(),
),
items: scriptCategories
.map((e) => DropdownMenuItem(value: e, child: Text(e)))
.toList(),
onChanged: (value) {
if (value != null) _category = value;
},
)
注意Flutter较新版本里,DropdownButtonFormField 原来用的 value 参数已经被标记成废弃,改用 initialValue。我们开发时曾经因为直接从老版本代码迁移,在编译阶段收到过提示,如果你用最新版Flutter才发现这个情况,不必困惑,跑一遍 flutter analyze 就能看到具体指引。
在OpenHarmony真机上,目前遇到过一个外观问题:DropdownButtonFormField 在Material 3主题下弹出的菜单背景偶发会变成半透明。对比后发现,部分低版本适配把 MenuStyle 的背景色解析成了透明。一个低风险做法是把页面主题固定用Material 2风格的组件,或者自定义 DropdownMenuItem 背景。
dart复制Theme(
data: Theme.of(context).copyWith(
dropdownMenuTheme: const DropdownMenuThemeData(
textStyle: TextStyle(color: Colors.black87),
),
),
child: DropdownButtonFormField<String>(...),
)
3.2 用FormField包住自定义步进器
人数控件我一开始想直接用系统的 Stepper,但发现这个组件纵向布局占用空间太大,剧本人数的操作密度完全不需要这么大,更合理的是一个沉浸式的加减步进器。可如果这个控件直接维护自己的内部状态,它和Form的校验体系就断了。这里就需要用 FormField<T> 来包装一个自定义控件,把它的值纳入Form的validator体系:
dart复制FormField<int>(
initialValue: _memberCount,
validator: (value) {
if (value == null || value < 1) {
return '人数至少为1';
}
return null;
},
builder: (state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('需要队友数'),
const Spacer(),
IconButton(
onPressed: state.value! <= 1
? null
: () => state.didChange(state.value! - 1),
icon: const Icon(Icons.remove_circle_outline),
),
Text('${state.value}', style: Theme.of(context).textTheme.headlineSmall),
IconButton(
onPressed: state.value! >= 12
? null
: () => state.didChange(state.value! + 1),
icon: const Icon(Icons.add_circle_outline),
),
],
),
if (state.hasError)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
state.errorText ?? '',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
);
},
)
这套 FormField 的封装思路,后面所有自定义控件都能复用。重点在于,控件内部要调用 state.didChange() 去更新值,而不是直接setState自己存一份。这么做的另一个好处是:提交的时候 form.save() 会调用 onSaved,把最终值收集给我们,整个页面的数据流是单一方向,没有多份数据源互相打架。
当初这个页面在做代码走查时,也特意强调了人数上限。剧本杀常见的组队规模是4到8人,但这不代表不能出现大型机制本,所以最终步进器做到了12人。非硬核场景下不要让用户加太多,免得“10人高级车”成为了活动常态。
4. 时间选择器与“一键填充”的小心机
4.1 日期与时间组合选择器的封装
时间选择是很多表单做得糟糕的重灾区。剧本杀组队页开始时间,不能搞一个自由文本让用户填“明天晚上7点”,这种非结构化字段会导致后续列表筛选和提醒推送都无法工作。我直接用了系统自带的日期选择组件,但进行了二次封装:把开始的日期和时间做成一个状态,用户在卡片上点击就能唤起选择器。
dart复制Future<void> _pickStartTime() 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,
);
});
}
在OpenHarmony模拟器/真机上,日期组件和原生Android有一些差异。最明显的是:OpenHarmony设备上弹出的中文日期选择器布局初看和原生没什么两样,但在小屏设备上底部取消/确定按钮偶尔会被切掉,这通常是系统的窗口安全区域没有正确传递给Flutter。临时方案是缩小日期选择器的显示范围,或者在 showDatePicker 外层包 MediaQuery 修改文本大小。
后续调用接口时,还要把本地时间转为服务端需要的UTC格式:
dart复制String toUtcIso(DateTime value) => value.toUtc().toIso8601String();
这里如果直接把带时区的本地时间传给后端,跨平台的解析很容易出现差8小时的问题。
4.2 快速时间卡片:比“选择器”更顺畅的交互
只放一个选择器还是太慢了,用户每次都要点两下,甚至日期和时间分开选容易让用户烦躁。后来产品侧加了一排“快捷时间”组件,效果不错:点击“今天 2小时后”直接设置 _startTime,点击“今晚 20:00”设置当天的20点。我把这套设计放出来,给同做组队类的开发者做个参考:
dart复制Wrap(
spacing: 8,
children: [
ActionChip(
label: Text(_formatShort(DateTime.now().add(const Duration(hours: 2)))),
onPressed: () {
setState(() {
_startTime = DateTime.now().add(const Duration(hours: 2));
});
},
),
ActionChip(
label: const Text('今晚 20:00'),
onPressed: () {
final now = DateTime.now();
setState(() {
_startTime = DateTime(now.year, now.month, now.day, 20);
if (_startTime.isBefore(now)) {
_startTime = _startTime.add(const Duration(days: 1));
}
});
},
),
ActionChip(
label: const Text('周六 14:00'),
onPressed: () { /* 计算下周六的逻辑 */ },
),
],
)
这套实现本质上是“减少人为跳转次数”,大多数用户都知道自己今晚能不能玩、周六有没有空,一个Chip点击即可,不必重复执行“打开日历、翻月份、找日子、调小时、确认”这种五步操作。需要提醒的是,快设置之后,下面的时间展示卡片必须立刻刷新,因为参与者在查看组队车列表时常会取消那些不能保证到场时间太远的队伍。
4.3 时间展示与日程冲突的约定
在页面上展示开始时间时,我习惯格式化成本地时间短格式,并且在卡片上行把星期几显示出来:
dart复制String _formatTime(DateTime time) {
const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
final wd = weekdays[time.weekday - 1];
final hh = time.hour.toString().padLeft(2, '0');
final mm = time.minute.toString().padLeft(2, '0');
return '${time.month}月${time.day}日 $wd $hh:$mm';
}
剧本杀活动通常要预留至少1小时,真正发布时间比开始时间晚太多,会导致玩家凑不齐。因此我在点击发布时加了规则:如果开始时间比当前时间早,发布会直接失败并提示;如果不到30分钟,提示“距离开始时间过近”,但允许用户再次确认。这是很典型的业务小决策,技术实现并不难,难的是你真正去玩过一次剧本杀之后才会意识到时间约束很重要。
5. 数据提交:表单保存、防重复点击与列表页刷新
5.1 onSaved后如何组装请求体
当用户点击发布按钮,form.validate() 返回 true 后,代码会执行 form.save(),这时所有 TextFormField 的 onSaved 回调会把输入值写进页面字段。我们上面已经用Controller和局部变量保存了表单实时状态,所以save()这一段更像是兜底,比如你随时改代码、把Controller丢到onSaved里,都还不容易出现空指针。
完整提交代码,看起来类似这样:
dart复制Future<void> _submit() async {
setState(() => _submitted = true);
final form = _formKey.currentState;
if (form == null || !form.validate()) return;
form.save();
final int categoryIndex = scriptCategories.indexOf(_category);
final request = TeamCreateRequest(
title: _titleController.text.trim(),
category: categoryIndex < 0 ? '其他' : _category,
memberCount: _memberCount,
startTime: _startTime,
locationLabel: _locationLabel,
locationDetail: _locationDetailController.text.trim(),
contactWechat: _wechatController.text.trim(),
description: _descriptionController.text.trim(),
);
setState(() => _submitting = true);
try {
final result = await TeamRepository().createTeam(request);
if (!mounted) return;
Navigator.of(context).pop(result);
} catch (e) {
if (!mounted) return;
setState(() => _submitting = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('发布失败:$e')),
);
}
}
5.2 防重复提交与按钮Loading态控制
在原生开发里,防重复提交很容易被人忽略,但这些年在Flutter中见过太多次同一个数据被创建两张日票的情况。只要按钮响应了第一次点击,但请求网络缓慢、后端还没回应,用户又点了第二次,就可能创建成功两条数据。这里用 _submitting 布尔值做了一把锁,发布按钮在提交期间显示一个圆形进度条并禁掉点击:
dart复制FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('发布组队'),
)
按钮禁用之后,首次点击时如果网络请求一直悬挂在OpenHarmony设备上,用户看到按钮转圈会有一种“卡死”的感觉,所以我额外在 createTeam 请求外层加了一个8秒超时:
dart复制Future<TeamEntity> createTeam(TeamCreateRequest request) async {
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
));
final resp = await dio.post(
'/team/create',
data: request.toJson(),
);
// 解析响应...
}
超时时间设置成了两个5秒,期间按钮保持忙碌状态,超过之后会走异常分支,把按钮恢复,让用户知道“这次发布失败了,你可以重试”,避免无限等待。
5.3 发布成功后返回结果与列表联动
创建完成后,要回到上一级页面,也就是组队列表页。但因为列表页通过路由进入发起页,双方没有共享一个跨页面的State,直接把发布结果返回给列表页需要依赖路由返回值。
列表页跳转的地方这样编写:
dart复制final bool? created = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (context) => const CreateTeamPage()),
);
if (created == true) {
_refreshTeamList();
}
发起页在接口成功后执行:
dart复制Navigator.of(context).pop(true);
这样列表页监听到 true 就会重新拉取队伍列表,用户刚刚发布的组队车立刻出现在页面顶部。不需要引入全局状态管理库,也不需要在两个页面里维护同一个内存列表,代码简单且足够优雅。对于小团队项目,这样处理是很合理的。
6. OpenHarmony环境下表单开发的兼容性排查
6.1 键盘弹出与中文输入法的适配观察
OpenHarmony设备上跑Flutter表单,最典型的坑是键盘弹出问题,主要出现在OpenHarmony低版本、应用未申请输入法权限,又或者运行在无物理键盘的rk3568开发板上时。键盘出不来的时候,先别怀疑Flutter控件,检查工程里的系统键盘服务是否打开、设备是否允许软键盘弹出。在Flutter层面唯一能做的保障是让页面内容放在可滚动区域里,并设置 resizeToAvoidBottomInset。
另一个影响体验的问题是中文输入法候选栏在部分OpenHarmony ROM上不会触发 TextEditingController 变更时间的回调。排查后发现,某些输入法在拼音未上屏阶段也会通过组合文本改变controller底层的TextEditingValue,导致校验产生误报。我的规避策略是:不要在 TextEditingController 的 addListener 里做校验,统一依赖Form的validator机制;只把实时文本用于本地草稿缓存,不在每个按键处触发后端搜索。
6.2 日期选择器与下拉框在特定主题下的表现差异
页面引入日期选择器后,没有看到很大兼容冲突,但下拉菜单确实存在一些细节问题。开发时曾遇到 DropdownButton 在新版组件里面Popup的shape圆角不生效的怪问题,后续发现是全局Theme设置了 visualDensity: VisualDensity.compact,导致部分列表项在OpenHarmony设备上高度异常。
从那之后,页面级组件都会尽量使用局部Theme去包裹,例如给列表区块单独设置compact密度,而从不给全局页面设置,这样能规避掉不少UI边缘问题。
6.3 插件与依赖在OHOS分支下的适配
OpenHarmony上真正跑Flutter表单,用到的依赖不能像Android原生一样随意找库。我把一些可能用到的三方库筛选了一遍,在移植建议上给出一个实际可用的速查表:
| 需求 | 推荐做法 | 备注 |
|---|---|---|
| 网络请求 | Dio | 纯Dart实现,OHOS可用 |
| 状态管理 | Provider/Riverpod | 纯Dart实现,OHOS可用 |
| 弹窗日期选择 | 系统自带的showDatePicker/showTimePicker | 官方Material组件 |
| 下拉刷新 | 自写RefreshIndicator | 良好适配 |
| 分享/剪贴板 | 剪贴板建议用OpenHarmony原生扩展 | 需要走平台通道,注意插件OHOS适配分支 |
最初计划用某个社区提供的“涟漪日期时间选择器”,后来在OpenHarmony上跑起来发现该库内部使用了 platform channel 读取默认时区,导致部分真机上崩溃。最后干脆回退到Flutter官方的showDatePicker,既稳定又少一个依赖。做跨端项目,关键逻辑能压在Flutter侧就压在Flutter侧,三方依赖能省则省。
6.4 真机热重载与构建hap的注意事项
普通的Flutter热重载 r 和热重启 R 能力,在OpenHarmony适配版里基本可用,但在真机上频率不要太高。频繁在输入框聚焦状态执行热重载会导致键盘状态错乱,看起来像页面卡死,实际上是被开发态进程影响。建议改表单页面时,先改代码保存编译,再整体热重启一次,避免每次键盘弹起后状态没同步。
构建发布包的时候,注意当前适配分支有没有默认开启某个不太兼容的编译选项。在构建命令这块,OpenHarmony的Flutter包管理不再是纯apk,也不是纯HAP,可以通过下面命令构建:
bash复制flutter build hap --release
如果在编译时出现 hvigor 初始化失败或者ohpm依赖解析失败,优先检查当前机器上DevEco Studio的node环境和SDK版本,因为该项目要求OpenHarmony SDK版本和Flutter的OHOS适配版本保持一致。你可以去检查 local.properties 中配置的ohos SDK路径是否正确,写错的概率其实挺大。
7. 表单页里这些细节帮你避开未来5个版本的大坑
7.1 不要过早引入复杂状态管理库
在这个表单页面,连Riverpod都没有用上,只用了StatefulWidget自身维护状态。原因很简单:这个表单本身是一个局部页面,没有跨页面共享的实时数据,强行上全局Store只会增大模块间的耦合。
当然,如果后面有“我的组队草稿箱”需求,希望不同页面复用这组表单状态,再把state逻辑抽到Controller层也来得及。当前更重要的目标是让项目尽快跑通,不要被架构纯度束缚。
7.2 校验规则集中管理
页面里多个校验函数如果直接内联堆在build里,代码会越来越乱。建议把所有正则和公共校验函数放到一个 validators.dart 文件里统一管理,这个习惯能让第二个表单页面启动时节约很多时间。举个例子:
dart复制String? validateRequired(String? value, String message) {
final text = value?.trim() ?? '';
return text.isEmpty ? message : null;
}
String? validateMeetingCode(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) return null;
final kReg = RegExp(r'^\d{6}$');
return kReg.hasMatch(text) ? null : '约局码是6位数字';
}
7.3 把控件拆成小组件,测试和迭代会快很多
在OpenHarmony真机上调试的过程比普通Android模拟器更费时间,如果页面所有布局都写成一坨,任何一次微调都需要重新构建hap包,那调试周期会翻倍。我后来把“快捷时间卡片”“联系方式输入组”“人数步进器”拆成了独立小组件,局部修改时可以单独渲染预览,不再带着整个Form页面一起重启。
dart复制class MemberCountFormField extends StatelessWidget {
final int initialValue;
final ValueChanged<int>? onChanged;
const MemberCountFormField({super.key, required this.initialValue, this.onChanged});
@override
Widget build(BuildContext context) {
return FormField<int>(
initialValue: initialValue,
validator: (value) {
if (value == null || value < 1) return '请选择需要的人数';
return null;
},
builder: (state) {
return Row(
children: [
Text('人数:${state.value ?? 1}'),
IconButton(
icon: const Icon(Icons.remove),
onPressed: state.value == null || state.value! <= 1
? null
: () => state.didChange((state.value ?? 1) - 1),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () => state.didChange((state.value ?? 1) + 1),
),
],
);
},
);
}
}
页面拆分成小组件后,配合Flutter自带的 flutter run --debug -d <device> 可以只加载变更部分,效率提升非常直观。
8. 最后的经验:一个表单页看似简单,但它决定用户愿不愿意把组局交给你
做到这里,表单页基本已经完整可用。从我实际使用的角度看,Flutter for OpenHarmony 在表单类场景中的还原度很高,页面滚动、键盘联动、校验表现、返回刷新都可以正常承受真实用户操作。最重要的始终是产品层面的思考,比如“我为什么让用户填微信而不填手机号”“为什么默认人数是5而不是6”“为什么快捷时间给的是2小时后而不是今天19点”——这些决定通常比编写一行TextFormField要花更多时间,但恰恰决定了这次活动的转化率。
如果你正跟着这个系列实战,建议你在拿到这套表单实现后,先跑一遍真机,把所有快捷时间卡片和下拉选项点一次,把中英文输入法都切一遍观察校验时机,再根据自己剧本杀门店的实际供给做调整。组队发车这种事,表单漏一个字段可能只是多一次点击,但如果错误数据进了列表,整组玩家体验都会崩掉。
