最近因为在做猫咪健康管理把之前一直用原生写的“猫咪管家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 World2.1 Flutter 与 OpenHarmony SDK 的版本搭配先说版本搭配因为这一环决定你后面会遇到多少坑。我最终稳定的组合是OpenHarmony SDKAPI 9Stage 模型DevEco Studio4.0 以上Flutter SDKopenharmony分支基于 3.10 稳定线JDK17如果你用别的组合也可以但一定要保证 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 和 NinjaWindows 上还要装好 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(MapString, 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?, ); } MapString, 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 的showDatePickerFuturevoid _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的validatorString? _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.readWeightModel().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; ListWeightRecord _records []; WeightModel(this._dao); ListWeightRecord get records _records; Futurevoid loadRecords(String catId) async { _records await _dao.getRecordsForCat(catId); notifyListeners(); } Futurevoid 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: ConsumerWeightModel( 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)), ); }, ); }, ), ); } }这里ConsumerWeightModel会订阅变化所以添加页保存后返回列表已经是最新的。如果你不想依赖 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 偶发闪退可以回退到 Skiaflutter run --no-enable-impeller这个开关用起来要谨慎不同真机表现不一样。后面我准备给体重模块加两个功能一个是按周的体重趋势折线图直接把WeightModel.records加个按时间分组的方法就能拿到数据另一个是体重异常波动提醒比如连续几天下降超过 10% 就在猫咪详情页弹个提示。这些功能都已经有数据结构支撑加的时候不会痛苦。回头再看“添加体重”这个功能它其实把 Flutter for OpenHarmony 开发里的大部分基础点都串起来了数据模型、数据库、状态管理、平台适配、测试。希望这篇能帮你少走一点弯路尤其是日期选择器和键盘避让这两个问题如果你也遇到了不用怀疑自己代码写错了确实是适配层还有待完善。