Flutter for OpenHarmony开发实战:组队表单从模型到入库全流程解析
2026/9/9 15:15:22 网站建设 项目流程

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(); Map<String, 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(Map<String, 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 State<CreateGroupPage> createState() => _CreateGroupPageState(); } class _CreateGroupPageState extends State<CreateGroupPage> { final _formKey = GlobalKey<FormState>(); 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进入。这里我建议使用MaterialPageRoute,OpenHarmony侧的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,直接照抄会报错。

DropdownButtonFormField<ScriptType>( 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。

Future<void> _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; Future<Database> get database async { _db ??= await _initDb(); return _db!; } Future<Database> _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);

列表页这样接收:

Future<void> _openCreatePage() async { final created = await Navigator.push<bool>( 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 showDialog<bool>( 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里流动起来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询