HarmonyOS今天吃什么——数据库层的持久化设计与CRUD实现 📅 2026/7/17 12:18:53 美食转盘的数据层藏了一个有意思的设计决策——用文件存储食物数据用 Preferences 存主题设置。同样是持久化为什么不能统一用一种方案因为数据量不同食物数组可能有几十上百条记录每次转盘都要全量读取和筛选主题设置只有一个字符串。文件存储适合大块数据Preferences 适合小配置。这个区分决定了整个数据层的架构。完整效果一、为什么用文件而不是 Preferences两种持久化方案的对比特性Preferences文件 (fileIo)数据量单键 ≤ 1MB无限制数据结构字符串/数字/布尔任意 JSON读写方式key-value全量读/全量写适合场景配置项主题、开关列表数据食物、历史Preferences 是钥匙柜——每把钥匙对应一个小抽屉。文件是档案袋——一个袋子装一整份数据。食物数据需要按分类筛选、按收藏过滤、统计 pickCount——这些操作必须拿到完整数组才能做。用 Preferences 存 50 个食物需要 50 次 get 调用用文件只需要 1 次 read。这个 App 的数据分布数据存储方案原因食物列表foods.json 文件大数组需要筛选历史记录history.json 文件大数组需要排序主题设置Preferences单值频繁读取两种方案各管各的领地——不混用不替代。二、数据库类的初始化两个文件路径constructor(ctx:common.UIAbilityContext){this.fpctx.filesDir/foods.json;this.hpctx.filesDir/history.json;}构造函数接收 UIAbilityContext——不是页面级的 Context。这是 Stage 模型的要求fileIo 操作需要应用级 Context 的 filesDir 路径。页面级 Context 的 filesDir 可能不同。init 方法的五步加载asyncinit():Promisevoid{try{constfdthis.read(this.fp);this.foodsfd?JSON.parse(fd)asFoodItem[]:getDefaultFoods();if(!fd)this.saveFoods();consthdthis.read(this.hp);this.historyhd?JSON.parse(hd)asPickRecord[]:[];}catch(e){this.foodsgetDefaultFoods();this.history[];}}五步操作步骤操作目的1read(foods.json)读取食物数据2判断文件是否存在有→解析无→用默认数据3首次写入默认数据给 foods.json 创建初始内容4read(history.json)读取历史数据5catch 兜底文件损坏时恢复默认“有则解析无则创建”——初始化逻辑兼顾首次运行和后续加载。catch 块防止 JSON.parse 报错导致整个数据库崩溃。getDefaultFoods() 的作用this.foodsfd?JSON.parse(fd)asFoodItem[]:getDefaultFoods();首次运行时 foods.json 不存在read 返回空字符串fd 为 falsy——用 getDefaultFoods() 填充。然后立刻 saveFoods() 把默认数据写入文件——下次启动就能读到了。三、文件读写的底层实现read 方法privateread(f:string):string{if(!fileIo.accessSync(f))return;constsfileIo.statSync(f);constfdfileIo.openSync(f,fileIo.OpenMode.READ_ONLY);constabnewArrayBuffer(s.size);fileIo.readSync(fd.fd,ab);fileIo.closeSync(fd);returnbuffer.from(ab).toString();}四步操作步骤API作用1accessSync检查文件是否存在2statSync获取文件大小3openSync readSync打开文件读取全部内容到 ArrayBuffer4buffer.from(ab).toString()把 ArrayBuffer 转成字符串为什么用 ArrayBuffer 而不是直接读字符串fileIo 的 readSync 接收 ArrayBuffer 参数——这是 HarmonyOS 文件 API 的设计。读完后用 buffer 模块转换成字符串。write 方法privatewrite(f:string,d:string):void{constbbuffer.from(d).buffer;constfdfileIo.openSync(f,fileIo.OpenMode.CREATE|fileIo.OpenMode.WRITE_ONLY);fileIo.writeSync(fd.fd,b);fileIo.closeSync(fd);}CREATE | WRITE_ONLY 模式——文件不存在就创建存在就覆盖。每次写入都是全量替换——整个 foods.json 被新数据覆盖。这比增量写入简单但数据量大时可能有性能问题。对于几十条食物记录来说全量写入完全够用。读写对称性read: accessSync → statSync → openSync(READ_ONLY) → readSync → closeSync write: buffer.from → openSync(CREATE|WRITE_ONLY) → writeSync → closeSyncread 和 write 是对称的——一个读 ArrayBuffer 转字符串一个字符串转 ArrayBuffer 写入。这是文件 I/O 的标准模式。四、CRUD 操作的实现Create——addFoodasyncaddFood(f:FoodItem):Promisevoid{this.foods.push(f);awaitthis.saveFoods();}push 直接修改内存数组然后异步写入文件。两步操作不是原子的——如果 push 后、saveFoods 前崩溃内存数据和文件数据不一致。但对于本地 App 来说这个窗口极小可以接受。Read——getFoodsgetFoods(cat?:string):FoodItem[]{if(!cat||catall)returnthis.foods.slice();returnthis.foods.filter((f:FoodItem)f.categorycat);}slice() 返回浅拷贝——防止外部修改影响内部数据。filter() 返回新数组——不影响原数组。两个方法都保证了数据的不可变性。Update——toggleFavasynctoggleFav(id:string):Promisevoid{constfthis.foods.find((x:FoodItem)x.idid);if(f){f.isFavorite!f.isFavorite;awaitthis.saveFoods();}}find 找到对象引用直接修改属性。对象是引用类型——修改 f.isFavorite 等同于修改 this.foods[i].isFavorite。Delete——deleteFoodasyncdeleteFood(id:string):Promisevoid{constithis.foods.findIndex((f:FoodItem)f.idid);if(i0){this.foods.splice(i,1);awaitthis.saveFoods();}}findIndex 找到索引splice 删除元素。splice 会修改原数组——这是预期行为。findIndex 比 find 多返回索引用于 splice。五、转盘抽签的 pick 方法这是整个数据库最复杂的方法asyncpick(cat?:string):PromiseFoodItem|null{constpoolthis.getFoods(cat);if(pool.length0)returnnull;constppool[Math.floor(Math.random()*pool.length)];p.pickCount;p.lastPickedDate.now();awaitthis.saveFoods();this.history.push({id:h_Date.now(),foodName:p.name,emoji:p.emoji,category:p.category,pickedAt:Date.now()}asPickRecord);awaitthis.saveHistory();returnp;}四步操作的顺序步骤操作原因1getFoods(cat)按分类筛选候选池2随机选一个Math.random()3更新 pickCount lastPicked统计数据4写入历史记录完整记录两次 save 的必要性awaitthis.saveFoods();// 保存 pickCount 变化awaitthis.saveHistory();// 保存新历史记录pickCount 是食物的属性存在 foods.json 里。历史记录存在 history.json 里。两个文件各自独立需要分别保存。history 记录的字段{id:h_Date.now(),// 唯一 ID时间戳生成foodName:p.name,// 食物名称冗余存储emoji:p.emoji,// 表情冗余存储category:p.category,// 分类冗余存储pickedAt:Date.now()// 被选中时间}history 里冗余存了 foodName 和 emoji——不存 foodId 引用。这是刻意的设计如果食物被删除历史记录仍然保留名字和表情。用引用的话删除食物后历史记录就断链了。六、历史记录的查询与清理getHistory 方法getHistory():PickRecord[]{returnthis.history.slice().sort((a:PickRecord,b:PickRecord)b.pickedAt-a.pickedAt);}slice() 返回副本sort() 排序——不修改原始数组。降序排列b - a——最新的在前面。每次调用都重新排序——保证数据一致性。clearHistory 方法asyncclearHistory():Promisevoid{this.history[];awaitthis.saveHistory();}直接清空数组然后写入文件。没有确认弹窗——历史记录不包含关键数据食物本身不受影响清空后可以重新积累。resetFoods 方法asyncresetFoods():Promisevoid{this.foodsgetDefaultFoods();awaitthis.saveFoods();}恢复出厂设置——用默认数据替换当前数据。用户自己添加的食物全部丢失但历史记录保留——resetFoods 不调用 clearHistory。七、两种存储方案的统一文件存储的部分方法文件操作saveFoods()foods.json全量写入saveHistory()history.json全量写入getFoods()内存读取slice/filtergetHistory()内存读取slice/sortPreferences 存储的部分方法键操作读主题‘theme’preferences.get写主题‘theme’preferences.put flush食物和历史用文件主题用 Preferences——各取所长。文件存大数组Preferences 存小配置。两种方案在 Theme.ets 和 Index.ets 里分别调用互不干扰。八、性能考量全量读写的代价数据量文件大小读取耗时写入耗时10 条食物~2KB1ms1ms50 条食物~8KB1ms1ms100 条食物~15KB2ms2ms500 条历史~40KB5ms5ms几十 KB 的 JSON 序列化在手机上几乎无感。如果数据量到几万条才需要考虑增量写入或数据库方案。对于这个 App全量写入完全够用。内存 vs 文件的一致性每次 CRUD 操作都是先改内存、再写文件。如果 App 被系统杀死内存不足最后一次写入前的数据会丢失。但这种情况极少——HarmonyOS 会尽量保活前台 App。九、整体嵌套结构FoodDatabase 数据库类 ├→ foods: FoodItem[] 内存中的食物数组 ├→ history: PickRecord[] 内存中的历史数组 ├→ fp / hp 文件路径 │ ├→ read / write 底层文件 I/O ├→ init() 初始化读文件或用默认 │ ├→ getFoods / addFood 食物 CRUD ├→ deleteFood / toggleFav │ ├→ pick() 转盘抽签最复杂 │ ├→ getHistory / clearHistory 历史管理 └→ resetFoods 重置数据数据层是一个自包含的类——所有持久化逻辑都在这里面UI 层不需要知道数据存在文件里还是 Preferences 里。Index.ets 调用 db.getFoods() 拿到数组不关心数组是从 JSON 解析的还是从数据库查的。这就是封装的意义。