做Flutter for OpenHarmony开发的朋友应该都有这种感觉:前面环境搭建、工程跑通、页面框架这些事,折腾几次也就顺了,真正开始有意思的是从业务页面落地那一刻。这个剧本杀组队App系列前几篇,我们把Flutter for OpenHarmony的开发环境配好、工程初始化完成、底部导航和几个核心页面的骨架也搭出来了。这一篇终于进入正题——发起组队的表单页。
剧本杀组队的核心场景是这样的:某个玩家想开一车剧本杀,需要把剧本名、车次时间、人数范围、难度偏好这些信息整理出来,发到组队广场上等人来报名。如果这个录入过程做得不好,用户填到一半就烦了,组队意愿直接折半。所以发起组队表单看起来只是一个简单的信息录入页面,实际上承载了产品转化率的关键一步。
这期内容我会从业务字段设计、技术方案选型、表单校验实现,到OpenHarmony平台上真机实测遇到的中文输入、软键盘遮挡这些坑,完整走一遍。无论你是刚开始接触Flutter for OpenHarmony,还是已经在做跨端应用开发,这期都值得认真看完——表单是所有管理类、社交类App里最躲不开的基础模块,把这一套吃透,后面做任何信息录入页面都能直接套用。
1. 组队表单的业务拆解与设计思路
1.1 剧本杀组队场景下到底要收集哪些信息
做表单之前先别急着写代码,我习惯先把业务方需求表述翻译成结构化字段。剧本杀组队和普通活动报名最大的区别在于:信息维度多、时效性强、玩家之间匹配要求细。
我和模拟项目X的产品负责人聊下来,最终确认了六个核心字段,一张表说清楚:
| 字段 | 输入方式 | 说明 |
|---|---|---|
| 剧本名称 | 文本输入 | 必填,支持手输和从热门剧本列表选择 |
| 剧本类型 | 下拉选择 | 还原本、情感本、机制本、恐怖本、阵营本、欢乐本 |
| 开本时间 | 日期+时间选择 | 必填,精确到分钟 |
| 人数范围 | 滑杆/计数器 | 最小2人,最大12人,同时支持区间选择 |
| 玩家要求 | 多选标签 | 老带新、高配车、新手友好 |
| 备注说明 | 多行文本 | 选填,比如"已组到3人还差2"之类 |
这个字段清单看起来简单,但要注意几个容易被忽略的点。首先是人数范围,剧本杀每个剧本的配置要求不一样,有的本子4人就能开,有的必须6人起。如果我们只存一个固定数字,后面做智能匹配时会非常被动。所以这里我用的是一个区间——最小人数和最大人数都存下来。
其次是剧本类型,我一开始想用自由文本让用户自己填,后来发现不行。因为组队广场后续要做筛选和推送,类型不规范的话,筛选逻辑全乱套。所以这里必须用枚举下拉框控制输入。这也是表单设计里一个很重要的原则:凡是需要被搜索、筛选、聚合的字段,尽量用受控输入,少用自由文本。
1.2 为什么这个阶段用Flutter for OpenHarmony而不直接上原生
可能有人会问,既然目标是OpenHarmony生态,为什么不直接用ArkUI开发,非要套一层Flutter?
这个问题我在系列第1篇里详细聊过,这里简单回顾。我们这个App的目标不止一个平台,剧本杀组队这个场景天然适合跨端:组织者可能在手机上发车,参与者在平板上浏览,甚至后续还要扩展到桌面设备。如果每个端都写一套UI,维护成本直接翻倍。
Flutter for OpenHarmony的价值在于,它保留了Flutter标准框架的UI渲染能力和组件生态,同时通过适配层把引擎跑在OpenHarmony的系统能力上。也就是说,我们写的Flutter页面代码,理论上可以在Android、iOS、OpenHarmony等平台复用。对于小团队做垂直业务来说,这是性价比最高的路线。
而且从我这几个月的实测看,Flutter for OpenHarmony的表单相关组件适配得已经比较稳了。Form、TextFormField、DropDownButtonFormField这些核心组件在OpenHarmony设备上跑的渲染效果和Android基本一致。后面我会专门讲几个需要特别注意的平台差异点。
1.3 表单校验规则的提前设计
很多新手做表单喜欢把校验逻辑写到提交按钮里,然后堆一长串if-else。这样做不是不行,但维护起来很痛苦。我的习惯是提前把每个字段的校验规则定义清楚,做成一张规则表,写代码时按图索骥。
我这次的校验规则如下:
- 剧本名称:必填,去除首尾空格后长度至少2个字符,最多30个字符
- 剧本类型:必选,不允许为空
- 开本时间:必选,且不能早于当前时间(防止发已过去的车)
- 人数范围:最小值必须小于等于最大值,且区间跨度不能超过6(不然匹配太散)
- 备注说明:选填,最多200字
这些规则看起来繁琐,但每一条背后都有实际场景支撑。比如"开本时间不能早于当前时间"这条,是我第一次测试时真的踩过坑——测试环境时钟没问题,但用户在前一天晚上发起了一个次日凌晨的车次,日期选择器默认给了当天日期,提交时才发现时间已经过去了。没有这条校验,用户就会发出一辆永远等不到人的"幽灵车"。
2. 表单开发前的环境配置与组件选型
2.1 OpenHarmony平台的构建配置回顾
写表单页之前,先把工程环境捋一遍。Flutter for OpenHarmony的工程结构和标准Flutter工程基本一致,但有几个配置上的差异点得注意。
首先是pubspec.yaml,如果你的表单页用到了一些Flutter标准库之外的包,比如date_format这类纯Dart工具包,直接加依赖就行,不需要特殊处理。但如果你用到涉及平台能力的包(比如文件选择、拍照上传),就要确认是否有对应的ohos平台实现。纯Dart实现的第三方包在OpenHarmony上基本都能直接用,这是Flutter for OpenHarmony兼容性做得比较好的一点。
构建命令方面,标准Flutter打Android包是flutter build appbundle,OpenHarmony平台对应的是:
flutter build hap --target-platform ohoshap就是OpenHarmony的应用安装包格式。实测下来这个构建流程已经比较成熟了,不需要额外配置复杂的签名或权限文件。需要注意的是,如果你同时在跑Android模拟器和OpenHarmony模拟器,flutter run命令要加上对应的设备参数,不然它会默认选择第一个可用设备,有时候会选错。
表单页会用到日期时间选择能力,这在Flutter里走的是showDatePicker和showTimePicker。在OpenHarmony上,这两个组件的底层实现已经适配了系统的日期时间弹窗。不过我要提醒一句:如果你在模拟器上测试日期选择,点开弹窗后偶尔会有卡顿,这是模拟器渲染性能问题,换到真机上就正常了。遇到这种情况别急着改代码,先换设备试试。
2.2 为什么选Form加TextFormField的组合而不是自由拼装
Flutter的表单方案其实有好几种,最简单的做法是给每个TextField单独维护一个controller和错误提示状态,需要校验时逐个检查。但这种方式代码重复度太高了,而且多个字段的错误提示状态需要一个一个管理,很容易出现"这个字段报错了、那个字段没报错"的混乱局面。
我选用的是Form加TextFormField的组合,这是Flutter官方推荐的标准做法,也是社区里最常用的方案。核心思路是:用一个Form组件包裹所有表单项,FormSection里放各个TextFormField,然后通过一个GlobalKey统一触发表单校验。
这样做有三个好处。第一,错误提示状态由Form统一管理,TextFormField只需要提供validator回调,被校验时框架自动处理错误消息的展示和清除。第二,提交时一行代码就能触发全部字段的校验,不用手动遍历。第三,表单项之间的联动逻辑(比如人数区间变化时,玩家要求选项自动更新)可以在Form的范围内统一处理。
2.3 表单状态管理:本地StatefulWidget就够,别急着上重量级框架
做表单时很多人会纠结要不要上Provider、Riverpod、Bloc这些状态管理方案。我的建议很明确:如果只是做一个独立的表单页面,根本不需要引入状态管理框架,StatefulWidget就够了。
原因很简单,表单页面的状态边界非常清晰——所有状态都存在于当前页面内部,页面销毁后状态就随之消失。这种局部状态用setState维护完全够用,代码可读性和调试体验反而是最好的。
什么时候才需要状态管理框架?等你需要在多个页面之间共享表单数据时,比如发起组队的草稿要跨页面保存、组队列表要实时同步表单状态,那时候再引入也不迟。我们这个项目在表单阶段先不引入,后面做到组队列表和我的页面时,再评估是否需要集中管理状态。很多项目的状态管理方案都是被过早设计出来的,表单页这个阶段,保持简单就是最好的方案。
3. 发起组队表单的完整实现
3.1 表单数据模型定义
我习惯先把数据模型写出来,再写页面。这样页面的每个字段对应模型里的哪个属性,一目了然。我这次用的是一个可变的FormData类,因为表单在填写过程中是逐步完善的,用不可变对象反而增加代码复杂度。
class GroupFormData { String scriptName; String scriptType; DateTime startTime; int minPlayers; int maxPlayers; String playerRequirement; String note; GroupFormData({ this.scriptName = '', this.scriptType = '', this.startTime, this.minPlayers = 4, this.maxPlayers = 6, this.playerRequirement = '新手友好', this.note = '', }); bool get isValidate => scriptName.trim().isNotEmpty && scriptType.isNotEmpty && startTime != null && startTime.isAfter(DateTime.now()) && minPlayers <= maxPlayers; Map<String, dynamic> toJson() { return { 'scriptName': scriptName, 'scriptType': scriptType, 'startTime': startTime?.toIso8601String(), 'minPlayers': minPlayers, 'maxPlayers': maxPlayers, 'playerRequirement': playerRequirement, 'note': note, }; } }toJson方法我顺手写了,因为后面提交表单后要把数据发送到服务端,这个Json序列化方法到时候直接用就行。你如果用了json_serializable这类代码生成器也可以,但一个表单数据类手动写toJson完全够用,没必要引入构建阶段处理。
isValidate这个getter是给我自己调试用的,方便在开发阶段快速验证数据是否满足最基本要求。实际提交时还是走Form的validator逻辑,因为getter没法精确告诉用户到底是哪个字段出了问题。
3.2 页面骨架与基础字段实现
接下来是页面主体。我的做法是先用一个ListView把所有表单项按顺序排好,最后放提交按钮。这一步有一个关键细节:ListView必须包一层SingleChildScrollView或者直接就是可滚动视图,否则软键盘弹出来的时候,输入框会被完全挡住。实际我用的是ListView自带滚动能力,配合resizeToAvoidBottomInset参数控制键盘弹出时的页面收缩行为。
先看页面核心骨架和剧本名称、类型两个字段:
class CreateGroupPage extends StatefulWidget { @override _CreateGroupPageState createState() => _CreateGroupPageState(); } class _CreateGroupPageState extends State<CreateGroupPage> { final _formKey = GlobalKey<FormState>(); final GroupFormData _formData = GroupFormData(); final TextEditingController _scriptNameController = TextEditingController(); final TextEditingController _noteController = TextEditingController(); static const List<String> _scriptTypes = [ '还原本', '情感本', '机制本', '恐怖本', '阵营本', '欢乐本', ]; @override void dispose() { _scriptNameController.dispose(); _noteController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('发起组队'), actions: [ TextButton( onPressed: _submitForm, child: Text('发布'), ), ], ), body: SafeArea( child: Form( key: _formKey, child: ListView( padding: EdgeInsets.all(16), children: [ TextFormField( controller: _scriptNameController, maxLength: 30, decoration: InputDecoration( labelText: '剧本名称', hintText: '如:年轮、病娇男孩的精分日记', prefixIcon: Icon(Icons.menu_book), border: OutlineInputBorder(), ), validator: (value) { final text = value?.trim() ?? ''; if (text.isEmpty) { return '请填写剧本名称'; } if (text.length < 2) { return '剧本名称至少2个字符'; } return null; }, ), SizedBox(height: 16), DropdownButtonFormField<String>( initialValue: _formData.scriptType.isEmpty ? null : _formData.scriptType, decoration: InputDecoration( labelText: '剧本类型', prefixIcon: Icon(Icons.category), border: OutlineInputBorder(), ), items: _scriptTypes .map((type) => DropdownMenuItem( value: type, child: Text(type), )) .toList(), onChanged: (value) { setState(() { _formData.scriptType = value ?? ''; }); }, validator: (value) { if (value == null || value.isEmpty) { return '请选择剧本类型'; } return null; }, ), // 后续字段... ], ), ), ), ); } }这里有两个细节我要专门说明。第一个是TextFormField一定要配controller,并且记得在dispose里释放。不释放controller的直接后果是内存泄漏,长期运行后会越来越卡。第二个是DropdownButtonFormField的initialValue参数,我这里根据_formData.scriptType是否为空来决定初始值。标准写法里这个参数叫value,但新版本Flutter已经改成了initialValue,如果你用的是新SDK还写value的话,编译期就会报警告。
剧本类型为什么用dropdown而不是别的方式?因为类型只有六个选项,下拉框占用的屏幕空间最小,而且不会出现误输入。有些App喜欢用横向滑动选择器,但那个对滑动手势的误触率高,尤其在一滑到底的列表页里。下拉框是最稳的选择。
3.3 日期时间选择和人数滑杆实现
日期和人数是本表单里交互复杂度最高的两个字段。日期需要用到showDatePicker和showTimePicker两个系统弹窗,而且用户很自然地会希望"先选日期、再选时间"。我这里用一个组合的显示控件来呈现已选择的时间信息。
// 日期时间选择字段 FormField<DateTime>( validator: (value) { if (_formData.startTime == null) { return '请选择开本时间'; } if (_formData.startTime.isBefore(DateTime.now())) { return '开本时间不能早于当前时间'; } return null; }, builder: (field) { return InkWell( onTap: _selectDateTime, child: InputDecorator( decoration: InputDecoration( labelText: '开本时间', prefixIcon: Icon(Icons.schedule), border: OutlineInputBorder(), errorText: field.errorText, ), child: Text( _formData.startTime == null ? '选择日期和时间' : _formatDateTime(_formData.startTime), style: TextStyle( fontSize: 16, color: _formData.startTime == null ? Colors.grey : Colors.black87, ), ), ), ); }, )_formData.startTime这个字段我用FormField包了一层,这样不用单独维护错误提示状态,校验错误能统一显示在字段下方。_selectDateTime方法里,先弹日期选择器,拿到日期后再弹时间选择器:
Future<void> _selectDateTime() async { final now = DateTime.now(); final date = await showDatePicker( context: context, initialDate: _formData.startTime ?? now, firstDate: now.subtract(Duration(days: 1)), lastDate: now.add(Duration(days: 365)), helpText: '选择开本日期', ); if (date == null) return; final time = await showTimePicker( context: context, initialTime: _formData.startTime != null ? TimeOfDay.fromDateTime(_formData.startTime) : TimeOfDay(hour: 19, minute: 0), helpText: '选择开本时间', ); if (time == null) return; setState(() { _formData.startTime = DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); }时间选择器我给了默认晚上7点,这是剧本杀开本的高峰时段,能少点几下。firstDate我设成了当前时间减一天,目的是容忍用户设备时钟偏差,配合validator里面的"不能早于当前时间"做双保险。
人数范围这里,我一开始想用RangeSlider,做出来之后发现交互上有个问题:用户拖起来特别容易误触,而且范围提示不够直观。后来改成了两个Stepper搭配显示,一个调最小人数,一个调最大人数,实际使用体验反而更好。核心代码是这样:
Row( children: [ Text('人数范围'), Spacer(), _buildStepper('最小', _formData.minPlayers, 2, 10), SizedBox(width: 16), Text('至'), SizedBox(width: 16), _buildStepper('最大', _formData.maxPlayers, 4, 12), ], ),Stepper封装里面对边界做了限制,最小人数最大加到10,保证给最大人数留空间;最大人数最小从4开始,跟剧本杀常见的4人本保持一致。还有一层校验写在validator里:如果minPlayers大于maxPlayers,就提示"最小人数不能大于最大人数"。
3.4 玩家要求、备注和发布逻辑
玩家要求我这里没做复杂的多选标签组件,因为OpenHarmony上Flutter的原生Chip组件适配还不是特别完善,真机上偶尔有渲染闪烁。我折中了一下,用ChoiceChip做三个固定选项的单选:新手友好、高配车、老带新。这三个选项代表了剧本杀组队最核心的三种需求,单选逻辑也简单,不纠缠。
备注字段就是普通的multiline TextFormField,maxLines设为3,maxLength限200,不再多展开。
最后是提交逻辑,也是整个表单的收口:
void _submitForm() { final isValid = _formKey.currentState?.validate() ?? false; if (!isValid) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('请完善表单信息')), ); return; } // 组装数据 _formData.scriptName = _scriptNameController.text.trim(); _formData.note = _noteController.text.trim(); // TODO: 调用接口发布组队 // 发布成功后跳转组队广场 Navigator.pushReplacementNamed(context, '/groupSquare'); }发布按钮放在AppBar右上角,比页面底部的大按钮更顺手——用户填完表单后拇指就在右上角区域,不用往下找按钮。这里SnackBar的提示文案我故意写得比较宽泛,因为具体哪个字段没填好,表单已经用红字标出来了,SnackBar只是给一个整体反馈,避免两个位置的提示信息互相打架。
这里有个非常关键的点:表单校验通过后,我从controller里重新读取了一遍文本再赋给_formData。这么做是有原因的——用户在填写过程中可能修改过内容,而_textField的validator只是做了校验,并不会自动把最新值写回_formData。如果我在onChanged里逐字段同步更新,代码量会大幅增加。这里用"提交时统一从controller取值"的方式,代码最简洁,也不容易漏字段。
4. 常见问题与排查技巧实录
4.1 中文输入框的光标跳动问题
这个坑我刚开始完全没有预料到。在OpenHarmony真机上测试表单页时,中文输入时光标偶尔会跳到文本开头,导致输入体验像"打字时光标自己乱跑"。
排查过程是这样的:先用排除法确定不是输入法的问题——换系统自带输入法现象依旧;然后检查代码,发现TextFormField没有显式设置键盘类型,默认的文本键盘在拼音输入法唤起时,偶尔会触发一个光标重算的渲染异常。
解决办法很简单,给剧本名称输入框显式指定键盘类型:
keyboardType: TextInputType.text,同时把输入行为里的光标处理交给Flutter框架默认逻辑,不要手动设置selection。在OpenHarmony当前适配版本下,手动干预光标位置导致异常的概率更高。
4.2 软键盘遮挡表单项
表单页最常见的体验问题,没有之一。在标准Flutter里,Scaffold默认的resizeToAvoidBottomInset就是true,键盘弹出来页面自动收缩。但我在OpenHarmony上实测发现,个别版本的系统输入法会在键盘弹出时出现"输入框被完全盖住"的问题,尤其当输入框在页面下半部分时。
我的处理方案是双重保险:一是保持resizeToAvoidBottomInset为默认值true,二是在ListView外面包一层Padding,用MediaQuery.viewInsets.bottom动态增加底部间距:
Padding( padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: ListView(...), )这样即使输入法的弹出动画导致页面收缩失效,输入框底部也不会被遮挡。但这个方案有一个副作用:键盘收起时底部会有短暂白边,不过动画过渡很快,实测感知不强。
4.3 FormField和TextFormField混用时的校验协调
日期选择字段我用的是FormField而不是TextFormField,这两种组件在Form里同时出现时,需要留意一个时序问题:Form.validate()会按组件树顺序依次调用每个字段的validator。如果前面的字段校验失败,当前字段的validator依然会被调用,但错误提示是否渲染,取决于该字段自己的state状态。
我遇到的问题是:日期字段在用户尚未选择时,validator返回"请选择时间",但错误提示没有立即显示,直到用户触碰了该字段区域才出现。原因是FormField在validate时只更新了内部的errorText,但如果没有触发rebuild,界面不会刷新。
解决方法是把日期FormField的builder里故意依赖了_formData.startTime这个状态,当其它字段setState触发重建时,日期字段也会跟着重新build,错误提示就能及时显示。说白了就是让日期字段的UI状态和表单数据绑定,而不是让它孤立的自我管理。
4.4 常见问题排查速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 中文输入时光标跳动 | 输入法唤起时渲染异常 | 显式设置keyboardType,不手动干预selection |
| 软键盘遮挡输入框 | 系统输入法动画未正确同步页面收缩 | resizeToAvoidBottomInset保持true,底部加viewInsets |
| 日期校验提示不显示 | FormField未随表单重建 | builder里依赖表单数据状态,触发setState重建 |
| Dropdown初始值不显示 | 新旧SDK参数名差异 | 新SDK用initialValue替代value |
| 表单提交后数据缺失 | 只在validator里校验未回写值 | 提交时从controller统一读取值赋给数据模型 |
| 模拟器上日期弹窗卡顿 | 模拟器渲染性能问题 | 换真机测试,不要改业务代码 |
这张表我建议直接收藏,后面做任何表单页面都会遇到类似问题。第5行的"表单提交后数据缺失"尤其坑,很多人只在TextFormField的onChanged里同步数据,忘记了提交时重新读取controller的值,导致用户改了内容但提交的是旧数据。这个bug特别隐蔽,测试时很难发现,因为你不一定会改完再提交。
5. 实操心得和这个阶段的一些体会
表单页做完的那一刻,我其实没有太多成就感,因为代码量并不大,几百行而已。但回头审视整个开发过程,这个模块的价值比预想中大得多。
我自己最大的体会是:表单页面的难点从来不在写代码,而在设计决策。哪些字段该必填、哪些该选填、用下拉还是单选、错误提示放在哪里、键盘弹出来怎么办——这些决策直接决定了用户的填写体验,也决定了后续数据能不能被高效利用。比如剧本类型的枚举化设计,当时多花了十分钟讨论,但后面做组队广场的筛选功能时,几乎零成本就实现了按类型过滤。
另外,OpenHarmony平台的适配工作比预想中顺利。Flutter标准表单组件在这套平台上跑起来,和Android端的表现一致性很高,真正需要针对性处理的也就中文输入、键盘遮挡这几个点。如果是从别的跨端框架转过来,对OpenHarmony的适配深度应该会比较惊讶。
最后分享一个小技巧:我在开发调试时给表单页加了一个"一键填充测试数据"的入口,藏在页面右上角的调试菜单里。每次启动App直接点一下,所有字段都会被合法的测试数据填满,省去了每次手动输入剧本名、选时间、调人数的重复劳动。这个习惯帮我省下了大量联调时间,等接口联调完,再把这个入口在发布版本里去掉就行。
下一期我会继续做组队广场列表页,到时候就会涉及从服务端拉数据、下拉刷新、组队卡片设计这些内容了。表单页的数据模型、提交逻辑都已经准备好,列表页正好可以接上后端的组队实体来做展示。到时候见。