ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Flutter OpenHarmony表单实战:剧本杀组队表单设计与实现

2026/9/9 15:15:47 拓冰建站 浏览量
Flutter OpenHarmony表单实战:剧本杀组队表单设计与实现 最近在调自己这套基于 Flutter 的 OpenHarmony 剧本杀组队 App前面的工程结构、路由、基础页面都铺好了这周正好做到“发起组队”表单趁着思路还热乎把整个实现过程、踩过的坑和最终落地方案完整记下来。剧本杀组队的核心流程很简单有人发起一个局其他人快速加入。而发起局这件事几乎所有信息都落在同一个入口上——发起组队表单。做这类 App表单写得好不好直接决定用户是不是愿意把“组局”这个动作做完。如果你也在用 Flutter 适配 OpenHarmony或者只是想把一个长表单从“能跑”做到“好用”这篇值得看一下。1. 组队表单的需求拆解与字段设计1.1 表单不只是 UI先想清楚“一个局”包含什么剧本杀组队的表单和普通的注册表单、调查问卷有很大差异。用户填这张表的目的是什么是告诉其他玩家我想在什么时间、什么地点、玩什么本、缺几个人。这些信息直接决定组队成功率。我在动手之前把常见的剧本杀组队 App 翻了一遍反推他们“发起组队”页面的字段发现核心字段其实稳定在这么几类剧本名称、游戏类型、开始时间、人数区间、组队说明、联系方式或集合地点。有些 App 还会加“新手是否可入”“是否指定性别”这类过滤条件。设计字段时最大的顾虑是字段越多填写成本越高流失率也越高。我当时给自己定了一个原则必填项不超过 5 个其余全部做成选填或者给默认值。比如游戏类型默认“欢乐本”人数区间默认 4-6 人用户直接点提交也能发出去。这个思路不是从 UI 角度出发而是从业务转化角度出发你不能让用户在一个组队表单前面思考太久想太久他就去玩别的了。1.2 给表单建立一个独立的数据模型很多新手写表单喜欢在提交时直接把所有 controller 里的值拼成一个 Map 传给后端。短平快但后面维护非常痛苦。我在前面几篇里已经定义过一些实体这一篇我建议为“创建组队”单独建一个数据模型而不是复用后端的接口模型。为什么因为前端表单需要承载“中间状态”比如某些字段还没填、某些字段有候选值而后端模型是结果态的两者混在一起会让代码越改越乱。这里给出一个我在项目里用的基础模型class CreateTeamFormData { String scriptName; String gameType; DateTime startTime; int minPlayers; int maxPlayers; String contact; String note; bool isBeginnerFriendly; CreateTeamFormData({ this.scriptName , this.gameType 欢乐本, this.startTime, this.minPlayers 4, this.maxPlayers 6, this.contact , this.note , this.isBeginnerFriendly false, }); MapString, dynamic toJson() { return { scriptName: scriptName, gameType: gameType, startTime: startTime?.toIso8601String(), minPlayers: minPlayers, maxPlayers: maxPlayers, contact: contact, note: note, isBeginnerFriendly: isBeginnerFriendly, }; } }toJson方法是为了后面提交接口时直接序列化。如果你项目里已经上了 json_serializable那直接用注解生成也行我这里是手写便于你看清字段。模型独立出来的另一个好处是以后如果后端字段改了你只需要改模型和他的映射页面里的逻辑不用动。1.3 校验规则先把“不可能的数据”列出来写校验之前不能只想着“这个字段必填”。我习惯先列一版“不可能的数据”也就是用户一旦这么填后端大概率会报错或者组队根本没法进行的数据。对剧本杀组队这个场景我列过这么几条剧本名称为空或者只有空格。开始时间早于当前时间。剧本杀没法穿越回去选过去的时间没有任何意义。最少人数小于 1最大人数小于最少人数。通常剧本杀单本人数区间在 4-10 人之间上下限太离谱的局根本组不起来。联系方式空或明显格式不对。组队说明超过一定长度比如 100 字防止有人刷屏。这几条就是后续 validator 的雏形。校验规则不是拍脑袋写而是从“这条数据落到后端会出什么问题”倒推出来的。这样写出来的表单用户提交成功率明显更高后端同学也少挨骂。我见过太多人把校验当摆设最后数据问题全堆到接口层去排查反而更费劲。2. Form 协作机制与页面搭建2.1 Form、FormField、GlobalKey 是怎么串起来的Flutter 里有一个现成的表单闭环Form 是整个表单的容器内部用 FormField 来组织输入项TextFormField 就是 FormField 在文本场景下的具体实现。Form 内部会自动维护一个 FormState我们通过 GlobalKey 拿到这个 FormState 之后就能统一触发 validate、save、reset。我用一个生活类比来解释Form 就像一张答题卡TextFormField 是一个个填空位FormState 是收卷子的老师。平时你只管在填空位里写字交卷的时候老师统一检查每道题validate检查通过后再统一誊写到成绩册save要重写就统一擦干净reset。这个协作模式最大的好处是你不必在每个输入控件上单独处理校验逻辑所有逻辑在提交那一瞬间统一跑一遍。注意GlobalKey 的currentState在 Form 未挂载时为 null所以在调用validate()之前一定要判空否则会空指针闪退。这个坑在热重载时尤其容易出现因为热重载会重建组件树FormState 的挂载时机可能和你的预期不一致。2.2 一个最小可跑的 Form 示例下面这是我在项目里最常用的 Form 结构去掉了业务字段保留骨架final _formKey GlobalKeyFormState(); final _scriptNameController TextEditingController(); Form( key: _formKey, child: Column( children: [ TextFormField( controller: _scriptNameController, decoration: const InputDecoration( labelText: 剧本名称, hintText: 比如年轮、窗边的女人, border: OutlineInputBorder(), prefixIcon: Icon(Icons.menu_book_outlined), ), validator: (value) { if (value null || value.trim().isEmpty) { return 请填写剧本名称; } return null; }, onSaved: (value) { formData.scriptName value?.trim() ?? ; }, ), const SizedBox(height: 16), FilledButton( onPressed: _submit, child: const Text(发起组队), ), ], ), )注意两个地方validator和onSaved。validator的返回值语义是返回null代表通过返回非空字符串代表错误文案这段文案会直接显示在输入框下方。onSaved则是在formState.save()被调用时执行把界面值同步到我们的数据模型。我习惯把界面 controller 和底层模型分开界面管显示模型管数据提交时只在 save 阶段合并这样各自的职责清晰排查问题也方便。2.3 页面内 setState 还是上状态管理做多字段表单时有人喜欢用一个 ChangeNotifier 把整个表单包起来每次输入都 notifyListeners。这个方案全局通知如果页面里还有列表、地图之类的大组件很容易造成整页重建表现为输入卡顿、焦点丢失。我踩过这个坑之后给这个项目的建议是字段少、逻辑简单的表单页面内 setState 足够字段多且有联动时可以用 ValueListenableBuilder 配合 TextEditingController 做局部刷新。具体来说每个需要联动显示的字段我注册对应的 controller 监听器_scriptNameController.addListener(() { setState(() {}); });如果只是少数几个控件需要跟随输入变化用易读的 setState 没问题。但当你在 OpenHarmony 上跑的时候能明显感觉到整页 setState 比局部重建要吃力尤其是低配设备上。所以这个项目我尽量控制 setState 范围把需要联动的部分拆成小 Widget用 ValueListenableBuilder 包住能局部刷新就局部刷新。3. 剧本杀场景的交互控件与校验细节3.1 时间选择别直接用 showDatePickerFlutter 的showDatePicker默认是 Material 风格在 Android 上表现还行但在 OpenHarmony 上我实测发现它弹出的不是系统原生日历而是 Flutter 自己画的一个日期选择面板观感上和系统整体的 HarmonyOS Design 风格有明显割裂。做个人项目无所谓但如果你想让它看起来像“鸿蒙原生 App”就要自己实现。我最终采用的方式是用showModalBottomSheet弹出一个底部面板里面放CupertinoDatePicker或者ListWheelScrollView做滚轮选择。原因有两个一是滚轮选择在很多国产 App 里已经是用户习惯交互成本低很多人都用过类似的时间选择器二是底部面板可以自定义标题和确认按钮风格统一不用迁就 Material 那套。下面是核心代码我抽取了面板构建部分FutureDateTime showStartTimePicker(BuildContext context) async { final now DateTime.now(); final result await showModalBottomSheetDateTime( context: context, builder: (ctx) { // 默认选当前时间往后两小时给玩家留出准备时间 DateTime selected now.add(const Duration(hours: 2)); return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Padding( padding: EdgeInsets.all(16), child: Text(选择开始时间, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), ), SizedBox( height: 200, child: CupertinoDatePicker( initialDateTime: selected, minimumDate: now, minuteInterval: 10, mode: CupertinoDatePickerMode.dateAndTime, onDateTimeChanged: (value) { selected value; }, ), ), Padding( padding: const EdgeInsets.all(16), child: SizedBox( width: double.infinity, child: FilledButton( onPressed: () Navigator.pop(ctx, selected), child: const Text(确定), ), ), ), ], ), ); }, ); return result ?? now.add(const Duration(hours: 2)); }细节minimumDate设为now防止用户选到过去时间minuteInterval设为 10 分钟减少滚动选择成本。为什么初始值设置为当前时间加 2 小时因为大部分人发起组队至少会给其他人留出准备时间如果默认选“现在”用户经常忘记去改最后组队信息里写着“立即开始”很容易造成体验事故——别人还没来得及看到局就“开始”了。3.2 人数区间加减小组件比 RangeSlider 更好用人数区间我试过用 RangeSlider但剧本杀的人数通常是离散的整数而且大多数本子区间不宽RangeSlider 拖动起来不够精确很难正好拖到某个整数上。后来我改成两个并排的“减号-数字-加号”小组件一个管最少人数一个管最大人数左右联动用户一眼就能看懂点按也精准。联动逻辑是这样的最少人数改变时如果最少人数大于当前最大人数最大人数自动跟着升。最大人数改变时如果最大人数小于当前最少人数最少人数自动跟着降。这个联动看起来简单但很容易漏。第一版我只做了单向联动结果用户先设最少 8 人再往回拖最大人数出现了一个“最少 8、最大 4”的神奇区间。后来我写了一个统一的调整方法void _updateMinPlayers(int delta) { setState(() { formData.minPlayers (formData.minPlayers delta).clamp(1, formData.maxPlayers); if (formData.minPlayers formData.maxPlayers) { formData.maxPlayers formData.minPlayers; } }); } void _updateMaxPlayers(int delta) { setState(() { formData.maxPlayers (formData.maxPlayers delta).clamp(formData.minPlayers, 10); }); }clamp用得很关键它把数值限制在一个合法区间内。这里的数据边界就是我在 1.3 里列的“不可能数据”代码和校验规则一一对应。如果你不做这个边界控制后面接口就得做兜底但前端多花两行代码就能避免接口收到脏数据这笔账怎么算都划算。3.3 标签选择与游戏类型游戏类型这类单选字段合适的控件是 FilterChip 或 ChoiceChip配合 Wrap 自动换行。我在项目里同时支持两种数据来源一种是固定写死的类型列表比如欢乐本、阵营本、恐怖本、情感本、硬核推理另一种是后续从接口动态获取。所以这个控件我封装成了动态数据源的形式列表为空时隐藏不为空时展示为Wrap(children: chips)。比较麻烦的是剧本名称输入。一个好的体验是用户输入前几个字下拉提示已经存在的剧本。但做这个功能需要剧本库接口如果后端暂时没有接口我的方案是提供“快速填写常用剧本”的 Tag 区域点一下 Tag 就把剧本名称填进输入框。不完美但比纯手输舒服很多而且对用户来说也降低了输入成本。这个思路适合几乎所有“有固定候选值但又不能完全限制候选值”的字段。3.4 校验的即时反馈与联合校验Flutter 的autovalidateMode有三种disabled只在提交时校验、always每次改变都校验、onUserInteraction用户交互过才校验。我推荐 onUserInteraction而不是 always。因为 always 模式下用户第一次进入页面还没输入界面上就飘着错误提示很劝退。onUserInteraction 会在用户离开某个输入框、产生第一次交互之后才开始持续校验该字段。它解决的是“一直红字”的焦虑感。联合校验的场景在这个表单里有一个联系人字段。如果用户勾选了“允许陌生玩家联系我”那么联系方式为必填如果没有勾选联系方式可以留空。这种依赖其他字段的校验我的做法是把勾选状态存到页面 State 里validator 里直接读取validator: (value) { if (_needContact (value null || value.trim().isEmpty)) { return 勾选了允许联系请填写联系方式; } return null; }注意这行逻辑一旦用户取消勾选我们还要主动触发一次该字段的重新校验否则红字会一直留着。做法是监听 CheckboxListTile 的 onChanged在改变_needContact后调用 setState让 validator 重跑。4. 提交流程与数据落地4.1 提交时统一校验失败后自动滚动到第一个错误字段提交按钮的 onPressed 里第一件事是调用 validate这没悬念void _submit() { if (!(_formKey.currentState?.validate() ?? false)) { _scrollToFirstError(); return; } _formKey.currentState?.save(); _doSubmit(); }这里我想重点说_scrollToFirstError。用户在长表单里填错了中段某个字段点提交只见顶部飘红页面还停在底部用户根本不知道哪里错了。真正好用的体验是校验失败后页面自动滚到第一个错误字段并且让该字段获得焦点。Flutter 里可以用Scrollable.ensureVisible实现。我维护了一个焦点节点注册表每个输入框创建时按顺序记录它的 BuildContext 和 FocusNodefinal List_FieldSlot _fieldSlots []; void _registerField({ required BuildContext fieldContext, required FocusNode focusNode, }) { _fieldSlots.add(_FieldSlot(fieldContext, focusNode)); }在校验失败时遍历表单所有字段找到第一个 validator 返回非空的字段用Scrollable.ensureVisible滚动过去再 requestFocus。这样一个几十行的小方法能让表单体验上一个台阶。注意在滚动之前先调用一次FocusScope.of(context).unfocus()把键盘收起来否则键盘会把目标字段顶出可视区滚动过去了也看不到。4.2 提交的三种状态防连点、加载中、成功回跳网络请求没有一口气完成的如果按钮不做状态处理用户连点两下“发起组队”同一个局可能被创建两次。这个问题我专门踩过坑真实测试环境里复现过一次体验非常差。我处理提交按钮状态的方式是用submitState这个枚举值控制按钮的显示和可用性。enum SubmitState { idle, submitting, submitted }onPressed: _submitState SubmitState.idle ? _submit : null提交中按钮文案切换成“发布中...”同时禁用。如果发布失败把状态重置回 idle用 SnackBar 提示失败原因。如果发布成功用Navigator.pop把创建好的数据集回传上一页。有一种情况需要提醒如果你在提交过程中用 showDialog 弹了一个加载框一定要记得在失败分支里关闭这个 dialog否则用户会被一个永远不会消失的加载框卡死。我自己遇到过后来就改成了按钮内 loading 态少用全局弹窗状态更容易控制也不容易出现多层弹窗互相遮挡的麻烦。4.3 数据回传让上一页马上刷新组队列表发起组队成功之后用户自然期望回到列表页并立刻看到自己刚创建的局。这里我用的是Navigator.pop(context, formData)上一页通过 await 这个 push 的返回值来感知结果有返回值就刷新列表。上一页的伪代码final data await Navigator.pushCreateTeamFormData( context, MaterialPageRoute(builder: (_) CreateTeamPage()), ); if (data ! null) { _fetchTeamList(); }这个模式比用事件总线或者全局状态广播简单得多也够用。数据流是单向的表单页创建数据、回传数据、列表页接收数据、刷新 UI。不会出现那种“A 页面改了值B 页面不知道还得手动通知”的混乱情况。如果你的项目里已经上了 Provider 或 Riverpod也可以借助它们做跨页状态同步但单页场景下pop 回传是最轻量的方案。4.4 草稿暂存填写到一半不能白填长表单最容易出现的问题用户填到一半切后台回来发现输入框全被系统回收了。这种流失非常可惜。我的方案是每次输入防抖 1 秒后把当前表单内容序列化到本地偏好存储页面再次进入时自动恢复。OpenHarmony 上做本地偏好存储很多人直接想到 shared_preferences 插件。如果你用的是 OpenHarmony 的 Flutter 适配分支需要确认你安装的 shared_preferences 版本是否支持 ohos 平台。我实际编译时发现部分版本的 shared_preferences 对 OpenHarmony 支持并不完善运行时读不到数据。我最终稳妥的做法是在 OpenHarmony 侧保留一个简单的偏好存储工具类内部用轻量级偏好 API 实现Flutter 侧通过 MethodChannel 调用。如果你不想引入平台侧代码也可以只在 Flutter 侧用 shared_preferences但要在不同鸿蒙设备上多测几轮再上线。草稿恢复的时机也要注意只在页面首次初始化时读一次不要在每次 build 时读否则会把用户正在编辑的内容覆盖掉。5. OpenHarmony 平台适配与常见坑5.1 键盘弹起与滚动容器表单页几乎必有输入框所以键盘处理和滚动容器是躲不开的。我一开始直接在 Scaffold 里塞 Form没有包滚动容器结果键盘一弹输入框被顶到屏幕中间下面的按钮完全不可见。后来加上 SingleChildScrollView并把 padding 设置成和键盘避让区域一致才正常。还有resizeToAvoidBottomInset这个参数默认是 true理论上会自动避让键盘。但在 OpenHarmony 的 Flutter 环境里实测发现不同版本的表现不一致有时键盘弹起后底部按钮被遮挡需要额外监听MediaQuery.of(context).viewInsets.bottom动态调整底部间距。我的兜底方案是把提交按钮改成 bottomNavigationBar并在布局底部加一个 AnimatedPaddingpadding 值跟随 viewInsets.bottom 变化AnimatedPadding( duration: const Duration(milliseconds: 120), padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: _buildSubmitButton(), )这个方法在 Android、OpenHarmony 上表现都稳定。注意别过度依赖默认避让手动留出空间是最保险的。另外BottomSheet 里的输入框也要做同样的处理不然在弹窗里输入时会发现确认按钮被键盘顶到屏幕外面去了。5.2 字体与视觉风格OpenHarmony 系统默认字体是 HarmonyOS Sans但 Flutter 默认的字体栈是 Roboto。如果你不配置中文字符会回退到系统字体细看会有轻微的字体闪烁和宽度抖动尤其是在输入框里输入中文的时候。我的做法是在工程里配置一个中文字体族把 HarmonyOS Sans 的 ttf 放进 assets在 ThemeData 里设置 fontFamily。如果你不想内置字体文件也可以用fontFamilyFallback回退到系统但实测效果不如显式配置稳定尤其是输入框的光标位置和文字宽度在输入过程中偶尔会跳动。样式上OpenHarmony 用户已经熟悉了 HarmonyOS Design 的圆角卡片、大间距、不刺眼的色彩。我把输入框边框统一改成了圆角 12、填充底色和细描边的组合比默认的 OutlineInputBorder 更贴近鸿蒙的视觉语言。这个改动不复杂但能显著减少“这个 App 是不是直接拿 Android 包套壳”的感觉。5.3 网络权限与 dio 请求这一篇的表单数据提交用的是 dio。在 OpenHarmony 上跑网络请求最容易漏的是权限声明Android 有 AndroidManifest.xmlOpenHarmony 有 module.json5两者是分开的。如果只配了 Android 的权限在 OpenHarmony 环境里运行一定请求失败而且报错还很隐蔽dio 抛的大多是连接超时看起来像网络问题实际上根本没有联网权限。在 OpenHarmony 工程的 module.json5 里需要配置requestPermissions: [ { name: ohos.permission.INTERNET } ]另外如果你在鸿蒙模拟器上调试本地后端接口模拟器对 localhost 的映射和 Android 模拟器不一样直接写http://localhost:8080有时访问不到宿主机。可以优先用http://10.0.2.2这种模拟器专用地址或者在构建配置里动态切换 BaseUrl。这个坑我花了大半个下午才定位到一开始一直以为是代码问题最后才发现是地址指向错了。5.4 第三方插件兼容性排查这个项目里我用了日期选择用的 CupertinoDatePicker还有可能的 shared_preferences、dio。这些插件在 OpenHarmony 上的兼容程度不完全一样。我的建议是在选择 Flutter 插件时先去它的 pubspec.yaml 里看是否声明了 ohos 平台支持如果没有就要做好自己写平台通道的准备。排查插件的一个实用流程把插件加进 pubspec 后直接跑一次 OpenHarmony 的构建。如果编译报错优先去插件的 issues 页面搜 openharmony 关键词很多常用插件已经有社区适配方案。不要一遇到编译失败就放弃插件换方案社区已经帮你趟过一部分坑了。如果确实没有适配方案再考虑自行封装 MethodChannel但尽量把平台通道封装在一个文件里方便后续切换插件方案。做表单这件事乍看没什么技术含量真正动手之后才发现坑都藏在细节里校验时机怎么选、联动字段怎么处理、键盘弹起怎么避让、OpenHarmony 和 Android 的权限差异怎么解决。我在踩过几次坑之后最大的体会是表单一定要先把“不可能的数据”列出来再写逻辑而且提前把不同平台的差异考虑进去。哪怕你暂时不打算做 OpenHarmony 适配按这个思路把一个表单从纯 UI 做到稳健可用也能帮你省下大量后期联调的时间。最后分享一个小技巧做完表单后别急着提交代码自己先以“恶意用户”身份把所有字段往极端值填一遍比如人数填 1-1、时间选过去、联系方式填乱码再点提交。这一遍下来发现的问题往往比测试同学找出来的还要多。表单的稳定性就是从这些“不正常的输入”里磨出来的。