ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Flutter for OpenHarmony实战:剧本杀组队表单从拆解到落地

Flutter for OpenHarmony实战:剧本杀组队表单从拆解到落地 做剧本杀组队App做到第4个实战篇前面几篇我们解决了框架搭建、首页信息流、剧本库列表和房间详情这些场景本质都在解决“找局”的问题。而“发起组队”这一步才是用户从被动浏览转向主动建局的关键动作。在Flutter for OpenHarmony这套开发组合下很多人以为表单不过是一堆TextFormField配上按钮实际动手才知道剧本选择、人数区间、日期时间这类偏业务逻辑的控件加上OpenHarmony设备上输入法弹起、焦点切换、校验时机这些环境差异任何一个点都能卡住大半天。这篇我就把发起组队表单从需求拆解、Form基座搭建、特色控件实现到最后的提交链路完整过一遍。适合已经跑通Flutter for OpenHarmony基础环境、准备开始写业务模块的开发者也适合正在纠结“表单到底应该拆成几个部分”的Flutter初学者。文章里所有代码都是我自己在项目中验证过的可以直接抄。1. 发起组队表单的需求拆解先定字段再谈控件1.1 剧本杀组队的核心信息结构很多人一上来就铺输入框这是表单设计里最容易踩的坑。在动手写任何代码之前我得先想清楚一个事发起组队这个动作用户到底需要提供哪些信息剧本杀组队本质上是在创建一个“游戏局”。这个局的信息可以分成三类局本身的信息包括玩什么剧本、多少人、什么时候开参与者的筛选条件比如是否需要推理担当、是否接受新手线下履约的信息比如在哪个店、有没有额外备注。这个分类直接决定了表单的视觉分组和字段排序而不是让所有输入框平铺在页面上。字段录入控件校验规则设计原因剧本名称只读输入框 选择弹层必填1-20字组队的核心信息优先从剧本库选择开始时间日期选择器 时间选择器必填不早于当前时间玩家决策的关键条件人数区间Stepper 步进器必填3-12人下限不超过上限剧本杀有人数硬性要求角色需求FilterChip 多选选填最多4个辅助组人时的角色匹配集合地点普通输入框选填最多30字线下履约的场所信息备注多行输入框选填最多100字补充说明比如“新手友好”逐个解释一下为什么这么定。剧本名称用了只读输入框加选择弹层而不是让用户直接打字是因为当用户从剧本库选出来时我们可以顺势带上剧本的封面图、默认人数上下限、难度标签等关联数据这几个数据在创建房间后的详情页会用到能少让用户填一次。开始时间拆成日期和时间两个Picker组合比用一个长的DateTime输入框直观得多尤其OpenHarmony上中英文输入法切换时纯文本时间输入很容易出格式问题。人数区间这个设计比较特殊剧本杀不是固定人数而是“下限多少人能开上限多少人封车”所以单独一个数字满足不了必须用“下限上限”两个值。1.2 哪些信息不该出现在创建表单里确定完该有的字段还要明确哪些信息不该放在这个表单里。产品经理或者作为开发者的我们自己很容易把表单越做越长“要不要加一个是否接受新手的开关”“要不要加组局宣言”“要不要加付费方式”每多一个字段用户的心理门槛就高一截从“随便填一下”变成“要慎重考虑”转化率肉眼可见往下掉。我的取舍原则很简单创建时必须要有、并且影响别人是否加入的信息才放进这个表单。像“是否接受新手”这种锦上添花的筛选条件放在房间列表页的筛选器里或者创建成功后进详情页再编辑付费方式这种涉及资金的内容更是要等局组起来之后单独走结算流程放进组队表单里反而会引发信任问题。这也意味着表单要支持后续编辑所以数据模型设计时接口层就要预留PATCH能力。1.3 校验规则按业务定义而不是按控件定义校验逻辑看起来是技术活实际上是业务逻辑的翻译。必填、长度限制只是最基础的抽屉真正的业务校验是这几条人数下限必须小于等于上限且下限不低于3、上限不超过12这来自剧本杀行业的普遍规则——绝大多数剧本的最低开本人数是3-4人最大上限是12人开始时间不能早于当前时间这是为了避免创建一个“昨天就开”的无效局时间范围限制在未来7天内防止用户把时间排到遥遥无期局永远组不起来。这些规则写在validator里只是前端第一道拦截服务端必须做同样的校验因为客户端的校验可以被绕过。我习惯在提交时把校验拆成两层一层是表单字段本身的校验用Form的validator机制另一层是业务逻辑校验比如“开始时间不能再今天之前”这个会单独写一个方法在点击提交按钮时统一检查。这样好处是代码职责清晰validator只负责字段格式业务规则逻辑不会被拆散到各个控件里。2. Form基座搭建Form、Field与Controller的正确协作2.1 一个局一个KeyGlobalKey 的使用边界Flutter的表单核心是Form组件。它本身不提供任何UI只是一个状态容器通过GlobalKey 让我们能够触发表单内所有字段的校验、重置等操作。一个页面用一个自己的GlobalKey就够了不需要全局静态变量也不需要每个输入框单独配一个FormState。class CreateTeamPage extends StatefulWidget { const CreateTeamPage({super.key}); override StateCreateTeamPage createState() _CreateTeamPageState(); } class _CreateTeamPageState extends StateCreateTeamPage { final _formKey GlobalKeyFormState(); override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(发起组队)), body: Form( key: _formKey, child: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ _buildScriptField(), _buildTimeField(), _buildPlayerCountField(), _buildRoleTagsField(), _buildLocationField(), _buildNoteField(), _buildSubmitButton(), ], ), ), ); } }这里有几个细节。第一Form包住的是ListView而不是Column因为表单页在OpenHarmony设备上一定会遇到软键盘弹起的情况ListView天然支持滚动键盘弹起时输入框能滚到可视区域第二字段构建方法拆成独立方法可读性更好也方便后面用条件来控制字段显隐第三每个TextFormField之间用SizedBox或间距组件隔开不靠Form帮你做布局布局是页面自己的责任。GlobalKey 最常用的操作有三个validate()触发所有字段校验reset()清空所有字段到初始状态save()触发每个FormField的onSaved回调。在发起组队这个场景里提交时我们只用validate()因为数据是手动从controller里取的不依赖onSaved机制。2.2 控制器的生命周期管理TextEditingController是连接文本输入框和数据源的桥梁。它不能在build方法里创建每次build都会创建新实例会导致输入框反复失去焦点、输入内容丢失这种问题在Flutter社区里被问过无数次。正确做法是在State的字段上声明在dispose里释放。class _CreateTeamPageState extends StateCreateTeamPage { final _formKey GlobalKeyFormState(); final _scriptNameController TextEditingController(); final _locationController TextEditingController(); final _noteController TextEditingController(); override void dispose() { _scriptNameController.dispose(); _locationController.dispose(); _noteController.dispose(); super.dispose(); } }注意所有用了controller的输入框在widget销毁时必须释放controller否则在开发模式控制台会看到“A TextEditingController was used after being disposed”之类的报错严重情况下会导致内存泄漏。另外如果页面里使用了FocusNode来管理焦点切换FocusNode同样必须在dispose里释放。这里有个容易被忽略的点读only的TextField也需要controller。剧本名称那个输入框用户不能直接打字但需要点击后弹层选择选择完把结果赋值到controller里。如果你图省事用Text来展示提交的时候还得额外维护一个字段去取当前选中的剧本反而多此一举。2.3 Validator和autovalidateMode的配合策略TextFormField的validator是个返回String?的函数返回null代表校验通过返回错误文本代表校验失败。一个字段的校验规则可能不止一条比如剧本名称既要判空又要判长度我习惯用提前return的方式逐条检查而不是嵌套多层if。String? _validateScriptName(String? value) { if (value null || value.trim().isEmpty) { return 请选择或输入剧本名称; } if (value.trim().length 20) { return 剧本名称不能超过20个字; } return null; }校验时机是另一个值得琢磨的点。Flutter的TextFormField默认不在输入过程中校验只在validate()调用时或者autovalidateMode触发时才校验。如果表单一开始就设成AutovalidateMode.always用户还没开始填呢整页就飘红体验非常劝退。我习惯初始设为AutovalidateMode.disabled当用户点击过一次提交且校验失败后再通过setState把模式切换成AutovalidateMode.onUserInteraction这样用户修改字段时能实时看到错误消失又不会一进页面就被红色包围。2.4 为什么没有直接上三方表单库Flutter生态里其实有不少表单库比如flutter_form_builder、reactive_forms之类的封装了校验、状态管理、动态字段等能力。我在这个项目里选择全部手写原因有两点。第一这个表单只有6个字段规则并不复杂引入一个三方库意味着要学习它的API后续还要跟着升级维护成本反而高于收益。第二也是更重要的OpenHarmony上的Flutter生态和标准Flutter生态有一些兼容性差异三方库如果依赖了平台特定插件在OpenHarmony上可能直接跑不起来。比如有些表单库内部依赖url_launcher这类插件虽然表单库本身不用url_launcher但项目里间接引入后就需要验证OpenHarmony版本的插件支持情况。在这个阶段稳定比省事重要表单这种核心业务逻辑自己掌控最放心。3. 剧本杀特色控件的落地从日期时间到人数选择3.1 剧本选择只读输入框 底部弹层搜索剧本名称字段如果做成普通TextFormField用户随便打个“恐怖本”也能提交但“恐怖本”不是一个具体剧本组队信息就是无效的。所以这里采用只读输入框加底部弹层的交互点击输入框弹出一个全屏或半屏的搜索列表里面展示剧本库的剧本用户选择后把剧本名回传到controller里。TextFormField( controller: _scriptNameController, readOnly: true, decoration: const InputDecoration( labelText: 剧本名称, hintText: 点击选择剧本, suffixIcon: Icon(Icons.arrow_drop_down), ), validator: _validateScriptName, onTap: () async { final selected await Navigator.of(context).pushMapString, String( MaterialPageRoute(builder: (_) const ScriptSearchPage()), ); if (selected ! null mounted) { setState(() { _scriptNameController.text selected[name] ?? ; _selectedScriptId selected[id] ?? ; }); } }, )readOnly: true这个属性很关键。它让TextField不弹出软键盘但onTap仍然有效从而把交互拦截到我们的弹层上。这里顺手把选中的剧本ID也存一下因为提交接口需要传脚本ID而不是仅传一个展示用的名称字符串。搜索弹层本身不需要太复杂一个搜索框加一个ListView。搜索框用TextFormField监听onChanged对剧本库列表做模糊过滤。这里有个性能考量如果剧本库数据量大每次输入都实时过滤可能会卡顿可以加个简单的防抖比如300毫秒间隔但就当前项目的剧本库规模来说几百个剧本直接内存过滤完全没问题。3.2 日期和时间两个Picker的组合开始时间是由日期加时间两个部分组成的用两个Picker组合起来是Flutter最主流的做法。showDatePicker和showTimePicker都是Flutter原生能力OpenHarmony版本的Flutter SDK对这两个组件做了兼容我实测可以直接使用。Futurevoid _pickStartTime() async { final now DateTime.now(); final initial _startTime ?? now.add(const Duration(hours: 2)); final date await showDatePicker( context: context, initialDate: initial, firstDate: now, lastDate: now.add(const Duration(days: 30)), ); if (date null) return; final time await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(initial), ); if (time null) return; setState(() { _startTime DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); }这个实现有几个细节要注意。第一个是firstDate和lastDate的设定。firstDate设为当前时间这样日历控件里过去的日期全灰不可点这比validator里再判断日期是否合法直观得多。lastDate设为30天以后避免用户选一个过于遥远的时间。第二个是initialTime的计算如果用户已经选择过时间打开Picker的初始值应该是之前的选择而不是重新回到当前时间的后两小时不然用户想微调时间时Picker每次跳回默认位置会非常恼火。第三个细节在用户取消操作的处理上。showDatePicker返回null表示用户取消直接returnshowTimePicker也同理。这里需要注意如果用户选完日期但在时间选择器里取消了那么这次操作的结果应该是“未改变时间”而不是“日期已改但时间没改”。我的做法是先把日期和时间都选完再一次性赋值setState避免中间状态。3.3 人数区间Stepper比Slider更好用人数选择在剧本杀场景里必须是离散值不存在3.5个人这种说法。Flutter里做离散选择有Slider、DropdownButton、Stepper几种思路。Slider在触屏上拖着选看起来方便但“精准命中某个值”并不容易尤其在OpenHarmony的触控采样率不同的设备上拖到5松手变6是家常便饭。DropdownButton倒是精准但比起Stepper多了一步展开选择的操作。我这里直接用两个Stepper一个管下限一个管上限配合联动逻辑。下限加一不能超过上限上限减一不能低于下限。Widget _buildPlayerCountField() { return Row( children: [ Expanded( child: _buildStepperLabel(下限, _minPlayers, () { if (_minPlayers 3) setState(() _minPlayers--); }, () { if (_minPlayers _maxPlayers) setState(() _minPlayers); }), ), const Text(—), Expanded( child: _buildStepperLabel(上限, _maxPlayers, () { if (_maxPlayers _minPlayers) setState(() _maxPlayers--); }, () { if (_maxPlayers 12) setState(() _maxPlayers); }), ), ], ); }Flutter自带一个Stepper组件但它主要用于“分步向导”不是“数值增减器”硬套到这个场景还得改样式不如直接用IconButton自己搭一个加号减号的组合代码量没多多少样式完全可控。按钮在临界点自动禁用比如下限已经等于上限时下限的加号是灰色不可点的用户不需要靠报错来知道规则交互上就给了暗示。3.4 角色需求标签FilterChip的多选逻辑剧本杀组队有个很常见的需求房主希望招特定类型的玩家比如“推理担当”“气氛组”“情感本玩家”。这些角色需求用标签形式展示比下拉框友好得多用户一眼扫过心里就有数点几下就完成选择。Flutter的FilterChip就是干这个的。它是一种可切换的标签选中状态通过selected参数控制点击回调里维护一个List 。final _selectedRoleTags String[]; static const _roleTags [推理担当, 气氛组, 情感本玩家, 恐怖本坦克, 新手友好]; Widget _buildRoleTagsField() { return Wrap( spacing: 8, runSpacing: 8, children: _roleTags.map((tag) { return FilterChip( label: Text(tag), selected: _selectedRoleTags.contains(tag), onSelected: (selected) { setState(() { if (selected) { if (_selectedRoleTags.length 4) _selectedRoleTags.add(tag); } else { _selectedRoleTags.remove(tag); } }); }, ); }).toList(), ); }限制最多4个标签是为了防止用户选太多导致信息失去重点。这里有个细节当用户尝试选第5个标签时onSelected回调里传入的selected参数已经是true了但我们的逻辑会拦截掉这个添加这会导致这个FilterChip没有被选中的视觉反馈而其他的标签都被选中了。这是我实际开发中遇到的交互卡点后来在界面上加了个提示当达到上限时用SnackBar轻提示“最多选择4个角色需求”就不再困惑了。这个字段本身不是必填所以不需要validator。但提交数据时要注意为空时传给后端一个空的List而不是null后端处理空列表比处理null更省事。4. OpenHarmony上表单的适配与踩坑键盘、焦点与输入法4.1 软键盘顶起布局resizeToAvoidBottomInset不是万能的跨端开发最怕的就是环境差异。同样的Flutter代码在Android模拟器上跑得好好的到OpenHarmony真机上软键盘一弹起来就把提交按钮顶到键盘后面或者整个页面被压缩得变形。Flutter默认的Scaffold有个resizeToAvoidBottomInset属性默认值是true意思是当软键盘弹起时Scaffold的body区域自动缩小到键盘以上的可视区域。这个机制在标准Flutter上工作正常但在OpenHarmony的某些输入法实现上缩小的时机和输入法动画不一定同步体感上会出现“键盘弹起来了页面还没缩键盘收起时页面又跳了一下”的问题。我的处理方案是保持resizeToAvoidBottomInset为true但把页面的根布局从Column换成ListView同时给ListView设置一个足够的底部padding。这样即使键盘把可视区域顶小ListView也能滚动到底部用户不会找不到提交按钮。实测下来这个方案在OpenHarmony 4.0和5.0的几个版本上表现稳定。另外提一个细节TextField的textInputAction设成TextInputAction.next键盘右下角会变成“下一步”按钮用户点击后焦点自动跳到下一个输入框这在表单页能明显减少键盘来回弹出收起的干扰。4.2 焦点切换与失焦校验的时序问题表单里如果有多个输入框焦点的切换顺序和校验时机是需要协调的。Flutter中焦点的默认行为是用户点击哪个输入框焦点就跳到哪个。但如果是通过“下一步”按钮跳转我们需要显式地控制下一个焦点。final _noteFocusNode FocusNode(); TextField( controller: _noteController, focusNode: _noteFocusNode, textInputAction: TextInputAction.done, maxLines: 3, maxLength: 100, onSubmitted: (_) { _noteFocusNode.unfocus(); _submit(); }, )这里有个失焦校验的坑TextFormField的validator默认在validate()时统一触发不会因为失焦就自己校验。如果你希望“用户一离开这个输入框就校验”需要把autovalidateMode设为AutovalidateMode.onUserInteraction但那样又会变成“每输入一个字符就校验”比较打扰。我的中间方案是提交前统一校验校验失败后切到自动校验模式这个在前面2.3节已经说过了。FocusNode同样要在dispose里释放。多行备注这个输入框我单独建了一个FocusNode是为了在键盘的“完成”按钮点击时主动收起键盘并触发提交。如果只有一个输入框需要这样处理单独建FocusNode是最清晰的如果表单里多个输入框要连续切换建议用一个FocusNode列表来管理。4.3 中文输入法与多行文本的显示差异中文输入法是OpenHarmony设备上绕不开的痛点尤其对多行文本输入框。之前遇到过两个问题。第一个是候选词遮挡。输入备注时中文输入法的候选词条会悬浮在软键盘上方如果输入框刚好在屏幕中下部候选词条可能把输入框本身盖住。这个很难从Flutter侧完全控制因为候选词条是输入法应用自己渲染的但可以缓解把多行输入框放在页面上方区域或者保证页面支持滚动让用户能手动把输入框滚到不被遮挡的位置。第二个是字符统计的差异。TextField的maxLength统计的是Dart字符串的length属性对中文来说基本一个汉字算一个字符但某些特殊字符比如emoji会被按两个字符算。如果用户输入了emoji表情可能还没到100个字就提示超长了。因为组队备注本来就是短文本这个影响不大我查了下确实存在这个现象后面如果要做严格计数得用characters包来按用户感知的字素簇统计这里先不做过多纠结。5. 提交链路的最后一公里数据模型、校验与防重复提交5.1 用不可变数据模型承载表单数据表单控件负责收集数据提交之前需要把这些散落在各个controller里的数据组装成一个请求对象。我习惯为创建接口单独定义一个请求模型类而不是直接用Map传参这样有类型检查字段名也更安全。class CreateTeamRequest { final String scriptId; final String scriptName; final int minPlayers; final int maxPlayers; final DateTime startTime; final ListString roleTags; final String location; final String note; const CreateTeamRequest({ required this.scriptId, required this.scriptName, required this.minPlayers, required this.maxPlayers, required this.startTime, required this.roleTags, required this.location, required this.note, }); MapString, dynamic toJson() { return { scriptId: scriptId, minPlayers: minPlayers, maxPlayers: maxPlayers, startTime: startTime.toIso8601String(), roleTags: roleTags, location: location, note: note, }; } }字段全部声明为final并在构造函数里required从源头避免“创建一个空对象然后慢慢塞数据”这种容易漏字段的写法。startTime转成ISO8601字符串传给后端是跨端开发的标准做法比传毫秒时间戳可读性好很多后端解析也不容易踩时区坑。这里我特意同时传了scriptId和scriptName。scriptId是后端用来关联剧本库真正数据的scriptName只是为了服务端回显方便因为服务端可能没接剧本库数据或者剧本库数据同步不及时。当然这只是一种折中设计如果后端能通过ID查到名称不传scriptName也完全可以。5.2 提交触发的完整状态流提交不是一个“点击按钮就发请求”的简单动作它要经历一整个状态流校验、组装数据、请求中、成功、失败。bool _submitting false; Futurevoid _submit() async { if (_submitting) return; if (!_formKey.currentState!.validate()) { return; } final gameTime _startTime; if (gameTime null || gameTime.isBefore(DateTime.now())) { _showErrorToast(请选择有效的开始时间); return; } final request CreateTeamRequest( scriptId: _selectedScriptId ?? , scriptName: _scriptNameController.text.trim(), minPlayers: _minPlayers, maxPlayers: _maxPlayers, startTime: gameTime, roleTags: List.unmodifiable(_selectedRoleTags), location: _locationController.text.trim(), note: _noteController.text.trim(), ); setState(() _submitting true); try { final teamId await widget.apiService.createTeam(request); if (!mounted) return; Navigator.of(context).pop(teamId); } catch (e) { if (!mounted) return; setState(() _submitting false); _showErrorToast(创建失败请检查网络后重试); } }注意流程的顺序。第一步先通过_formKey.currentState!.validate()触发所有表单字段的validator校验这一步拦的是“空值、格式错误”这类基础问题。第二步再检查业务字段_startTime是否为空、是否为过去时间。这两个拆开来是因为validator里存的是字段级的错误信息而_startTime不是输入框没有对应的validator所以必须单独判断。这里还隐藏了一个细节validate()返回true不代表数据一定合法因为某些业务规则没有绑到validator上。比如人数下限3、上限12这个在Stepper的联动里已经保证了但未来如果有人改成那种下滑选择器记得要在提交时加上这道检查。5.3 防重复提交与错误反馈防重复提交在表单页是刚需。用户连点两下提交按钮如果接口没有幂等处理就会出现两个重复的组队房间这是很尴尬的事故。防重复的关键是_submitting这个状态标志位。在请求发出之前先判断如果已经在提交中就return只有请求结束并已经处理完成功或失败分支后才把_submitting重置为false。按钮组件也要根据_submitting显示加载态比如禁用点击、显示一个转圈指示器从视觉上切断用户第二次点击的念头。这里有个容易写错的地方把_submitting置回false的时机。有的代码喜欢在成功分支里也重置失败分支里也重置看似没问题但一旦网络请求超时被catch住可能漏掉某个分支导致按钮永远卡在加载态。更稳妥的做法是像上面代码一样只在catch分支里重置成功分支直接pop页面离开页面都关了状态重置已经没有意义。错误反馈我用的是SnackBar弹出在页面底部。相比对话框SnackBar不打断用户操作流而且SnackBar支持自定义背景色和时长。如果是网络错误文案统一提示“创建失败请检查网络后重试”不把后端原始错误信息直接抛给用户那里面可能带着SQL语法或者堆栈信息既不安全也不友好。最后分享一个我个人的小技巧。这段提交逻辑里我用了try-catch而不是then-catch是因为async/await的可读性更好尤其后续如果想添加日志上报只需要在catch分支里加一行代码就行。表单页是整个组队流程的起点数据质量好不好就看这一层把关严不严所以多花点心思在提交链路上后面做列表筛选、详情展示都会轻松很多。
返回列表