1. 为什么先做“发起组队”这个入口
剧本杀组队App做到第4期,前几期我们把环境、路由、底部导航这些骨架搭完了,这期要做的是一个用户真正会高频触达的核心页面:发起组队。
为什么先做它?道理很简单,剧本杀组队这个场景,用户打开App的第一诉求通常是“我想找人玩”,要么是加入别人的车,要么是自己开一辆车等人上车。App的价值在于撮合,而“发起组队”就是整个撮合链路的起点。如果起点做不好,后面匹配、聊天、房间管理都无从谈起。
技术上选“发起组队表单”来写一期,还有一个现实原因:这个页面虽然看起来只是录入信息,但它几乎串起了Flutter表单体系里的所有基础能力,包括文本输入、数字选择、日期时间选择、单选/多选、自定义选择器、表单校验、跨页面传参,以及和OpenHarmony侧交互时的键盘避让、弹窗适配这些坑。把这一页搞明白,后面做个人资料编辑、房间配置、活动创建都是同一套方法论,等于一次性把表单技能树点亮了。
先说清楚本期项目背景。设备是OpenHarmony的RK3568开发板加一块触摸屏,Flutter版本建议用4.0以上的稳定分支,项目里要同时兼容Android调试和鸿蒙真机运行。代码层面,我们会新建一个CreateTeamPage,通过底部导航Tab点击跳转进入,完成后把表单数据整理成TeamEntity对象,丢给Bloc做状态保存。
这一期会涉及到的内容比较多,我们分几个模块来搞:先设计好表单的数据模型和UI骨架,再逐字段实现输入控件,接着做校验策略,最后接上OpenHarmony的软键盘适配和真机验证。老规矩,每个坑我都会标注具体踩坑现场和解决思路,不是那种只能跑通的Demo代码,而是能直接上到生产项目里的写法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字段设计与数据模型先行
写表单页第一件事不是列Widget,而是先把字段定下来。很多新手上来就写各种输入框,写到一半发现字段不够、数据类型不合适,回头改组件改到崩溃。正确的顺序是先设计数据结构,再验证UI方案,最后动手堆组件。
2.1 发起组队需要哪些字段
剧本杀组队不同于普通的饭局组局,它有很强的“指定性”。玩家需要一个剧本、具体的时间和人数。所以发起组队表单至少要覆盖这几个维度:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| 剧本名称 | String | 空 | 必填,比如“年轮”“病娇男孩的精分日记” |
| 游戏类型 | String | 待选择 | 阵营本/硬核推理/欢乐本/情感本/恐怖本 |
| 游戏人数 | int | 6 | 组队目标人数,包括自己 |
| 开始时间 | DateTime | 当前时间+1小时 | 预计发车时间 |
| 游戏时长 | int | 4(小时) | 部分本子标称时长,默认4小时 |
| 房间号 | String | 空 | 线下店开到房间后录入,选填 |
| 详细描述 | String | 空 | 对拼车要求的补充说明,选填 |
有人会问,为什么需要房间号和详细描述?只留剧本名、类型、时间不行吗?实际运营下来,拼车最大的问题是信息不完整导致的时间损耗。描述可以写得自由一点,但字段不存在的话,用户就算想写也没有入口。所以在设计阶段就把这些业务细节想清楚,比后面打补丁省事得多。
数据类型方面要特别注意人数和时长尽量用int而不是String。表单控件展示的时候可以转字符串,但业务内部计算(比如拼车剩余空位)用数字类型更安全,后边做数据校验也方便。
2.2 模型层代码:TeamCreateModel
字段定好了,接下来在lib/model/team_model.dart里定义这次组队发起的数据模型。不直接用Map传递数据,是为了后面扩展字段时有类型提示,改起来不会在项目里搜字符串key搜到头疼。
dart复制class TeamCreateModel {
final String scriptName;
final String gameType;
final int maxPlayers;
final DateTime startTime;
final int playHours;
final String roomNumber;
final String description;
TeamCreateModel({
required this.scriptName,
this.gameType = '阵营本',
this.maxPlayers = 6,
DateTime? startTime,
this.playHours = 4,
this.roomNumber = '',
this.description = '',
}) : startTime = startTime ?? DateTime.now().add(const Duration(hours: 1));
Map<String, dynamic> toJson() {
return {
'scriptName': scriptName,
'gameType': gameType,
'maxPlayers': maxPlayers,
'startTime': startTime.toIso8601String(),
'playHours': playHours,
'roomNumber': roomNumber,
'description': description,
};
}
}
这里写了构造函数默认值,startTime默认是当前时间往后推一个小时,这样用户不进时间选择器也能直接提交,减少操作路径。后面如果接后端接口,toJson()直接就能序列化,省得在地下层再写一遍字段映射。
2.3 进入入口与路由跳转
首页拿到这期任务后,我在底部Tab“组队”页面上加了一个悬浮按钮进入创建流程。App里的main_tab_page.dart里对应Tab的index是1,页面内容先预留一个GroupListPage,里面放一个FloatingActionButton:
dart复制FloatingActionButton.extended(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const CreateTeamPage(),
),
);
},
icon: const Icon(Icons.add),
label: const Text('发起组队'),
)
有读者会问这期不是做表单吗,为什么要先跳页面?因为发起组队是以完整页面的形式进入的,而不是首页中间弹一个Dialog。剧本杀组队需要输入的信息不少,对话框放不下。全屏Page的好处是键盘弹出、滚动输入、选择器弹出这些场景适配起来更从容。这也是为什么我们没有用showModalBottomSheet直接包表单的原因——那种交互适合3个字段以内的轻录入。
3. 表单页面骨架与状态管理选型
真到写页面的时候,核心问题不是“用哪几个Widget”,而是表单数据怎么管、UI怎么布局才能让用户输入过程不乱。
3.1 选择TextEditingController还是Form的onSaved
Flutter表单数据管理常见有三种方案:TextEditingController手动管理每个输入项、Form+TextFormField利用FormState做校验、用状态管理库(Provider/Bloc/GetX)统一维护数据源。
这次我选的是TextEditingController + 局部变量做状态管理,配合Form组件做字段校验,不引入额外的状态库。原因有三:
第一,表单页是一次性的采集页,数据生命周期只存在于用户填表到提交的那几十秒,没必要搬出一个重量级库来管理一个页面的瞬态数据。
第二,文本类输入(剧本名、房间号、详情描述)天然适合TextEditingController,而选择类字段(类型、人数、时间)本质上不是文本输入,它们需要的是“选择数据的交互控件”。用FormState统一管选择类字段会非常别扭,反而绕弯路。
第三,Bloc这类库是用来管理全局共享状态(比如用户登录态、组队列表数据不同页面共用)的,处理表单这类的本地状态反而增加了模板代码量,我之前的经验是等表单提交到列表页真正需要共享团队数据时再倒回Bloc管理,这期不做多余设计。
所以页面里的做法是:
dart复制final _formKey = GlobalKey<FormState>();
final _scriptNameController = TextEditingController();
final _roomNumberController = TextEditingController();
final _descController = TextEditingController();
String _selectedGameType = '阵营本';
int _maxPlayers = 6;
DateTime _startTime = DateTime.now().add(const Duration(hours: 1));
int _playHours = 4;
String _selectedGameType这些字段的定义一定要放在State类里,重build的时候不会被重置,这也是Flutter表单比较常见的一个坑:如果是build方法内临时定义变量再让Widget引用,用户选择完类型页面一刷新,选择结果就丢了。
3.2 可滚动布局:ListView还是SingleChildScrollView
表单字段一多,屏幕必然放不下,所以需要可滚动容器。多数教程是在Column外面套一个SingleChildScrollView,其实更推荐用ListView。
两者最大的区别是ListView是懒加载的,目前表单只有七八个字段差距还不明显,但App后面要加“常用剧本推荐”“历史组队记录一键填充”这些模块时,ListView只要往children里加内容就行,性能不会退化。而且ListView天然支持滚动到底部按钮吸底这种交互,组合更自然。
页面基本骨架如下:
dart复制@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF7F8FA),
appBar: AppBar(
title: const Text('发起组队'),
elevation: 0,
centerTitle: true,
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
children: [
_buildScriptNameField(),
_buildGameTypeField(),
_buildPlayersAndTime(),
_buildDurationAndRoom(),
_buildDescriptionField(),
],
),
),
bottomNavigationBar: _buildSubmitBar(),
);
}
这个页面我把输入项分成了五个视觉区块,每个区块有独立的卡片样式,区块内上下两个字段组合时用Row排布,比如“人数”和“开始时间”如果都占一整行,就会显得尤其空洞,二次滚动也多。组合式布局的详细信息后面实操里会配截图讲解。底部放bottomNavigationBar是为了让提交按钮始终吸附在屏幕最底端,而不是跟随滚动内容跑到看不见的地方,这是组队表单操作的效率点。
说到padding,底部一定要留出足够空间,不然最后几个字段的内容会被底栏遮挡。比如这次ListView的EdgeInsets.only(bottom: 100),具体值要看底部提交栏的高度和键盘弹出情况微调,不要照抄网上其他项目的数值,得实际跑起来截图看效果。
4. 关键输入控件的详细实现
这一节是本期内容的核心,我会把每个输入控件的代码、样式、交互逻辑逐步拆开讲。
4.1 剧本名称:必填文本输入框
第一个字段是剧本名。输入框本身没什么特殊的,重点在校验逻辑和使用体验上。我用的是TextFormField而不是TextField,区别在于它可以配合Form的validator自动在校验时不通过时展示错误信息。
dart复制TextFormField(
controller: _scriptNameController,
maxLength: 30,
decoration: InputDecoration(
labelText: '剧本名称',
hintText: '请输入想要体验的剧本,如:年轮',
prefixIcon: const Icon(Icons.menu_book_outlined),
counterText: '',
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return '请填写剧本名称';
}
return null;
},
)
说几个容易被忽视的细节。
maxLength: 30不是为了限制用户,剧本名一般都很短,30字绰绰有余,遇到一些极长的网文IP改的本子也不至于被截断。但是Flutter默认会在右下角显示一个“3/30”的计数器,丑得不行,而且我们还有个描述区域也带计数,两处同时出现很零散,所以加counterText: ''把计数隐藏起来。
validator里用了value.trim(),这个处理比直接判断value.isEmpty要合理。用户在输入框里敲了几个空格,看起来“有内容”,实际上什么都没填。过去我遇到过很多数据质量问题的来源就是这种隐藏空格,表单校验这层必须过滤掉。
剧本名标签有“请填写剧本名称”的提示,这个提示文字的位置要跟字段对应上。校验失败时Flutter会把错误文字渲染在输入框下方,并通过InputDecoration的errorText自动管理。后续提交按钮触发时执行_formKey.currentState!.validate(),所有注册在Form下的TextFormField都会依次执行自己的validator,这个机制在双字段联动校验时(比如结束时间必须晚于开始时间)还能派上用场。
4.2 游戏类型:不用DropdownButton而是自定义底部弹层
游戏类型这种枚举值,常见写法是DropdownButtonFormField。但这个控件在OpenHarmony的Flutter适配上有几个问题,一个是列表展开后的动画在部分版本下不跟手,另一个是选项样式在深色模式下不可控。最要命的是,剧本杀的类型往往是5个以上选项,原生下拉在平板上列表高度限制得很死,体验不好。
所以这次我改用点击“弹出底部选择面板”的交互,模拟iOS的ActionSheet。用户点“游戏类型”那一行,页面底部弹出半屏的选项面板,手指选中后面板关闭,同时回填选项。代码块长这样:
dart复制Widget _buildGameTypeField() {
return _buildSectionCard(
title: '游戏类型',
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.category_outlined),
title: Text(_selectedGameType),
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
onTap: _showGameTypePicker,
),
);
}
Future<void> _showGameTypePicker() async {
final result = await showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) => _GameTypeSheet(
currentType: _selectedGameType,
),
);
if (result != null) {
setState(() {
_selectedGameType = result;
});
}
}
底部弹层里是ListView加RadioListTile实现的,每个类型前面放一个单选圆圈图标,选中的那项加粗高亮。这段内容不用展开讲所有UI代码,核心点是:底部弹层选中后要把结果通过Navigator.pop(context, result)回传,这个result就是这个选项字符串,然后页面拿到result后setState刷新。
这里有个很容易踩的坑:showModalBottomSheet返回的是一个Future,等面板关闭后才会拿到返回值。所以如果用户没选择任何项直接点其他地方关闭,返回值就是null,要判空再刷新,否则setState里拿了个null赋值,页面直接崩。
OpenHarmony上这个交互我实测过,RK3568设备上底部弹层的弹出动画会出现几百毫秒的掉帧,这是GPU性能短板导致的,不是Flutter代码的问题。可以给_showGameTypePicker内部加一个await Future.delayed缩短动画区间,或者用showModalBottomSheet的sheetAnimationStyle参数调整动画时长,把弹层的过渡动画从默认的250ms改成150ms,观感会流畅很多。
4.3 游戏人数:更推荐用封装的Tag选择器
人数选择有两种方案:Slider滑块或者Stepper步进器。考虑到剧本杀人数是离散的几个数字(比如4/5/6/7/8/9/10),用连续滑块反而多此一举,用Tag点击选择更直观,被选中的Tag高亮成主色,用户一眼就知道自己选了几人局。
人数这块我用的是一个Wrap包裹若干ChoiceChip:
dart复制Widget _buildPlayerCountSelector() {
const playerCounts = [4, 5, 6, 7, 8, 9, 10];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('游戏人数'),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: playerCounts.map((count) {
final isSelected = _maxPlayers == count;
return GestureDetector(
onTap: () => setState(() => _maxPlayers = count),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF4A6CF7) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected ? const Color(0xFF4A6CF7) : const Color(0xFFE0E0E0),
),
),
child: Text(
'$count人',
style: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
),
),
),
);
}).toList(),
),
],
);
}
7个Tag平铺在卡片里可能需要换行,用Wrap能自动根据屏幕宽度排列,比Row硬塞好看得多。
有人会问为什么不直接用系统的ChoiceChip组件,而是自定义Container手写一个Tag?因为在比较老的OpenHarmony Flutter适配版本里,ChoiceChip选中状态的颜色要单独去设置theme,而且选中动画容易出现闪烁。手写Container后所有颜色、状态都是直接控制,开发周期反而短。等以后官方适配稳定了,再考虑要不要换回组件。
4.4 日期时间选择:混合选择器要拆成两个入口
“开始时间”字段,用showDatePicker和showTimePicker可以分两步选择,但用户会觉得麻烦。我在实际产品里更喜欢用showDatePicker选完日期后,弹一个TimePicker让用户选具体时间,两步之间状态传递要处理好。
代码实现:
dart复制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)),
helpText: '选择发车日期',
);
if (date == null || !mounted) return;
final timeOfDay = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_startTime),
helpText: '选择发车时间',
);
if (timeOfDay == null || !mounted) return;
setState(() {
_startTime = DateTime(
date.year,
date.month,
date.day,
timeOfDay.hour,
timeOfDay.minute,
);
});
}
注意三个地方。
firstDate设置成当前时间now而不是一个随便写的过去时间,用户在剧本杀场景下不可能选昨天开局,从源头堵住无效选项。
mounted判断不能少,picker弹出来以后用户可能随手点了系统返回键退出页面,这时候再执行setState会触发“setState called after dispose”的报错。这个错误我在早期项目中见过几十次,都是异步函数里忘记检查mounted。
在OpenHarmony上,showDatePicker弹出的对话框默认是全屏Dialog,但我在rk3568上遇到过日历选择器月份切换动画卡住,排查后发现是设备系统UI的硬件加速没开导致的。如果大家在企业定制设备上遇到日历表卡成PPT,优先检查系统的persist.sys.gpu.rendering相关配置,而不是改Flutter逻辑。
显示方面,_startTime在卡片里要格式化成人话,比如“03-18 19:30 周六”,这是用intl包的DateFormat处理的,这个库本身没问题,但要注意OpenHarmony的Flutter版本对intl的兼容性。如果报错找不到Intl库,需要确认在oh-package.json5里把intl注册进去,这跟Android模块有一点差异,下面会专门讲。
4.5 时长和房间号:数字辅助选择与可选文本
“游戏时长”用CupertinoPicker或DropdownButton做都行,我更推荐用类似人数选择的Tag方式,但时长粒度是[2,3,4,5,6],单位用小时。这里不做三选一的复杂操作,把选择值展示出来就好。
房间号这种是门店拼车才会用到的业务字段,直接做成TextFormField选填,不需要校验。但是要注意键盘类型要设置成数字键盘,剧本杀店里的房间号就是“A03”“VIP2”这种纯数字或字母加数字,所以用TextInputType.number会在某些输入法上无法输入字母,这里用TextInputType.text就好,等用户填错了再加格式校验,别一开始就把输入限制死。
4.6 详细描述:多行文本输入与实时计数
描述区域大家最容易犯的错误是只放一个普通的TextField,最多把maxLines设置成3。事实上描述需要一个额外的“必填建议”状态提示和字符计数提醒,否则用户不知道自己要写什么。
我的做法:
dart复制TextFormField(
controller: _descController,
maxLines: 4,
maxLength: 200,
decoration: InputDecoration(
hintText: '介绍一下你的组队要求,如:新手友好、只玩硬核本,有车麻烦带带',
),
)
maxLength一旦设置,右下角自动出现“0/200”,这是个天然的好用提示,说明已经写了多少字,不想让它显示的话再通过counterText隐藏。
但这样一个“选填”的描述框,到底要不要校验?我的场景里可以不校验,但如果是有运营规则的活动(比如必须写满10字才能发车),就给这个字段加validator。这个就交给业务侧配置,代码层面不做死。
4.7 提交按钮与数据回传
所有字段(结构见上文)写完后,底部栏放一个满宽主按钮。
dart复制Widget _buildSubmitBar() {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF4A6CF7),
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text(
'发起组队',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
),
);
}
SafeArea在平板上主要是防止底部导航条遮盖按钮,脚本杀App预计会跑在“手机+平板+鸿蒙触屏”三种形态上,保险起见保留。
_submit()方法承担三个职责:校验通过后调用回调或路由返回、把数据转成模型后让列表页刷新。具体实现看我们这期用的是Navigator返回传值:
dart复制void _submit() {
FocusScope.of(context).unfocus();
if (_formKey.currentState!.validate() == false) return;
final model = TeamCreateModel(
scriptName: _scriptNameController.text.trim(),
gameType: _selectedGameType,
maxPlayers: _maxPlayers,
startTime: _startTime,
playHours: _playHours,
roomNumber: _roomNumberController.text.trim(),
description: _descController.text.trim(),
);
Navigator.of(context).pop(model);
}
提交之前主动调用FocusScope.of(context).unfocus()让软键盘收起,否则如果用户正在输入描述直接点按钮,键盘会先覆盖住按钮导致误触,这在OpenHarmony的某些输入法上问题更突出。
返回出去的model对象在前一页(也就是组队列表页)的await处就能收到,这个对象后续进入列表后直接转化为列表项展示,原型阶段的组队数据流就这样串起来了。
5. 表单状态管理与页面联动优化
当表单涉及多个相关字段、或者稍复杂的联动逻辑时,我们还需要考虑“什么时候触发某件事件”,比如人数上限变了,描述区的“剩余空位”提示要跟着变化,或者时间过去之后自动切到下一场这类动态逻辑。
5.1 局部刷新还是全页面刷新
我们的表单控件写完以后,大多数变更都是局部刷新。但我用的setState是整个页面rebuild。表单页就一次性的,输入框数据其实放在Controller里,rebuild并不会让它丢焦点或清空内容,所以用setState是简单有效的方案。
不要在这个页面上强行引入Flutter Bloc的BlocBuilder来包裹每一行字段然后做局部build,纯属过度优化。Flutter真正需要局部刷新优化的场景是列表页里几十个cell的点赞状态切换,不是一屏只有10来个控件的表单。
5.2 联动:当“游戏类型”变了,描述占位文本跟着变
稍微做一点点联动的优化:选择“恐怖本”时,描述的hint变成“介意NPC恐搜可以备注”,选择“情感本”变成“吃沉浸的来”。这种做法的核心作用不是炫技,而是引导用户写出更多有效标注,减少后续沟通成本。
实现也不复杂,在_selectedGameType变化的setState里同时更新一个字符串变量_descHintText即可。
dart复制void _changeGameType(String type) {
setState(() {
_selectedGameType = type;
if (type == '恐怖本') {
_descHintText = '介意NPC恐搜、贴脸可以提前说';
} else if (type == '情感本') {
_descHintText = '吃演绎、吃沉浸的来,新手友好';
} else {
_descHintText = '介绍一下你的组队要求';
}
});
}
这个交互优化加分不少,因为剧本杀玩家群体里新手和老手对同一类型本子的需求差异很大,能提前把这两拨人筛选好,拼车成功率会明显更高。
6. 表单校验策略与错误提示设计
单页表单的校验,策略要跟UI同时设计。项目里如果只在提交时把所有错误一次性弹出来,用户会当场懵掉,尤其是7个字段全报错的时候。本次的重点是“提交校验”加“实时纠错”两步。
6.1 必填与区间校验规则
必填校验大家都会写,容易漏的是“最大值校验”和“联动校验”。
比如“人数上限不能小于已加入人数”。在发起组队这页,当前用户自己是发起人,人数默认至少是1,最大空位理论上不限,但在业务上前期限制到10人可以保证房间可控。数字上限校验写在点击保存的时候做:选择的Tag本身就在限制范围里,这一步天然安全,不需要额外校验代码。
真正需要的联动校验是时间:开始时间必须晚于当前时间。如果picker把firstDate限制在今天,那其实已经保证了一部分,但时区问题会让边界有漏洞——例如OpenHarmony设备系统时区错误,用户选的时间仍然早于服务器当前时间,所以服务端必须二次校验。这里客户端校验写成:
dart复制if (_startTime.isBefore(DateTime.now())) {
// 不应该发生的场景,兜底提示
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('开赛时间必须晚于当前时间,请重新选择')),
);
return;
}
6.2 错误提示展示形式
Flutter的TextFormField校验错误默认是在输入框下面红字提示。注意不要在出现错误后把剩余Tag和选择器全标红,那是在报复用户。
底部选择面板的错误提示方式是:如果某个字段未选型就点了提交,面板不会弹出来,而是在对应字段标题旁加一个红色的小提示文字“请选择游戏类型”,用AnimatedOpacity做淡入动画。我做了个简单的校验逻辑:
dart复制String? _validateGameType;
...
setState(() {
_validateGameType = _selectedGameType.isEmpty ? '请选择游戏类型' : null;
});
对应ListTile下方:
dart复制if (_validateGameType != null)
Padding(
padding: const EdgeInsets.only(left: 40, top: 4),
child: Text(
_validateGameType!,
style: const TextStyle(color: Color(0xFFE53935), fontSize: 12),
),
)
这里用了left: 40对齐上面的标题文字而不是整行左对齐,视觉上更协调。实际效果比弹Toast清楚,因为Toast几秒就消失,而内联错误会一直保留到用户修正动作发生。表单在没有通过前给出多次主动提醒的机会。
6.3 校验成功后键盘与SnackBar的处理
校验通过后页面返回到上一页(列表页),列表页拿到新创建的TeamCreateModel后,直接把它插入列表的第一条,并弹出“发起成功”。这一步不放在表单页里做,是因为当前页不持有列表数据源,所有的应用状态数据最好向上收拢,表单只负责提交。
7. OpenHarmony适配过程中踩过的坑
写到第4篇,OpenHarmony相关的坑积累了不少。这期只要跟表单输入相关的就有四个必须记录的。
7.1 键盘弹出避让与窗口insets
RK3568跑OpenHarmony接触摸屏,一旦输入框聚焦点到底部描述区,软键盘弹出后最上面的输入框可能会被顶出可视区域。Flutter在Android上有resizeToAvoidBottomInset默认机制,但OpenHarmony的窗口Insets支持在不同版本上差异很大。
我的处理是:Scaffold里显式不关resizeToAvoidBottomInset(保持默认true),然后给表单列表容器设置padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom)。实测的效果是软键盘弹出来时最下面的提交按钮能跟着键盘上方走,不至于被完全盖住。
在较老版本的OpenHarmony Flutter引擎上,MediaQuery.of(context).viewInsets.bottom取值可能一直为0,需要在页面里加一个监听:
dart复制import 'package:flutter/services.dart';
...
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
然后重新获取一次ViewInsets。不同开发板行为不一致,建议大家在真机上反复验证键盘避让。
7.2 showDatePicker在OpenHarmony上的兼容处理
RK3568上运行showDatePicker,日历滚轮容易出现横向滚动区域的误触,经常用户想滑动切换月份,结果意外选错日期。
我的替代方案是在OpenHarmony真机上一律使用输入格式校验的方式来代替日历选择:文本框加一个“日期 yyyy-MM-dd”的hint,然后通过输入格式化加校验处理。
但这样的录入效率比较低。所以如果只是体验Demo,可以在设置里做判断:
dart复制final bool isOpenHarmony =
Platform.isLinux && (Platform.environment['OHOS_ARCH'] ?? '').isNotEmpty;
不过这只是一个不太优雅的trick。更好的做法是通过项目的ohos插件通道暴露一个能力,告诉Flutter层当前是否运行在鸿蒙设备上,然后有针对性的换UI。后面如果有时间,我会专门做一期Flutter引擎在OpenHarmony上的能力差异梳理。
7.3 intl库在OpenHarmony上的注册问题
这个其实不是Flutter的问题,而是OpenHarmony的模块依赖管理需要单独注册原生侧支持。在oh-package.json5里把依赖库加进去:
json5复制{
"deps": {
"intl": "1.9.0"
}
}
然后entry/src/main/module.json5的requestPermissions里,我这里并没有做网络访问等运行时权限,不需要额外配置。但如果通过HTTP上传组队数据,则需要配置ohos.permission.INTERNET,否则请求会静默失败,很多人会漏掉这一步。
7.4 textInputAction和enter key的适配
剧本名称输入完成后,用户点键盘右下角的“下一项”应该跳到游戏类型选择(但游戏类型是点按弹出BottomSheet,无法直接聚焦)。这时不如把键盘action直接设成done,输入完成直接收起键盘,用户自行点选其他区域。如果要硬编码“下一项”去帮用户移动焦点,反而会在OpenHarmony上出现焦点错乱,焦点不知道跑到哪个控件上,体验更差。
8. 真机验证与调试记录
工程在rk3568开发板上跑起来后,我在几个真实场景里做了操作验证,记录如下。
8.1 提交数据回传验证
进入发起页,填写字段:
- 剧本名:年轮
- 游戏类型:硬核推理
- 人数:5
- 开始时间:明天19:30
- 时长:4小时
- 描述:硬核玩家来,不挂机
点击发起组队,页面pop后,断点在列表页的await返回处,数据正确变成TeamCreateModel对象。7个字段全部赋值,没有发现空指针和字段错位。
8.2 键盘覆盖问题复测
修复viewInsets监听后,触摸屏软键盘调出时,底部按钮自动上移,描述输入框不会被完全遮挡,可以正常输入。OpenHarmony输入法工具里需要注意输入法面板高度的上报机制在不同系统版本上略有差异,如果升级系统后发现适配又坏了,大概率要检查该版本的系统是否修改了输入法Insets上报逻辑。
8.3 低内存设备页面退出问题
RK3568设备内存相对吃紧,连续打开表单弹层、日期选择器又快速关闭,有概率出现页面黑屏回来后控件重建的情况。这里要特别检查:弹窗返回结果之后,不能直接用context来调ScaffoldMessenger.of(context)做错误提示。因为如果页面已经pop了,这里拿到的context处于离线状态,会报“Looking up a deactivated widget‘s ancestor is unsafe”。所有异步拿到弹窗结果后,都要先判断mounted再用安全context。
9. 后续扩展:从单页到全流程
写完发起组队页,App只完成了组队链路的一小半。后面几期我会在这个表单基础上推几个方向:
- 发起成功后,列表页实时展示“待发车/进行中/已结束”状态。
- 组队详情页支持成员加入、人满自动锁定、队长踢人。
- 接后端服务后,表单数据POST到服务端,并同步到其他人的App,这就涉及到状态管理和网络层封装。
这期的表单设计和状态管理方法也能直接迁移到其他场景。比如活动报名页、个人资料编辑页、创建房间页,它们的核心无非就是“字段定义 + UI控件编排 + 校验策略 + 数据聚合提交”,把这套逻辑吃透,后面做新页面基本就是复制一套表单骨架再改字段。
如果大家在RK3568或者其他OpenHarmony设备上跑这段代码遇到问题,建议优先检查Flutter SDK版本和OpenHarmony SDK的匹配关系——我遇到过不少诡异问题最后都升级SDK就消失了。下一期我们做组队列表的卡片视觉与状态筛选,这一期的数据正好可以作为填充内容用。
