最近因为在做猫咪健康管理,把之前一直用原生写的“猫咪管家App”挪到了 Flutter for OpenHarmony 上。这个 App 功能不算复杂,就是记录每只猫的体重、喂食、疫苗接种情况,其中“添加体重”是最基础也最容易出错的一块——输入、校验、存储、列表刷新是一条完整链路。这篇文章就专门聊这一条链路,把我实测下来的思路和踩过的坑一起写出来,适合已经在 OpenHarmony 上跑过 Flutter 项目、想参考完整功能实现的人;如果你环境还没搭好,也可以从第 2 部分开始照着做。
1. 为什么我会在 OpenHarmony 上跑 Flutter 做猫咪管理
1.1 项目背景:从单个功能到整套 App
养了四只猫之后,单纯靠脑子记体重根本不现实。最开始我用的是一个简单网页,后来手机记录更方便,就做了一个本地 App。第一版是纯 ArkTS 写的,界面做起来顺手,但我想让这套东西同时跑在 Android 和 OpenHarmony 上,而且团队里更多人熟悉 Dart,所以决定转到 Flutter。
“猫咪管家App”这个称呼是我自己起的,里面主要分三个模块:猫咪档案、体重记录、喂养日志。体重记录看起来最简单,但它是以后做健康趋势分析的数据基础,所以数据库表结构和查询方式需要一开始就设计好,不能为了快而省。
1.2 Flutter for OpenHarmony 的适配现状
现在 OpenHarmony 上的 Flutter 已经可以用,但还没有像 Android 那样开箱即用,需要拉特定的 sdk 分支,还得通过 DevEco Studio 配合构建。我用的版本是 Flutter 3.10 对应的 OpenHarmony 分支,配 OpenHarmony API 9,实际跑下来大部分 UI 组件和插件都能工作,少数平台相关的接口需要自己处理。
很多人会问 ArkTS 和 Flutter 到底选哪个。我的看法是,如果只做 OpenHarmony 一款应用,ArkTS 没问题;但要做跨端,Flutter 仍然是更省事的选择。OpenHarmony 的 Flutter 版本也接入了 Impeller 渲染引擎,在动画和复杂页面上的表现比原来的 Skia 路径更稳定,后面专门讲性能时再细说。
2. 环境搭建:SDK、仓库和第一个 Hello World
2.1 Flutter 与 OpenHarmony SDK 的版本搭配
先说版本搭配,因为这一环决定你后面会遇到多少坑。我最终稳定的组合是:
- OpenHarmony SDK:API 9,Stage 模型
- DevEco Studio:4.0 以上
- Flutter SDK:
openharmony分支(基于 3.10 稳定线) - JDK:17
如果你用别的组合也可以,但一定要保证 Flutter 分支和 OpenHarmony SDK 大版本一致。API 9 的项目如果拉到 API 10 上编,经常会出现ohos相关头文件缺失的报错。
2.2 配置开发环境的坑:镜像、路径、编译链
OpenHarmony 的 Flutter 分支并不是flutter.dev的官方版本,而是 OpenHarmony 仓库里的flutter_flutter和flutter_engine。我拉取后把路径写进了环境变量:
export PATH=/opt/flutter_flutter/bin:$PATH这一步有同学容易踩坑:Flutter SDK 的路径不能有中文和空格,否则后续构建会莫名失败。另外,OpenHarmony 插件的原生部分需要 CMake 和 Ninja,Windows 上还要装好 Visual Studio 的 C++ 工具链,否则执行flutter doctor时会提示工具缺失。
我当时第一次建项目,跑flutter run -d OpenHarmony直接报错,提示找不到ohpm命令。后来发现是因为 DevEco Studio 里的 SDK 没有配到环境变量:
export PATH=/path/to/DevEco-Studio/sdk/9/ohpm/bin:$PATH export DEVECO_SDK_HOME=/path/to/DevEco-Studio/sdk/92.3 创建项目并跑通模拟器
环境配好之后,创建项目的方法和普通 Flutter 一样:
flutter create --platforms ohos cat_manager cd cat_manager flutter run -d ohos如果你第一次跑不起来,不要急着怀疑框架。先执行flutter doctor -v查看有没有缺少组件,然后看工程目录里是否已经生成了ohos文件夹。如果没有,说明当前分支还不支持你这个平台,可以用:
flutter create --platforms ohos .补生成。跑通 Hello World 之后,再继续引入体重模块,不然环境问题和业务问题混在一起,排查难度会翻倍。
3. 猫咪管家 App 的目录结构与体重模块定位
3.1 模块划分:从猫咪列表到体重记录
我没有把体重记录做成一个独立 App,而是放进猫咪管家整体结构里作为一个 feature。项目目录划分大概是:
lib/ main.dart models/ cat.dart weight_record.dart db/ app_database.dart weight_dao.dart providers/ cat_model.dart weight_model.dart pages/ cat_list_page.dart cat_detail_page.dart weight_add_page.dart widgets/ weight_form.dart体重记录依赖猫咪档案,所以在cat_detail_page里点击“添加体重”跳转到weight_add_page。这个依赖关系很清晰,避免了模块循环引用。
3.2 数据层:WeightRecord 模型和数据库封装
体重记录需要存储几个字段:猫咪 id、体重数值 Kg、记录日期、备注。模型我这样写:
class WeightRecord { final int? id; final String catId; final double weightKg; final DateTime recordDate; final String? note; WeightRecord({ this.id, required this.catId, required this.weightKg, required this.recordDate, this.note, }); factory WeightRecord.fromMap(Map<String, dynamic> map) { return WeightRecord( id: map['id'] as int?, catId: map['cat_id'] as String, weightKg: (map['weight_kg'] as num).toDouble(), recordDate: DateTime.fromMillisecondsSinceEpoch(map['record_date'] as int), note: map['note'] as String?, ); } Map<String, dynamic> toMap() { return { 'id': id, 'cat_id': catId, 'weight_kg': weightKg, 'record_date': recordDate.millisecondsSinceEpoch, 'note': note, }; } }为什么不用shared_preferences存 JSON?因为体重记录要做趋势折线图,要按日期聚合、排序,用轻量数据库更合适。我在 OpenHarmony 上选择了sqflite的适配版本,原因是团队熟悉 SQL,而且sqflite_common_ffi可以很轻松跑单元测试。
建表语句:
CREATE TABLE weight_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, cat_id TEXT NOT NULL, weight_kg REAL NOT NULL, record_date INTEGER NOT NULL, note TEXT );索引不要漏:
CREATE INDEX idx_weight_cat_date ON weight_record(cat_id, record_date);有了这个索引,后续查某只猫的体重曲线就非常快。
4. 添加体重页面:表单、校验和交互
4.1 页面布局与表单控件
添加体重页面的布局非常简单:一个体重输入框、一个日期选择器、一个备注输入框、一个保存按钮。
class WeightAddPage extends StatelessWidget { final String catId; const WeightAddPage({super.key, required this.catId}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('添加体重记录')), body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: WeightForm(catId: catId), ), ); } }这里直接用了SingleChildScrollView,是为了防止键盘弹起后表单被顶出屏幕。在 OpenHarmony 上键盘避让的行为和 Android 不完全一样,后面第 6 节会专门讲。
体重输入框我用了TextFormField,键盘类型是:
TextInputType.numberWithOptions(decimal: true)这样做可以让用户输入小数,但比较麻烦的是TextField获取到的还是字符串,需要自己转double。
4.2 日期选择器与键盘适配
日期的默认值是今天,点击后弹日期选择器。我用的是 Material 的showDatePicker:
Future<void> _pickDate() async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: DateTime(now.year, now.month, now.day), firstDate: DateTime(2020), lastDate: DateTime(now.year + 1), ); if (picked != null) { setState(() { _recordDate = picked; }); } }这个控件在最新分支上已经能正常弹出,但如果你遇到弹不出日期框的情况,不要死磕;可以将日期改成三个下拉框(年月日)或者一个TextField输入,再解析字符串。App 的核心是体重数据,日期选择只是交互辅助,不能因为一个插件卡住整个功能。
4.3 校验逻辑和保存按钮交互
保存按钮的onPressed里先做两件事:校验输入、写入数据库。
校验逻辑写在Form的validator:
String? _validateWeight(String? value) { if (value == null || value.trim().isEmpty) { return '请输入体重'; } final weight = double.tryParse(value.trim()); if (weight == null) { return '体重必须是数字'; } if (weight <= 0 || weight > 30) { return '体重必须大于0且不超过30kg'; } return null; }为什么上限设 30kg?这是根据普通家猫的体重范围定的,防止误输入。如果你想更严谨,应该从猫咪档案里读取品种对应的参考范围,再动态调整。
校验通过后:
await context.read<WeightModel>().addRecord( WeightRecord( catId: catId, weightKg: weight, recordDate: _recordDate, note: _note.isEmpty ? null : _note, ), ); if (!mounted) return; Navigator.of(context).pop();保存成功就返回上一页,列表页通过 Provider 自动刷新。
5. 状态管理:用 Provider 串联体重列表
5.1 为什么选 Provider 而不是 setState
体重记录页面和列表页是分开的两个 Page,用setState只能管当前页面状态,返回时列表不可能自动更新。虽然可以用Navigator.push的返回值回调,但涉及多个入口、后续还要编辑删除时,回调方式会变得很难维护。
我选了Provider,理由很简单:团队熟悉、轻量、不需要额外生成代码。如果你习惯 Riverpod 也可以,核心思路是一样的。
依赖加入:
flutter pub add provider5.2 ChangeNotifier 与数据库双向同步
WeightModel继承ChangeNotifier,把数据库操作封装在上层,页面不直接和sqflite打交道。
class WeightModel extends ChangeNotifier { final WeightDao _dao; List<WeightRecord> _records = []; WeightModel(this._dao); List<WeightRecord> get records => _records; Future<void> loadRecords(String catId) async { _records = await _dao.getRecordsForCat(catId); notifyListeners(); } Future<void> addRecord(WeightRecord record) async { await _dao.insert(record); await loadRecords(record.catId); } }核心逻辑是每次增删改后都重新从数据库加载列表,然后notifyListeners。这样列表页自然刷新。
之所以“重新加载”而不是“往数组里插一条”,是因为数据库可能还有其他排序规则,比如同一日期多条记录合并,重新查询能直接拿到正确结果,代码也简单。
5.3 组件通信:从列表页跳到添加页再回来的刷新
你可能会想,既然 Provider 是全局的,添加页保存后调用notifyListeners,列表页会自动刷新。对,但前提是列表页已经监听了同一个WeightModel。
在CatDetailPage里这样写:
class CatDetailPage extends StatelessWidget { final String catId; @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => WeightModel(WeightDao())..loadRecords(catId), child: Consumer<WeightModel>( builder: (context, model, _) { final records = model.records; return ListView.builder( itemCount: records.length + 1, itemBuilder: (context, index) { if (index == 0) { return GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => WeightAddPage(catId: catId), ), ); }, child: Card(child: Text('添加体重')), ); } final record = records[index - 1]; return ListTile( title: Text('${record.weightKg} kg'), subtitle: Text(formatDate(record.recordDate)), ); }, ); }, ), ); } }这里Consumer<WeightModel>会订阅变化,所以添加页保存后返回,列表已经是最新的。如果你不想依赖 Provider,也可以在Navigator.push后使用.then((_) => model.loadRecords()),但那样每个入口都要写一遍,容易漏。
6. 踩坑记录:OpenHarmony 下 Flutter 的兼容性问题
6.1 键盘弹起导致布局溢出
在 Android 上,默认情况下Scaffold收到键盘弹起事件会把body上移,避免输入框被遮挡。OpenHarmony 的适配版本里,MediaQuery.of(context).viewInsets.bottom有时候始终为 0,导致键盘弹起来直接把底部按钮顶没了,或者报overflowed。
排查过程是这样的:我先在输入框上打了keyboardAppearance相关日志,发现viewInsets一直是 0,说明引擎没有把键盘高度同步到 Flutter 层。确认是适配层问题后,解决办法是全局设置:
Scaffold( resizeToAvoidBottomInset: false, ... )然后在列表最下方加一块SizedBox(height: MediaQuery.of(context).size.height / 4)作为占位,保证键盘弹出时,输入框可以通过外层SingleChildScrollView手动滚动到可见区域。
这个方法不算优雅,但在当前版本上很管用。
6.2 日期选择器无法弹出
还有一个诡异的问题是showDatePicker在部分 OpenHarmony 真机上点开没反应,控制台也没有报错。后来我看了引擎日志,发现是platform channel里的DatePicker对应原生组件没有注册成功。由于是仓库分支问题,我放弃了继续深入,改用自定义日期输入。
现在项目里用的是三个DropdownButton,年、月、日分别选择。丑一点,但稳定。等你跑起来之后,可以再尝试用新版 Flutter 分支,看官方有没有修复。
6.3 ArkTS 与 Flutter 混编时的生命周期切换
猫咪管家 App 里有一部分页面是 ArkTS 写的,比如系统设置。Flutter 页面嵌入到 ArkTS 页面时,生命周期不会完全自动同步。我发现 Flutter 页面在切后台再回前台,AppLifecycleState.resumed偶尔不触发,导致体重列表没有重新加载。
解决办法是在 ArkTS 侧通过Colum接口把页面切到前台的事件传给 Flutter:
// ArkTS 侧 aboutToReappear() { this.controller.pushState(); }Flutter 侧监听onResume回调,再手动触发:
WidgetsBinding.instance.addObserver(MyLifecycleObserver());这里要注意,不要在自己的业务逻辑里也加一套,否则会出现加载两次。我最初就是既监听了 Flutter 生命周期,又让 ArkTS 主动调用,结果数据库查询被并发触发,列表卡了一下。最后统一交给 ArkTS 调用,Flutter 侧只接受回调,问题解决。
7. 测试与发布:把体重记录真正跑起来
7.1 单元测试数据库操作
体重记录的核心是数据存取,必须测试。sqflite在测试环境需要用sqflite_common_ffi初始化:
import 'package:flutter_test/flutter_test.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; void main() { test('WeightDao insert and query', () async { sqfliteFfiInit(); final factory = databaseFactoryFfi; final db = await factory.openDatabase(inMemoryDatabasePath); final dao = WeightDao.forTest(db); await dao.insert(WeightRecord( catId: 'cat01', weightKg: 4.2, recordDate: DateTime(2025, 1, 10), )); final records = await dao.getRecordsForCat('cat01'); expect(records.length, 1); expect(records.first.weightKg, 4.2); }); }这里inMemoryDatabasePath可以让每个测试用例之间互不影响,比用本地文件可靠。
7.2 Widget 测试表单校验
表单校验也要用 Widget 测试覆盖,防止以后改样式把校验逻辑弄坏:
testWidgets('empty weight shows error', (tester) async { await tester.pumpWidget( MaterialApp( home: WeightForm(catId: 'cat01'), ), ); await tester.tap(find.widgetWithText(FilledButton, '保存')); await tester.pumpAndSettle(); expect(find.text('请输入体重'), findsOneWidget); });注意这里我把WeightForm独立成了一个 Widget,专门为了测试方便。只要被测对象不直接依赖Provider,测试就简单很多。如果你把 Provider 依赖写死,还要setUp里创建 Provider,比较麻烦。
7.3 性能优化与后续计划
功能跑通后,我顺手把 OpenHarmony 上的 Impeller 打开了。方法是在main.dart里加:
// 在 runApp 前 if (const bool.fromEnvironment('enable-impeller')) { // OpenHarmony 分支默认已经启用,这里就是占位 }真机上滚动列表时,jank 明显下降。但如果你的设备内存紧张,Impeller 偶发闪退,可以回退到 Skia:
flutter run --no-enable-impeller这个开关用起来要谨慎,不同真机表现不一样。
后面我准备给体重模块加两个功能:一个是按周的体重趋势折线图,直接把WeightModel.records加个按时间分组的方法就能拿到数据;另一个是体重异常波动提醒,比如连续几天下降超过 10% 就在猫咪详情页弹个提示。这些功能都已经有数据结构支撑,加的时候不会痛苦。
回头再看“添加体重”这个功能,它其实把 Flutter for OpenHarmony 开发里的大部分基础点都串起来了:数据模型、数据库、状态管理、平台适配、测试。希望这篇能帮你少走一点弯路,尤其是日期选择器和键盘避让这两个问题,如果你也遇到了,不用怀疑自己代码写错了,确实是适配层还有待完善。