OpenHarmony上跑Flutter:油耗追踪器实战开发全记录
发布时间:2026/9/30 3:30:29来源:尧图网络
1. 项目缘起为什么要在 OpenHarmony 上跑 Flutter 做油耗追踪器先说这个项目的来由。我一直有记录加油数据的习惯每次加油顺手把里程、升数、金额记下来几个月下来就能算出真实油耗和养车成本。市面上现成的油耗 App 不少但大多绑定账号、有广告、数据还要上传云端我只想要一个纯本地、打开就能看、界面清爽的小工具。正好那段时间我在研究 OpenHarmony 上的跨平台开发Flutter 对 OpenHarmony 的适配已经能跑起来了就决定自己写一个。项目名叫 FillUp核心场景就是随手记录加油然后打开概览页就知道三件事最近一箱油跑了多少、平均油耗是多少、这个月花了多少钱。这个 App 的技术选型可能有人觉得绕跑在国产操作系统上用跨平台框架做的是一个很小的工具类应用。但我的想法很直接Flutter 在 OpenHarmony 生态里是少数能保证 UI 一致性和开发效率的方案而油耗追踪器这种数据模型清晰、页面结构固定的应用正好适合用来验证整个工具链是否可用。如果你也在研究 Flutter 和 OpenHarmony 的配合或者想把手头的小工具迁移到 OpenHarmony 上这篇实战记录适合你。2. 工程环境Flutter SDK 与 OpenHarmony 工程的联动配置2.1 用 flutter_fluh 而不是官方 Flutter SDKOpenHarmony 上跑 Flutter第一步就有一个很关键的差异你不能直接用 flutter.dev 下载的官方 SDK 来创建 OpenHarmony 工程。OpenHarmony 社区维护了一个独立的 Flutter 仓库叫 flutter_fluh它有专门适配 OpenHarmony 平台的分支。你需要把这个仓库 clone 下来切换到对应的版本分支然后用这个 SDK 来执行 flutter create。我用的是 flutter_fluh 的 OpenHarmony 3.x 版本分支命令大概是这样的git clone https://gitee.com/openharmony-sig/flutter_fluh.git git checkout 某个适配分支 export PATH$PWD/flutter_fluh/bin:$PATH flutter doctor这里提醒一下flutter doctor 输出里 OpenHarmony 相关的检查项可能不是绿色的因为它的检测逻辑主要面向 Android我们只要确认 flutter 命令能正常执行版本号对得上就可以继续。创建工程时有个小坑早期版本的 flutter_fluh 默认不会生成 ohos 平台目录需要手动执行flutter create --platformsohos .之类的命令或者从模板里复制。不同分支能力不一样我的做法是先建一个空工程跑通再往里面加代码。2.2 DevEco Studio 联动与签名配置Flutter 工程创建完之后真正的 OpenHarmony 侧工程结构是由 DevEco Studio 来处理的。这里要理解 Flutter 和 OpenHarmony 的分工Flutter 负责 Dart 侧的 UI 和业务逻辑DevEco Studio 负责编译 OpenHarmony 端的壳工程entry 模块我遇到的最影响效率的问题就是签名配置。OpenHarmony 应用在真机上运行必须配置调试签名否则安装后会闪退或者被拒。DevEco Studio 需要登录并自动生成签名同时还要保证签名文件对应的 bundle 名和 Flutter 工程里配置的包名一致。建议把包名在工程初期就定死。我在改过一次包名之后才体会到这个选择有多么重要包名不统一会导致 DevEco 侧签名的 bundleName 与 Flutter 侧生成的 profile 对不上每次构建都要手动去调整很消耗耐心。2.3 热重载与调试体验差异OpenHarmony 上的 Flutter 热重载体验比 Android 差一些这是客观事实。热重载只对 Dart 代码生效如果你修改了 ohos 目录下的原生代码必须重新构建。还有一个特点OpenHarmony 上热重载偶尔会失去响应尤其是改动了涉及平台通道的部分。我整理一个比较实用的操作习惯把构建分成两步先命令行编译再用 DevEco 连接设备调试。flutter build hap --debug这条命令会把整个 HAP 包构建出来然后通过 DevEco 或 hdc 安装到设备上。如果你在 DevEco 里直接点运行它会执行整链路编译首次构建大概需要几分钟后面增量会快很多。我的建议是日常写业务代码时依赖 flutter run 连模拟器即可确认逻辑没问题了再走 DevEco 打包。3. 数据层设计加油记录、存储选型与油耗计算逻辑3.1 表结构设计先定义一次加油记录油耗追踪器的核心数据是加油记录。一个字段都不能少我经过几版调整后最终的表结构是这样字段类型说明idTEXT主键使用 uuid 生成dateINTEGER加油时间戳毫秒级odometerREAL当前里程表读数公里litersREAL本次加油升数costREAL本次总花费元price_per_literREAL单价由 cost / liters 算出is_fullINTEGER是否加满0 或 1noteTEXT备注比如加油站名is_full 这个字段对油耗计算至关重要。只看单次记录无法判断这箱油到底跑了多少必须结合上次满箱记录来推算。所以我把是否加满单独拎出来而不是让用户每次手动算。3.2 本地存储sqflite 还是 drift在 OpenHarmony 上做本地存储比在 Android 上多了一层插件适配的顾虑。Android 生态里有 Room、GreenDAO 这些成熟方案但在 Flutter for OpenHarmony 的生态里我们只能从 Flutter 插件里找已经有 ohos 适配的。我对比了两个方案sqflite最成熟有 OpenHarmony 适配版本sqflite_ohos 或社区 forkAPI 简单SQL 字符串直接写适合到处查数据的场景。drift基于 SQLite 的 ORM类型安全代码生成但编译期依赖比较多在 OpenHarmony 上集成需要额外验证。最终选了 sqflite。原因很务实油耗查询的 SQL 没几条都是简单的聚合查询不需要 ORM 的复杂映射来提升开发效率。建表 SQL 是这样的CREATE TABLE fillups ( id TEXT PRIMARY KEY, date INTEGER NOT NULL, odometer REAL NOT NULL, liters REAL NOT NULL, cost REAL NOT NULL, price_per_liter REAL, is_full INTEGER DEFAULT 0, note TEXT );另一个存储点是偏好设置比如默认币种、油耗单位L/100km 还是 km/L这个用 shared_preferences 的 ohos 适配版就够了不需要建表。3.3 油耗计算的核心算法油耗计算是整个 App 的灵魂逻辑必须讲清楚。单次油耗的正确算法是用两次连续加满记录之间的里程差和加油量来计算。举个例子3月1日里程 12000加满 35L3月15日里程 12480加满 32L从 3月1日到 3月15日车跑了 480 公里消耗了整整一箱油后一次加满的量就是这段路程的消耗量所以油耗是油耗(L/100km) 32 / (12480 - 12000) * 100 6.67注意不能用第一次加油的升数也不能抛开两次记录的先后顺序只看单次数据。这里有一个新手很容易犯的错误把每次记录的 liters 字段直接当油耗计算分母。如果油箱没满liters 只代表本次加了多少不代表这段路耗了多少。概览页需要展示几个聚合数据平均油耗最近 N 次有效油耗的加权平均数权重就是里程差。总花费SUM(cost)这个简单但要注意按月份过滤时的时间戳边界。总里程MAX(odometer) - MIN(odometer)。续航估算油箱容量设定值除以最近一次油耗再乘 100。这个计算逻辑我在 Dart 侧封装了一个类每次查询数据库之后立刻生成统计结果不缓存因为数据量实在太小一次全表扫描也就是几百条记录性能完全不是瓶颈。4. 概览页拆解统计卡片的组件化实现4.1 页面骨架从打开即用到无感刷新概览页是这个 App 的门面设计原则就一条打开就要让用户看到最重要的信息不需要任何点击。页面骨架用的是一列垂直布局最上面是可滑动的统计卡片区接下来是油耗趋势图最下面是最近的加油记录列表。整体结构有点类似健康类 App 的首页——信息密度高但不让人觉得乱。根组件是 CustomScrollView配合几个 Sliver这样上下滑动时整个页面是一体的。我在页面创建时注册了数据库监听class OverviewPage extends StatefulWidget { override StateOverviewPage createState() _OverviewPageState(); } class _OverviewPageState extends StateOverviewPage { late Streamvoid _dataChangedStream; override void initState() { super.initState(); _dataChangedStream FillUpDatabase.instance.watchChanges(); } ... }数据库发生变化后通过 Stream 通知概览页刷新。这样即使你在别的 Tab 页面新增了一条加油记录回到概览页时数据会自动更新不用手动下拉刷新。4.2 统计卡片组一行四列还是两行两列统计卡片我想了很久最后选择了两行两列布局。一行四列在手机屏幕上太挤了数字稍微大一点就溢出。两行两列有足够的空间展示大数字和单位。四个卡片分别是最近油耗展示上一次有效计算的 L/100km 数值平均油耗一个阶段内的平均油耗总花费全部加油费用之和总里程所有记录的里程跨度每个卡片是一个 StatelessWidget接收数值和标题只负责显示。卡片的实现很直观class StatCard extends StatelessWidget { final String title; final double value; final String unit; final Color accentColor; final VoidCallback? onTap; override Widget build(BuildContext context) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), child: Container( padding: EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: ...), SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text(value.toStringAsFixed(1), style: ...), SizedBox(width: 4), Text(unit, style: ...), ], ), ], ), ), ), ); } }卡片用 Material InkWell 包了一层这样点击时有涟漪效果。虽然现在只是跳到详情页但后续如果加点击卡片查看该指标的详细趋势交互基础已经打好了。4.3 数字动效用 AnimatedSwitcher 做数据切换概览页最影响观感的一个细节是数据刷新时数字的切换。如果不加动画刷新时数字会直接跳到新值显得生硬。我用了 Flutter 自带的 AnimatedSwitcher在数值变化时做上下淡入淡出的效果每次切换耗时 300 毫秒左右不会让人觉得拖沓。AnimatedSwitcher( duration: Duration(milliseconds: 300), transitionBuilder: (child, animation) { return FadeTransition( opacity: animation, child: SlideTransition( position: TweenOffset( begin: Offset(0, 0.2), end: Offset.zero, ).animate(animation), child: child, ), ); }, child: Text( value.toStringAsFixed(1), key: ValueKey(value.toStringAsFixed(1)), style: ..., ), )这里有个关键细节AnimatedSwitcher 是通过 child 的 key 来判断值是否变化的所以必须给 Text 一个和数值强相关的 Key否则相同的数字不会触发动画。我用的是格式化后的字符串作为 ValueKey比如 6.7。如果两次刷新后的数值恰好一样不触发动画这个行为是合理的。另外一个容易忽略的问题文本宽度变化会导致布局抖动。数字从 6.7 变成 68.3 时文本宽度变大了卡片里的 Row 会重新布局。解决办法是给数字部分加Fixed宽度约束或者使用 tabular figures等宽数字字体。我用了后者在 TextStyle 里设置fontFeatures: [FontFeature.tabularFigures()]这个对数字类 UI 特别实用。5. 油耗趋势图用 CustomPainter 手写折线图5.1 图表插件选型老实的逻辑概览页最核心的可视化部分是油耗趋势图。一开始我当然想到了 fl_chart它是 Flutter 生态里最常用的图表库。但在 OpenHarmony 上我犹豫了。fl_chart 本身是纯 Dart 实现理论上不依赖平台能力应该能跑。但问题在于它依赖的字体度量、文本缩放等能力在某些 OpenHarmony 版本的 Flutter 引擎上表现不一致而且图表库的事件处理缩放、拖拽在触摸屏驱动的适配上有时候会出现手势响应延迟。我最后决定自己写一个折线图。理由有三油耗数据只有几十个点不需要性能优化折线图加渐变填充的绘制逻辑不复杂100 行左右的 CustomPainter 就能搞定自己绘制的可定制性强后续配色、标签、图例想改就改这个选择可能不符合快速开发的思路但从技术验证的角度看在一个不算成熟的平台上减少第三方依赖永远是对的。5.2 折线图绘制流程拆解绘制流程分成三步坐标映射、折线路径、渐变填充。坐标映射的关键是 y 轴范围。不能直接取数据的最小值和最大值因为如果有一箱油特别费油比如 12L/100km而大部分数据都在 6-8 之间整个曲线就会变得很平看不出变化趋势。我给 y 轴加了 20% 的上下留白final minY data.reduce(min) * 0.8; final maxY data.reduce(max) * 1.2;x 轴是等距的因为加油记录不一定每天都有按照时间戳算间距会让点分布不均匀用索引号等距分布更美观。绘制折线的核心代码class FuelTrendPainter extends CustomPainter { final Listdouble values; final Color lineColor; final Color fillColor; override void paint(Canvas canvas, Size size) { final paint Paint() ..style PaintingStyle.stroke ..strokeWidth 2.5 ..strokeCap StrokeCap.round ..color lineColor; final path Path(); final n values.length; final dx size.width / (n - 1); for (var i 0; i n; i) { final x i * dx; final y normalizeY(values[i], size.height); if (i 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, paint); // 填充 final fillPath Path.from(path) ..lineTo(size.width, size.height) ..lineTo(0, size.height) ..close(); canvas.drawPath(fillPath, Paint()..style PaintingStyle.fill..color fillColor); } }有个小细节容易踩坑当n 1时dx会变成无穷大size.width / (n - 1)直接除零崩溃。我在实际开发中先做了数据过滤只有两条及以上的记录才显示趋势图否则显示一个记录太少继续加油吧的占位图。5.3 双维度对应油耗还是费用曲线趋势图只有一个曲线不太过瘾我把它做成了双数据维度默认显示油耗曲线右上角有一个小的切换按钮可以切换成费用曲线。费用曲线同样用 CustomPainter只是把数据源从每百公里油耗换成了每次加油的总费用。这样用户既能看出油耗稳定性也能看出消费趋势。切换时我用了一个 TweenAnimationBuilder让两条曲线之间有平滑的过渡效果TweenAnimationBuilderdouble( tween: Tween(begin: 0, end: 1), duration: Duration(milliseconds: 400), builder: (context, t, child) { return CustomPaint( painter: FuelTrendPainter( values: isFuelMode ? fuelValues : costValues, progress: t, ... ), ); }, )这个过渡不是自动的要配合一个动画控制器在isFuelMode变化时重置 Tween视觉上就是从一条曲线渐变到另一条曲线。6. EventChannel 与原生能力打通油量、电量与通知6.1 为什么要用 EventChannelFillUp 虽然核心业务是记录但我希望概览页还能展示一些来自系统侧的实时数据比如当前设备的油量信息。如果有蓝牙 OBD 设备连接原生的 CAN 总线数据就可以通过这个通道传给 Flutter。这就涉及 Flutter 平台通道里的 EventChannel。它和 MethodChannel 的区别在于MethodChannel 是一次性请求-响应模式EventChannel 是持续的事件流原生侧主动往 Flutter 推数据在 OpenHarmony 上EventChannel 的 Flutter 侧 API 和标准 Flutter 完全一致差异在原生侧实现。Flutter 侧代码static const EventChannel _fuelLevelChannel EventChannel(fillup/telemetry/fuel_level); Streamdouble get fuelLevelStream { return _fuelLevelChannel.receiveBroadcastStream().map((event) { return (event as num).toDouble(); }); }6.2 ohos 侧原生实现OpenHarmony 侧的实现基于 DevEco Studio 的 ArkTS 代码。需要在 entry 模块的 MainAbility 里注册 EventChannel并在模块侧通过EventChannel的setStreamListener提供数据源。核心逻辑是这么几步初始化 EventChannel名称和 Flutter 侧保持一致通过setStreamListener监听消费者是否订阅订阅后通过后台任务周期性从系统接口读取油量数据调用eventSink?.success(data)推送let eventChannel new rpc.EventChannel(fillup/telemetry/fuel_level); eventChannel.setStreamListener({ onEvent: (eventSink) { this.sink eventSink; this.startTelemetry(); }, onCancel: () { this.stopTelemetry(); } });这里面有一个特别需要注意的问题EventChannel 的推送频率不能太高。如果每 100 毫秒推一次Flutter 侧会因为事件积压出现 UI 卡顿尤其是在 ListView 滑动时。我最后把推送频率限制在 1 秒一次并且只在车牌识别到车辆启动后才开始推送避免电量消耗。6.3 PlatformView 场景预留虽然 FillUp 现在没有内嵌原生地图但我在工程规划里预留了 PlatformView 的集成位置。如果后续版本要显示常用加油站在哪里的导航入口可能要用原生地图组件。在 OpenHarmony 的 Flutter 适配中PlatformView 是一个比较特殊的组件Flutter 侧用UiAwareView或PlatformViewLink来承载原生视图终端侧需要实现PlatformView接口然后返回原生组件。这个流程和 Android 的 PlatformView 类似但 API 有差异。我在这块的策略是先把接口定义好不去实现等真需要的时候再填坑。7. 踩坑实录TabBar 动画、组件通信与页面状态保持7.1 IndexedStack 保命切换 Tab 不丢状态FillUp 的主页面结构是底部三个 Tab概览、记录、设置。一开始我用的是NavigationBar加IndexedStack的方式。IndexedStack的好处是三个页面的状态同时保留切换时不会重新创建。这个对概览页特别重要因为概览页包含一个滚动位置和一个已绘制好的趋势图如果每次切换 Tab 都销毁重建用户会明显感觉到卡顿和数据重置。后来我测试了另一种方案用PageView加AutomaticKeepAliveClientMixin。这个方案的问题是页面不在当前屏时仍然会被系统回收只是通过 KeepAlive 机制保留状态行为在 OpenHarmony 上表现得不太稳定。最终我还是回到IndexedStack。为什么 IndexedStack 在 OpenHarmony 上表现更好因为它把三个子页面一次性全部加入树中只是通过 opacity 控制显隐不涉及页面生命周期调度。看起来三个页面同时存在会浪费内存但对 FillUp 这种轻量页面来说总页面数量固定且不多这点开销完全值得。7.2 TabBar 点击取消动画底部 Tab 切换时Flutter 默认的 NavigationBar 会有一个水波纹加指示器滑动的动画大概 300 毫秒。在 OpenHarmony 上这个动画有时会导致快速连续点击时页面响应不同步尤其是点击 Tab 后马上又想操作概览页的手势动画还没结束手势就被吃掉了。解决办法有几种用NavigationBar的labelBehavior调整显示方式但动画没法直接取消换成自绘底部导航栏用GestureDetector手动控制页面切换保持NavigationBar不变在快速点击时忽略多余的事件我最后选择了自绘底部导航栏因为这样可以直接控制点击行为不加动画。自定义导航栏的核心逻辑是Row( children: List.generate(_tabs.length, (index) { return Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { setState(() { _currentIndex index; }); }, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(_tabs[index].icon), Text(_tabs[index].label), ], ), ), ); }), )没有InkWell的涟漪没有淡入淡出页面切换就是 instant 的。从交互角度说这种硬切换更适合工具类 App用户不会在意滑动指示器有多丝滑只在意响应快不快。7.3 组件通信InheritedWidget 到 Stream填写加油记录的页面在第二个 Tab概览在第一个 Tab两个页面之间怎么通信这个问题虽然不大但也能看出一个人的工程习惯。我的方案是组合拳数据库变化通过StreamController.broadcast()广播概览页监听后刷新设置页修改了单位L/100km 还是 km/L通过共享的 AppState 通知概览页重新格式化数字组件级的局部状态比如某个卡片是否展开用ValueNotifier没必要全局管理最不推荐的做法是每个小状态都走全局状态管理框架。FillUp 这种项目规模Riverpod 或 Bloc 的重度使用只会增加代码复杂度不会带来实质收益。我在记录页和概览页之间共享的数据只有两类加油记录列表和设置项用一个ChangeNotifier就能覆盖。热词里有人提到 cubit我理解对于一些大型项目Bloc/Cubit 是有它的优势的尤其是状态流转复杂、需要严格分离业务逻辑和 UI 的时候。但如果你只是为了在三个 Tab 页面之间传一个油耗数值引入 cubit 属于用大炮打蚊子。8. 性能调优Impeller 渲染与页面切换体验8.1 Impeller 在 OpenHarmony 上的表现Flutter 3.x 引入了 Impeller 渲染引擎在 iOS 上默认开启在 Android 和 OpenHarmony 上还在逐步推进。flutter_fluh 的某个版本分支开始支持 Impeller 的编译开关。在 OpenHarmony 上Impeller 的收益和 Android 类似减少首帧包体积提升渲染一致性。不过我在实测中发现FillUp 这个项目的页面复杂度并不高Impeller 和 Skia 的表现差异基本感知不到。真正影响体验的是列表页滚动时的像素重绘不是渲染引擎。如果你的应用有大量阴影、圆角、渐变那 Impeller 在 OpenHarmony 上应该能发挥优势因为它的渲染策略对 GPU 更友好。但如果只是简单的卡片布局不需要为 Impeller 纠结用默认引擎就够了。8.2 滑动性能从卡顿到流畅的关键优化概览页有一个叠加在 CustomScrollView 上的趋势图滑动时 CustomPainter 会反复触发重绘最开始这个区域在滚动时明显掉帧。排查下来发现原因不是绘制本身而是火焰图里显示的文本布局耗时。趋势图下面的标签日期、油耗数值每帧都在重新布局而文本布局在 Flutter 里是比较重的操作。优化手段是把静态标签用RepaintBoundary包起来让 Flutter 知道这部分不随滚动变化趋势图区域的 CustomPaint 设置isComplex为 true提示引擎缓存绘制结果给数值文本用TextPainter提前缓存一次布局结果而不是每帧重新 layout经过这三项优化概览页在 OpenHarmony 模拟器和真机上都能稳定跑到 60fps。RepaintBoundary( child: CustomPaint( painter: FuelTrendPainter(...), isComplex: true, willChange: false, ), )willChange这个参数很多人会忽略。如果一帧内绘制结果稳定不变设成 false 可以启用光栅化缓存。但要注意如果你的图表会动态更新比如动画必须设成 true否则会出现显示不更新的问题。8.3 后续扩展方向FillUp 的概览实现到这一步已经覆盖了核心需求。后续可以扩展的方向我简单列一下月度汇总对比把按月的油耗和费用做成柱状图判断季节对油耗的影响CSV 导出把数据库内容导出成表格文件用于备份或进一步分析故障码读取通过蓝牙 OBD 模块读取车辆故障码在概览页显示整车健康状况多车管理为家里另一辆车建立独立的数据空间表结构里增加 vehicle_id 字段如果真要支持多车数据模型调整要趁早否则后续迁移数据很麻烦。这是我在 FillUp 开发到一半时想到的幸好当时表结构里预留了足够弹性加一个外键字段就能支持。写到这里FillUp 的概览实现也告一段落了。作为验证 Flutter 在 OpenHarmony 上开发体验的项目它的价值已经达到了。一个工具类应用从数据模型、存储、UI 到平台通道全链路跑通开发过程中踩过的坑也都找到了对应的解法。接下来我打算把这次的经验整理成一份适配清单方便社区里的其他开发者少走弯路。
网站建设高端定制企业官网