最近在整理加油小票的时候我突然意识到一个问题我根本不知道自己这台车的真实油耗是多少。仪表盘上那个瞬时油耗和表显平均油耗短期看看还行时间一长就会发现它跟实际开销对不上——特别是冬天冷启动、夏天开空调、周末跑高速这几类场景混在一起之后表显数据根本解释不了油钱的去向。于是我想自己做一个小 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 范围、以及系统分享/文件等原生能力是否已有现成插件。这三件事确认了后面就能把精力集中在业务本身。做油耗趋势这类功能时别一上来就铺大而全的图表方案先把数据口径和字段准确性搞定图画得好不好看是锦上添花数据计算错了才是灾难。我一版一版迭代下来最满意的反而不是配色和动画而是那个一秒钟就能算清楚这箱油到底跑出多少油的核心流程。