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

📅 2026/7/17 11:17:19
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, b2name:string// 美食名称如 麻辣香锅emoji:string// 表情符号如 category:string// 分类 key如 chineseisFavorite: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)// 如 lq3k8h36 进制用 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():Promisevoid{try{constpawaitpreferences.getPreferences(this.ctx,fd)constfawaitp.get(foods,[])consthawaitp.get(hist,[])this.foodsJSON.parse(fasstring)asFoodItem[]this.historyJSON.parse(hasstring)asPickRecord[]this.loadedtrue}catch(e){this.foods[]this.history[]this.loadedtrue}}一次加载两张表constfawaitp.get(foods,[])// 食物列表consthawaitp.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.categorycategory)}可选参数的设计category是可选参数——不传返回全部传了按分类过滤。调用方不需要区分全部和特定分类——一个方法搞定两种场景。为什么不用 Map 预索引// 不用这种方式privatecategoryMap:Mapstring,FoodItem[]newMap()数据量小几十个食物——filter 的线性扫描完全够用。Map 预索引适合数据量上千或查询频率极高的场景。美食转盘的食物列表不会超过几百个——不需要优化。八、addFood新增食物asyncaddFood(food:FoodItem):Promisevoid{this.foods.push(food)awaitthis.save()}createFoodItem 的调用位置// Index.ets 的 addFood 方法privateasyncaddFood():Promisevoid{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):Promisevoid{this.foodsthis.foods.filter((f:FoodItem)f.id!id)awaitthis.save()}filter 返回新数组——排除目标 id。删除食物后历史记录里仍然保留该食物的记录——历史是过去发生的事不应该因为食物被删除而消失。十、toggleFav切换收藏状态asynctoggleFav(id:string):Promisevoid{for(leti0;ithis.foods.length;i){if(this.foods[i].idid){this.foods[i].isFavorite!this.foods[i].isFavoritebreak}}awaitthis.save()}直接修改对象属性——因为 isFavorite 是布尔值取反即可。不需要替换整个对象——只改一个字段。为什么不用 map 创建新数组// 不用这种this.foodsthis.foods.map((f:FoodItem)f.idid?{...f,isFavorite:!f.isFavorite}:f)map 创建新数组——对于单字段修改太重。直接修改属性 save 更高效——数组引用不变只改一个布尔值。十一、pick随机选择的核心asyncpick(category?:string):PromiseFoodItem|null{constlistthis.getFoods(category)if(list.length0)returnnullconstidxMath.floor(Math.random()*list.length)constfoodlist[idx]// 记录历史constrecord:PickRecord{foodId:food.id,foodName:food.name,emoji:food.emoji,pickedAt:Date.now()}this.history.unshift(record)awaitthis.save()returnfood}随机选择的实现constidxMath.floor(Math.random()*list.length)标准的随机索引——Math.random() 返回 [0, 1)乘以长度后向下取整。保证每个食物被选中的概率相等。历史记录的写入this.history.unshift(record)unshift 把新记录插入数组头部——最新的记录排在最前面。用户打开历史 Tab 时最近的选择在最上面——符合最近优先的浏览习惯。pick 的返回值返回选中的 FoodItem——UI 层用它显示结果。如果没有食物可选返回 null——UI 层不显示结果。十二、clearHistory清空历史asyncclearHistory():Promisevoid{this.history[]awaitthis.save()}空数组 save——清空历史不影响食物列表两张表独立。确认弹窗的实现// Index.ets 的 showClear 状态if(this.showClear){Stack(){Column(){Column(){Text(清空历史)Text(确定清空所有历史记录)Row(){Text(取消).onClick(()this.showClearfalse)Text(清空).backgroundColor(this.gc().error).onClick(()this.clr())}}}.backgroundColor(#00000080)// 半透明黑色遮罩}}半透明遮罩 居中弹窗——标准的确认对话框模式。用 Stack 实现弹窗覆盖——弹窗在内容层之上。两种清空入口入口位置触发条件历史页右上角清空文字history.length 0设置页列表项“️ 清空历史”始终可见两个入口指向同一个弹窗——showClear 控制弹窗显示。不管从哪里触发最终都调用 clr() 清空。十三、resetFoods恢复默认食物asyncresetFoods():Promisevoid{this.foodsDEFAULT_FOODS.slice()awaitthis.save()}用预定义的 DEFAULT_FOODS 替换当前列表——恢复到出厂状态。slice() 复制——不直接引用常量数组。resetFoods vs clearHistory操作影响范围可恢复性resetFoods仅食物列表用预置数据恢复clearHistory仅历史记录不可恢复resetFoods 是可逆操作——有预置数据可以恢复。clearHistory 是不可逆操作——清空后无法找回。所以 clearHistory 需要确认弹窗resetFoods 不需要风险更低。十四、save统一持久化privateasyncsave():Promisevoid{try{constpawaitpreferences.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 不直接读 PreferencesPreferences 不直接操作磁盘系统处理。Preferences 的 1MB 上限Preferences 的单个键值对大小上限约 1MB。一个 FoodItem 对象 JSON 序列化后约 150-200 字节——1MB 可以存约 5000-6000 个食物。对于个人使用完全够用。