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 刷新方式 |
|---|---|---|
| 读取 | 磁盘→内存→UI | load() |
| 写入 | 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 个食物。对于个人使用完全够用。