最近在整理加油小票的时候我突然意识到一个问题:我根本不知道自己这台车的真实油耗是多少。仪表盘上那个瞬时油耗和表显平均油耗,短期看看还行,时间一长就会发现它跟实际开销对不上——特别是冬天冷启动、夏天开空调、周末跑高速这几类场景混在一起之后,表显数据根本解释不了油钱的去向。于是我想自己做一个小 App,名字就叫 FillUp,核心就一件事:每次加油时随手记一笔,攒够几条记录之后自动算出真实的百公里油耗,再用趋势图把油耗变化画出来,让你一眼看出车况和驾驶习惯的波动。
这个项目比较特殊的地方在于,我并没有继续做常规的 Android 版本,而是把目标平台放在了 OpenHarmony 设备上,用 Flutter 来做跨端 UI。一方面是想验证 Flutter 在 OpenHarmony 上的工程可行性,另一方面也是因为手头正好有这类设备,想看看把一套 Dart 代码跑在非传统移动平台上到底要踩多少坑。现在项目第一版已经能完整跑通:加油记录、油耗计算、历史流水、油耗趋势图都能用了。这篇就把我做这个油耗追踪器的全过程拆开讲清楚,从数据口径到图表实现、再到真机适配的各种细节,希望给也想在 OpenHarmony 上做 Flutter 应用的朋友一些参考。
项目的方向确认:油耗追踪器到底要解决什么
1.1 油耗统计数据怎么用才有价值
先说痛点。绝大多数车主并不是真的算不清油耗,而是没法长期坚持把数据记下来。加油这件事本身频率不高,一两周一次,每次不过两分钟,难的是坚持半年、一年之后还能把数据组织起来,并且看出趋势。我这里定的核心需求很简单:每次加油时登记里程、油量、金额,系统自动计算本次油耗和累计统计,再通过图表展示油耗随里程或时间的变化。
真正上手之后会发现,"算油耗"这三个字里藏着不少讲究。如果只是把每次加油量相加、总里程相减,再做一个除法,其实得到的是这一段时间的平均油耗,这个值能反映整体水平,却掩盖了单次行程的波动。而 FillUp 需要的是另一种能力:把每两笔加油记录之间那段里程的实际油耗拆出来,才能看到"这次跑高速很省油""上次在市区堵车油耗飙了"这些有价值的信息。所以我在需求阶段就把功能边界切成两块:第一块叫登记与流水,解决数据从哪里来的问题;第二块叫计算与趋势,解决数据怎么呈现、怎么解读的问题。
1.2 为什么把宝押在 Flutter 和 OpenHarmony 上
选 Flutter 倒不是我有多偏爱它,而是这类工具型 App 的特点决定的。油耗追踪器的界面并不复杂,无非是列表、表单、数字输入、图表,但交互状态多,比如计算中途切后台、长按编辑记录、图表点击显示数值,这些用原生去写要分别维护好几套代码。Flutter 的自绘 UI 有一个天然优势:同一套界面代码在不同分辨率、不同平台的渲染结果是稳定的,不用太担心某个控件在 OpenHarmony 上被系统风格带偏。
至于 OpenHarmony,我更看中的是它的开源属性。相比封闭生态,直接在设备上跑一个开源系统、用跨端框架把应用打进去,这种组合非常适合验证一套代码通吃多个平台的思路。实际开发的时候也确实如此:绝大多数 Dart 层代码不需要改,只有极少数与系统能力(比如硬件信息、文件导出)相关的功能需要走平台通道。
1.3 第一版的范围收敛
做第一版的时候,我给自己定的原则是"记录优先、图表其次、统计最后"。原因很现实:一个没有数据的统计页是最没意义的,所以先把录入流程做顺,再把数据给出来。所以首版只做了四件事:车辆信息管理、加油记录登记、历史流水列表、油耗趋势图。至于油耗排行榜、保养提醒、费用月报这些,先统统不做,否则项目周期会拖得很长。
先把油耗算法定死:满箱法与其他口径的取舍
2.1 满箱法为什么是唯一靠谱的口径
油耗计算有好几种口径,比如"单次加油量除以单次行驶里程""表显油耗读取""电脑诊断口数据",但落到一个轻量 App 里,最靠谱的其实是满箱法。满箱法的逻辑是这样的:假设你加油时把油箱加到跳枪(每次加到基本相同的油位),那么两次加油之间的油量消耗,就等于第二次加进去的油量。反过来看,第一次加满到第二次加满之间行驶的里程,就是这些油实际跑的里程。
公式很简单:
本次阶段油耗(L/100km)= 本次加油量(L)÷(本次里程 - 上次里程)× 100
举个例子:上次加油里程是 12000 公里,加到跳枪;这次加油里程是 12480 公里,又加到跳枪,加了 38 升油。那这 480 公里的实际油耗就是 38 ÷ 480 × 100 ≈ 7.92 L/100km。这个方法不需要借助任何 OBD 设备,不需要读 ECU 数据,只依赖一个前提:两次都加到油箱口同一位置。所以 App 里必须在每次记录时让用户标记"是否加满",这是整个计算逻辑的基石。
2.2 为什么不能直接用单次加油量算单次油耗
很多人第一次做油耗功能时会犯一个错误:拿每一次的加油量直接除以从上次加油到现在跑的里程。这在绝大多数情况下是不成立的,因为这次加进去的油并不是全部消耗在这次里程里的。比如你这次加了 40 升,但油箱里还剩 15 升,那这 40 升里只有一部分属于这段里程消耗。除非油箱完全见底再加油,否则单次加油量不能单独作为计算依据。
满箱法巧妙地绕开了"油箱剩余量"这个问题。因为两次都是加满,油箱起始油位和结束油位完全相同,差值相互抵消,你只需要知道加进去了多少升,以及这段里程跑了多远。这也是为什么我在数据表里专门设计了 is_full 这个布尔字段,只有两次连续满油记录之间才能形成一个有效的油耗点,其他记录只作为金额流水保存,不参与计算。
2.3 数据表设计:车辆表和加油记录表
为了支持一台设备管理多辆车,我设计了两个核心表:车辆表和加油记录表。车辆表很简单,存车辆名称、车牌、燃料类型、初始里程;加油记录表则存每次加油的日期、里程表读数、加油量、金额、是否加满、备注。这里有个容易被忽略的细节:必须把"车牌/车辆名称"和"加油记录"做关联,否则多车数据会混在一起,趋势图就没法看了。SQLite 建表语句大概是这样的:
CREATE TABLE vehicles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, plate TEXT, fuel_type TEXT DEFAULT '汽油', initial_mileage REAL DEFAULT 0, created_at INTEGER ); CREATE TABLE refuel_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, vehicle_id INTEGER NOT NULL, refuel_date INTEGER NOT NULL, odometer REAL NOT NULL, volume REAL NOT NULL, price_per_liter REAL, total_cost REAL, is_full INTEGER DEFAULT 0, note TEXT, created_at INTEGER );2.4 Dart 模型与计算函数
Dart 侧我用一个 RefuelRecord 类来映射记录,核心计算函数放在独立的 service 文件里。这里有几个细节值得说一下:里程和油量必须用 double 而不是 int,因为加油量经常有小数点;日期用 int 存毫秒时间戳而不是字符串,方便排序;计算阶段油耗时,一定要先按里程排序再取上一条记录,因为用户录入顺序不一定和实际里程顺序一致。
double? calcStageConsumption(RefuelRecord current, RefuelRecord previous) { if (!current.isFull || !previous.isFull) return null; final distance = current.odometer - previous.odometer; if (distance <= 0) return null; return current.volume * 100 / distance; }这个函数很短,但它是整个趋势图的数据源头。如果两次记录没有连续加满,就返回 null,界面上的趋势点就跳过这一笔,这是符合实际情况的。
工程准备:在 OpenHarmony 上把 Flutter 环境跑起来
3.1 依赖工具链清单
OpenHarmony 上跑 Flutter 并不像 Android 那样开箱即用,需要先确认几个组件。我本地环境里装的是 DevEco Studio 作为 OpenHarmony 的 IDE,配套安装对应的 SDK;然后拉取社区维护的 Flutter for OpenHarmony 适配版本,把它配到 PATH 里;再准备好 JDK、git 和 ohpm 包管理器。这一堆东西缺一个就可能在编译阶段报奇怪的错。
给第一次接触的朋友一个忠告:不要试图一次性把所有最新版本都装上。OpenHarmony 的 SDK 版本、Flutter 适配版本、DevEco Studio 版本这三者之间有对应关系,版本不匹配经常会出现 API 缺失或者编译不过去。我建议先装好 IDE,再按 IDE 提示安装 SDK,最后拉 Flutter 适配分支,并且严格看它的 README 里写了支持哪个 SDK 版本。
3.2 初始化项目并接入 OpenHarmony 平台
项目创建走的是标准 Flutter 流程,先建一个 Flutter 工程,然后在工程里通过适配版 Flutter 的命令追加 OpenHarmony 平台支持。这里不同适配版本的命令差异很大,以你手里那份 README 为准。我当时操作完,工程目录里就多出了 ohos 子目录,里面是 OpenHarmony 工程骨架,包括 entry 模块、hvigor 配置文件、module.json5 这些。换句话说,Flutter 工程和 OpenHarmony 工程是共存的:Dart 代码在 lib 目录,OpenHarmony 原生代码在 ohos 目录,两边通过一套桥接机制通信。
构建阶段有个经验:第一次编译会非常久,因为要把 Flutter engine 产物和 OpenHarmony 平台代码都打进去。我的旧电脑上跑了将近十分钟,还一度让我以为卡死了。后来发现只要 CPU 在转、日志在推进,就耐心等。建议在工程里打开详细的构建日志,至少能看到走到哪个 task 了。
3.3 目录结构与平台通道约定
Flutter 和 OpenHarmony 两边交互的约定,本质上和 Android 平台通道差不多。Dart 侧通过 MethodChannel 发消息,OpenHarmony 侧的 entry 模块里注册对应的 Handler。我在做文件导出功能时才用到这个,其他纯 UI 功能其实根本不需要碰原生代码。对我这种以 Dart 为主、尽量不写原生的人来说,这一点非常友好:90% 的业务逻辑都停留在 Flutter 生态内。
这里补充一个细节:OpenHarmony 模拟器的表现和真机差异不小,尤其是涉及到触摸事件和字体渲染的时候。建议从第一天开始就坚持真机调试,别在模拟器上把功能验证完就收工,很多适配问题模拟器上是测不出来的。
核心页面与本地存储:把加油记录这件事做顺
4.1 加油登记页的交互设计
加油场景通常是这样的:你站在加油机旁边,一手拿手机一手拿油枪,可能还夹着付款小票。这时候你需要的不是一个复杂的表单单页,而是一个手指够得着、字段少、回车键明确的登记界面。所以我把加油登记页做成了上下排列:里程表读数、加油量、单价/总金额、是否加满,最下面一个大大的保存按钮。里程和加油量用 TextField 配合数字键盘,金额可以由油量和单价自动算出来,用户可以手动改。
有一个体验细节很多人会忽略:自动带上上次的里程。用户每次加油的里程数大概率比上次多,但不会离谱到重新输入。我在页面初始化时直接查当前车辆的最后一条记录的里程,把它预填进去,用户只需要改数字,省掉大部分打字动作。这一套做下来,整个登记过程大概十秒就能完成,比掏出记账本手写还快,用户才愿意坚持用。
4.2 记录列表与编辑删除
历史流水列表用 ListView.builder,每一行显示日期、里程、油量、金额和阶段油耗。阶段油耗只在连续满油记录存在时才显示,否则留空并显示一个“-”,避免让用户误以为单次记录也有油耗。列表默认按里程倒序,最新的记录在最上面,这是基于一个很朴素的判断:用户通常想找的是最近一次加的是什么价、加了多少升。
编辑和删除我用了长按弹底部操作菜单的方式。删除一条记录之后有个连锁问题:如果删除的是中间一笔,它前后两条记录之间的油耗关系会被打断。我的处理是删除后重新扫描该车辆的所有记录,重建计算缓存。这个逻辑我放在 Repository 层,UI 只负责调用 post-process 方法,不需要关心计算细节。
4.3 本地存储选型:sqflite 还是别的
数据存储我在 sqflite 和 shared_preferences 之间纠结了一下。shared_preferences 存少量配置没问题,但加油记录这种可以累积到几百上千条的数据,用键值对去存既不便于排序,也不便于更新,最后还是选了 sqflite。在 OpenHarmony 适配环境里,sqflite 底层的文件路径映射需要确认一下。实际用下来,在 OpenHarmony 上默认的数据库目录能够正常读写,不需要做额外迁移。
数据库初始化我用了一个比较传统的方式:在 Flutter 启动后异步打开数据库,执行建表语句,然后做一次版本迁移检查。第一版可以不搞复杂的迁移框架,直接用 SQLite 的 user_version 字段做判断就行。这样后续加字段、建新表都有台阶可下。
final db = await openDatabase( join(await getDatabasesPath(), 'fillup.db'), version: 1, onCreate: (db, version) async { await db.execute(CREATE_TABLE_VEHICLES); await db.execute(CREATE_TABLE_REFUEL_RECORDS); }, );油耗趋势图实现:图表不是简单画点连线
5.1 先定统计口径,再选图表方案
很多人一说到趋势图就急着找图表库,其实真正的难点不在画图,而在数据口径。油耗趋势图的横轴是纪录序号或日期,纵轴是每段里程的百公里油耗。关键就在于:纵轴上的每个点必须对应一段"完整里程",而不是单次加油记录。所以我在展示趋势之前,先做了一个预处理:把某辆车的所有满油记录按里程排序,然后两两计算一次阶段油耗,生成一个专门用于绘图的数据列表。如果两个满油记录之间隔着未加满记录,中间那段未加满记录并不会影响这个油耗点的值,因为满箱法只依赖两头。
5.2 用 fl_chart 画油耗折线图的要点
图表库我第一版选了 fl_chart,原因是它纯 Dart 实现,不依赖系统 WebView,在 OpenHarmony 上的渲染行为和 Android 一致的概率高。折线图配置的核心是 LineChartData 里传入 spots 列表,再配合 FlDotData、LineChartBarData、TitlesData 做样式调整。
实现中有两个坑值得写下来。第一是横轴标题,fl_chart 默认的横轴文字用的是 Text 组件,中文字体适配一般没什么问题,但如果间隔过密会挤成一团,需要动态设置 interval,比如记录少于 10 条时每小时刻度都显示,超过 20 条时只显示第 1、10、20 这种关键点。第二是纵轴单位,坐标轴最大值不要写死,要根据数据动态算出上限并加一点余量,否则某一箱油油耗特别高时会顶破图例。
5.3 用 CustomPaint 做一个油耗摘要卡
除了完整趋势页,我在首页还放了一个迷你趋势卡:最近十次油耗点的小折线,加上当前平均油耗数字。迷你图不需要引入 fl_chart,直接用 CustomPaint 画就行,二十行代码搞定。这里我学到的经验是:图表这种东西,库能帮你解决 80% 的通用问题,但一个产品里最能出彩的往往是那 20% 的定制元素。CustomPaint 画折线其实就是把数据点映射为画布坐标,再用 Path 连起来,唯一要留意的是边界留白和点与点之间的插值方式——数据点之间默认用直线连,如果想更平滑可以用三次贝塞尔做小幅弯曲,但注意油耗曲线本质上是真实数据,过度平滑会让人误读趋势。
5.4 异常数据怎么筛选与展示
做趋势图时有一个绕不开的现实问题:数据里存在异常值。比如油箱没加满但用户手滑点了是,或者两次记录的里程差只有二十公里(比如短途加油),这种情况下算出来的油耗会高得离谱。我的做法分三步:第一,计算阶段油耗时直接过滤掉里程差小于 30 公里的记录,这类数据没有统计意义;第二,绘图前做简单截断,超过平均油耗三倍以上的点不直接丢弃,而是压到最大值附近,并保留一个明显的峰值形状;第三,在图表下方加一条参考线表示所有点的平均值,让用户一眼看出哪些记录在平均线之上,哪些在之下。这种"过滤但不过度清理"的思路,比直接删点更能保护数据的真实性。
真机调试与适配问题实录
6.1 首帧白屏和字体渲染
真机跑起来之后第一个问题是首帧白屏。Flutter 引擎在 OpenHarmony 上初始化需要一点时间,白屏过程大概一两秒,虽然不算严重,但体验很廉价。解决方法是监听首帧回调,在首帧渲染前显示一个带 Logo 的纯色启动界面,等首帧完成再切到主界面。字体方面,默认字体在部分 OpenHarmony 设备上中文渲染偏细,容易发虚。最后我在构建配置里把字体资源打进去,强制使用加载的字体文件,同时在正文风格里设置了文字高度参数,保证临界大小时不出现截断。
6.2 列表滚动帧率与性能
装到中低端设备之后,历史记录列表滚动时偶有掉帧。定位下来发现主要原因是列表里每一行都做了多次数据库查询,导致构建 item 时同步卡顿。优化方式是先把车辆的所有记录一次性查出来,在内存里按日期和里程排序,列表直接从内存取;同时在列表 item 构建时尽量避免重复创建大的 TextStyle 和 BoxDecoration,能复用的对象提到外面。改完之后帧率基本稳定,这个优化对纯 Flutter 开发也是通用经验。
6.3 图表点击区域错位
fl_chart 的触摸事件在 OpenHarmony 真机上出现过点击坐标偏移,具体表现是手指点到某个 spot 附近,tooltip 显示的是另一条记录的值。排查后确认是图表组件外层容器在安全区和系统导航栏叠加后,触摸坐标和绘制坐标偏移了一个固定的距离。解决办法有两个:一是把图表放进没有额外 padding 的容器里,二是在自定义触摸回调里减去容器偏移量。我最后选了前者,因为能从根本上避免不同设备上安全区高度不一致的问题。
6.4 数据备份:给用户留一条后路
轻量级工具类 App 最怕数据没了,所以我在设置页加了一个导出功能:把加油记录导出成 CSV 文件。这个功能需要访问 OpenHarmony 的文件系统,于是用到了平台通道。Dart 侧把记录列表转成 CSV 字符串,通过 MethodChannel 传给原生侧,原生侧把文件写到应用的文档目录里,然后通过系统的分享能力把文件分享出去。这个功能虽然看着不起眼,但对一个记录型工具来说属于"不做就等于白做"的功能,因为没有人愿意在换手机时手动抄几百条加油记录。
6.5 关于适配的几条总结
整个项目从搭环境到第一版跑通,前后花了两周左右,其中约三分之一的时间是在跟 OpenHarmony 的构建和真机适配周旋。我的体会是,Flutter 本身的开销和复杂度并不高,真正的成本在于跨端平台的能力差异。如果你准备做类似的事,建议提前确认三件事:目标设备的 OpenHarmony 版本、Flutter 适配版本支持的 API 范围、以及系统分享/文件等原生能力是否已有现成插件。这三件事确认了,后面就能把精力集中在业务本身。做油耗趋势这类功能时,别一上来就铺大而全的图表方案,先把数据口径和字段准确性搞定,图画得好不好看是锦上添花,数据计算错了才是灾难。我一版一版迭代下来,最满意的反而不是配色和动画,而是那个一秒钟就能算清楚"这箱油到底跑出多少油"的核心流程。