HarmonyOS厨房助手实战第5篇:JSON持久化、Repository分层与数据兼容

摘要

本文继续完善 HarmonyOS 厨房助手项目,重点解决“数据怎样可靠地保存在本机”这一问题。文章从应用沙箱、JSON 文件存储、临时文件原子替换、Repository 分层、Service 缓存、Schema 版本和旧数据兼容几个方面,完整拆解一个离线优先 ArkTS 应用的数据层。

示例不是孤立的文件读写片段,而是一条可落地的数据链路:

ArkUI 页面
   ↓
业务 Service
   ↓
Repository
   ↓
JsonStore
   ↓
应用 filesDir

读完本文可以获得一套适用于食谱、计划、购物清单、库存和收藏等多类业务数据的本地持久化方案。

一、为什么不让页面直接读写 JSON

小型应用很容易从下面的写法开始:

Button('保存')
  .onClick(async () => {
    await fs.writeText(path, JSON.stringify(this.form))
  })

它能运行,但随着功能增加,会迅速暴露问题:

  1. 页面同时承担交互、校验、业务规则和文件操作,职责过重。
  2. 多个页面可能使用不同文件名、不同 JSON 结构和不同异常处理。
  3. 每次进入页面都读磁盘,频繁操作会拖慢界面。
  4. 数据模型升级后,旧文件缺少新字段,页面会出现空值或崩溃。
  5. 写入中途失败可能留下不完整文件。
  6. 单元测试难以隔离 UI 和数据层。

厨房助手采用四层结构:

层级 主要职责 不应该处理
Page / Component 展示状态、接收输入、触发动作 文件路径和 JSON 格式
Service 校验、聚合、缓存、业务规则 ArkUI 组件布局
Repository 某一类模型的序列化和反序列化 页面提示
JsonStore 通用文件读写 具体 Recipe 字段

这样做的价值不是“代码看起来高级”,而是让变化停留在正确的边界内。

二、应用沙箱与 filesDir

HarmonyOS 应用可以通过 UIAbilityContext.filesDir 获得应用私有文件目录。厨房助手把业务 JSON 文件放在这个目录中:

import { common } from '@kit.AbilityKit';

export class JsonStore {
  private context: common.UIAbilityContext;

  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }

  private resolvePath(name: string): string {
    return `${this.context.filesDir}/${name}`;
  }
}

选择应用沙箱有三个直接好处:

  • 不需要让用户管理内部业务文件;
  • 其他普通应用不能随意读取;
  • 卸载应用时数据会按系统规则清理。

但沙箱路径不适合作为用户可见的备份位置。如果用户需要把数据保存到“文件管理”,应使用文件选择器让用户选择位置。内部持久化和用户导出是两条不同链路,后续备份文章会单独展开。

三、封装通用 JsonStore

JsonStore 只处理字符串,不理解 Recipe、Favorite 或 InventoryItem:

import fs from '@ohos.file.fs';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0xC1;
const TAG: string = 'JsonStore';

export class JsonStore {
  private context: common.UIAbilityContext;

  constructor(context: common.UIAbilityContext) {
    this.context = context;
  }

  private resolvePath(name: string): string {
    return `${this.context.filesDir}/${name}`;
  }

  async read(name: string): Promise<string | null> {
    const path: string = this.resolvePath(name);
    if (!fs.accessSync(path)) {
      return null;
    }
    try {
      return await fs.readText(path);
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'read failed: %{public}s', JSON.stringify(err));
      return null;
    }
  }
}

这里把“文件不存在”和“读取失败”都转换成 null,上层可以统一决定返回空数组还是展示错误。实际项目也可以进一步定义结果类型,把两种情况区分开:

export interface ReadResult {
  ok: boolean;
  exists: boolean;
  content: string;
  message: string;
}

如果应用对错误恢复要求更高,结构化结果比单纯返回 null 更合适。

四、为什么写临时文件再 rename

直接覆盖正式文件存在风险:应用被终止、磁盘空间不足或写入异常时,原文件可能已经被截断,新内容又没有完整落盘。

项目采用“临时文件 + 替换”的写入方式:

async write(name: string, content: string): Promise<boolean> {
  const path: string = this.resolvePath(name);
  const tmp: string = `${path}.tmp`;
  try {
    const file = await fs.open(
      tmp,
      fs.OpenMode.READ_WRITE |
      fs.OpenMode.CREATE |
      fs.OpenMode.TRUNC
    );
    await fs.write(file.fd, content);
    await fs.close(file.fd);

    if (fs.accessSync(path)) {
      await fs.unlink(path);
    }
    await fs.rename(tmp, path);
    return true;
  } catch (err) {
    hilog.error(DOMAIN, TAG, 'write failed: %{public}s', JSON.stringify(err));
    return false;
  }
}

写入流程如下:

序列化新数据
   ↓
写入 recipes.json.tmp
   ↓
关闭文件描述符
   ↓
移除旧 recipes.json
   ↓
临时文件重命名为正式文件

这个方案显著缩短正式文件处于不完整状态的时间。更严格的实现还可以保留 .bak 备份,并在启动时检测遗留的 .tmp 文件。

五、Repository 只负责一种模型

通用存储层完成后,食谱仓库负责 recipes.json 的结构:

interface RecipeFilePayload {
  schemaVersion: number;
  recipes: Recipe[];
}

export class RecipeRepository {
  private store: JsonStore;

  constructor(context: common.UIAbilityContext) {
    this.store = new JsonStore(context);
  }

  async saveAll(recipes: Recipe[]): Promise<boolean> {
    const payload: RecipeFilePayload = {
      schemaVersion: SchemaVersion.current,
      recipes: recipes
    };
    return await this.store.write(
      StorageFiles.recipes,
      JSON.stringify(payload)
    );
  }
}

不要把所有实体塞进一个巨大的 AppRepository。按模型拆仓库有几个优点:

  • 单个文件损坏不会阻塞所有模块;
  • 备份时可以独立统计各类数据;
  • Service 依赖更清晰;
  • 新增库存或收藏时不需要修改食谱仓库;
  • 测试样本更小。

厨房助手使用多个独立文件,例如:

recipes.json
meal-plans.json
shopping.json
inventory.json
favorites.json

六、读取时必须处理空文件和坏 JSON

可靠的读取流程不能假设文件一定正确:

async loadAll(): Promise<Recipe[]> {
  const raw: string | null = await this.store.read(StorageFiles.recipes);
  if (raw === null || raw.length === 0) {
    return [];
  }

  try {
    const payload: RecipeFilePayload =
      JSON.parse(raw) as RecipeFilePayload;
    if (!payload || !payload.recipes) {
      return [];
    }
    return payload.recipes.map((r: Recipe) => this.normalize(r));
  } catch (err) {
    hilog.error(
      DOMAIN,
      TAG,
      'parse failed: %{public}s',
      JSON.stringify(err)
    );
    return [];
  }
}

这里覆盖了四种常见情况:

  1. 首次安装,文件不存在;
  2. 文件存在但为空;
  3. JSON 语法损坏;
  4. JSON 可以解析,但缺少 recipes

页面最终得到稳定的 Recipe[],不必在每个列表组件里重复写空值判断。

七、normalize 解决旧数据兼容

模型升级最常见的场景是新增字段。早期食谱可能只有单张 cover,新版本增加了 coversnutrition。如果直接把旧 JSON 强转成新类型,TypeScript 类型并不能在运行时补出字段。

项目在 Repository 读取阶段做归一化:

private normalize(r: Recipe): Recipe {
  const nutrition: RecipeNutrition =
    r.nutrition === undefined || r.nutrition === null
      ? emptyNutrition()
      : {
          kcal: r.nutrition.kcal ?? 0,
          proteinG: r.nutrition.proteinG ?? 0,
          fatG: r.nutrition.fatG ?? 0,
          carbsG: r.nutrition.carbsG ?? 0
        };

  return {
    id: r.id,
    name: r.name,
    category: r.category,
    cover: r.cover ?? '',
    covers: r.covers ?? (r.cover ? [r.cover] : []),
    rating: r.rating,
    durationMin: r.durationMin,
    servings: r.servings,
    intro: r.intro,
    ingredients: r.ingredients ?? [],
    steps: r.steps ?? [],
    tip: r.tip,
    tags: r.tags ?? [],
    nutrition: nutrition,
    createdAt: r.createdAt,
    updatedAt: r.updatedAt,
    schemaVersion: r.schemaVersion ?? 1
  };
}

兼容逻辑放在 Repository,而不是散落在 UI 中。页面从拿到数据的那一刻起,就可以相信字段结构完整。

八、SchemaVersion 的作用

文件顶层和每条记录都可以保留版本号:

{
  "schemaVersion": 2,
  "recipes": [
    {
      "id": "recipe_001",
      "name": "番茄炒蛋",
      "schemaVersion": 2
    }
  ]
}

顶层版本适合判断整个文件格式,记录版本适合渐进迁移。常见迁移策略有三种:

策略 适用情况 特点
读取时补默认值 只新增可选字段 简单、风险低
读取后统一升级再保存 字段需要重命名或转换 下次读取更快
多段迁移函数 版本跨度较大 可追踪每次结构变化

例如:

function migrateRecipe(raw: LegacyRecipe): Recipe {
  let current = raw;
  if ((current.schemaVersion ?? 1) < 2) {
    current = migrateV1ToV2(current);
  }
  if ((current.schemaVersion ?? 2) < 3) {
    current = migrateV2ToV3(current);
  }
  return normalizeV3(current);
}

不要只在 TypeScript 接口里新增字段而忽略磁盘上的历史数据。

九、Service 缓存减少重复 I/O

Repository 负责磁盘,Service 可以维护内存缓存:

export class RecipeService {
  private cache: Recipe[] = [];
  private loaded: boolean = false;

  async list(): Promise<Recipe[]> {
    if (!this.loaded) {
      this.cache = await this.repo.loadAll();
      this.loaded = true;
    }
    return this.cache.slice();
  }

  invalidate(): void {
    this.loaded = false;
    this.cache = [];
  }
}

返回 slice() 而不是直接返回内部数组,可以减少页面意外修改缓存的风险。缓存需要明确失效时机:

  • 保存成功后更新缓存;
  • 删除成功后更新缓存;
  • 导入备份后 invalidate()
  • 调试或设置页手动清理缓存;
  • 外部能力修改数据后通知刷新。

缓存不是越多越好。数据量很小且访问频率低时,简单读取也可能足够;但一旦多个页面共享同一批数据,统一 Service 能避免状态不一致。

十、保存失败时不要更新内存状态

一个常见错误是先修改缓存,再写磁盘:

this.cache = next;
await this.repo.saveAll(next);

如果写入失败,当前界面显示新数据,重启后却消失。更稳妥的顺序是:

const ok: boolean = await this.repo.saveAll(next);
if (ok) {
  this.cache = next;
}

如果希望界面先响应再异步落盘,就需要引入乐观更新和回滚机制,而不是忽略失败。

十一、并发写入要注意什么

即使单机离线应用,也可能出现两个异步操作接近同时保存:

操作 A 读取旧数组
操作 B 读取旧数组
操作 A 追加一条并保存
操作 B 删除一条并保存
最终 A 的追加可能被 B 覆盖

小型项目可以在 Service 中串行化写操作:

private writeQueue: Promise<void> = Promise.resolve();

private enqueue(task: () => Promise<void>): Promise<void> {
  this.writeQueue = this.writeQueue.then(task, task);
  return this.writeQueue;
}

数据规模和并发继续增长时,应考虑关系型数据库、事务或统一状态容器。JSON 文件适合低频、小规模、结构相对简单的数据,不是所有场景的万能方案。

十二、可观测性与日志

数据层错误不应该只显示“保存失败”。日志至少要包含:

  • 模块标签;
  • 操作类型;
  • 文件名;
  • 异常摘要;
  • 数据条数,而不是完整隐私数据;
  • Schema 版本。

示例:

hilog.error(
  DOMAIN,
  TAG,
  'save recipes failed, count=%{public}d, schema=%{public}d',
  recipes.length,
  SchemaVersion.current
);

不要在生产日志中输出完整食谱、用户笔记或备份 JSON。

十三、测试清单

本地数据层至少覆盖以下测试:

  1. 首次启动没有文件时返回空数组;
  2. 保存一条数据后可以完整读取;
  3. 连续保存不会留下 .tmp
  4. 空字符串和坏 JSON 不导致页面崩溃;
  5. 旧数据缺少 nutrition 时补零;
  6. 旧数据只有 cover 时转换成 covers
  7. 保存失败时缓存保持旧值;
  8. 导入后缓存失效并重新读取;
  9. 多次快速操作不会丢失最后一次修改;
  10. Schema 高于当前版本时给出明确提示。

十四、适用边界

这套方案适合:

  • 纯本地工具应用;
  • 几百到几千条以内的小型数据;
  • 低频写入;
  • 需要可导出 JSON;
  • 模型之间查询关系不复杂。

以下情况更适合数据库:

  • 多条件排序和分页;
  • 高频增删改;
  • 多表关联;
  • 事务要求高;
  • 数据规模持续增长;
  • 需要精确并发控制。

技术选型的目标不是使用最重的工具,而是让复杂度和需求匹配。

十五、总结

厨房助手的数据层最终形成了清晰边界:

页面只关心状态
Service 处理业务与缓存
Repository 处理模型文件
JsonStore 处理通用 I/O
normalize 兼容旧数据
SchemaVersion 管理演进

这套结构让食谱、用餐计划、购物清单、库存和收藏可以共享可靠的底层能力,同时保持各自独立。下一篇将基于这套数据层,实现食材库存的过期状态、收藏切换与跨页面数据概览。

常见问题

1. JSON.stringify 是否需要格式化缩进?

应用内部文件通常不需要缩进,体积更小。用户导出的调试备份可以使用 JSON.stringify(payload, null, 2) 提高可读性。

2. 为什么文件不存在不算错误?

首次安装时不存在是正常状态。Repository 把它解释为空集合,比向页面抛异常更符合业务语义。

3. 为什么不用 Preferences 保存食谱?

Preferences 更适合少量设置项,例如开关和主题偏好。食谱是结构化列表,独立 JSON 文件更清晰,也方便备份和迁移。

4. normalize 会不会掩盖坏数据?

它只补可安全推断的默认值。主键、名称等核心字段仍应校验;无法修复的记录应跳过并记录日志。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐