HarmonyOS 6.1 全场景实战|《灵犀厨房》实战(二十八):【数据持久化】收藏与浏览历史------让数据在 App 重启后依然"活着"
摘要 :收藏一道菜谱、回顾之前看过什么菜------这些功能在前 27 篇中只能活在内存里。App 重启后,所有收藏和历史全部消失。本篇利用已有的
RelationalStoreHelper(完整 CRUD 封装),新增三张持久化表,让收藏和历史在 App 重启后依然存在。你还会学到:为什么收藏用主键约束而历史用追加写入?为什么浏览历史的写入失败不阻塞页面跳转?以及,如何用约 45 行代码完成从建表到 UI 联动的完整持久化闭环。
一、引言:内存的"失忆症"
一个有趣的测试:在第 27 篇的基础上,收藏一道"番茄牛腩煲",然后关掉 App,重新打开。
收藏按钮恢复成了空心------App 完全忘记了刚才的操作。
这不是 Bug,这是内存的失忆症 。前 27 篇中,所有用户数据------收藏的菜谱、浏览过的记录、个人偏好------都存储在 @State 或 @Local 变量中。这些变量的生命周期与组件绑定,组件销毁时数据也随之消失。
| 数据类型 | 存储位置 | 生命周期 | App 重启后 |
|---|---|---|---|
| 推荐结果 | HomeViewModel.recommendedRecipes |
页面级 | ❌ 消失 |
| 收藏状态 | RecipeDetailPage.heartLiked |
组件级 | ❌ 消失 |
| 浏览记录 | 无存储 | --- | ❌ 从未存在过 |
🎯 本篇目标 :利用已有的
RelationalStoreHelper,新增收藏表、浏览历史表和社区分享表,配合 UI 层的两处微小改动,让数据在 App 重启后依然"活着"。核心代码仅约 45 行。
二、核心原理:关系型数据库的"记账本"模型
2.1 为什么是 SQLite 而非 Preferences?
HarmonyOS 提供了两种本地持久化方案:
| 方案 | 数据结构 | 查询能力 | 适用场景 |
|---|---|---|---|
| Preferences | 键值对 | 仅 get(key) | 简单配置(头像路径、昵称) |
| RelationalStore(SQLite) | 表 + SQL | SELECT/INSERT/DELETE/ORDER BY | 结构化数据(收藏、历史、订单) |
收藏和历史属于后者------你需要按时间排序查询"最近浏览的 10 道菜",需要判断"这道菜是否已收藏"。这些需求用键值对也能实现(把所有数据序列化为 JSON 存一个 key),但查询效率低、代码丑陋、容易出错。
关系型数据库就像一个记账本:每笔收藏是一行,每笔浏览也是一行。你可以随时翻阅(SELECT)、追加(INSERT)、划掉(DELETE),不需要关心这本账怎么保存------SQLite 替你管。
2.2 已有的基础设施
前 27 篇中,我们已经在 RelationalStoreHelper 中建立了完整的 CRUD 封装------initDatabase()、executeSql()、insert() 方法。这些方法已用于用户登录注册的本地存储(local_users 表)。本篇不新增任何数据库基础设施,只扩展现有实例。
三、表结构设计:每一列都有存在的理由
在已有的数据库 LingxiKitchen.db 中新增三张表:
sql
-- 收藏表(recipe_id 为主键,保证同一菜谱只收藏一次)
CREATE TABLE IF NOT EXISTS favorite_recipes (
recipe_id INTEGER PRIMARY KEY,
recipe_name TEXT NOT NULL,
saved_at INTEGER DEFAULT (strftime('%s','now'))
);
-- 浏览历史表(每次浏览追加一条,不设主键约束)
CREATE TABLE IF NOT EXISTS recipe_history (
recipe_id INTEGER NOT NULL,
recipe_name TEXT NOT NULL,
viewed_at INTEGER DEFAULT (strftime('%s','now'))
);
-- 社区分享表(预留,为社区功能做准备)
CREATE TABLE IF NOT EXISTS shared_recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_name TEXT NOT NULL,
recipe_name TEXT NOT NULL,
ingredients TEXT,
steps TEXT,
shared_at INTEGER DEFAULT (strftime('%s','now'))
);
建表语句在 RelationalStoreHelper.createTables() 中追加,使用 CREATE TABLE IF NOT EXISTS 保证幂等------重复调用不会出错。
设计考量:
| 设计点 | 选择 | 理由 |
|---|---|---|
favorite_recipes 主键 |
recipe_id |
收藏是唯一性操作------同一菜谱只需一条记录,取消收藏时 DELETE,重新收藏时 INSERT |
recipe_history 主键 |
无(追加写入) | 浏览是可重复操作------用户可能三天看五次"番茄牛腩煲",完整时间线比最新记录更有分析价值 |
saved_at / viewed_at 默认值 |
strftime('%s','now') |
SQLite 内置时间函数,无需在 ArkTS 侧传入时间戳,减少代码量和时钟偏差风险 |
shared_recipes 步骤字段 |
TEXT(JSON 序列化) |
步骤是数组结构,SQLite 不直接支持数组类型,JSON 序列化是最简单的跨语言兼容方案 |
四、收藏逻辑:INSERT 与 DELETE 的一体两面
4.1 写入时机
在 RecipeDetailPage 底部操作栏的收藏按钮中,在 onClick 中调用 this.toggleFavorite():
typescript
.onClick(() => {
this.heartLiked = !this.heartLiked;
this.heartScale = 1.3;
setTimeout(() => { this.heartScale = 1; }, 150);
this.toggleFavorite(); // ← 新增持久化,在动画播放的同时异步写入
})
4.2 toggleFavorite 方法
typescript
private async toggleFavorite(): Promise<void> {
try {
if (this.heartLiked) {
await storeHelper.insert('favorite_recipes', {
recipe_id: this.recipe.id,
recipe_name: this.recipe.name,
saved_at: Date.now()
});
} else {
await storeHelper.executeSql(
'DELETE FROM favorite_recipes WHERE recipe_id = ?',
[this.recipe.id.toString()]
);
}
} catch (err) {
console.error('[RecipeDetail] 收藏持久化失败:', JSON.stringify(err));
}
}
SQLite RelationalStoreHelper 收藏按钮 👤 用户 SQLite RelationalStoreHelper 收藏按钮 👤 用户 #mermaid-svg-ursNCMusDwPEOQRz{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ursNCMusDwPEOQRz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ursNCMusDwPEOQRz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ursNCMusDwPEOQRz .error-icon{fill:#552222;}#mermaid-svg-ursNCMusDwPEOQRz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ursNCMusDwPEOQRz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ursNCMusDwPEOQRz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ursNCMusDwPEOQRz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ursNCMusDwPEOQRz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ursNCMusDwPEOQRz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ursNCMusDwPEOQRz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ursNCMusDwPEOQRz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ursNCMusDwPEOQRz .marker.cross{stroke:#333333;}#mermaid-svg-ursNCMusDwPEOQRz svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ursNCMusDwPEOQRz p{margin:0;}#mermaid-svg-ursNCMusDwPEOQRz .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ursNCMusDwPEOQRz text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ursNCMusDwPEOQRz .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ursNCMusDwPEOQRz .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ursNCMusDwPEOQRz .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ursNCMusDwPEOQRz .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ursNCMusDwPEOQRz #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ursNCMusDwPEOQRz .sequenceNumber{fill:white;}#mermaid-svg-ursNCMusDwPEOQRz #sequencenumber{fill:#333;}#mermaid-svg-ursNCMusDwPEOQRz #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ursNCMusDwPEOQRz .messageText{fill:#333;stroke:none;}#mermaid-svg-ursNCMusDwPEOQRz .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ursNCMusDwPEOQRz .labelText,#mermaid-svg-ursNCMusDwPEOQRz .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ursNCMusDwPEOQRz .loopText,#mermaid-svg-ursNCMusDwPEOQRz .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ursNCMusDwPEOQRz .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ursNCMusDwPEOQRz .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ursNCMusDwPEOQRz .noteText,#mermaid-svg-ursNCMusDwPEOQRz .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ursNCMusDwPEOQRz .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ursNCMusDwPEOQRz .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ursNCMusDwPEOQRz .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ursNCMusDwPEOQRz .actorPopupMenu{position:absolute;}#mermaid-svg-ursNCMusDwPEOQRz .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ursNCMusDwPEOQRz .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ursNCMusDwPEOQRz .actor-man circle,#mermaid-svg-ursNCMusDwPEOQRz line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ursNCMusDwPEOQRz :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击收藏 heartLiked = true scale 弹跳动画 insert('favorite_recipes', {id, name}) INSERT INTO favorite_recipes rowId ✅ 再次点击(取消) executeSql('DELETE WHERE id=?') DELETE FROM favorite_recipes ok ✅
图一解读 :收藏和取消收藏是同一操作的两个方向------用 recipe_id 作为主键,INSERT 和 DELETE 对称操作。数据库不关心用户是第一次收藏还是取消后重新收藏------它只执行 SQL,由 ArkTS 侧的 heartLiked 状态决定方向。
4.3 设计考量:为什么不阻塞动画?
toggleFavorite() 是异步的,但 onClick 没有 await 它。这意味着动画先播(150ms 弹跳),数据库写入在后台并行进行。如果数据库写入失败(比如磁盘满),用户已经看到了动画反馈------这会不会不一致?
不会 。收藏功能的核心价值是再次打开 App 时还能看到收藏 ,而不是"点击瞬间的数据一致性"。如果写入失败,下次打开 App 时收藏会丢失------这确实是个问题,但它发生的概率远低于用户因为等待 I/O 而感知到的卡顿。用户体验的优先级是:即时反馈 > 数据持久化 > 错误处理。前两者保证了"好用",第三者保证了"不出大问题"。
五、浏览历史:追加写入,静默失败
5.1 写入时机
在 Index.ets 的 handleRecipeTap 方法开头新增写入:
typescript
private handleRecipeTap(recipe: Recipe): void {
// ★ 写入浏览历史(失败不阻塞跳转)
try {
storeHelper.insert('recipe_history', {
recipe_id: recipe.id,
recipe_name: recipe.name,
viewed_at: Date.now()
});
} catch (_err) {}
// 原有跳转逻辑(不受历史写入影响)
this.getUIContext().getRouter().pushUrl({ ... });
}
5.2 设计考量:为什么静默吞错误?
浏览历史不是关键路径。用户点击菜谱卡片时的核心诉求是看到菜谱详情,而不是"确保这次浏览被记录"。如果数据库写入失败(磁盘满、表损坏),阻塞跳转或弹出错误提示都会严重破坏体验。
#mermaid-svg-3uFH3SJUj7mLBZsU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3uFH3SJUj7mLBZsU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3uFH3SJUj7mLBZsU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3uFH3SJUj7mLBZsU .error-icon{fill:#552222;}#mermaid-svg-3uFH3SJUj7mLBZsU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3uFH3SJUj7mLBZsU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3uFH3SJUj7mLBZsU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3uFH3SJUj7mLBZsU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3uFH3SJUj7mLBZsU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3uFH3SJUj7mLBZsU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3uFH3SJUj7mLBZsU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3uFH3SJUj7mLBZsU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3uFH3SJUj7mLBZsU .marker.cross{stroke:#333333;}#mermaid-svg-3uFH3SJUj7mLBZsU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3uFH3SJUj7mLBZsU p{margin:0;}#mermaid-svg-3uFH3SJUj7mLBZsU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-3uFH3SJUj7mLBZsU .cluster-label text{fill:#333;}#mermaid-svg-3uFH3SJUj7mLBZsU .cluster-label span{color:#333;}#mermaid-svg-3uFH3SJUj7mLBZsU .cluster-label span p{background-color:transparent;}#mermaid-svg-3uFH3SJUj7mLBZsU .label text,#mermaid-svg-3uFH3SJUj7mLBZsU span{fill:#333;color:#333;}#mermaid-svg-3uFH3SJUj7mLBZsU .node rect,#mermaid-svg-3uFH3SJUj7mLBZsU .node circle,#mermaid-svg-3uFH3SJUj7mLBZsU .node ellipse,#mermaid-svg-3uFH3SJUj7mLBZsU .node polygon,#mermaid-svg-3uFH3SJUj7mLBZsU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-3uFH3SJUj7mLBZsU .rough-node .label text,#mermaid-svg-3uFH3SJUj7mLBZsU .node .label text,#mermaid-svg-3uFH3SJUj7mLBZsU .image-shape .label,#mermaid-svg-3uFH3SJUj7mLBZsU .icon-shape .label{text-anchor:middle;}#mermaid-svg-3uFH3SJUj7mLBZsU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-3uFH3SJUj7mLBZsU .rough-node .label,#mermaid-svg-3uFH3SJUj7mLBZsU .node .label,#mermaid-svg-3uFH3SJUj7mLBZsU .image-shape .label,#mermaid-svg-3uFH3SJUj7mLBZsU .icon-shape .label{text-align:center;}#mermaid-svg-3uFH3SJUj7mLBZsU .node.clickable{cursor:pointer;}#mermaid-svg-3uFH3SJUj7mLBZsU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-3uFH3SJUj7mLBZsU .arrowheadPath{fill:#333333;}#mermaid-svg-3uFH3SJUj7mLBZsU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-3uFH3SJUj7mLBZsU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-3uFH3SJUj7mLBZsU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3uFH3SJUj7mLBZsU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-3uFH3SJUj7mLBZsU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3uFH3SJUj7mLBZsU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-3uFH3SJUj7mLBZsU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-3uFH3SJUj7mLBZsU .cluster text{fill:#333;}#mermaid-svg-3uFH3SJUj7mLBZsU .cluster span{color:#333;}#mermaid-svg-3uFH3SJUj7mLBZsU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-3uFH3SJUj7mLBZsU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-3uFH3SJUj7mLBZsU rect.text{fill:none;stroke-width:0;}#mermaid-svg-3uFH3SJUj7mLBZsU .icon-shape,#mermaid-svg-3uFH3SJUj7mLBZsU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-3uFH3SJUj7mLBZsU .icon-shape p,#mermaid-svg-3uFH3SJUj7mLBZsU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-3uFH3SJUj7mLBZsU .icon-shape .label rect,#mermaid-svg-3uFH3SJUj7mLBZsU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-3uFH3SJUj7mLBZsU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-3uFH3SJUj7mLBZsU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-3uFH3SJUj7mLBZsU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 失败
用户点击菜谱卡片
写入浏览历史
跳转详情页
静默忽略
用户看到菜谱详情 ✅
图二解读:浏览历史是一条分叉路------主路径(跳转)和副路径(写入)并行。副路径失败不影响主路径。这是"非关键路径静默失败"的设计模式------适用于所有"有更好、没有也行"的增值功能。
5.3 为什么是追加而非更新?
如果用户三天内看了五次"番茄牛腩煲",你应该存五条记录还是一条记录?
| 策略 | 存储方式 | 能回答的问题 |
|---|---|---|
| 更新(UPDATE) | 一条记录,更新 viewed_at |
"最近什么时候看过这道菜" |
| 追加(INSERT) | 五条记录,各自有时间戳 | + "看过多少次" + "什么时候最常看" + "看了之后收藏了吗" |
追加的成本只是多占几行磁盘空间(每条约 100 字节),但换来了完整的行为时间线。后续可以扩展"最近浏览"列表、"猜你喜欢"推荐、"看了但没收藏"提醒等功能。追加不是冗余,是未来数据分析的基础设施。
六、代码交付清单
| 文件 | 新增/修改 | 行数 | 说明 |
|---|---|---|---|
RelationalStoreHelper.ets |
修改 | +25 | createTables 新增三张建表 SQL |
RecipeDetailPage.ets |
修改 | +15 | 收藏按钮加入 toggleFavorite() 调用 |
Index.ets |
修改 | +5 | handleRecipeTap 新增浏览历史写入 |
七、设计决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 收藏表主键 | recipe_id |
收藏是唯一性操作,同一菜谱只需一条记录 |
| 历史表写入策略 | 追加(INSERT)而非更新 | 保留完整行为时间线,为数据分析打基础 |
| 历史写入失败处理 | 静默吞错误,不阻塞跳转 | 浏览历史是增值功能,非关键路径 |
| 动画与持久化的顺序 | 动画先播,持久化异步并行 | 用户感知的延迟来自 I/O,动画填补了这段空白 |
| 不新建 DataSource 类 | 直接复用 storeHelper 单例 |
已有完整 CRUD,不引入额外抽象层 |
八、本阶段总结与下篇预告
本篇用约 45 行新增代码,让《灵犀厨房》的收藏和浏览历史从"内存失忆"变为"持久记忆":
- 三张新表 :
favorite_recipes(收藏)、recipe_history(浏览历史)、shared_recipes(预留社区) - 收藏的 INSERT/DELETE 对称操作 :
recipe_id主键让收藏和取消是同一操作的镜像 - 浏览历史的追加写入:静默失败不阻塞跳转,完整时间线为未来数据分析打基础
- 最小侵入:UI 层仅两处改动,数据库基础设施复用已有封装
现在重新打开 App,收藏依然在,浏览历史可追溯------App 开始有了"记忆"。
下篇预告:第 29 篇《个人中心:偏好持久化与推荐联动》。我们将把用户的口味偏好、忌口设置和健康档案持久化到本地数据库,并让推荐引擎在下次启动时自动读取这些偏好------真正做到"越用越懂你"。
📚 本系列持续更新中:下一篇将让推荐引擎与用户偏好联动,开启个性化推荐的正循环。
🔗 专栏入口:《HarmonyOS6.1全场景实战》合集
📦 获取基线版本源码包 :包括第1-15篇所有代码 + 架构文档 + Flask 后端
如果你觉得这篇文章对您有所帮助,麻烦您动动发财之手点赞 👍、收藏 ⭐ 和评论 💬。谢谢大家!!