HarmonyOS今天吃什么——数据库层的双表架构与持久化策略

HarmonyOS今天吃什么——数据库层的双表架构与持久化策略

美食转盘 App 的数据层只有两个概念——食物列表和选择历史。FoodDatabase 类把这两个概念映射成两个 Preferences 键:‘foods’ 存食物数组,‘hist’ 存历史数组。所有操作都在内存里完成,写入时整体序列化成 JSON 存到 Preferences。简单、直接、够用。数据层还提供了一个关键操作:resetFoods——恢复默认食物列表,在数据恢复场景下非常实用。

完整效果

一、双表结构

两张"表"的职责

键名数据类型职责读取频率
‘foods’FoodItem[]美食列表高(转盘/管理/设置)
‘hist’PickRecord[]选择历史中(历史 Tab)

食物和历史是两种不同的数据:食物是"可编辑的配置",历史是"只追加的记录"。分开存储让各自的 CRUD 互不干扰。

Preferences 的存储结构

Preferences('fd') ├→ key: 'foods' → '[{"id":"a1","name":"麻辣香锅","emoji":"🍲",...},...]' └→ key: 'hist' → '[{"foodId":"a1","foodName":"麻辣香锅","emoji":"🍲","pickedAt":1752712345678},...]'

键名 ‘fd’ 是 Food Database 的缩写——键名只是标识符,不影响功能。

二、FoodItem 接口

exportinterfaceFoodItem{id:string// 唯一标识(如 'a1', 'b2')name:string// 美食名称(如 '麻辣香锅')emoji:string// 表情符号(如 '🍲')category:string// 分类 key(如 'chinese')isFavorite:boolean// 是否收藏createdAt:number// 创建时间戳}

六个字段的设计意图

字段为什么需要读取方
id唯一标识(删除/收藏依赖)全部
name转盘显示、历史记录转盘/管理/历史
emoji转盘大图标、列表小图标转盘/管理/历史
category分类筛选转盘/管理
isFavorite收藏筛选、星标显示管理
createdAt时间排序管理(隐含)

id 用字符串而不是数字——因为美食转盘可能需要批量导入预置数据,字符串 id(如 ‘a1’)比数字 id 更容易预定义。

三、PickRecord 接口

exportinterfacePickRecord{foodId:string// 选中的食物 IDfoodName:string// 食物名称(冗余)emoji:string// 表情(冗余)pickedAt:number// 选择时间戳}

冗余字段的设计决策

foodName 和 emoji 是冗余的——可以通过 foodId 关联食物表获取。但历史记录需要独立展示——如果关联查询,每次显示历史都要遍历食物列表。冗余存储让历史展示变成"直接读取",不需要关联。

方案优点缺点
冗余存储展示快,独立性强食物删除后历史仍有旧名称
关联查询数据一致每次展示需要遍历

选择冗余存储——因为历史记录的价值在于"记录",不在于"关联"。用户删除一个食物后,历史记录里保留它的名字是合理的——"我曾经选过麻辣香锅"不会因为删除了麻辣香锅就消失。

四、createFoodItem 工厂函数

exportfunctioncreateFoodItem(name:string,category:string,emoji:string):FoodItem{return{id:Date.now().toString(36)+Math.random().toString(36).substring(2,6),name,emoji,category,isFavorite:false,createdAt:Date.now()}}

id 生成策略

id:Date.now().toString(36)+Math.random().toString(36).substring(2,6)

两部分拼接——时间戳(36进制)+ 随机字符串(4位)。时间戳保证全局唯一——同一毫秒不会创建两个食物。随机字符串增加混淆度——防止用户猜到 id 规律。

为什么用 36 进制

Date.now().toString(36)// 如 'lq3k8h'

36 进制用 0-9 + a-z 表示——比 10 进制更短。10 进制的 1752712345678 是 13 位,36 进制只有 9 位。更短的 id 在 JSON 序列化和显示时更节省空间。

五、FoodDatabase 类的核心方法

方法总览

方法作用操作的键
init()加载数据‘foods’ + ‘hist’
getFoods(cat?)获取食物列表‘foods’
getHistory()获取历史记录‘hist’
addFood(food)新增食物‘foods’
deleteFood(id)删除食物‘foods’
toggleFav(id)切换收藏‘foods’
pick(cat?)随机选择‘foods’ + ‘hist’
clearHistory()清空历史‘hist’
resetFoods()恢复默认‘foods’

9 个方法——4 个读、4 个写、1 个混合(pick 同时读写两张表)。

六、init:初始化加载

asyncinit():Promise<void>{try{constp=awaitpreferences.getPreferences(this.ctx,'fd')constf=awaitp.get('foods','[]')consth=awaitp.get('hist','[]')this.foods=JSON.parse(fasstring)asFoodItem[]this.history=JSON.parse(hasstring)asPickRecord[]this.loaded=true}catch(e){this.foods=[]this.history=[]this.loaded=true}}

一次加载两张表

constf=awaitp.get('foods','[]')// 食物列表consth=awaitp.get('hist','[]')// 历史记录

两次读取在同一个 try 块里——一次异常处理覆盖两张表。如果任一读取失败,两张表都回退到空数组。

默认值 ‘[]’ 的意义

第一次打开 App 时 Preferences 里没有 ‘foods’ 和 ‘hist’ 键——返回默认值 ‘[]’。JSON.parse(‘[]’) 得到空数组——App 初始化完成。

七、getFoods:带分类筛选的查询

getFoods(category?:string):FoodItem[]{if(!category)returnthis.foods.slice()returnthis.foods.filter((f:FoodItem)=>f.category===category)}

可选参数的设计

category是可选参数——不传返回全部,传了按分类过滤。调用方不需要区分"全部"和"特定分类"——一个方法搞定两种场景。

为什么不用 Map 预索引

// 不用这种方式privatecategoryMap:Map<string,FoodItem[]>=newMap()

数据量小(几十个食物)——filter 的线性扫描完全够用。Map 预索引适合数据量上千或查询频率极高的场景。美食转盘的食物列表不会超过几百个——不需要优化。

八、addFood:新增食物

asyncaddFood(food:FoodItem):Promise<void>{this.foods.push(food)awaitthis.save()}

createFoodItem 的调用位置

// Index.ets 的 addFood 方法privateasyncaddFood():Promise<void>{if(!this.newName.trim()||!this.db)return;awaitthis.db.addFood(createFoodItem(this.newName.trim(),this.newCat,this.newEmoji||'🍽️'));this.newName='';this.newEmoji='';this.load();}

工厂函数在 UI 层调用——Database 层只接收完整的 FoodItem 对象。这是"创建逻辑和存储逻辑分离"——Database 不关心 FoodItem 怎么创建的,只管存进去。

默认 emoji 的回退

this.newEmoji||'🍽️'

用户不输入 emoji 时默认用 🍽️(餐盘)。这保证每个食物都有 emoji——转盘不会显示空白。

九、deleteFood:删除食物

asyncdeleteFood(id:string):Promise<void>{this.foods=this.foods.filter((f:FoodItem)=>f.id!==id)awaitthis.save()}

filter 返回新数组——排除目标 id。删除食物后,历史记录里仍然保留该食物的记录——历史是"过去发生的事",不应该因为食物被删除而消失。

十、toggleFav:切换收藏状态

asynctoggleFav(id:string):Promise<void>{for(leti=0;i<this.foods.length;i++){if(this.foods[i].id===id){this.foods[i].isFavorite=!this.foods[i].isFavoritebreak}}awaitthis.save()}

直接修改对象属性——因为 isFavorite 是布尔值,取反即可。不需要替换整个对象——只改一个字段。

为什么不用 map 创建新数组

// 不用这种this.foods=this.foods.map((f:FoodItem)=>f.id===id?{...f,isFavorite:!f.isFavorite}:f)

map 创建新数组——对于单字段修改太重。直接修改属性 + save 更高效——数组引用不变,只改一个布尔值。

十一、pick:随机选择的核心

asyncpick(category?:string):Promise<FoodItem|null>{constlist=this.getFoods(category)if(list.length===0)returnnullconstidx=Math.floor(Math.random()*list.length)constfood=list[idx]// 记录历史constrecord:PickRecord={foodId:food.id,foodName:food.name,emoji:food.emoji,pickedAt:Date.now()}this.history.unshift(record)awaitthis.save()returnfood}

随机选择的实现

constidx=Math.floor(Math.random()*list.length)

标准的随机索引——Math.random() 返回 [0, 1),乘以长度后向下取整。保证每个食物被选中的概率相等。

历史记录的写入

this.history.unshift(record)

unshift 把新记录插入数组头部——最新的记录排在最前面。用户打开历史 Tab 时,最近的选择在最上面——符合"最近优先"的浏览习惯。

pick 的返回值

返回选中的 FoodItem——UI 层用它显示结果。如果没有食物可选,返回 null——UI 层不显示结果。

十二、clearHistory:清空历史

asyncclearHistory():Promise<void>{this.history=[]awaitthis.save()}

空数组 + save——清空历史不影响食物列表,两张表独立。

确认弹窗的实现

// Index.ets 的 showClear 状态if(this.showClear){Stack(){Column(){Column(){Text('清空历史')Text('确定清空所有历史记录?')Row(){Text('取消').onClick(()=>this.showClear=false)Text('清空').backgroundColor(this.gc().error).onClick(()=>this.clr())}}}.backgroundColor('#00000080')// 半透明黑色遮罩}}

半透明遮罩 + 居中弹窗——标准的确认对话框模式。用 Stack 实现弹窗覆盖——弹窗在内容层之上。

两种清空入口

入口位置触发条件
历史页右上角"清空"文字history.length > 0
设置页列表项“🗑️ 清空历史”始终可见

两个入口指向同一个弹窗——showClear 控制弹窗显示。不管从哪里触发,最终都调用 clr() 清空。

十三、resetFoods:恢复默认食物

asyncresetFoods():Promise<void>{this.foods=DEFAULT_FOODS.slice()awaitthis.save()}

用预定义的 DEFAULT_FOODS 替换当前列表——恢复到出厂状态。slice() 复制——不直接引用常量数组。

resetFoods vs clearHistory

操作影响范围可恢复性
resetFoods仅食物列表用预置数据恢复
clearHistory仅历史记录不可恢复

resetFoods 是"可逆操作"——有预置数据可以恢复。clearHistory 是"不可逆操作"——清空后无法找回。所以 clearHistory 需要确认弹窗,resetFoods 不需要(风险更低)。

十四、save:统一持久化

privateasyncsave():Promise<void>{try{constp=awaitpreferences.getPreferences(this.ctx,'fd')awaitp.put('foods',JSON.stringify(this.foods))awaitp.put('hist',JSON.stringify(this.history))awaitp.flush()}catch(e){}}

为什么每次 save 都写两张表

简化逻辑——不需要追踪"哪张表变了"。每次操作后都写两张表,保证数据一致。性能影响可以忽略——JSON 序列化 + Preferences 写入在百级数据量下是毫秒级。

flush 的重要性

不调用 flush,数据只在内存缓冲区——App 被杀后丢失。这是 Preferences 使用的关键陷阱——很多开发者忘记调用 flush。

十五、数据流的完整链路

用户操作(新增/删除/收藏/转盘/清空) → Index.ets 调用 db 方法 → 内存数组更新 → save() 异步持久化 → Preferences 写入磁盘 → load() 刷新 UI

数据流方向

操作数据流向UI 刷新方式
读取磁盘→内存→UIload()
写入UI→内存→磁盘load()

读写都通过 load() 刷新 UI——保证数据和界面同步。

十六、数据层的整体架构

UI 层(Index.ets 四个 Tab) ↓ 调用 db 方法 FoodDatabase(内存 CRUD) ↓ JSON.stringify / JSON.parse Preferences(键值对存储) ↓ 文件系统 磁盘文件

四层架构——UI→数据库→Preferences→磁盘。每一层只和相邻层交互——UI 不直接读 Preferences,Preferences 不直接操作磁盘(系统处理)。

Preferences 的 1MB 上限

Preferences 的单个键值对大小上限约 1MB。一个 FoodItem 对象 JSON 序列化后约 150-200 字节——1MB 可以存约 5000-6000 个食物。对于个人使用完全够用。