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 个食物。对于个人使用完全够用。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐