1. 从需求到页面:表单设计才是重头戏
前面三篇实战我们搞定了项目骨架、底部导航和首页的剧本列表,这套 Flutter for OpenHarmony 的组合拳打下来,相信你已经对跨端开发有了体感。这次要做的“发起组队表单”,表面上看只是个表单页,但真正动手后你会发现,它把 Flutter 的 Form 体系、状态管理、日期/时间选择、输入校验这些核心知识点全串在了一起,而且还要照顾 OpenHarmony 端的兼容差异。
这个页面解决的是什么问题?玩家想开一局剧本杀,需要把“玩什么本、几点开、缺几个人、有什么要求”这些信息变成一条结构化的组队帖子,发给其他玩家。没有这个入口,App 就只是个浏览工具,有了它才真正具备“组局”的能力。所以这期内容适合正在做 Flutter 跨端应用、尤其是面向 OpenHarmony 发布的朋友参考,抄作业的同时也把 Flutter 表单这套玩法吃透。
设计阶段我想得比较久。表单不长,但每个字段背后都有产品逻辑:剧本选择走下拉还是跳转列表页?人数是固定数字还是区间?“性别要求”这种敏感字段要不要做?最终我定了五个字段:剧本名称、开始时间、人数区间、玩法标签、补充说明。前四个结构化,最后一个自由填写,这个组合在实际组队场景中最高效,不至于让玩家填一堆东西失去耐心。
字段落定后,真正花心思的是数据结构。表单页面要产出一个干净的模型对象,后续提交给服务端、存入本地缓存、甚至分享给第三方平台,都依赖这个模型。我在项目里建了一个TeamInfo类,字段不多,但每个字段的类型约束明确。比如人数我用RangeValues存区间而不是两个独立 int,这样传递给后端和 UI 层时语义更清晰。
class TeamInfo { String scriptName; DateTime startTime; RangeValues playerRange; List<String> tags; String description; TeamInfo({ this.scriptName = '', this.startTime, this.playerRange = const RangeValues(4, 6), this.tags = const [], this.description = '', }); Map<String, dynamic> toJson() => { 'script_name': scriptName, 'start_time': startTime?.toIso8601String(), 'player_min': playerRange.start.toInt(), 'player_max': playerRange.end.toInt(), 'tags': tags, 'description': description, }; }我刻意没用playerCount的单数概念。剧本杀组队天然存在弹性区间,比如“4-6人车”,写死人数反而给自己找麻烦。RangeValues配合 Slider 组件是 Flutter 生态里最顺手的区间选择方案,后面做页面对接时也有这个好处,不需要额外的数据转换。
注意:
RangeValues来自 Flutter 的 foundation 库,OpenHarmony 的 Flutter 适配版本里这个类是完整的,但这提醒我们一件事——尽量使用 Flutter SDK 自带的基础类型,少依赖第三方扩展包,跨端时出问题的概率小得多。
2. 表单骨骼:Form 与 TextFormField 的正确打开方式
Flutter 里实现表单不是简单地堆TextField。用Form组件包裹,配合TextFormField的 validator 机制,才能实现“统一校验、统一报错”的效果。这个机制的核心在于Form维护了一个FormFieldState列表,调用FormState.validate()时,所有已注册的表单字段会依次执行自己的校验逻辑,任何一个不通过都会返回 false 并显示错误信息。
我的外观字段是这样安排的:剧本名称用TextFormField,开始时间用只读输入框 + 点击弹时间选择器,人数区间用RangeSlider,标签用FilterChip多选,补充说明用多行TextFormField。这样一个表单里集成了 Flutter 表单家族的五大金刚,对 OpenHarmony 端 Flutter 渲染引擎的布局能力也是一次实战检验。
先说剧本名称这个字段。组队帖里剧本名是灵魂,玩家第一眼要看的就是玩什么本。校验规则我定为必填、最长 30 字。30 字听起来宽松,但大多数剧本名都在 15 字以内,这个长度的冗余是为了兼容一些名字特别长的城限本。
TextFormField( controller: _nameController, maxLength: 30, decoration: InputDecoration( labelText: '剧本名称', hintText: '例如:窗边的女人', prefixIcon: Icon(Icons.menu_book), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), validator: (value) { if (value == null || value.trim().isEmpty) { return '请填写剧本名称'; } return null; }, )maxLength加 30 的设定,配合counterText可以隐藏烦人的计数器。这里提醒一个细节:validator里value != null的判断要写在value.isEmpty前面,因为 controller 没初始化时可能出现 null,虽然 Flutter 的TextFormField默认value不会是 null,但防御性写法能避免线上偶发崩溃。
再说下拉选择。剧本名我不想让用户自由输入,但也不能做一个几百条数据的大列表,那会拖垮页面渲染。折中方案是:做一个常用剧本的候选列表,用RawAutocomplete实现联想。这个组件是 Flutter 2.10 之后引入的,OpenHarmony 适配版里可用,实测联想弹出层的定位在部分版本上有偏移,我用optionsViewOpenDirection和OverlayPortal手动调整了位置。
实际项目里,如果剧本库要动态从服务端拉取,RawAutocomplete的异步加载会变得复杂,建议换成搜索页 + 结果回调的模式,反过来把表单页作为展示层。但当前版本做 MVP,本地候选列表足够,也省了网络层的工作。
2.1 时间选择:showTimePicker 与日期组件的跨端适配
开始时间字段是整个表单里最容易出兼容问题的部分。Flutter 提供了showDatePicker和showTimePicker,但分开用体验割裂,我想要一个“日期 + 时间”的联动选择。开头提到的时间戳字段,我用showDatePicker先选日期,拿到结果后再调showTimePicker选时间,最后拼成完整的DateTime。
Future<void> _selectDateTime() async { final now = DateTime.now(); final date = await showDatePicker( context: context, initialDate: now, firstDate: now, lastDate: now.add(const Duration(days: 30)), helpText: '选择出发日期', ); if (date == null) return; final time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(now), ); if (time == null) return; setState(() { _teamInfo.startTime = DateTime(date.year, date.month, date.day, time.hour, time.minute); _formattedTime = _formatDateTime(_teamInfo.startTime); }); }这里有个关键决策:firstDate不允许选过去的时间,lastDate限制 30 天内。剧本杀组队通常不会提前一个月开团,限制太宽只会增加无效选项。如果你要做的业务是预约制,这个限制条件需要放宽,但产品逻辑上还是建议设上限,给用户做减法。
日期选择器在 OpenHarmony 端有个已知现象:Material 日期弹出层偶尔出现动画卡顿,但功能正常。这是由于 OpenHarmony 的自绘渲染引擎在处理某些 Material 动画曲线时还没有完全对齐。我实测后确认只是动画掉帧,不影响交互,可以在ThemeData里把splashFactory调整为NoSplash.splashFactory,顺带让整个 App 的点击水波纹效果显示更统一。
显示层我用InputDecorator包裹一个只读容器,点击时触发选择器,而不是直接用TextFormField。这样避免了键盘弹出打断选择流程,也方便我自定义“选择时间”这个行为的视觉反馈。
InkWell( onTap: _selectDateTime, borderRadius: BorderRadius.circular(12), child: InputDecorator( decoration: InputDecoration( labelText: '开始时间', prefixIcon: Icon(Icons.access_time), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text( _formattedTime ?? '请选择开始时间', style: TextStyle( color: _formattedTime == null ? Colors.grey : Colors.black87, ), ), ), )一个易踩的坑:InputDecorator的文本颜色不会自动随 focus 状态变化,需要手动根据_formattedTime是否为空来切换颜色。我看到不少新手在这里直接放一个Text,导致“已选择时间”和“未选择时间”视觉区分度不够,玩家容易误操作。
2.2 人数区间:RangeSlider 的参数调优
人数是组队信息里另一个高频互动的字段。剧本杀的车从 4 人本到 12 人本都有,但常见区间是 4~8 人。我用RangeSlider做了个双端滑块,最小 3 人最大 12 人,默认 4~6 人。
RangeSlider( values: _teamInfo.playerRange, min: 3, max: 12, divisions: 9, labels: RangeLabels( '${_teamInfo.playerRange.start.toInt()}人', '${_teamInfo.playerRange.end.toInt()}人', ), onChanged: (values) { setState(() { _teamInfo.playerRange = values; }); }, )divisions设为 9 意味着滑块按整数步进。这里有个细节:如果不设divisions,滑块是连续变化的,出来个 4.7 人就尴尬了;设成 9 是因为 12 - 3 = 9,正好一步一人。如果你改动了min和max,记得重新算divisions,这个公式是(max - min) / 1。
人数描述里我会顺带显示组队状态,比如:“3缺1”“4缺2”。这个功能需要知道当前已有多少人,但在发起组队阶段还没有队友加入,所以我用人数下限减去当前已确认人数来计算,已确认人数初始为 1,代表队长本人。虽然这里看起来是件小事,但交互细节决定了页面温度,玩家看到“4缺2”会比“4-6人”更有画面感。
2.3 标签多选:FilterChip 的场景化使用
标签代表玩法偏好,比如“硬核推理”“欢乐机制”“情感沉浸”“恐怖惊悚”“阵营对抗”。这些标签是并列关系,适合用FilterChip多选。我维护了一个Set<String> _selectedTags,选中即添加,再点取消。
Wrap( spacing: 8, children: _tags.map((tag) { final selected = _selectedTags.contains(tag); return FilterChip( label: Text(tag), selected: selected, onSelected: (isSelected) { setState(() { if (isSelected) { _selectedTags.add(tag); } else { _selectedTags.remove(tag); } }); }, ); }).toList(), )Wrap在这里是关键。标签数量多了以后,水平方向放不下时会自动换行,避免溢出报错。有些新手用Row+SingleChildScrollView横向滑,体验其实不如Wrap自然。手机屏幕宽度有限,标签横向排列容易把第二屏的内容挤下去,Wrap按需换行是最符合直觉的布局。
标签的视觉反馈我做了二次加工:选中的标签不仅高亮,还在右侧加了个小对勾图标。这个功能在FilterChip上自带,selectedColor和checkmarkColor配置好了就行。但要注意,OpenHarmony 端部分版本的FilterChip选中态背景色与文字颜色对比度不够,我把selectedColor从默认主题色改成了偏浅一点的色调,保证不影响可读性。
3. 组装与校验:把表单串成一条可用的链路
页面骨架完成之后,接下来是“提交”这个动作的设计。表单页的提交按钮通常有两种做法:底部固定按钮或跟随内容滚动。我选择了底部固定,因为内容本身就占了差不多一个屏幕,按钮固定在底部可以让玩家随时点到,不用滚动到底才能操作。这里用Scaffold的bottomNavigationBar放按钮,用Column+Expanded包表单主体,实现固定底栏。
提交逻辑的核心是FormState.validate()。我在按钮的onPressed里拿到FormState,先校验所有字段,再校验自定义约束(比如时间不能为空),最后组装TeamInfo对象并回调给上一页。冒烟测试时我故意点了几次空白提交,报错提示都准确出现,字段聚焦后错误自动消失,这套体验是 FlutterTextFormField的autovalidateMode帮了大忙。
关于autovalidateMode,我建议用AutovalidateMode.onUserInteraction。这个模式意味着用户一交互就开始校验,而不是死等提交时才提示。第一版我用的AutovalidateMode.disabled,发现一个体验问题:玩家填完剧本名后想立刻看到合法性反馈,但必须等滚动到页面底部点提交才触发,这个过程太长了。改为onUserInteraction后,填一个字段校验一个字段,体验完全不一样。
但onUserInteraction有副作用:页面刚加载时如果有的字段已经有默认值,比如人数区间的初始值,这些字段不会触发校验;只有用户实际操作后才开始。这在当前场景没问题,因为人数本来就是合法值。如果你后续要加“编辑旧数据”的功能,记得把initialValue传进TextFormField,并在validator里对旧值做同步校验。
提交按钮本身除了onPressed之外,我还做了防重复点击。用一个_isSubmitting标志位,第一次点击置为 true,按钮显示 loading 状态,同时禁用点击。这在真实网络环境下非常关键,否则玩家手快连点两下,就生成两条几乎一样的组队帖。
ElevatedButton( onPressed: _isSubmitting ? null : _submitForm, child: _isSubmitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('发起组队'), )3.1 从页面到数据:回调与路由结果
发起组队页不能自己把数据吞了,要把结果交出去。我的路由方式是Navigator.push,上一页用await等待返回结果。如果返回的是一个TeamInfo对象,说明组队创建成功,上一页把这条记录插入列表并刷新。如果返回 null,说明用户取消了操作。
final result = await Navigator.push<TeamInfo>( context, MaterialPageRoute(builder: (context) => const CreateTeamPage()), ); if (result != null) { setState(() { _teamList.add(result); }); }这个传值方式在 Flutter 里是标准做法,但有两个点要提醒。第一,MaterialPageRoute不是唯一选择,OpenHarmony 端也可以用CupertinoPageRoute,只不过返回手势在部分版本上支持不完美,建议统一用 Material 风格。第二,数据量大时不要用Navigator返回值传对象,建议用全局状态管理或临时存储,避免栈内存紧张引发性能问题。
再说说校验的可视化。表单提交失败时,页面会同时出现多个错误提示,这些提示默认在表单字段下方,视觉上比较密集。我加了一个“滚动到第一个错误字段”的能力:在校验失败后,用Scrollable.ensureVisible把第一个出错的字段滚动到可视区域。
这个功能实现起来不算复杂——先给每个字段的GlobalKey用一个 List 存起来,validator 逐个执行时记录第一个失败的 key,最后滚动过去。但 Flutter 的GlobalKey在列表里用需要小心,不要在每个 build 周期重建。我在 State 里声明了固定数量的 key,跟表单字段一一对应,滚动逻辑稳定可靠。
3.2 输入控制器生命周期与内存管理
表单页用到了TextEditingController,一个常见的坑是忘记 dispose。我在State的dispose方法里释放了所有的 controller。但这里有个 OpenHarmony 端的特殊点:不要只在dispose里释放,还要考虑页面被系统回收的场景。Flutter 的State生命周期在跨端引擎上表现一致,但如果你在某个版本上发现 controller 没有被回收(可以用日志验证),多半是页面被某个全局变量持有了引用,要检查是不是不小心把 controller 存到了静态类里。
我用的折衷方案是写一个TextEditingController的工具类,统一管理创建和销毁,代码里不直接TextEditingController(),而是TextControllerFactory.create()。这不是什么高深技术,但工程上少犯低级错误。
3.3 表单数据的序列化与日志埋点
开篇那个toJson方法可不是摆设。表单提交不是只把对象传给上一页就完事,通常还要写入本地缓存或上传服务端。为了调试方便,我在提交时顺手打了日志,里面包含完整的TeamInfo.toJson结果。这样在 OpenHarmony 设备上跑的时候,可以用日志工具观察表单字段是否完整、是否有异常值。
日志埋点里有个细节:时间字段我用的是toIso8601String(),而不是毫秒时间戳。为什么?ISO 8601 格式可读性强,带时区信息,调试时一眼就能看出时间对不对;毫秒时间戳看起来就是一串数字,对比时还得换算。服务端解析 ISO 8601 也更容易,大多数语言都有现成库。唯一要注意的是时区问题,建议统一用 UTC 存储,展示时转本地时。但 MVP 阶段为了省事,我直接用了本地时间,真机测试没有发现问题。如果后续要做跨时区组队,这个点必须处理。
经验:表单页的日志要打全量字段,不要只打部分。有一次我排查一个“组队时间比当前时间早”的 bug,就是因为只看提交按钮的回调,没看表单内部的完整数据,浪费了半小时。把
toJson()全量打出来,一眼看到底是哪个字段的默认值没被覆盖。
4. OpenHarmony 端的适配实录:键盘、定位与包体
标题一直强调 OpenHarmony,这部分集中讲我在真机适配中的几个具体问题。如果你只在 Android 上跑过 Flutter,下面这些问题可能没见过。
第一个是键盘遮挡问题。OpenHarmony 的自绘渲染引擎处理Scaffold.resizeToAvoidBottomInset的行为跟 Android 有差异:默认值下,键盘弹出时页面会 resize,但如果你的页面里有FixedExtentScrollController之类的滚动控制器,偶尔会出现 resize 后滚动位置错乱。这是我在多人测试时发现的:一个玩家在填写描述时,页面突然跳回顶部。解决方式是给Scaffold设置resizeToAvoidBottomInset: false,同时手动在输入框外层包一个SafeArea+SingleChildScrollView,用Padding补偿键盘高度。
但手动补偿键盘高度又引出了新问题:如何拿到键盘高度?Flutter 有个MediaQuery.viewInsets.bottom,在 OpenHarmony 上这个值大部分时候是准的,但某些输入法弹窗有悬浮高度变化时,会有一个延迟。我最后放弃了这个方案,改回resizeToAvoidBottomInset: true,然后给滚动区域设置了keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag。这样玩家拖动页面时键盘自动收起,反而比手动补偿更顺滑。
第二个是输入法光标定位。在 OpenHarmony 上,TextFormField 的点击定位偶发不准,尤其是在中英文混合输入时。这个问题的根因是输入法引擎与 Flutter 的文本布局之间的协调有偏差。我查了社区说法,有人建议关闭输入法的智能联想功能,但在 App 层面我们控制不了系统输入法设置,只能在代码上把输入框的enableInteractiveSelection保持默认 true,让玩家可以自由选择文本,避免光标问题进一步放大。
第三个是依赖包体积。OpenHarmony 的 Flutter 应用壳本身就比 Android 大一点,第三方的日期选择器如果引入了额外的 intl 国际化数据,包体会膨胀得很厉害。我在表单页没有用第三方日期库,全部走 Flutter 内置组件,实测安装包增量不到 1MB。这是个值得参考的取舍原则:在跨端项目中,优先用 SDK 自带功能,把第三方依赖数量压低,减少兼容面。
4.1 真机调试时无法弹出键盘的排查
这个场景值得单独提一下:我在某款搭载 OpenHarmony 的平板上调试时,表单页的所有TextFormField都点不出键盘。当时第一反应是键盘插件没装好,检查了各种配置都不对。后来发现问题是这台平板的系统输入法服务没启动,而不是 Flutter 的问题。让我说下排查路径:
先看日志里有没有InputMethodService相关的报错,没有的话手动唤起一下系统设置里的输入法,再回来点击输入框。如果问题依旧,检查是不是在AndroidManifest或 OpenHarmony 的 module 配置里禁用了adjustResize。Flutter 引擎的通知是靠系统 window 焦点,如果焦点被某个原生组件抢占,输入法也不出来。
这个问题的启发是:跨端项目里不少“诡异问题”其实是系统环境和第三方组件的组合问题,遇到时不要第一时间怀疑 Flutter 框架本身,按系统输入法状态、窗口焦点、配置项的顺序排查,效率更高。
4.2 日期选择器在 OpenHarmony 上的显示异常
showDatePicker的 Material 弹出层在 OpenHarmony 上还有一个已知坑:日历表格的周日列偶尔显示为空白。这个问题我排查了挺久,最后确认是字体渲染的锅。OpenHarmony 系统默认字体对某些语言文字的宽度计算有差异,导致日期的星期头被“挤”出可视区域。解决方式有两种:一种是给MaterialApp设置theme时显式指定字体族;另一种是调整DatePickerTheme的dayShape,给单元格多一点宽度余量。
我选了第三种,用localizationsDelegates指定中文语言包,因为日期选择器的星期头和月份标题完全依赖本地化数据,中文本地化后还用英文语言包,计算逻辑就会出现偏差。GlobalMaterialLocalizations.delegates和GlobalWidgetsLocalizations.delegates配合locale: Locale('zh'),日期显示回归正常,中英文混排的测试也都通过了。
5. 从表单到列表:刷新机制与状态同步
表单提交只是链路上的第一环,上一页收到结果后的刷新也很讲究。我的首页组队列表是一个ListView,setState加新数据后ListView会自动 rebuild。但这里有个体验细节:新加的组队项应该出现在列表顶部还是底部?从产品逻辑上讲,最新开的组队应该在最上面,这样玩家打开首页最先看到新鲜队伍。所以我是把result插到列表下标 0,而不是 append。
如果你有下拉刷新、搜索过滤等功能,这个插入位置还要考虑与当前过滤条件的匹配。比如首页当前筛选条件是“恐怖本”,新组队的标签是“欢乐本”,直接插到顶会被用户认为是 bug。所以更稳妥的方式是:提交成功后不要立刻插入列表,而是弹提示“发起成功,已保存在页面上”,然后回到首页时用下拉刷新拉取最新数据。但 MVP 阶段我们的数据是本地内存态,没有服务端,所以我选择了 UI 即时反馈的简单方案,并在代码注释里标注了后续要切换到服务端数据源。
这个取舍背后的逻辑是:表单页与列表页的数据流转,核心是保持状态一致,宁可少做误导性的 UI 动画,也要保证数据的新鲜度。如果你后续接入了状态管理库(比如 Provider 或 Riverpod),这个刷新逻辑会天然解耦,表单提交回调里直接更新 store,列表页监听 store 变化自动刷新。
5.1 数据缓存与本地持久化
本地内存态有个尴尬点:App 重启后数据全没了。对 MVP 演示来说问题不大,但组队场景需要数据留存。我在表单提交成功的同时,把TeamInfo.toJson()写入了本地缓存,用shared_preferences存 JSON 字符串列表。加载首页时先读缓存,再拼上内存新增的数据。
OpenHarmony 端shared_preferences插件的兼容性整体可用,但写入路径与 Android 不同,文档上说的是标准路径。实际调试时,我建议先调用prefs.clear()跑一次全量写入读取,验证插件工作正常后,再做增量追加逻辑。这个检查步骤花不了两分钟,但能让你把所有注意力放在业务逻辑上,而不是怀疑缓存插件本身有坑。
写入时还有个小技巧:不要把每次提交都整体重写列表。shared_preferences适合小数据量,如果列表超过几十条,建议改用轻量数据库或文件存储。我这里组队帖子数量有限,JSON 列表一张大字符串存储完全够用,加载时jsonDecode还原对象列表。
5.2 处理时间:倒计时显示与格式函数
列表页展示组队时间时,我写了个格式化函数。如果是当天,显示“今天 19:30”,如果是明天,显示“明天 14:00”,其他显示“12月3日 09:00”。这样玩家不用手动换算。这个格式化函数在表单页和列表页都要用,我抽成了一个纯函数放在utils目录。
String formatTeamTime(DateTime time) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final target = DateTime(time.year, time.month, time.day); if (target == today) { return '今天 ${_twoDigits(time.hour)}:${_twoDigits(time.minute)}'; } else if (target == today.add(const Duration(days: 1))) { return '明天 ${_twoDigits(time.hour)}:${_twoDigits(time.minute)}'; } else { return '${time.month}月${time.day}日 ${_twoDigits(time.hour)}:${_twoDigits(time.minute)}'; } } String _twoDigits(int n) => n.toString().padLeft(2, '0');这里有个容易忽略的点:直接用DateUtils.isSameDay也行,但需要intl包。为了减少依赖,我手写了日期比较逻辑。日期比较完全是纯计算,不涉及时区转换,只要本地时间不过分偏移就准确。如果后续要做服务端同步,建议在数据层统一转成 UTC 时间戳再做比较,避免玩家手机时区不一样导致倒计时偏移。
6. 优化迭代:从“能工作”到“好体验”
表单功能完成后,我花了两轮优化才让它达到“可发布”的水准。第一轮是页面可用性细节,第二轮是数据交互的防呆设计。
第一轮优化里,我把提交按钮的文案做成了动态的。默认是“发起组队”,当人数区间下限大于当前已确认人数时,变成“发起组队并等待补位”,这个细节让玩家理解他们创建的是一个可以继续拉人的帖子,而不是一个已经成型的团队。按钮下方的辅助文案也跟着变,比如“已有 1 人,还缺 3 人”。
这个优化来源于实际使用中的困惑:有的玩家创建组队后不知道还要等人,以为创建完立刻就能开本。动态文案把这个预期管理做清楚了,玩家操作时的心理负担明显降低。
第二轮防呆设计是校验提交时间。表单里还有一个不算字段的字段:当前时间。如果玩家提交时,开始时间已经早于当前时间(比如填表填了太久,导致原本的未来时间变成了过去时间),提交要一律拦截,提示“开始时间不能早于当前时间”。这个校验放在 validator 里做,而不是_submitForm里,是因为它属于字段本身的合法性,而不是提交流程的额外逻辑。
6.1 边界情况与异常处理清单
表单场景的边界情况比想象中多,我整理了一个速查表,供你在实现时逐条对照:
| 场景 | 处理策略 | 对应代码位置 |
|---|---|---|
| 剧本名为空 | 校验拦截,提示必填 | TextFormField.validator |
| 时间早于当前 | 校验拦截,提示重新选择 | 自定义校验函数 |
| 人数区间跨度过大 | 不拦截,但提示“人数较多,开本难度高” | RangeSlider.onChanged |
| 标签重复选择 | 用Set天然去重 | FilterChip.onSelected |
| 描述输入软键盘遮挡 | resizeToAvoidBottomInset+ 滚动时收起键盘 | Scaffold属性 |
| 双击提交按钮 | _isSubmitting锁定 | ElevatedButton.onPressed |
这张表不是教科书里的标准答案,是我在实现过程中一步步踩出来的。比如“人数区间跨度过大”这个场景,一开始我没想到要加提示,结果一个玩家选了 3~12 人的跨度,页面看起来像是故意的,实际是滑动时不小心把两端都推到了尽头。加上提示之后,玩家能立刻意识到选择异常,重新调整。
异常处理清单的核心原则:不是每个异常都要拦截,但每个异常都要有“可感知的反馈”。弹窗、红字、灰色按钮,都是反馈的形式,关键是让玩家知道系统收到了操作、并且给出了判断。
6.2 键盘 Escape 逻辑与误触保护
还有个小细节,手机上没有 Escape 键,但页面顶部有个返回箭头。我在处理返回逻辑时加了一道保护:如果表单有未提交的字段,返回时弹确认框,避免玩家填了半天不小心返回全丢了。这个提示框在移动端属于常规操作,但跨端框架里有个小坑:WillPopScope已经废弃,要用PopScope。这个组件在 OpenHarmony 适配版里我用得比较少,所以我干脆改成在提交按钮旁边的“清空”按钮上做二次确认,而不是拦截系统返回。这样简化了逻辑,也不至于让玩家觉得页面“粘手”。
“清空”按钮本身我放在了表单右上角的一个IconButton上。点击后所有输入框置空,标签清空,时间恢复默认提示。这个按钮也是防误触的重灾区,我给它加了与提交按钮相同的二次确认弹窗。弹窗文案是“确定要清空表单吗?”而不是“确定吗”,明确告诉玩家操作的对象和结果是清空所有字段。这个文案细节能显著减少玩家的误操作,值得养成习惯。
7. 实战收尾:重构时的几个“后悔药”
最后一次重构发生在提交逻辑与路由返回值之间。最初的版本里,表单页直接修改了一个传进来的TeamInfo对象,然后上一页发现数据变了,刷新视图。这个做法的问题是:如果玩家中途退出,对象已经被改了一半,列表页带着脏数据刷新,出现“未发起组队但列表出现半条记录”的诡异状态。
后来我改成了纯新建对象并返回的模式:表单页内部维护一份副本,只有点击提交时才构建新的TeamInfo并作为路由结果返回。这个模式在写法上多了几步,但数据可靠性大幅提升。我的体会是:跨端开发中,表单页这种“用户反复输入可能中断”的场景,数据要么不产生副作用,要么只在用户明确确认时产生。这个原则写进所有页面逻辑里,比任何状态管理库都重要。
OpenHarmony SDK 升级时,Flutter 引擎也跟着走,一些弃用 API 会带来编译警告。比如RangeSlider的onChanged在某个版本里要求放宽成Null安全类型,我第一时间没有更新,导致真机 Debug 模式下偶发崩溃。这种升级兼容问题没有捷径,只能在每次版本发布后跑一遍全流程的冒烟测试。
最后再分享一个表单相关的习惯:我会在提交逻辑里保留一个“测试模式”开关,开启后直接返回当前表单的 JSON,不执行真实的页面跳转。这个开关在端上验证数据格式时非常有用,尤其做 OpenHarmony 版本回归时,我可以快速跑完所有字段组合,而不用一遍遍手动点击。你在自己的项目里也可以留一手,表单类的功能永远值得多跑几组边界测试。