HarmonyOS厨房助手实战第5篇:JSON持久化、Repository分层与数据兼容
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))
})
它能运行,但随着功能增加,会迅速暴露问题:
- 页面同时承担交互、校验、业务规则和文件操作,职责过重。
- 多个页面可能使用不同文件名、不同 JSON 结构和不同异常处理。
- 每次进入页面都读磁盘,频繁操作会拖慢界面。
- 数据模型升级后,旧文件缺少新字段,页面会出现空值或崩溃。
- 写入中途失败可能留下不完整文件。
- 单元测试难以隔离 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 [];
}
}
这里覆盖了四种常见情况:
- 首次安装,文件不存在;
- 文件存在但为空;
- JSON 语法损坏;
- JSON 可以解析,但缺少
recipes。
页面最终得到稳定的 Recipe[],不必在每个列表组件里重复写空值判断。
七、normalize 解决旧数据兼容
模型升级最常见的场景是新增字段。早期食谱可能只有单张 cover,新版本增加了 covers 和 nutrition。如果直接把旧 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。
十三、测试清单
本地数据层至少覆盖以下测试:
- 首次启动没有文件时返回空数组;
- 保存一条数据后可以完整读取;
- 连续保存不会留下
.tmp; - 空字符串和坏 JSON 不导致页面崩溃;
- 旧数据缺少
nutrition时补零; - 旧数据只有
cover时转换成covers; - 保存失败时缓存保持旧值;
- 导入后缓存失效并重新读取;
- 多次快速操作不会丢失最后一次修改;
- 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 会不会掩盖坏数据?
它只补可安全推断的默认值。主键、名称等核心字段仍应校验;无法修复的记录应跳过并记录日志。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)