HarmonyOS厨房助手实战第5篇:JSON持久化、Repository分层与数据兼容
HarmonyOS厨房助手实战第5篇JSON持久化、Repository分层与数据兼容摘要本文继续完善 HarmonyOS 厨房助手项目重点解决“数据怎样可靠地保存在本机”这一问题。文章从应用沙箱、JSON 文件存储、临时文件原子替换、Repository 分层、Service 缓存、Schema 版本和旧数据兼容几个方面完整拆解一个离线优先 ArkTS 应用的数据层。示例不是孤立的文件读写片段而是一条可落地的数据链路ArkUI 页面 ↓ 业务 Service ↓ Repository ↓ JsonStore ↓ 应用 filesDir读完本文可以获得一套适用于食谱、计划、购物清单、库存和收藏等多类业务数据的本地持久化方案。一、为什么不让页面直接读写 JSON小型应用很容易从下面的写法开始Button(保存).onClick(async(){awaitfs.writeText(path,JSON.stringify(this.form))})它能运行但随着功能增加会迅速暴露问题页面同时承担交互、校验、业务规则和文件操作职责过重。多个页面可能使用不同文件名、不同 JSON 结构和不同异常处理。每次进入页面都读磁盘频繁操作会拖慢界面。数据模型升级后旧文件缺少新字段页面会出现空值或崩溃。写入中途失败可能留下不完整文件。单元测试难以隔离 UI 和数据层。厨房助手采用四层结构层级主要职责不应该处理Page / Component展示状态、接收输入、触发动作文件路径和 JSON 格式Service校验、聚合、缓存、业务规则ArkUI 组件布局Repository某一类模型的序列化和反序列化页面提示JsonStore通用文件读写具体 Recipe 字段这样做的价值不是“代码看起来高级”而是让变化停留在正确的边界内。二、应用沙箱与 filesDirHarmonyOS 应用可以通过UIAbilityContext.filesDir获得应用私有文件目录。厨房助手把业务 JSON 文件放在这个目录中import{common}fromkit.AbilityKit;exportclassJsonStore{privatecontext:common.UIAbilityContext;constructor(context:common.UIAbilityContext){this.contextcontext;}privateresolvePath(name:string):string{return${this.context.filesDir}/${name};}}选择应用沙箱有三个直接好处不需要让用户管理内部业务文件其他普通应用不能随意读取卸载应用时数据会按系统规则清理。但沙箱路径不适合作为用户可见的备份位置。如果用户需要把数据保存到“文件管理”应使用文件选择器让用户选择位置。内部持久化和用户导出是两条不同链路后续备份文章会单独展开。三、封装通用 JsonStoreJsonStore只处理字符串不理解 Recipe、Favorite 或 InventoryItemimportfsfromohos.file.fs;import{common}fromkit.AbilityKit;import{hilog}fromkit.PerformanceAnalysisKit;constDOMAIN:number0xC1;constTAG:stringJsonStore;exportclassJsonStore{privatecontext:common.UIAbilityContext;constructor(context:common.UIAbilityContext){this.contextcontext;}privateresolvePath(name:string):string{return${this.context.filesDir}/${name};}asyncread(name:string):Promisestring|null{constpath:stringthis.resolvePath(name);if(!fs.accessSync(path)){returnnull;}try{returnawaitfs.readText(path);}catch(err){hilog.error(DOMAIN,TAG,read failed: %{public}s,JSON.stringify(err));returnnull;}}}这里把“文件不存在”和“读取失败”都转换成null上层可以统一决定返回空数组还是展示错误。实际项目也可以进一步定义结果类型把两种情况区分开exportinterfaceReadResult{ok:boolean;exists:boolean;content:string;message:string;}如果应用对错误恢复要求更高结构化结果比单纯返回null更合适。四、为什么写临时文件再 rename直接覆盖正式文件存在风险应用被终止、磁盘空间不足或写入异常时原文件可能已经被截断新内容又没有完整落盘。项目采用“临时文件 替换”的写入方式asyncwrite(name:string,content:string):Promiseboolean{constpath:stringthis.resolvePath(name);consttmp:string${path}.tmp;try{constfileawaitfs.open(tmp,fs.OpenMode.READ_WRITE|fs.OpenMode.CREATE|fs.OpenMode.TRUNC);awaitfs.write(file.fd,content);awaitfs.close(file.fd);if(fs.accessSync(path)){awaitfs.unlink(path);}awaitfs.rename(tmp,path);returntrue;}catch(err){hilog.error(DOMAIN,TAG,write failed: %{public}s,JSON.stringify(err));returnfalse;}}写入流程如下序列化新数据 ↓ 写入 recipes.json.tmp ↓ 关闭文件描述符 ↓ 移除旧 recipes.json ↓ 临时文件重命名为正式文件这个方案显著缩短正式文件处于不完整状态的时间。更严格的实现还可以保留.bak备份并在启动时检测遗留的.tmp文件。五、Repository 只负责一种模型通用存储层完成后食谱仓库负责recipes.json的结构interfaceRecipeFilePayload{schemaVersion:number;recipes:Recipe[];}exportclassRecipeRepository{privatestore:JsonStore;constructor(context:common.UIAbilityContext){this.storenewJsonStore(context);}asyncsaveAll(recipes:Recipe[]):Promiseboolean{constpayload:RecipeFilePayload{schemaVersion:SchemaVersion.current,recipes:recipes};returnawaitthis.store.write(StorageFiles.recipes,JSON.stringify(payload));}}不要把所有实体塞进一个巨大的AppRepository。按模型拆仓库有几个优点单个文件损坏不会阻塞所有模块备份时可以独立统计各类数据Service 依赖更清晰新增库存或收藏时不需要修改食谱仓库测试样本更小。厨房助手使用多个独立文件例如recipes.json meal-plans.json shopping.json inventory.json favorites.json六、读取时必须处理空文件和坏 JSON可靠的读取流程不能假设文件一定正确asyncloadAll():PromiseRecipe[]{constraw:string|nullawaitthis.store.read(StorageFiles.recipes);if(rawnull||raw.length0){return[];}try{constpayload:RecipeFilePayloadJSON.parse(raw)asRecipeFilePayload;if(!payload||!payload.recipes){return[];}returnpayload.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 读取阶段做归一化privatenormalize(r:Recipe):Recipe{constnutrition:RecipeNutritionr.nutritionundefined||r.nutritionnull?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}]}顶层版本适合判断整个文件格式记录版本适合渐进迁移。常见迁移策略有三种策略适用情况特点读取时补默认值只新增可选字段简单、风险低读取后统一升级再保存字段需要重命名或转换下次读取更快多段迁移函数版本跨度较大可追踪每次结构变化例如functionmigrateRecipe(raw:LegacyRecipe):Recipe{letcurrentraw;if((current.schemaVersion??1)2){currentmigrateV1ToV2(current);}if((current.schemaVersion??2)3){currentmigrateV2ToV3(current);}returnnormalizeV3(current);}不要只在 TypeScript 接口里新增字段而忽略磁盘上的历史数据。九、Service 缓存减少重复 I/ORepository 负责磁盘Service 可以维护内存缓存exportclassRecipeService{privatecache:Recipe[][];privateloaded:booleanfalse;asynclist():PromiseRecipe[]{if(!this.loaded){this.cacheawaitthis.repo.loadAll();this.loadedtrue;}returnthis.cache.slice();}invalidate():void{this.loadedfalse;this.cache[];}}返回slice()而不是直接返回内部数组可以减少页面意外修改缓存的风险。缓存需要明确失效时机保存成功后更新缓存删除成功后更新缓存导入备份后invalidate()调试或设置页手动清理缓存外部能力修改数据后通知刷新。缓存不是越多越好。数据量很小且访问频率低时简单读取也可能足够但一旦多个页面共享同一批数据统一 Service 能避免状态不一致。十、保存失败时不要更新内存状态一个常见错误是先修改缓存再写磁盘this.cachenext;awaitthis.repo.saveAll(next);如果写入失败当前界面显示新数据重启后却消失。更稳妥的顺序是constok:booleanawaitthis.repo.saveAll(next);if(ok){this.cachenext;}如果希望界面先响应再异步落盘就需要引入乐观更新和回滚机制而不是忽略失败。十一、并发写入要注意什么即使单机离线应用也可能出现两个异步操作接近同时保存操作 A 读取旧数组 操作 B 读取旧数组 操作 A 追加一条并保存 操作 B 删除一条并保存 最终 A 的追加可能被 B 覆盖小型项目可以在 Service 中串行化写操作privatewriteQueue:PromisevoidPromise.resolve();privateenqueue(task:()Promisevoid):Promisevoid{this.writeQueuethis.writeQueue.then(task,task);returnthis.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 会不会掩盖坏数据它只补可安全推断的默认值。主键、名称等核心字段仍应校验无法修复的记录应跳过并记录日志。