美食转盘的数据层藏了一个有意思的设计决策——用文件存储食物数据,用 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.fp=ctx.filesDir+'/foods.json';this.hp=ctx.filesDir+'/history.json';}构造函数接收 UIAbilityContext——不是页面级的 Context。这是 Stage 模型的要求:fileIo 操作需要应用级 Context 的 filesDir 路径。页面级 Context 的 filesDir 可能不同。
init 方法的五步加载
asyncinit():Promise<void>{try{constfd=this.read(this.fp);this.foods=fd?JSON.parse(fd)asFoodItem[]:getDefaultFoods();if(!fd)this.saveFoods();consthd=this.read(this.hp);this.history=hd?JSON.parse(hd)asPickRecord[]:[];}catch(e){this.foods=getDefaultFoods();this.history=[];}}五步操作:
| 步骤 | 操作 | 目的 |
|---|---|---|
| 1 | read(foods.json) | 读取食物数据 |
| 2 | 判断文件是否存在 | 有→解析,无→用默认数据 |
| 3 | 首次写入默认数据 | 给 foods.json 创建初始内容 |
| 4 | read(history.json) | 读取历史数据 |
| 5 | catch 兜底 | 文件损坏时恢复默认 |
“有则解析,无则创建”——初始化逻辑兼顾首次运行和后续加载。catch 块防止 JSON.parse 报错导致整个数据库崩溃。
getDefaultFoods() 的作用
this.foods=fd?JSON.parse(fd)asFoodItem[]:getDefaultFoods();首次运行时 foods.json 不存在,read 返回空字符串,fd 为 falsy——用 getDefaultFoods() 填充。然后立刻 saveFoods() 把默认数据写入文件——下次启动就能读到了。
三、文件读写的底层实现
read 方法
privateread(f:string):string{if(!fileIo.accessSync(f))return'';consts=fileIo.statSync(f);constfd=fileIo.openSync(f,fileIo.OpenMode.READ_ONLY);constab=newArrayBuffer(s.size);fileIo.readSync(fd.fd,ab);fileIo.closeSync(fd);returnbuffer.from(ab).toString();}四步操作:
| 步骤 | API | 作用 |
|---|---|---|
| 1 | accessSync | 检查文件是否存在 |
| 2 | statSync | 获取文件大小 |
| 3 | openSync + readSync | 打开文件,读取全部内容到 ArrayBuffer |
| 4 | buffer.from(ab).toString() | 把 ArrayBuffer 转成字符串 |
为什么用 ArrayBuffer 而不是直接读字符串?fileIo 的 readSync 接收 ArrayBuffer 参数——这是 HarmonyOS 文件 API 的设计。读完后用 buffer 模块转换成字符串。
write 方法
privatewrite(f:string,d:string):void{constb=buffer.from(d).buffer;constfd=fileIo.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——addFood
asyncaddFood(f:FoodItem):Promise<void>{this.foods.push(f);awaitthis.saveFoods();}push 直接修改内存数组,然后异步写入文件。两步操作不是原子的——如果 push 后、saveFoods 前崩溃,内存数据和文件数据不一致。但对于本地 App 来说,这个窗口极小,可以接受。
Read——getFoods
getFoods(cat?:string):FoodItem[]{if(!cat||cat==='all')returnthis.foods.slice();returnthis.foods.filter((f:FoodItem)=>f.category===cat);}slice() 返回浅拷贝——防止外部修改影响内部数据。filter() 返回新数组——不影响原数组。两个方法都保证了数据的不可变性。
Update——toggleFav
asynctoggleFav(id:string):Promise<void>{constf=this.foods.find((x:FoodItem)=>x.id===id);if(f){f.isFavorite=!f.isFavorite;awaitthis.saveFoods();}}find 找到对象引用,直接修改属性。对象是引用类型——修改 f.isFavorite 等同于修改 this.foods[i].isFavorite。
Delete——deleteFood
asyncdeleteFood(id:string):Promise<void>{consti=this.foods.findIndex((f:FoodItem)=>f.id===id);if(i>=0){this.foods.splice(i,1);awaitthis.saveFoods();}}findIndex 找到索引,splice 删除元素。splice 会修改原数组——这是预期行为。findIndex 比 find 多返回索引,用于 splice。
五、转盘抽签的 pick 方法
这是整个数据库最复杂的方法:
asyncpick(cat?:string):Promise<FoodItem|null>{constpool=this.getFoods(cat);if(pool.length===0)returnnull;constp=pool[Math.floor(Math.random()*pool.length)];p.pickCount++;p.lastPicked=Date.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;}四步操作的顺序
| 步骤 | 操作 | 原因 |
|---|---|---|
| 1 | getFoods(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():Promise<void>{this.history=[];awaitthis.saveHistory();}直接清空数组,然后写入文件。没有确认弹窗——历史记录不包含关键数据(食物本身不受影响),清空后可以重新积累。
resetFoods 方法
asyncresetFoods():Promise<void>{this.foods=getDefaultFoods();awaitthis.saveFoods();}恢复出厂设置——用默认数据替换当前数据。用户自己添加的食物全部丢失,但历史记录保留——resetFoods 不调用 clearHistory。
七、两种存储方案的统一
文件存储的部分
| 方法 | 文件 | 操作 |
|---|---|---|
| saveFoods() | foods.json | 全量写入 |
| saveHistory() | history.json | 全量写入 |
| getFoods() | 内存读取 | slice/filter |
| getHistory() | 内存读取 | slice/sort |
Preferences 存储的部分
| 方法 | 键 | 操作 |
|---|---|---|
| 读主题 | ‘theme’ | preferences.get |
| 写主题 | ‘theme’ | preferences.put + flush |
食物和历史用文件,主题用 Preferences——各取所长。文件存大数组,Preferences 存小配置。两种方案在 Theme.ets 和 Index.ets 里分别调用,互不干扰。
八、性能考量
全量读写的代价
| 数据量 | 文件大小 | 读取耗时 | 写入耗时 |
|---|---|---|---|
| 10 条食物 | ~2KB | <1ms | <1ms |
| 50 条食物 | ~8KB | <1ms | <1ms |
| 100 条食物 | ~15KB | <2ms | <2ms |
| 500 条历史 | ~40KB | <5ms | <5ms |
几十 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 解析的还是从数据库查的。这就是封装的意义。