最近用 Flutter for OpenHarmony 做了一款油耗追踪器 App,名字叫 FillUp,核心功能就三件事:快速记录每次加油数据、自动算百公里油耗、一键导出 CSV 文件。项目做完以后回头梳理,发现这套组合踩的坑远比想象中多,尤其是 OpenHarmony 工程接入、真机联调和 CSV 中文乱码这几个环节,每一处都足够单独写一篇复盘。这篇文章就把完整过程拆开讲,从技术选型到落地细节都有,适合正在试 Flutter for OpenHarmony 开发、或者想给自己的小工具 App 加导出功能的开发者参考。
1. 项目背景与技术选型思路
1.1 加油记录这个需求,到底在记录什么
油耗追踪器这类工具看起来简单,实际一细想就知道坑不少。车主真正需要的是每次加油时记下三个核心数值:当前里程表读数、加油升数、加油金额,然后由 App 算出两次加油之间的百公里油耗。如果加油时顺手记了单价,还能顺便统计每公里通勤成本。这些数据最好是长期累积的,因为单次油耗受路况、空调、油价影响波动很大,只有跑够几千公里以后,平均油耗才有参考价值。
市面上的油耗记录 App 不是不好,而是数据封闭在厂商服务器里,想导出来做二次分析非常麻烦。有的甚至强制登录、天天推广告。自己做 FillUp 的理由就两条:数据完全在自己手里,导出 CSV 后能用 Excel、Python 等任何工具分析。另外一个私心是正好赶上 OpenHarmony 生态起来了,想试试 Flutter 在非 Android 系统上的跨端表现。
这个项目的目标用户画像也清晰:有一定动手能力、对数据有掌控欲的车主,以及正在研究 OpenHarmony 应用开发的开发者。对前者来说,FillUp 是一个完全本地化、可导出数据的记录工具;对后者来说,它是一个跨端移植的参考样例。
1.2 为什么选 Flutter for OpenHarmony 这套组合
先说结论:如果只给 OpenHarmony 设备做应用,用 ArkUI 当然最省事,但如果你手头已经有一个 Flutter 项目,或者想一套代码同时覆盖 Android、iOS、OpenHarmony,那 Flutter for OpenHarmony 就是当前最值得尝试的路线。
OpenHarmony 的 ArkUI 声明式开发风格其实学习成本不高,但它跟 Flutter 最大的区别在于生态。Flutter 拥有全球范围内庞大的第三方包生态,OpenHarmony 这块还在早期。而 flutter_for_openharmony 这个项目,本质上是把 Flutter 引擎移植到了 OpenHarmony 系统上,让 Dart 代码可以直接跑在鸿蒙内核之上,同时复用的是 Flutter 的渲染引擎和 Widget 体系,意味着你之前写的 Flutter UI 代码基本不用改。
我这次选型的核心判断有三点:
- 团队技术沉淀复用:如果团队之前用 Flutter 写过 App,迁移到 OpenHarmony 时不需要重新学一套 UI 框架,业务逻辑、状态管理、数据层都可以照搬。
- 生态依赖支持度:Flutter 常用的 json_annotation、path_provider、shared_preferences 等包大多有对应的 OpenHarmony 适配版本,社区也在持续跟进。相比原生鸿蒙生态,Flutter 侧的可选方案更多。
- 避免厂商锁定:ArkUI 的应用基本只能在 OpenHarmony 系设备上跑,而 Flutter 代码可以随时反向编译到 Android、iOS、Web 等多个平台,数据层和 UI 层的代码资产都不浪费。
当然也有代价。最大的代价是 flutter_for_openharmony 目前主要支持 OpenHarmony 标准系统设备(RK3568、RK3588 开发板,以及部分已适配的手机和平板),轻量系统设备基本不用考虑。另外,个别 Flutter 插件在 OpenHarmony 上的实现还不完整,需要自己补平台通道。这是生态早期不可回避的问题,后面会详细讲。
1.3 整体设计思路和页面规划
FillUp 的整体设计遵循“够用就好”的原则,没必要为了炫技堆页面。最终确定的信息架构是三个页面:
- 记录列表页:按时间倒序展示所有加油记录,直接显示本次油耗和花费,列表顶部展示累计里程、平均油耗、总花费三个统计卡片。
- 添加/编辑页:表单形式,必填项为当前里程、加油升数、加油金额,选填项为日期和备注。里程和金额输入框在失去焦点后自动计算油耗。
- 导出页:实际上是一个半屏底部弹窗,展示可导出的数据范围(全部记录/近一年)、文件编码选项,点击导出后显示结果路径。
状态管理方面,这种小工具类的 App 完全不需要引入 Bloc 或 Riverpod 这种重量级方案,ChangeNotifier 加 setState 就够用了。我倾向于把数据层单独拆出来,用 Repository 模式封装本地数据库操作,这样以后想加云同步、定时提醒,都不需要动 UI 层代码。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境搭建与 OpenHarmony 工程接入
2.1 环境组件清单与版本匹配
环境配置这一步最容易翻车,因为 flutter_for_openharmony 的版本分支和 OpenHarmony SDK 版本是强绑定的,用错版本会有各种奇怪报错。我这里列出的是实测可行的组合:
| 组件 | 版本/说明 |
|---|---|
| OpenHarmony SDK | API 10 或更高版本,推荐 API 11 |
| DevEco Studio | 4.1 及以上版本,用于编译 HAP 包 |
| Flutter SDK | flutter_for_openharmony 分支,代码仓库地址以官方发布为准 |
| Dart SDK | 随 Flutter SDK 内置,无需单独安装 |
| 鸿蒙开发板 | RK3568 / RK3588 开发板,或已适配的手机平板 |
| hdc 工具 | DevEco Studio 自带,用于连接和部署 |
有一点要特别提醒:flutter_for_openharmony 的 Flutter 版本不是跟着谷歌官方走的,它维护在 OpenHarmony 组织自己的仓库里。也就是说,你本地的 Flutter SDK 不应该是 flutter 官方网站下载的稳定版,而应该是这个分支的源码。这是个很容易踩的坑,我一开始就在这卡了半个多小时,导入项目后一直提示 Flutter SDK 版本不兼容。
环境变量配置不算复杂,但需要区分两个 SDK。官方 Flutter SDK 的 flutter 命令路径和 OpenHarmony 的 hdc、ohpm 命令路径都要加到 PATH 里。建议单独写一个 ohos.sh 环境变量脚本,避免和 Android 开发环境互相干扰。
2.2 创建 Flutter 项目并接入 OpenHarmony 工程
创建项目和标准 Flutter 项目几乎一样,只是接入目标不同。基本流程是这样:
bash复制# 1. 克隆 flutter_for_openharmony SDK 到本地目录
git clone https://gitee.com/openharmony-sig/flutter_flutter.git
# 切到对应的 release 分支,比如 OpenHarmony-4.1-Release
# 2. 配置 PATH,让 flutter 命令指向这个 SDK
export PATH=$PATH:$HOME/ohos/flutter_flutter/bin
# 3. 创建项目,并指定 org 名称
flutter create --org com.example --project-name fillup fillup
创建完以后查看项目结构,会多出一个 ohos 目录,这跟 Android 工程的 android 目录、iOS 工程的 ios 目录是同一个层级。这个目录内部是一个完整的 DevEco Studio 工程,里面有 entry 子目录存放应用入口代码,以及 oh-package.json5 文件管理鸿蒙侧依赖。
要让 Flutter 工程能打出 HAP 包,需要在 ohos 目录下执行一次依赖安装:
bash复制cd ohos
ohpm install
cd ..
然后打开 DevEco Studio,选择“打开已有工程”,定位到 ohos 目录,等它同步完成。通过 DevEco Studio 可以直接配置签名、生成 HAP 包、安装到开发板,不需要在 Flutter 命令行侧做太多额外操作。
这个阶段我要提醒一个细节: ohos 目录下的 entry/src/main/module.json5 里,需要显式声明你用到的基础权限。如果 App 只做本地数据存储,一般不需要额外权限,但如果你想把 CSV 导出到公共下载目录,就需要申请 ohos.permission.WRITE_MEDIA 之类的权限。这个后面再展开。
2.3 真机运行与调试准备(RK3568、RK3588、UDID)
OpenHarmony 开发最常见的运行环境就是 RK3568 和 RK3588 开发板。先把开发板用 USB 连接电脑,然后打开 DevEco Studio 的 Terminal,输入 hdc list targets,如果能看到设备编号,说明连接成功。
这里会遇到一个和 Android 很不一样的坑。Android 用 adb devices 看的是序列号,但 OpenHarmony 的 hdc 工具会同时显示 devudid 和 serial 两个标识。很多教程会让你在命令行注册设备,但实际操作中,只要开发板和电脑建立 USB 连接,并且开发板已经解锁(至少是 user 版本系统),DevEco Studio 一般能自动识别底层的 serial。
如果 hdc list targets 看不到设备,先按这个顺序排查:
- 确认开发板 USB 口能供电——很多 RK 系列开发板有两个 USB 口,一个是 OTG,一个是 HOST,必须接 OTG 口。
- 在 DevEco Studio 里检查 hdc 路径是否配置正确,不同版本 DevEco Studio 自带的 hdc 路径不同。
- 有的开发板需要先打开开发者模式,这个选项藏在“设置 - 关于本机”里,连点版本号多次就能打开。
连接没问题以后,DevEco Studio 的“Run - Run 'Entry'”就能直接把 HAP 包安装到开发板上。首次运行会久一些,因为它要同时编译 Flutter 引擎和 OpenHarmony 原生层,后面增量编译会快很多。
3. 油耗追踪核心功能实现
3.1 油耗计算模型与数据结构设计
油耗计算这件事看着简单,公式其实有两个流派。第一种是“加油量法”:每次加油都是加满的话,本段油耗等于本次加油量除以两次加油之间行驶的里程差。第二种是“金额法”:只记录金额和油价,反推加油量。FillUp 默认采用第一种,因为“加满”这个动作本身就是天然的计量基准,误差最小。
百公里油耗的计算公式是:
text复制百公里油耗(L/100km) = 本次加油量(L) / (本次里程 - 上次里程) * 100
这要求第一条记录只是初始化里程,不计算油耗。比如你第一次记录时里程是 10000 km,加油量 30 L,因为上次里程不存在,这一条只当作里程基准。第二次记录里程 10500 km,加油量 35 L,那么油耗就是 35 除以 500 再乘 100,也就是百公里 7.0 L。
数据表设计上,我没有用复杂的多表结构,一张加油记录表就够了:
sql复制CREATE TABLE fillups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
odometer INTEGER NOT NULL,
fuel_liters REAL NOT NULL,
total_cost REAL NOT NULL,
price_per_liter REAL,
note TEXT,
created_at INTEGER DEFAULT 0
);
odometer 用整数存储,单位是公里,避免小数误差。fuel_liters 和 total_cost 用 REAL,保留两位小数。price_per_liter 是选填的,因为有的加油记录只记得总金额,不一定每笔都有单价。created_at 存毫秒时间戳,用于排序和导出。
Dart 侧对应的模型类也不复杂,核心就是 copyWith 和 toMap / fromMap 这几个方法。考虑到后续可能加导出功能,模型层只依赖 Dart 标准库,不引入 json_serializable,也能减少 OpenHarmony 侧的编译兼容性问题。
3.2 表单页与交互细节
添加记录的页面是全 App 交互最重的部分。我把它设计成了全屏页面而非弹窗,原因很简单:填完五个输入项的时间可能超过 30 秒,全屏页面在键盘弹出、焦点切换时体验比弹窗稳定得多。
表单布局选了四个输入项,顺序是日期、当前里程、加油量、加油金额。日期默认取当天,用户点开日期选择器修改;里程是数字键盘;加油量和金额都保留两位小数。备注放最后,方便别人借用车辆时记录“谁加的油”或者“是否开空调”等信息。
代码实现上有个小技巧值得分享。用 TextEditingController 控制输入框的值,并在 onChanged 里实时计算油耗预览,展示在页面底部。每当里程、加油量或金额有变化时,只要同时满足“里程有值”和“加油量有值”,就立即显示本次油耗。这样做的好处是用户不用等全部填完,就能直观看到当前操作对应的油耗结果,减少填错的概率。
另一个细节是 TextField 的文本输入类型和格式化。里程输入应该用 TextInputType.number,加油量和金额应该用 TextInputType.numberWithOptions(decimal: true),同时在 inputFormatters 里限制只能输入数字和一个小数点。这种拦截必须在输入层做掉,别等到提交时再做正则校验,体验完全不一样。
表单页面我推荐用 Form + TextFormField 的组合。Form 的 GlobalKey<FormState> 可以统一管理校验逻辑,每个 TextFormField 的 validator 返回错误文案,提交时调用 validate() 并高亮错误项。在 OpenHarmony 样式的 TextField 上,这部分的适配能力 Flutter 已经处理得还不错,颜色、边框、聚焦效果基本和 Android 表现一致。
3.3 本地存储方案选择与实现
本地存储我对比了三种方案,最终选了 sqflite。理由很直接:FillUp 数据结构虽然简单,但未来可能需要按月份、按车辆维度查询,SQL 的表达能力更强,也方便导出时做条件过滤。
- shared_preferences:适合存配置信息,比如用户设置的默认车牌、计量单位,但不适合存大量结构化记录。
- sqflite:SQLite 的 Flutter 封装,处理增删改查和聚合统计都方便。OpenHarmony 上需要用
sqflite_ohos这个适配版本。 - drift:虽然类型安全更强,但代码生成和 build_runner 的引入会让 OpenHarmony 的编译链条变复杂,初期不划算。
打开数据库的代码在不同平台上路径不同。Android 上默认路径在 getDatabasesPath(),OpenHarmony 上也做了对应适配。这里要注意,sqflite_ohos 插件的版本号跟官方 sqflite 版本是错开的,不能直接用 sqflite 包名,需要在 pubspec.yaml 里显式依赖 sqflite_ohos:
yaml复制dependencies:
flutter:
sdk: flutter
sqflite_ohos: ^2.2.0
数据的 CRUD 封装在 FillUpRepository 类里,对外暴露 insertFillUp、queryAll、queryByDateRange 等接口。统计卡片的数据用聚合 SQL 一次性查出,避免在 Flutter 侧做循环计算。比如累计里程就是 SELECT MAX(odometer)-MIN(odometer) FROM fillups,总花费就是 SELECT SUM(total_cost) FROM fillups,这些 SQL 跑在 SQLite 上比在 Dart 里遍历快得多,代码也更简洁。
4. 导出 CSV 功能实战
4.1 CSV 格式细节与 Excel 兼容问题
CSV 的全称是 Comma-Separated Values,本质就是一个纯文本文件,用逗号做列分隔符,用换行符做行分隔符。之所以在导出这一步有许多人翻车,是因为大家以为只要把数据拼成 a,b,c 这种字符串就可以了,实际上远远不够。
真正需要处理的坑有两个。第一个是转义规则。字段内容里如果包含逗号、换行符或双引号,必须用双引号把整个字段包起来,如果字段本身含有双引号,则需要把双引号替换成两个双引号。否则导出后用 Excel 打开,列会错位、数据会串行。第二个是编码问题。CSV 文件用 utf-8 编码写入后,在 Excel 里默认不识别,中文会直接显示成乱码。解决方法是在文件开头加一个 BOM 头(\uFEFF),Excel 检测到 BOM 后会正确识别为 UTF-8 编码。
我实测下来,最省心的做法是自己写一个 _csvEncode() 函数,因为引入第三方 csv 包在 OpenHarmony 上可能遇到原生依赖兼容性问题,而自己写只需要十几行 Dart 代码:
dart复制String _csvEncode(String field) {
if (field.contains(',') || field.contains('"') || field.contains('\n')) {
return '"' + field.replaceAll('"', '""') + '"';
}
return field;
}
String buildCsv(List<FillUp> records) {
final buffer = StringBuffer();
// BOM 头,让 Excel 正确识别 UTF-8
buffer.write('\uFEFF');
buffer.write('日期,里程(km),加油量(L),金额(元),单价(元/L),百公里油耗(L/100km),备注\n');
for (final record in records) {
buffer.write(_csvEncode(record.date));
buffer.write(',');
buffer.write(record.odometer.toString());
buffer.write(',');
buffer.write(record.fuelLiters.toStringAsFixed(2));
buffer.write(',');
buffer.write(record.totalCost.toStringAsFixed(2));
buffer.write(',');
buffer.write(record.pricePerLiter?.toStringAsFixed(2) ?? '');
buffer.write(',');
buffer.write(record.fuelEfficiency.toStringAsFixed(2));
buffer.write(',');
buffer.write(_csvEncode(record.note ?? ''));
buffer.write('\n');
}
return buffer.toString();
}
这里做了几件关键的事情:写 BOM 头、处理字段转义、数值用 toStringAsFixed(2) 统一保留两位小数、单价为空时留空而不是输出 null。每一处都是踩过坑以后补上的,比如不带固定小数的数字导出到 Excel 后,默认会显示成一长串,还得手动设单元格格式,体验很差。
4.2 导出选项与数据范围控制
导出功能不是简单地把全表数据倒出来就行,用户往往需要在多个维度上控制。FillUp 的导出弹窗提供三个选项:
- 范围:全部记录、最近一年、自定义日期区间。
- 内容:是否包含统计汇总行(在文件末尾追加平均油耗、总里程、总花费)。
- 文件名:默认按当前日期生成
fillup_2025-01-15.csv,用户可以改成任意名称。
数据范围用 Repository 层的条件查询实现,传入 startDate 和 endDate 即可。注意一个问题:日期字段在 SQLite 中存的是文本格式,比如 2025-01-15,字符串比较的排序规则刚好和日期顺序一致,所以直接用 WHERE date >= ? AND date <= ? 就能正确过滤,不需要额外转换成时间戳。
汇总行的设计是有意为之。很多车主把 CSV 导入到自己的 Excel 模板里做台账,如果文件末尾已经有一行汇总数据,导入后直接就能看结果,不用自己拉公式。我在汇总行里放的是:平均百公里油耗、总里程、总加油量、总花费、记录条数。
4.3 文件落盘与用户取文件的路径设计
CSV 生成以后,怎么让用户拿到这个文件,是 OpenHarmony 上最容易出问题的环节。Android 上你熟悉的那套 MediaStore、FileProvider 在 OpenHarmony 上完全不同。Flutter 侧最常用的 path_provider 插件在 OpenHarmony 上拿到了一个应用沙箱私有目录,文件写进去以后,用户是没法直接在系统的文件管理器里看到的。
我最初的方案是把 CSV 写到应用私有目录,再用系统分享能力发给微信或其他 App。这个方案能用,但有个缺陷:用户想存档到网盘或者拷贝到电脑时,多了一步转发操作。更好的方案是写到应用的公共文件目录,具体路径是 文件管理/内部存储/Download,用户打开系统文件管理器就能直接看到。
在 OpenHarmony 工程里写入公共目录需要声明媒体读取权限。在 module.json5 里增加:
json复制{
"requestPermissions": [
{
"name": "ohos.permission.READ_MEDIA",
"reason": "$string:media_permission_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
然后在 Dart 侧,我用的是自己封装的一个 Native 通道,通过 MethodChannel 调鸿蒙原生代码拿到可写入的公共目录路径。这段逻辑本身不复杂,核心代码如下:
dart复制static const platform = MethodChannel('fillup/core');
final String publicDir = await platform.invokeMethod('getPublicDownloadDir');
final String fullPath = '$publicDir/$fileName.csv';
鸿蒙原生侧用 AbilityContext 获取 Download 目录路径,返回给 Dart 层。拿到路径后,用 Dart 的 File 类写入内容即可。整个过程不需要任何第三方插件,稳定性最高。
5. 踩坑合集:编译、联调与 CSV 排错
5.1 Gradle Plugin 报错的排查过程
开发过程中最让人崩溃的报错是同步 OpenHarmony 工程时遇到 Flutter 插件加载失败。错误提示类似:
text复制You are applying Flutter's main Gradle plugin imperatively using the apply method.
或者:
text复制Error resolving plugin [id: 'dev.flutter.flutter-plugin-loader', version: '...']
这个报错本质上是 Gradle 配置语法问题。Flutter 官方从某个版本开始,要求插件必须用 plugins {} 语法声明,而不能再使用 apply plugin: 旧写法。flutter_for_openharmony 分支虽然整体基于 Flutter 引擎,但 ohos 目录里的 Gradle 模板可能还沿用旧结构,需要手动调整。
解决方案分三步:
- 打开
ohos/entry/build.gradle,找到所有apply plugin: 'dev.flutter.flutter-plugin-loader'或类似行。 - 将这段逻辑移到
settings.gradle的plugins {}块中,并把pluginManagement仓库指向 flutter_for_openharmony SDK 内置的插件仓库。 - 同步项目,确认
flutter-plugin-loader能正确解析到本地 Flutter SDK。
我在这一步花了不少时间,因为报错信息不会直接告诉你“仓库地址不对”或者“插件块写错了”,而只是笼统报一个“Error resolving plugin”。后来是逐个打印仓库地址、检查网络代理,才发现是仓库没有把本地 SDK 路径加进去。如果你也遇到类似问题,先别怀疑插件本身,优先检查 settings.gradle 里仓库有没有覆盖 Flutter SDK 目录下的 packages/flutter_tools/gradle。
5.2 OpenHarmony 真机连接与部署问题
真机联调阶段,我用的是一块 RK3568 开发板,系统是 OpenHarmony 4.1。这块开发板有个典型问题:系统默认不开启 USB 调试端口,每次重启后都必须手动打开开发者模式并授权电脑连接。
如果发现 DevEco Studio 安装了 HAP 包但应用打不开,大概率不是代码问题,而是签名没配好。OpenHarmony 应用默认要求有签名才能安装运行,DevEco Studio 的自动签名功能只支持已登录的设备。开发板第一次连接时,需要在 Project Structure 里配置签名信息,并把它注册为调试设备。
另外,如果你跟我一样用 OTG 线连接开发板,注意开发板端口的 USB 模式。RK3568 有的固件默认把 OTG 口当作 Host 而非 Device,需要在系统设置里把 OTG 模式切换为 Device,否则 hdc 永远发现不了设备。这个坑在刷过别的固件的板子上尤其常见。
5.3 CSV 导出的常见用户反馈与修复
导出功能上线后,内部测试收集到三类典型反馈,每一条都指向一个具体的实现缺陷:
- Excel 打开乱码:第一版 CSV 没有写入 BOM 头,Excel 按系统默认编码(GBK 或 ANSCII)解析,中文全部变成乱码。修复方式就是前面讲的,在文件开头写
\uFEFF。 - 长数字变成科学计数法:里程、金额这类大整数导出到 Excel 后,默认单元格格式会导致显示成
1.5E+4。这个不是 CSV 本身能解决的,是 Excel 的显示逻辑。做两个处理:导出的里程字段加一个 tab 前缀或声明为文本格式,或者更简单,在汇总表里让用户“以文本方式打开这一列”。实际项目中我选择了后者,因为 CSV 本身不支持强制单元格格式。 - 备注里的换行导致一行记录变成两行:虽然我实现了转义函数,但只考虑了英文逗号,没有考虑换行符。后来给备注字段的导出加上了双引号包裹,问题解决。更稳妥的做法是在写入前把备注里的换行替换成空格,避免下游工具解析差异。
每条反馈背后其实都是一个通用问题,不只是 FillUp 会遇到。只要你的 App 涉及导出数据给外部工具消费,这几条就值得做成一个导出清单,逐项确认。
写在最后
这次用 flutter_for_openharmony 做完 FillUp,最大的体会是技术选型这件事真的要看场景。单论开发效率,Flutter for OpenHarmony 目前还比不上 Flutter 官方在 Android 上的流畅度,插件生态也还在补课阶段,但它确实让我这套油耗记录工具真正跑进了 OpenHarmony 设备,而且代码几乎可以平移回其他平台。
如果让我给后来者提一个最现实的建议,那就是在做跨端项目之前,先把“数据如何导出、用户怎么拿到文件”这个链路想清楚。很多 App 上线以后被吐槽“数据进去出不来”,就是前期只设计了输入,没设计输出。FillUp 的 CSV 导出虽然是后加的,但它把整个产品的数据闭环补完整了,也让这个 App 从“自己写着玩”变成了“真的可以长期用下去”的工具。
最后再分享一个小技巧:如果你也打算给 Flutter App 加 CSV 导出,不要一上来就找第三方包,自己在 Dart 里写一个转义函数、加好 BOM 头、控制好字段顺序,比引包省心得多。数据文件这种东西,越少依赖黑盒,后面维护越轻松。
