在这里插入图片描述

到第 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 限制扩展名

导入的第一步是文件选择。
项目允许选择 csvjson

真实代码如下:

          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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐