flutter_for_openharmonyFillUp -油耗追踪器app实战+导入JSON实现
到第 16 篇为止,我们已经有了“备份/恢复”(数据库文件级别)。注意:这篇文章不讲“概念上的 JSON”。所有代码都来自。并且每段代码后面都紧跟解释。

到第 16 篇为止,我们已经有了“备份/恢复”(数据库文件级别)。
但工程上还需要另一条能力:
- 导入结构化数据(JSON/CSV),把外部数据写回当前车辆。
这一篇我们专讲:
- 如何导入 JSON
- JSON 结构如何兼容两种形式
- 如何做字段解析与数据校验
- 如何落库并刷新页面
注意:这篇文章不讲“概念上的 JSON”。
所有代码都来自 lib/app/fillup_app.dart。
并且每段代码后面都紧跟解释。
1. 为什么要做“导入 JSON”
导入 JSON 的动机通常比备份更现实:
- 你在电脑上做了数据清洗或迁移。
- 你从别的 App 导出了一份 JSON。
- 你希望把同一辆车的历史记录迁回到 FillUp。
相比“恢复数据库”,
导入 JSON 的特点是:
- 可以按车辆维度导入
- 数据可以跨版本做兼容
- 更便于调试与协作(文本可读)
所以项目里把它放在 Feature 页里,
和导出/分享并列。
2. Feature 分发:import_data 进入 _buildImport
导入功能属于 FeaturePage 的一个分支。
你在功能中心点“导入数据”,
最终会进入 _buildImport(context)。
真实代码如下(节选):
if (feature.id == 'export_share') ..._buildExport(context),
if (feature.id == 'import_data') ..._buildImport(context),
if (feature.id == 'backup_restore') ..._buildBackupRestore(context),
解释:
- feature.id 是字符串。
- import 的内容区块由
_buildImport()返回。
这也是这个项目“功能占位 + 部分功能先做实”的基础结构。
3. _buildImport:先拿到当前车辆
导入是“写入数据”的操作。
项目要求导入必须绑定到当前车辆:
- 没有车辆就不允许导入。
真实代码如下:
List<Widget> _buildImport(BuildContext context) {
final vehicles = Get.find<VehiclesController>();
final fillups = Get.find<FillUpsController>();
return <Widget>[
const _SectionHeader(title: '导入数据'),
SizedBox(height: 10.h),
FilledButton.tonalIcon(
onPressed: () async {
final v = vehicles.activeVehicle;
if (v == null) {
Get.snackbar('暂无车辆', '请先添加/选择车辆后导入', snackPosition: SnackPosition.BOTTOM);
return;
}
解释:
activeVehicle是一个强依赖。- 如果为 null,直接 snackbar 提示并 return。
这样做比“弹一个空导入页”更符合用户预期。
4. 选择文件:openFile 限制扩展名
导入的第一步是文件选择。
项目允许选择 csv 或 json。
真实代码如下:
final file = await openFile(
acceptedTypeGroups: const <XTypeGroup>[
XTypeGroup(label: 'Data', extensions: <String>['csv', 'json']),
],
);
if (file == null) return;
解释:
acceptedTypeGroups直接限制扩展名。- 用户取消选择,就直接 return。
这一步的价值是“降低错误输入”的概率。
否则你可能要处理各种奇怪的文件类型。
5. 读取文件并识别类型:path + lower.endsWith
文件选完以后,项目把文件内容读进来,然后用扩展名决定走哪条解析分支。
真实代码如下:
final path = file.path;
final text = await File(path).readAsString();
final lower = path.toLowerCase();
final entries = <FillUpEntry>[];
解释:
readAsString()让 JSON/CSV 都能走文本解析。lower用于做不区分大小写的扩展名判断。entries是解析输出,最终要写入数据库。
注意这里先建了一个空 list,
后面无论 JSON/CSV 分支,最终都要往里 add。
6. JSON 分支:兼容两种 JSON 结构
导入 JSON 最大的坑不是解析字段,
而是“外部数据结构长什么样”。
项目选择兼容两种结构:
- 对象包一层(包含
fillups) - 直接就是数组
真实代码如下(完整摘录):
if (lower.endsWith('.json')) {
final obj = jsonDecode(text);
dynamic list;
if (obj is Map && obj['fillups'] is List) {
list = obj['fillups'];
} else if (obj is List) {
list = obj;
}
if (list is List) {
for (final it in list) {
if (it is! Map) continue;
final map = Map<String, Object?>.from(it);
final dateMs = (map['date_ms'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
final odometer = ((map['odometer'] as num?) ?? 0).toDouble();
final liters = ((map['liters'] as num?) ?? 0).toDouble();
final price = ((map['price_per_liter'] as num?) ?? 0).toDouble();
final isFull = ((map['is_full'] as int?) ?? 1) == 1;
final station = (map['station'] as String?) ?? '';
final note = (map['note'] as String?) ?? '';
if (odometer <= 0 || liters <= 0 || price <= 0) continue;
entries.add(
FillUpEntry(
id: const Uuid().v4(),
vehicleId: v.id,
dateMs: dateMs,
odometer: odometer,
liters: liters,
pricePerLiter: price,
totalCost: liters * price,
isFullTank: isFull,
station: station,
note: note,
),
);
}
}
这一段就是“导入 JSON”的核心。
下面我分成 6 个点讲。
7. jsonDecode 的输出类型:不要假设它一定是 Map
final obj = jsonDecode(text);
解释:
jsonDecode的返回类型是 dynamic。- 它可能是
Map,也可能是List。
所以项目没有直接写:
final map = jsonDecode(text) as Map<String, dynamic>
而是用 is 判断。
这能避免导入外部 JSON 时,因为结构不同直接崩溃。
8. 结构兼容:Map[‘fillups’] 或者 List
dynamic list;
if (obj is Map && obj['fillups'] is List) {
list = obj['fillups'];
} else if (obj is List) {
list = obj;
}
解释:
- 如果是 Map 且存在
fillups列表,就导入fillups。 - 如果本身就是 List,就直接导入。
为什么需要兼容“对象包一层”?
- 因为项目的导出 JSON 就是这种结构:
vehicle + exported_at_ms + fillups。
所以导入必须对齐导出。
为什么还要兼容“纯数组”?
- 方便用户手工处理数据
- 方便从别的工具导出后直接导入
9. 逐项读取:Map<String, Object?>.from(it)
if (it is! Map) continue;
final map = Map<String, Object?>.from(it);
解释:
- 只接受 Map 类型的元素。
- 用
Map<String, Object?>.from做一次显式转换,
后面取字段就更稳定。
这种写法的好处是:
- 遇到脏数据(比如数组里混了字符串),直接跳过。
- 不会把整个导入流程搞崩。
10. 字段解析:int/num/String 的混合
导入 JSON 的字段解析,最容易踩坑的是类型。
项目在解析时做了三个策略:
- int 用
as int? - double 用
as num?再.toDouble() - String 用
as String?
真实代码对应这几行:
final dateMs = (map['date_ms'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
final odometer = ((map['odometer'] as num?) ?? 0).toDouble();
final liters = ((map['liters'] as num?) ?? 0).toDouble();
final price = ((map['price_per_liter'] as num?) ?? 0).toDouble();
final station = (map['station'] as String?) ?? '';
final note = (map['note'] as String?) ?? '';
解释:
- 用
num接住 JSON 里可能出现的 int/double。 .toDouble()统一成 double,后面计算更省心。- station/note 缺省就当空字符串。
11. is_full:用 int 表示 bool,缺省按 1 处理
final isFull = ((map['is_full'] as int?) ?? 1) == 1;
解释:
- 项目里
is_full用 1/0 存。 - 导入时缺省按 1 处理(也就是默认“加满”)。
这个取舍很重要:
- 如果旧数据没有
is_full字段,你不希望它导入后全部变成“未加满”。 - 否则统计会变得不可算。
12. 校验:odometer/liters/price 必须为正
if (odometer <= 0 || liters <= 0 || price <= 0) continue;
解释:
- 这是“最低限度校验”。
- 不合格的行直接跳过。
这样做的好处:
- 导入不会因为一条坏数据失败。
- 导入后统计也更可信。
你可以把它理解成“导入版的表单校验”。
13. 构造 FillUpEntry:ID 重新生成,vehicleId 强制绑定当前车
entries.add(
FillUpEntry(
id: const Uuid().v4(),
vehicleId: v.id,
dateMs: dateMs,
odometer: odometer,
liters: liters,
pricePerLiter: price,
totalCost: liters * price,
isFullTank: isFull,
station: station,
note: note,
),
);
解释几个关键取舍:
13.1 为什么 id 不复用导入文件里的 id
- 避免与当前数据库已有记录 ID 冲突。
- 避免“覆盖写入”的风险。
这里用 Uuid().v4() 直接生成新记录。
13.2 为什么 vehicleId 强制用 v.id
- 导入是“导入到当前车辆”。
- 不管外部文件里记录原本属于哪辆车,导入时都绑定当前车。
这样做的优点:
- 导入操作的结果非常明确。
13.3 totalCost 重新计算
totalCost: liters * price,
解释:
- 导入文件可能没有 total_cost。
- 即使有,也可能不可信。
- 用 liters * price 重新计算,数据一致性更好。
14. 统一出口:entries 为空就提示“导入失败”
无论 JSON 还是 CSV 分支,最终都会把解析结果放到 entries。
项目在解析完后统一做一次判断:
if (entries.isEmpty) {
Get.snackbar('导入失败', '未识别到可导入的数据', snackPosition: SnackPosition.BOTTOM);
return;
}
解释:
- 这是用户可理解的失败原因:没有任何有效数据。
- 也避免后续 upsert 一个空列表还显示“成功”的尴尬。
15. 落库:逐条 upsert,再 refreshAll
导入的本质是写数据库。
项目里采用“逐条 upsert”的方式:
for (final e in entries) {
await FillUpDb.instance.upsertFillUp(e);
}
await fillups.refreshAll(vehicleId: v.id);
Get.snackbar('导入成功', '已导入 ${entries.length} 条记录', snackPosition: SnackPosition.BOTTOM);
解释:
- 用
upsertFillUp是为了复用已有的“写入路径”。 refreshAll(vehicleId: v.id)让记录页立刻能看到新增数据。- 最后的 snackbar 给了“数量反馈”,用户能确认导入规模。
这里还有一个隐含的好处:
- 如果你未来要在 upsert 内增加字段迁移/清洗逻辑,导入也会自动享受。
小结
这一篇把“导入 JSON”落成了可交付能力:
- 入口在 feature 页(
import_data) - 文件选择限制为
csv/json - JSON 兼容两种结构(
{fillups: []}/[]),逐条解析并做最低限度校验 - 生成新 id 并强制绑定当前车辆
- 逐条 upsert 落库,刷新列表并反馈导入数量
下一篇继续完善“导出/分享”,让导入与导出形成闭环。
文章底部添加社区引导:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)