资讯动态

Flutter on OpenHarmony实战:剧本杀App发起组队表单页全解析

发布时间:2026/10/17 4:20:09 来源:尧图企业网站定制
1. 为什么发起组队是剧本杀App最绕不开的页面做剧本杀组队类App前期可以把首页、剧本库、个人中心都往后放一放第一个必须认真对待的页面一定是发起组队。原因很实际组队信息是整个产品的信息源是拉新、匹配、线下转化的起点没有组队信息后面所有列表页、详情页、消息通知都成了无源之水。这个页面本质上是把用户脑中的一个组队想法转换成结构化的组队数据再交给后端存储和分发。核心就两件事收集哪些字段、如何让用户低负担地填完这些字段。我在做这个Flutter for OpenHarmony实战系列的第04篇时正好卡在这个页面的实现上。用Flutter写OpenHarmony应用和写Android/iOS应用在表单这个场景下有大量共享的套路但也有几个OpenHarmony适配环境下才容易踩的细节。这一篇把整个实现过程拆开来讲从字段设计、布局骨架、校验交互到OpenHarmony上的适配注意点最后给一份可以直接落地的完整页面代码。2. 组队单到底要收集哪些信息数据模型先行写代码之前先把数据模型定清楚这是整个页面最不能省的一步。很多教程上来就摆Widget讲完布局就结束结果读者换个需求就不会用了。表单页面的技术含量一半在字段设计上。剧本杀组队单我按场景拆成四组字段信息分组字段类型说明剧本信息剧本名称文本必填组队的核心标识剧本信息剧本类型下拉选择推理/情感/机制/恐怖/欢乐等时间地点开本时间日期 时间必填两个控件配合时间地点地点/门店文本必填可以后续接地图选点队员配置需要人数数字必填区间输入队员配置性别比例要求开关 下拉可选部分车队有这个需求补充说明组队备注多行文本选填比如新手友好欢迎戏精这个字段集合不是我凭空定的是参考了几个成熟的剧本杀组队产品和线下门店的通用规则后收敛出来的。注意几个细节开本时间为什么拆成日期和时间两个控件Flutter的showDatePicker和showTimePicker分开调比较常规合并成一个DateTime选择器在跨端适配时误触率高而且OpenHarmony上的时间控件弹层样式与标准Flutter Pickers有细微差异拆开实现更稳定。需要人数为什么不直接做成数字输入框组队场景中主持人发车经常需要41或者整车这种语义。单纯的数字输入框容易填出0或负数我用的是包含候选值的选择器加手动输入兜底后面会讲到。性别比例需求是我在多个线下门店的组队群里观察到的真实需求部分玩家会在意队伍构成把它做成开关节省默认状态下的表单长度。数据模型用Dart的不可变类来定义字段全部用final修饰方便后续做深拷贝和状态管理class PartyFormModel { final String scriptName; final String scriptType; final String location; final DateTime startTime; final int memberCount; final bool genderReqEnabled; final String genderReqDesc; final String remark; const PartyFormModel({ required this.scriptName, required this.scriptType, required this.location, required this.startTime, required this.memberCount, this.genderReqEnabled false, this.genderReqDesc 不限, this.remark , }); MapString, dynamic toJson() { return { script_name: scriptName, script_type: scriptType, location: location, start_time: startTime.toIso8601String(), member_count: memberCount, gender_req_enabled: genderReqEnabled, gender_req_desc: genderReqDesc, remark: remark, }; } }有些人喜欢直接在Widget里用MapString, String收数据前期确实方便但后面接接口、做回显、做编辑功能时没有类型约束的Map会让错误在运行时才暴露。我更倾向用强类型模型编译器能帮我们挡住一批低级错误。这里刻意用toJson做序列化是为了后续接发布接口时直接把这个map塞给请求体。还有一点值得提醒为每个TextField都创建独立的TextEditingController不要图省事复用同一个控制器。Flutter的控制器和输入框是绑定的复用会引起输入串场或光标跳动问题。这在状态较多的表单页尤其明显。3. 分区块构建表单骨架布局结构和交互顺序设计数据模型定了接下来是页面布局。组队表单信息密度高全部字段堆在一个页面会非常劝退。我的策略是用分组卡片让视觉上有节奏感每组一个明确的填充目标。整体结构是一个CustomScrollView下层放两个分组Section底部用BottomAppBar固定一个提交按钮。选CustomScrollView而不是SingleChildScrollView是因为后续这个页面有可能会加SliverAppBar或者吸顶标题提前用CustomScrollView扩展性更好而且表单页用ScrollView是刚需字段一多小屏幕必然滚动。3.1 第一组核心信息区核心信息区包含剧本名称、剧本类型、需要人数三个字段。这是用户需要最先填、信息优先级最高的部分。Widget _buildCoreSection() { return Card( margin: EdgeInsets.all(16), child: Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionTitle(核心信息), TextFormField( controller: _scriptNameController, decoration: InputDecoration( labelText: 剧本名称, hintText: 如窗边的女人 / 病娇男孩的精分日记, border: OutlineInputBorder(), ), textInputAction: TextInputAction.next, ), SizedBox(height: 16), DropdownButtonFormFieldString( value: _scriptType, decoration: InputDecoration(labelText: 剧本类型, border: OutlineInputBorder()), items: _scriptTypeOptions .map((type) DropdownMenuItem(value: type, child: Text(type))) .toList(), onChanged: (value) setState(() _scriptType value!), ), SizedBox(height: 16), _buildMemberCountField(), ], ), ), ); }_buildMemberCountField我做成了文本输入框加快速选项的复合控件体验比单纯下拉更好。做法是上方一个TextFormField显示当前人数下方一排ChoiceChip提供4、5、6、7、8等常用车人数点击Chip会同步填充到输入框。这样如果用户心里有数就直接填没数就看快捷选项两种输入方式覆盖两种用户状态。TextFormField的textInputAction设置为TextInputAction.next配合FocusScope切换焦点让用户在键盘上按下一项就能跳到下一个输入框减少点屏幕的次数。这属于小细节但体验差距很大。3.2 第二组时间地点区时间和地点是组队信息里最容易出错的字段也是校验逻辑的重灾区。Widget _buildTimeLocationSection() { return Card( margin: EdgeInsets.symmetric(horizontal: 16), child: Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionTitle(时间地点), ListTile( contentPadding: EdgeInsets.zero, title: Text(开本时间), subtitle: Text( _startTime null ? 点击选择日期和时间 : _formatTime(_startTime!), style: TextStyle( color: _startTime null ? Colors.grey : Colors.black, ), ), trailing: Icon(Icons.calendar_month), onTap: _pickStartTime, ), Divider(), TextFormField( controller: _locationController, decoration: InputDecoration( labelText: 门店 / 线上频道, hintText: 输入剧本杀门店名称或线上语音频道, border: OutlineInputBorder(), ), ), ], ), ), ); }时间选择这里不要用TextField用ListTile加onTap。原因有几个时间本质上不是键盘输入的文本用TextField会唤起数字键盘用户还得手动格式化麻烦且易错。ListTile点击后弹出日期弹层和时间弹层回显的是格式化好的字符串所见即所得。_pickStartTime方法先弹日期选择器再弹时间选择器Futurevoid _pickStartTime() async { final now DateTime.now(); final date await showDatePicker( context: context, initialDate: _selectedDate ?? now, firstDate: DateTime(now.year - 1), lastDate: DateTime(now.year 2), helpText: 选择开本日期, ); if (date null) return; final time await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_selectedDate ?? now), helpText: 选择开本时间, ); if (time null) return; setState(() { _selectedDate DateTime(date.year, date.month, date.day, time.hour, time.minute); }); }注意一个细节日期选择器的lastDate用的是now.year 2。剧本杀组队通常提前一两天到一两周发车最长也就一个月的预热期给两年的时间范围已经是极大宽容了同时避免了无限选到明年的情况。firstDate设为一年前是为了照顾那些可能发了车但一直没组满的老车队。3.3 第三组补充说明区补充说明区我放了性别比例要求开关和组队备注。这里的布局思路和前面两组不一样用的是紧凑型卡片避免页面整体太长。性别比例需求这块开启开关后展开一个下拉选择选项包括不限、全男车、全女车、男女混搭、女性优先、男性优先。这个功能不是所有车队都需要默认关闭关着的时候不占空间需要的人打开就能用。SwitchListTile( title: Text(性别比例要求), value: _genderReqEnabled, onChanged: (value) { setState(() { _genderReqEnabled value; if (!value) _genderReqDesc 不限; }); }, ), if (_genderReqEnabled) Padding( padding: EdgeInsets.only(left: 8, right: 16, bottom: 8), child: DropdownButtonFormFieldString( value: _genderReqDesc, decoration: InputDecoration(labelText: 比例要求, border: OutlineInputBorder()), items: _genderReqOptions.map((option) DropdownMenuItem(value: option, child: Text(option))).toList(), onChanged: (value) setState(() _genderReqDesc value!), ), ),用if条件渲染来控制这个下拉的显隐状态由开关统一管理。这里有个实用心得关闭开关时把_genderReqDesc重置为不限避免用户先选了全女车然后关掉开关再打开时看到下拉框停留在全女车的尴尬情况。虽然模型上传时开关是false后端不会取这个字段但界面状态保持干净能避免用户误解。组队备注区域则需要处理换行TextFormField( controller: _remarkController, maxLines: 3, minLines: 2, decoration: InputDecoration( labelText: 组队备注, hintText: 新手友好、可反串、欢迎戏精等补充说明, border: OutlineInputBorder(), alignLabelWithHint: true, ), )alignLabelWithHint: true容易被忽略但对多行输入框特别重要。不加这个属性label在输入框整个高度的顶部而不是第一行的顶部视觉上会歪掉。多行输入框还有一个隐藏问题换行后的光标定位和Controller的text同步偶尔会不同步实测在快速输入中文时偶尔出现末尾字符丢失规避办法是不要用onChanged直接改写Controller内容只在提交时读一遍。整个表单布局到这里就完成了剩下的核心工作转移到校验逻辑上。表单UI只是骨架校验才是让提交数据可用的关键环节接下来单独展开。4. 校验规则和错误处理的完整链路表单页面做好了如果不加校验就提交后端拿到的数据大概率是脏数据剧本名称为空、开本时间已经过去、人数为0。校验不是为难用户是把错误拦截在数据生成阶段省得后面列表页、匹配页、通知页都来填这个坑。4.1 分层校验Form 独立GetterFlutter表单校验的标准姿势是Form TextFormField的validator组合。Form用GlobalKey 管理点提交时调用_formKey.currentState.validate()所有注册了validator的表单字段会依次校验任何一项不通过页面会显示错误并阻止提交。final _formKey GlobalKeyFormState(); TextFormField( controller: _scriptNameController, validator: (value) { if (value null || value.trim().isEmpty) { return 请填写剧本名称; } return null; }, )剧本名称、门店地址这些文本字段用validator就够了。有两类字段不能走这套逻辑需要单独处理开本时间。它没有TextFieldvalidator没法直接挂上去。我的做法是在提交校验时单独检查_selectedDate是否为null或者是否早于当前时间。如果是用ScaffoldMessenger.of(context).showSnackBar弹出错误提示并高亮对应的时间区域——用一个_timeError标志位让ListTile的标题变红。需要人数。它混用了输入框和快捷选项校验规则比普通文本复杂一点。验证逻辑要同时处理空值、非数字、超出范围、小数。我在validator里写validator: (value) { if (value null || value.trim().isEmpty) { return 请填写需要的人数; } final parsed int.tryParse(value.trim()); if (parsed null) { return 人数必须是整数; } if (parsed 1 || parsed 20) { return 人数需在1-20之间; } return null; }关于范围这里有一个来自实际运营场景的考量上限20人。剧本杀一车通常5-8人少数机制本可以到10人以上但超过20基本就是团建活动而不是组队了。给一个合理上限能避免用户误填100这种明显错误又不会限制特殊玩法的正常需求。4.2 提交时的统一拦截函数Form的validate只是第一步提交函数里还需要做二次确认和序列化。void _submitForm() { if (!_formKey.currentState!.validate()) { return; } if (_selectedDate null) { setState(() _timeError true); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(请选择开本时间)), ); return; } if (_selectedDate!.isBefore(DateTime.now())) { setState(() _timeError true); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(开本时间不能早于当前时间)), ); return; } final model PartyFormModel( scriptName: _scriptNameController.text.trim(), scriptType: _scriptType, location: _locationController.text.trim(), startTime: _selectedDate!, memberCount: int.parse(_memberCountController.text.trim()), genderReqEnabled: _genderReqEnabled, genderReqDesc: _genderReqDesc, remark: _remarkController.text.trim(), ); // TODO: 调用发布接口上传 model.toJson() debugPrint(发布组队: ${model.toJson()}); }校验顺序有讲究——先校验Form字段再校验非Form字段。因为Form的校验是全部字段一起跑的错误信息挂在各自字段下方用户能一次性看到所有文本类错误而对时间和人数的校验是一票否决式的放在前面会打断其他字段的错误展示体验不好。还有一个小细节_selectedDate!.isBefore(DateTime.now())这行。不加这个判断用户选了昨天甚至去年的日期也能提交成功。加了之后开本时间必须晚于当前时刻防止过期的组队单流入列表。4.3 关于自动校验触发时机的选择Form默认是在调用validate()时一次性校验所有字段。但很多教程会让用户边输入边校验也就是每次onChanged都触发validate。我在这个页面里没有这么做原因有二一是边输入边校验在表单字段多的时候会产生干扰。用户正在专心填剧本名称下面的门店地址区域弹出请填写门店信息的红色错误会让人分心。二是性能。每敲一个字符就遍历整个Form的所有字段做校验在低端设备上会有肉眼可见的掉帧。OpenHarmony适配的设备有不少是中低端配置这种无谓的开销能省则省。我的方案是提交时统一校验错误明确后后续改动时再重新校验一次。这样用户修正错误后有即时反馈没开始填时页面保持安静。5. 适配细节OpenHarmony平台上的Flutter表单要点Flutter官方已经支持OpenHarmony作为目标平台基础的布局、状态管理、常用组件在OpenHarmony上都能正常工作。但真要移植到OpenHarmony设备上有几个适配细节和坑值得单独拿出来说。5.1 自适应尺寸与安全区OpenHarmony设备不只手机还有平板、折叠屏、开发板等形态屏幕尺寸和系统UI布局差异比手机阵营更大。表单页如果写死宽度或者不处理安全区在平板和折叠屏上会显得非常业余。我给表单约束了最大宽度同时适配安全区body: SafeArea( child: Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 600), child: CustomScrollView(...), ), ), ),ConstrainedBox(maxWidth: 600)的作用是在平板和宽屏设备上表单内容不会无限拉宽——一行文字如果拉满整个屏幕宽度阅读体验很差DropDown的弹层位置也可能错乱。在手机上600px的限制不影响因为手机宽度本身不到600dp。这是Flutter在OpenHarmony上做平板适配最便宜的方案没有之一。5.2 键盘弹出遮挡问题表单页必然遇到键盘遮挡。常规做法是resizeToAvoidBottomInset: true这是Scaffold的默认值。但默认值生效依赖于底层平台的软键盘模式——在部分OpenHarmony设备上窗口默认会调整大小但调整的时机比Android略晚表现就是键盘弹起时表单短暂跳动。规避方案给CustomScrollView的bottom加一块和键盘高度无关的闲余空间用MediaQuery.of(context).viewInsets.bottom动态计算。CustomScrollView( slivers: [ SliverPadding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom 80, ), sliver: SliverList(...), ), ], )viewInsets.bottom在键盘弹出时返回键盘高度所以这个padding会在键盘弹出时自动增大保证最后一个输入框能被顶到键盘上方。加80是给底部按钮栏和手感余量让用户不至于贴死在键盘边缘。5.3 时间弹窗的时区与本地化OpenHarmony上默认的日期时间格式可能跟Android习惯不同showDatePicker和showTimePicker的文案、按钮布局也受本地化影响。我在main.dart里配置了MaterialApp( locale: Locale(zh, CN), localizationsDelegates: [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], supportedLocales: [Locale(zh, CN)], )这样确保日期选择器显示确定取消而不是英文的OK/CANCEL月份名也是中文。这个配置对OpenHarmony和Android都有效写在这里是为了提醒很多人在适配过程中总是忘记加localizationsDelegates弹窗会出现语言混杂的尴尬。时区方面要特别注意OpenHarmony设备的系统时区如果设置异常DateTime.now()和用户选择的时间可能有时差。提交时我统一用toIso8601String()序列化后端存储时再统一转时间戳前端不做本地时区转换——让服务端统一处理时区永远是分布式场景的正确答案。5.4 调试与真机预览的双环境注意OpenHarmony适配过程中你大概率需要在模拟器、真机和预览器三个环境切换。这里有个小坑预览器和部分模拟器上showDatePicker的弹窗可能出现定位偏移点不到确定按钮。不是代码问题而是预览器Web渲染对Overlay的不完全支持。遇到这种情况建议直接在真机上验证弹窗类控件。在开发阶段常用快捷键ShiftF5或IDE里的实时预览调试页面基本没问题但凡涉及showXxxPicker、showModalBottomSheet这类Overlay弹层的功能必须以真机为准。5.5 性能观察表单页的Build频率Flutter表单页在输入时会频繁触发setState每个字符都会重建部分Widget树。在低端OpenHarmony设备上setState范围控制不好就会出现打字卡顿、光标跳动。我的经验是setState永远只更新必要的状态变量不重建整个页面。比如时间选择器回调里只setState更新_selectedDate和_timeError开关只setState更新_genderReqEnabled和_genderReqDesc。文本输入框因为用Controller驱动TextFormField内部自己会更新UI不需要外层setState。这个习惯养成后页面Build次数会显著下降。6. 完整页面落地代码结构与模块化组织前面把字段、布局、校验、适配讲透了现在把整个页面的代码结构串起来给出一份可以直接跑起来的完整实现。6.1 页面主体结构class CreatePartyPage extends StatefulWidget { const CreatePartyPage({super.key}); override StateCreatePartyPage createState() _CreatePartyPageState(); } class _CreatePartyPageState extends StateCreatePartyPage { final _formKey GlobalKeyFormState(); final _scriptNameController TextEditingController(); final _locationController TextEditingController(); final _memberCountController TextEditingController(); final _remarkController TextEditingController(); String _scriptType 推理; bool _genderReqEnabled false; String _genderReqDesc 不限; DateTime? _selectedDate; bool _timeError false; static const _scriptTypeOptions [推理, 情感, 机制, 恐怖, 欢乐, 阵营, 还原本]; static const _genderReqOptions [不限, 全男车, 全女车, 男女混搭, 女性优先, 男性优先]; override void dispose() { _scriptNameController.dispose(); _locationController.dispose(); _memberCountController.dispose(); _remarkController.dispose(); super.dispose(); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(发起组队)), body: SafeArea( child: Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 600), child: Form( key: _formKey, child: CustomScrollView( slivers: [ SliverPadding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom 80, ), sliver: SliverList( delegate: SliverChildListDelegate([ _buildCoreSection(), _buildTimeLocationSection(), _buildRemarkSection(), ]), ), ), ], ), ), ), ), ), bottomNavigationBar: _buildBottomBar(), ); } }几个关键点说明controller在dispose里释放。很多人一开始写Flutter不会记着这一步但Controller不释放会造成内存泄漏页面频繁打开关闭后内存只增不减。这是硬性习惯不是可选项。static const的选项列表。剧本类型、性别要求选项不会变声明为static const避免每次build都重建List对象。这只是几KB级别的优化但养成这种习惯能让整个工程在更大规模时表现更好。6.2 底部提交按钮Widget _buildBottomBar() { return BottomAppBar( child: Padding( padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: FilledButton( onPressed: _submitForm, style: FilledButton.styleFrom( minimumSize: Size.fromHeight(48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: Text(确认发起组队), ), ), ); }底部按钮栏用BottomAppBar而不是自己写一个Container好处是自动适配系统手势条和底部安全区不用手动算SafeArea。FilledButton的minHeight做成48这是触屏可点击区域的下限尺寸保证手指点击不容易误触相邻区域。按钮的onPressed直接指向_submitForm表单校验、时间校验、提交队列都在里面统一处理不在build里做任何业务判断。Widget的build只负责渲染当前状态业务决策全部交给事件函数——这个原则保住了页面大部分性能。6.3 与后续系列文章的接口衔接_submitForm末尾现在只打印了model的JSON真正的发布接口会在后续篇章实现。但这里已经把toJson方法写好了后续接接口时只要把返回的Map塞进请求体即可。数据结构是{ script_name: 病娇男孩的精分日记, script_type: 推理, location: 某剧本杀推理馆, start_time: 2025-06-15T14:00:00.000, member_count: 6, gender_req_enabled: false, gender_req_desc: 不限, remark: 新手友好 }字段命名用的是snake_case这是大多数后端接口的通用风格。如果你所在团队的后端约定是camelCase把toJson里的map key改一下即可不影响其他逻辑。有一点要提前说明当前版本把JSON打印在控制台只是过渡方案。实际开发中发布功能会涉及接口鉴权、错误码处理、发布成功后跳转逻辑。这些内容等到接入网络层的那一篇文章再展开。表单页的本职工作——收集数据并保证数据合法——到这里已经完整了。7. 从表单页引申出的几个工程化思路实现完发起组队表单之后有几个工程化层面的想法值得单独聊两句这些想法决定这个页面在未来项目演进中能不能持续复用。7.1 把表单数据模型抽成独立文件PartyFormModel不要放在页面文件里。组队表单数据不仅会被发起组队页使用后面做编辑组队回显、列表页筛选、详情页展示时同一个数据结构要对齐。抽到model/party_form_model.dart所有页面引用同一个模型数据一致性才有保障。7.2 构建一个通用的表单分组卡片本文中的_buildCoreSection、_buildTimeLocationSection、_buildRemarkSection三个方法带着不同业务字段但它们的骨架——Card Padding Column 分组标题是完全相同的。如果你接手多个表单页面发布活动、创建房间、预约场地这套分组模式会在每个页面重复出现。可以抽一个FormSectionCard组件class FormSectionCard extends StatelessWidget { final String title; final ListWidget children; final EdgeInsetsGeometry padding; const FormSectionCard({ super.key, required this.title, required this.children, this.padding const EdgeInsets.symmetric(horizontal: 16, vertical: 8), }); override Widget build(BuildContext context) { return Card( margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Padding( padding: padding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionTitle(title), ...children, ], ), ), ); } }这样每个页面只写业务字段公共结构交给组件处理。普通需求撑不起这种抽象但业务线一多这个组件的价值立刻显现。7.3 校验器的独立管理当表单校验逻辑复杂到一定程度比如需要异步校验门店ID是否存在、剧本名称是否有收录把validator直接写在TextFormField里会显得臃肿。届时可以把每个字段的validator抽出来放到独立文件比如form_validators.dart方便单测和复用。本篇的校验都是同步规则暂不需要异步但这个演进方向要留好。任何工程重构的时机不是现在能用吗而是三个月后这个页面还要继续改吗。剧本杀组队App是持续迭代的项目表单验收集中的收益会越来越大。8. 实测过程中的几个坑和规避方案到这里完整的项目已经能在OpenHarmony设备上跑通。接着补几个我实测中踩过的坑这些坑不踩一遍很难发现但踩过就会记很久。8.1 中文输入法下TextFormField的偶发丢失字符在某款OpenHarmony真机上用系统中文输入法快速输入剧本名称时偶尔出现最后一个字符丢失表现是窗边输完变窗。排查后确认是输入法与Flutter文本输入通道的同步问题不是Controller的问题。规避办法有两个层面。一是在输入完成焦点丢失时用Controller.text为准不要依赖onChanged拼数据二是在提交时对必填内容做二次trim和判空即使丢了一个字至少不会让空数据提交成功。这个坑在不同设备上的表现不一样属于底层输入法适配问题前端只能兜底无法根治。8.2 OpenHarmony预览器上showTimePicker的按钮错位开发期用预览器调试时showTimePicker的确认按钮偶发偏出屏幕外点不到。换到真机上消失。这是预览器Web渲染Overlay弹层时对键盘弹起状态处理不一致导致的不是业务代码的问题。对策凡是涉及时间、日期选择器的页面开发完成后一定要过一遍真机。预览器适合验证普通页面布局不适合验证弹层交互。8.3 DropdownButtonFormField在表单重绘后value异常DropdownButtonFormField有一个经典警告如果下拉框的value在items里找不到对应项会抛出断言错误。我在性别比例需求里重置_genderReqDesc 不限时曾经踩过一次——重置后items是从_genderReqOptions构建的而_genderReqOptions包含了不限所以没问题。但如果哪天你改了options列表却忘了更新重置逻辑就等着崩吧。建议DropdownButtonFormField的value要么从items中选取要么在构建items前先做一次包含性检查if (!_genderReqOptions.contains(_genderReqDesc)) { _genderReqDesc _genderReqOptions.first; }8.4 关于列表页复用表单数据的考虑表单收集到的PartyFormModel后面列表页会用到startTime做排序、memberCount做人数筛选、scriptType做类型分类。所以在设计数据模型时我建议给PartyFormModel加上几个便捷getter比如已满员判断、时间是否过期等。这些getter现在不写等列表页做的时候也得补。与其到时候改来改去不如现在花两分钟加上bool get isExpired startTime.isBefore(DateTime.now()); int get remainingSeats memberCount; // 后续接入报名人数后改为差分值这种前瞻性补充看上去是过度设计但考虑到这是系列实战第04篇后续一定会有列表页和详情页这些getter几乎是必然需求。9. 表单下一步的演进路线发起组队表单本身已经完成但站在整个App的开发进度上看这只是发布组队链条第一环。后面要接的事情有一长串。接口对接_submitForm里的TODO要替换成真实请求。发布成功后需要返回组队ID然后跳转到组队详情页。失败时要有错误提示和重试机制不能只靠debugPrint。编辑回显用户发车后如果想改时间、改人数需要进入编辑页并回显原来填的数据。回显就是把PartyFormModel各字段重新填充到Controller和状态变量中这个场景会在后续某个篇章里用到。列表联动发起组队后首页的组队列表需要立即刷新。目前表单页和列表页各自独立后续引入状态管理框架时要对组队数据的变更做全局同步。我在做这个页面时的整体经验是表单页难的不是控件拼装而是数据边界的划分和校验时机的设计。把这两个问题想清楚了OpenHarmony适配和后续接口对接都只是顺水推舟的事。下一期继续。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑