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 接口
export interface FoodItem {
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 接口
export interface PickRecord {
foodId: string // 选中的食物 ID
foodName: string // 食物名称(冗余)
emoji: string // 表情(冗余)
pickedAt: number // 选择时间戳
}

冗余字段的设计决策
foodName 和 emoji 是冗余的——可以通过 foodId 关联食物表获取。但历史记录需要独立展示——如果关联查询,每次显示历史都要遍历食物列表。冗余存储让历史展示变成"直接读取",不需要关联。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 冗余存储 | 展示快,独立性强 | 食物删除后历史仍有旧名称 |
| 关联查询 | 数据一致 | 每次展示需要遍历 |
选择冗余存储——因为历史记录的价值在于"记录",不在于"关联"。用户删除一个食物后,历史记录里保留它的名字是合理的——"我曾经选过麻辣香锅"不会因为删除了麻辣香锅就消失。
四、createFoodItem 工厂函数
export function createFoodItem(
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:初始化加载
async init(): Promise<void> {
try {
const p = await preferences.getPreferences(this.ctx, 'fd')
const f = await p.get('foods', '[]')
const h = await p.get('hist', '[]')
this.foods = JSON.parse(f as string) as FoodItem[]
this.history = JSON.parse(h as string) as PickRecord[]
this.loaded = true
} catch (e) {
this.foods = []
this.history = []
this.loaded = true
}
}

一次加载两张表
const f = await p.get('foods', '[]') // 食物列表
const h = await p.get('hist', '[]') // 历史记录
两次读取在同一个 try 块里——一次异常处理覆盖两张表。如果任一读取失败,两张表都回退到空数组。
默认值 ‘[]’ 的意义
第一次打开 App 时 Preferences 里没有 ‘foods’ 和 ‘hist’ 键——返回默认值 ‘[]’。JSON.parse(‘[]’) 得到空数组——App 初始化完成。
七、getFoods:带分类筛选的查询
getFoods(category?: string): FoodItem[] {
if (!category) return this.foods.slice()
return this.foods.filter((f: FoodItem) => f.category === category)
}
可选参数的设计
category 是可选参数——不传返回全部,传了按分类过滤。调用方不需要区分"全部"和"特定分类"——一个方法搞定两种场景。
为什么不用 Map 预索引
// 不用这种方式
private categoryMap: Map<string, FoodItem[]> = new Map()
数据量小(几十个食物)——filter 的线性扫描完全够用。Map 预索引适合数据量上千或查询频率极高的场景。美食转盘的食物列表不会超过几百个——不需要优化。
八、addFood:新增食物
async addFood(food: FoodItem): Promise<void> {
this.foods.push(food)
await this.save()
}
createFoodItem 的调用位置
// Index.ets 的 addFood 方法
private async addFood(): Promise<void> {
if (!this.newName.trim() || !this.db) return;
await this.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:删除食物
async deleteFood(id: string): Promise<void> {
this.foods = this.foods.filter((f: FoodItem) => f.id !== id)
await this.save()
}
filter 返回新数组——排除目标 id。删除食物后,历史记录里仍然保留该食物的记录——历史是"过去发生的事",不应该因为食物被删除而消失。
十、toggleFav:切换收藏状态
async toggleFav(id: string): Promise<void> {
for (let i = 0; i < this.foods.length; i++) {
if (this.foods[i].id === id) {
this.foods[i].isFavorite = !this.foods[i].isFavorite
break
}
}
await this.save()
}
直接修改对象属性——因为 isFavorite 是布尔值,取反即可。不需要替换整个对象——只改一个字段。
为什么不用 map 创建新数组
// 不用这种
this.foods = this.foods.map((f: FoodItem) =>
f.id === id ? { ...f, isFavorite: !f.isFavorite } : f
)
map 创建新数组——对于单字段修改太重。直接修改属性 + save 更高效——数组引用不变,只改一个布尔值。
十一、pick:随机选择的核心
async pick(category?: string): Promise<FoodItem | null> {
const list = this.getFoods(category)
if (list.length === 0) return null
const idx = Math.floor(Math.random() * list.length)
const food = list[idx]
// 记录历史
const record: PickRecord = {
foodId: food.id,
foodName: food.name,
emoji: food.emoji,
pickedAt: Date.now()
}
this.history.unshift(record)
await this.save()
return food
}
随机选择的实现
const idx = Math.floor(Math.random() * list.length)
标准的随机索引——Math.random() 返回 [0, 1),乘以长度后向下取整。保证每个食物被选中的概率相等。
历史记录的写入
this.history.unshift(record)
unshift 把新记录插入数组头部——最新的记录排在最前面。用户打开历史 Tab 时,最近的选择在最上面——符合"最近优先"的浏览习惯。
pick 的返回值
返回选中的 FoodItem——UI 层用它显示结果。如果没有食物可选,返回 null——UI 层不显示结果。
十二、clearHistory:清空历史
async clearHistory(): Promise<void> {
this.history = []
await this.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:恢复默认食物
async resetFoods(): Promise<void> {
this.foods = DEFAULT_FOODS.slice()
await this.save()
}
用预定义的 DEFAULT_FOODS 替换当前列表——恢复到出厂状态。slice() 复制——不直接引用常量数组。
resetFoods vs clearHistory
| 操作 | 影响范围 | 可恢复性 |
|---|---|---|
| resetFoods | 仅食物列表 | 用预置数据恢复 |
| clearHistory | 仅历史记录 | 不可恢复 |
resetFoods 是"可逆操作"——有预置数据可以恢复。clearHistory 是"不可逆操作"——清空后无法找回。所以 clearHistory 需要确认弹窗,resetFoods 不需要(风险更低)。
十四、save:统一持久化
private async save(): Promise<void> {
try {
const p = await preferences.getPreferences(this.ctx, 'fd')
await p.put('foods', JSON.stringify(this.foods))
await p.put('hist', JSON.stringify(this.history))
await p.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 个食物。对于个人使用完全够用。
更多推荐


所有评论(0)