
1. 为什么这一期聚焦「发起组队表单」做Flutter for OpenHarmony的剧本杀组队App前面几期基本把项目脚手架、页面框架、数据流打通了。到了第04期我发现真正开始有“业务味”的东西就是这个发起组队的表单页。为什么单独拿一期来写表单因为在一款组队类App里表单不只是一个输入界面它承担了三件事用户意图的采集、业务规则的校验、以及后续所有列表数据和匹配逻辑的数据源头。你想想看玩家打开App想组一局剧本杀首先要填的就是“玩什么本、几个人、什么时间、在哪家店、有什么要求”。这些信息一旦录入有误后面的组队列表、房间匹配、消息通知全都会跟着出错。所以表单字段的设计、校验规则的定义、数据落库的方式直接决定了整个App的业务质量。这不是一个简单的“几个输入框拼一拼”的问题。另外在OpenHarmony这个目标平台上跑Flutter表单涉及到的输入法、日期选择器、下拉弹层、页面生命周期都会有和Android/iOS不完全一样的表现。这一期的内容我会把表单从模型定义到界面实现、从校验规则到本地入库、从真机适配到问题排查完整走一遍。适合正在做Flutter跨端应用开发、尤其是准备往OpenHarmony生态迁移的开发者参考。如果你是刚接触Flutter没多久只要跟着把每一步复现一次也能得到一个可直接用的组队表单模块。2. 数据模型与字段设计2.1 发起组队需要哪些信息开始写代码之前先把业务问题想清楚。剧本杀组队和普通的活动报名不一样有几个强业务字段是必须的剧本名称或剧本类型玩家靠这个判断要不要上车、人数上限剧本杀每个本都有固定角色数不是越多越好、组局时间人齐了才能开的局时间必须精确到几点、地点线下店名或线上房间号。这四个字段缺一个组队信息就是不完整的。除了必填字段还有几个建议加的辅助字段比如发起人留言、是否允许新人上车、性别偏好部分剧本杀局确实有这种需求。这些字段可以不填但提供了用户表达的弹性空间。在我做的这个版本里性别偏好先不做因为涉及到敏感的用户标签逻辑后面单独处理。本期先聚焦在剧本类型、标题、人数、时间、地点、留言、是否自动入队共七个字段。2.2 业务字段一览与校验规则字段定下来之后马上要定义的就是校验规则。这块不建议边写界面边想最好在模型层就把规则明确下来后面写TextFormField的validator时只需要直接映射。我列一下本期表单的字段规则你们可以参考字段类型必填校验规则组队标题文本是非空2到20个字符剧本类型枚举是必须选择一项人数上限整数是4到12人默认6人组局时间日期时间是不能早于当前时间地点文本是非空最多50个字符发起人留言多行文本否最多100个字符自动入队开关否布尔值人数上限为什么限定4到12因为市面上大部分剧本杀的配置就是4到12人经典本多数是6到8人太少了开不起来太多了也不现实。时间为什么不能早于当前时间没有人能发起一场已经过去的局这个校验能拦住大部分误操作。2.3 用代码建模TeamGroupModel有了字段和校验规则先写数据模型。我不会把模型写成纯粹的getter/setter而是直接把toMap和fromMap做进去后面存数据库、页面间传参都会方便很多。enum ScriptType { reasoning, // 推理本 emotion, // 情感本 horror, // 恐怖本 joy, // 欢乐本 mechanism, // 机制本 other; // 其他 static String label(ScriptType type) { switch (type) { case ScriptType.reasoning: return 推理; case ScriptType.emotion: return 情感; case ScriptType.horror: return 恐怖; case ScriptType.joy: return 欢乐; case ScriptType.mechanism: return 机制; case ScriptType.other: return 其他; } } } class TeamGroupModel { final int? id; final String title; final ScriptType scriptType; final int capacity; final DateTime groupTime; final String location; final String description; final bool autoJoin; final DateTime createdAt; TeamGroupModel({ this.id, required this.title, required this.scriptType, required this.capacity, required this.groupTime, required this.location, this.description , this.autoJoin false, DateTime? createdAt, }) : createdAt createdAt ?? DateTime.now(); MapString, dynamic toMap() { return { id: id, title: title, scriptType: scriptType.name, capacity: capacity, groupTime: groupTime.millisecondsSinceEpoch, location: location, description: description, autoJoin: autoJoin ? 1 : 0, createdAt: createdAt.millisecondsSinceEpoch, }; } factory TeamGroupModel.fromMap(MapString, dynamic map) { return TeamGroupModel( id: map[id] as int?, title: map[title] as String, scriptType: ScriptType.values.firstWhere( (e) e.name map[scriptType], orElse: () ScriptType.other, ), capacity: map[capacity] as int, groupTime: DateTime.fromMillisecondsSinceEpoch(map[groupTime] as int), location: map[location] as String, description: map[description] as String? ?? , autoJoin: (map[autoJoin] as int) 1, createdAt: DateTime.fromMillisecondsSinceEpoch(map[createdAt] as int), ); } }这里有一个细节时间字段我存的是毫秒时间戳而不是ISO字符串。原因很简单SQLite里对整数排序、比较范围都比字符串可靠得多而且Dart的DateTime.fromMillisecondsSinceEpoch恢复也很快。autoJoin用0/1整数存储是为了兼容SQLite没有布尔类型的问题这个习惯在Flutter本地数据库开发里建议保持。3. 表单界面实现3.1 页面骨架与导航表单页我用StatefulWidget来实现因为涉及多个可变化的状态标题输入、类型选择、人数增减、时间选择等。页面的整体结构是Form包ListView的布局这样既能享受Form自带的验证机制又能确保内容超出屏幕时可滚动。class CreateGroupPage extends StatefulWidget { const CreateGroupPage({super.key}); override StateCreateGroupPage createState() _CreateGroupPageState(); } class _CreateGroupPageState extends StateCreateGroupPage { final _formKey GlobalKeyFormState(); final _titleController TextEditingController(); final _descriptionController TextEditingController(); ScriptType _selectedType ScriptType.reasoning; int _capacity 6; DateTime? _groupTime; String _location ; bool _autoJoin false; override void dispose() { _titleController.dispose(); _descriptionController.dispose(); super.dispose(); } // ... }页面从组队列表页通过Navigator.push进入。这里我建议使用MaterialPageRouteOpenHarmony侧的Flutter引擎对标准路由的兼容很好不要一开始就用自定义页面过渡动画出问题的概率更高。3.2 文本输入与自定义验证标题输入是最基本的TextFormField。我在这个字段上加了两个验证非空校验和长度校验。这里强调一点Flutter表单验证的核心是TextFormField的validator返回值返回null代表通过返回字符串代表错误提示。用Form的GlobalKey可以统一触发所有字段的验证。TextFormField( controller: _titleController, maxLength: 20, decoration: const InputDecoration( labelText: 组队标题, hintText: 比如周六晚《雾鸦馆》来4个人, border: OutlineInputBorder(), ), validator: (value) { final text value?.trim() ?? ; if (text.isEmpty) { return 请填写组队标题; } if (text.length 2) { return 标题至少2个字; } return null; }, )标题字段的validator里我做了trim处理因为用户有可能只输入空格。这个问题在实际测试中很容易遇到如果不trim空格会被当成合法输入放过去但数据库里存了一行看起来空白的数据列表页渲染出来就是空卡片。地点字段也用TextFormField但业务逻辑比标题简单只做非空校验和最大长度限制。地点输入的hintText建议写成剧本杀店名或线上房间号这样用户一看就知道要填什么。3.3 剧本类型与人数选择剧本类型这里用DropdownButtonFormField会比自定义弹层简单得多而且Flutter官方组件在OpenHarmony上适配得比较成熟。但有一点要注意DropdownButtonFormField的value参数在Flutter 3.x版本里已经被废弃统一改用initialValue。很多老教程还在用value直接照抄会报错。DropdownButtonFormFieldScriptType( initialValue: _selectedType, decoration: const InputDecoration( labelText: 剧本类型, border: OutlineInputBorder(), ), items: ScriptType.values .map((type) DropdownMenuItem( value: type, child: Text(ScriptType.label(type)), )) .toList(), onChanged: (value) { if (value ! null) { setState(() { _selectedType value; }); } }, )人数上限我用的是减号加数字加加号的自定义布局不推荐在表单里用Slider因为玩家对上限人数的感知需要精确数字滑块虽然操作顺手但精度差。自增自减组件逻辑也不复杂用Row包两个IconButton和一个Text就能搞定。每次点击加减时都要做边界判断低于4不能减高于12不能加。3.4 日期时间选择器组局时间的选择是这一期表单里最容易出问题的部分。我用了showDatePicker先选日期再showTimePicker选时间两者都确认后合并成一个DateTime。Futurevoid _selectGroupTime() async { final now DateTime.now(); final initialDate _groupTime ?? now.add(const Duration(hours: 1)); final pickedDate await showDatePicker( context: context, initialDate: initialDate, firstDate: DateTime(now.year, now.month, now.day), lastDate: DateTime(now.year 1), helpText: 选择组局日期, cancelText: 取消, confirmText: 确定, ); if (pickedDate null || !mounted) return; final pickedTime await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime( _groupTime ?? now.add(const Duration(hours: 1)), ), helpText: 选择组局时间, cancelText: 取消, confirmText: 确定, ); if (pickedTime null || !mounted) return; setState(() { _groupTime DateTime( pickedDate.year, pickedDate.month, pickedDate.day, pickedTime.hour, pickedTime.minute, ); }); }这里的firstDate我设置成了当天零点限制用户不能选过去的日期。但这里有个坑如果用户选了今天时间选择器里仍然可以选择过去的时间。所以最终合并DateTime之后提交前还需要再校验一次“不能早于当前时间”。这个校验我放在了保存按钮的处理逻辑里而不是表单validator里因为validator依赖的是一个DateTime字段而不是TextFormField的controller。4. 提交、入库与列表联动4.1 表单数据组装保存按钮的onPressed逻辑是整个表单页的核心。我先调用_formKey.currentState?.validate()触发所有TextFormField的校验。字段全部通过之后再对非文本字段做二次校验时间、人数已经在交互层约束了时间还需要多校验一次。void _handleSubmit() async { if (!_formKey.currentState!.validate()) { return; } if (_groupTime null || _groupTime!.isBefore(DateTime.now())) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text(组局时间不能早于当前时间请重新选择)), ); return; } final model TeamGroupModel( title: _titleController.text.trim(), scriptType: _selectedType, capacity: _capacity, groupTime: _groupTime!, location: _location.trim(), description: _descriptionController.text.trim(), autoJoin: _autoJoin, ); // 入库并返回 }有读者可能会问为什么不在showTimePicker选中时就立刻拦截过去时间理论上可以但体验不好。用户选完日期发现时间不行又要重新从日期开始选操作成本高。我在提交前做统一拦截同时SnackBar给提示用户点进来重新选一下时间就行流程最短。4.2 本地数据库保存DAO层设计从热词里你们可能也看到了“flutter 内嵌数据库”和“flutter 做本地数据库后端同步”是很多人的痛点。本期我先不接后端专注把本地库这一层做干净。我用的是sqflite在OpenHarmony上跑Flutter时sqflite需要确保数据库路径的获取没问题。先在pubspec.yaml里加上sqflite的依赖然后创建一个数据库 helper 单例。我习惯把建表和DAO方法分开表结构定义在DatabaseHelper里增删改查的方法放在TeamGroupDao里这样后面加字段、加表都不会到处改代码。class DatabaseHelper { static final DatabaseHelper _instance DatabaseHelper._internal(); DatabaseHelper._internal(); static Database? _db; FutureDatabase get database async { _db ?? await _initDb(); return _db!; } FutureDatabase _initDb() async { final dbPath await getDatabasesPath(); return openDatabase( $dbPath/script_group.db, version: 1, onCreate: (db, version) async { await db.execute( CREATE TABLE team_group ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, scriptType TEXT NOT NULL, capacity INTEGER NOT NULL, groupTime INTEGER NOT NULL, location TEXT NOT NULL, description TEXT, autoJoin INTEGER DEFAULT 0, createdAt INTEGER NOT NULL ) ); }, ); } }这里有个值得说的细节数据库文件名我用了script_group.db而不是直接叫app.db。因为一个App往后可能有多张表、多个业务模块用功能命名文件后面定位问题时不会抓瞎。4.3 回跳并刷新列表数据入库之后页面要返回列表页并且让列表页刷新出新数据。这里最优雅的Flutter方式是pop时携带结果值然后用then回调刷新列表。我自己的偏好是PopScope加返回值配合列表页的StatefulWidget刷新。// 入库后返回 final db await DatabaseHelper.instance.database; await db.insert(team_group, model.toMap()); if (!mounted) return; Navigator.pop(context, true);列表页这样接收Futurevoid _openCreatePage() async { final created await Navigator.pushbool( context, MaterialPageRoute(builder: (_) const CreateGroupPage()), ); if (created true) { _loadGroups(); } }这块的逻辑价值在于页面返回后列表必须知道该不该刷新。如果你在返回时无条件刷新性能浪费如果你不返回结果列表就永远停留在旧数据。用bool返回值是最轻量直接的方案不用引入全局状态管理。5. OpenHarmony真机适配要点5.1 生命周期与输入法避坑在OpenHarmony设备上调试Flutter表单页第一个要处理的是输入法遮挡问题。Flutter默认状态下键盘弹出时页面会通过Scaffold的resizeToAvoidBottomInset自动压缩高度但在OpenHarmony的某些设备上输入法的高度计算偶尔会不准确导致底部按钮被顶出可视区域。我的解决方法是给表单最外层的Scaffold设置resizeToAvoidBottomInset: true保持默认同时确保ListView的padding里加了bottom: MediaQuery.of(context).viewInsets.bottom。这样即使输入法高度计算有误差ListView的内容区也能保证可滚动到底部按钮。另一个和生命周期相关的坑在OpenHarmony上应用从后台切回前台时如果表单页还停留着可能因为设备系统回收了页面状态导致TextEditingController失效。这个问题在真机上偶发建议在didChangeAppLifecycleState里对正在编辑的表单做一次“恢复焦点”的操作或者在initState里监听生命周期变化。5.2 选择器弹层与字体表现showDatePicker和showTimePicker在OpenHarmony上运行时默认Material风格的选择器界面基本可用但有几个文案会随系统语言走。如果你的App只做中文记得在MaterialApp里设置locale: Locale(zh)否则选择器的确定、取消按钮可能显示英文。下拉选择器DropdownButtonFormField在OpenHarmony上的弹层表现我实测下来下拉列表的弹出位置偶尔会偏左上这在特定分辨率设备上比较明显。这个问题的根源是Flutter的Overlay在OpenHarmony窗口尺寸变化时没有及时刷新。解决方案是如果遇到这个问题可以给下拉框一个明确的MenuAnchor封装或者退一步用底部的showModalBottomSheet包裹选项列表兼容性更好。字体方面的建议是表单页的中文输入在OpenHarmony设备上默认会走系统字体但你如果用了自定义字体包请务必把字体文件通过FontLoader注册好否则会出现“输入法弹窗正常但输入框内中文显示为方框”的诡异情况。这个我在测试机上遇到过一回排查了半天才发现是字体未加载。6. 常见问题与排查技巧6.1 表单验证不触发的三个原因很多人写Flutter表单发现点保存按钮没反应_formKey.currentState?.validate()好像没生效。这类问题通常有三个原因。第一TextFormField没有放在Form控件内部。很多人为了布局方便把输入组件放在自定义的Widget里忘了在Form的child树中包含这些组件。validate()只能找到它直接管辖的FormField后代节点脱离Form树的输入框根本不会被验证。第二按钮的onPressed里面没有调用validate()。这个听起来像废话但调试时真的容易漏。我见过有人只写了await _saveToDatabase()完全没走验证流程。第三validator内部逻辑有bug比如return条件写反了。调试时可以先用一个最简单的return null看看验证能不能走通排除框架层面问题后再细化业务逻辑。6.2 数据库读写最容易踩的坑sqflite在OpenHarmony上最典型的坑是数据库还没初始化就执行查询或者重复打开数据库导致连接泄漏。第一个问题一定要用我前面写的单例模式并且在所有DAO方法里都通过database getter获取实例。不要在某一个页面里单独final db await openDatabase(...)那样你会在另一个页面拿到不同的实例表结构操作会互相冲突。第二个问题打开数据库后一定要把实例缓存起来不要每次都open和close。sqflite支持同时多个连接但OpenHarmony上的文件锁偶有异常频繁开关数据库在高并发写入时可能报database is locked。实测下来用单例缓存连接基本不会再出现这个错误。第三个问题insert的时候强类型转换。你在toMap里可能存了DateTime对象进去但SQLite不认识Dart对象。必须存millisecondsSinceEpoch整数或字符串否则会抛类型不匹配异常。6.3 真机测试容易被忽略的细节OpenHarmony真机调试时有几个细节我觉得值得单独拎出来说。第一个是build模式的区别。Debug模式下Flutter表单页性能没问题但Release包在OpenHarmony设备上跑的时候路由动画和输入框焦点切换偶尔有掉帧。这个不影响功能但会显得不流畅。建议在表单页这种连续输入场景把页面内动画尽量用AnimatedContainer替代自定义AnimationController减少每帧重建的Widget数量。第二个是系统返回手势。OpenHarmony设备有左侧侧滑返回手势在表单页如果用户输入了一半想退出应用应该弹确认提示防止误触丢失内容。实现方式是用PopScope拦截返回如果有未提交的表单内容就先弹Dialog确认。PopScope( canPop: _titleController.text.isEmpty _descriptionController.text.isEmpty, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; final shouldPop await showDialogbool( context: context, builder: (ctx) AlertDialog( title: const Text(放弃编辑), content: const Text(当前填写的内容还没有保存确定要退出吗), actions: [ TextButton( onPressed: () Navigator.pop(ctx, false), child: const Text(继续编辑), ), TextButton( onPressed: () Navigator.pop(ctx, true), child: const Text(放弃), ), ], ), ); if (shouldPop true context.mounted) { Navigator.pop(context); } }, child: Scaffold(...), )第三个是软键盘的完成按钮。在文本输入框的textInputAction上标题框建议设置TextInputAction.next让用户键盘右下角直接显示“下一项”多行留言框设置TextInputAction.newline比较自然。不要所有输入框都用done否则用户输入完标题想继续填下一个字段还得先收起键盘点别的输入框操作路径长一倍。写在最后这一期做完我的感受是表单这玩意儿看着不起眼真要在OpenHarmony上做到顺手、不踩坑需要打磨的细节远比想象中多。从字段规划到模型设计从界面搭建到数据库联动每一步都在为后面的列表匹配和房间详情打基础。我个人在实际调试中的体会是不要急着把界面做完再去补校验先把数据模型和校验规则写清楚界面只是把规则映射出来而已这样改起来才快。下一个阶段我会把组队列表页和详情页接进来到时候这份表单数据就会真正在整个App里流动起来了。