Preferences 和 RDB 数据不一致怎么办:收藏、最近浏览和购物清单怎么对账
中式美食里有几类状态特别容易写乱:用户刚收藏了一道菜,回到列表没亮;刚浏览过的菜,首页“最近看过”没出现;购物清单里勾选了食材,刷新后又回到旧状态。表面看是页面刷新问题,本质通常是 Preferences 和 RDB 的边界没分清。
我的处理方式不是把所有数据都塞进一个地方,而是先分工:Preferences 放轻量、快速、可以重建的状态;RDB 放需要查询、排序、回溯的明细。然后再做一层对账,让页面状态能从数据层解释清楚。
本文导读
| 问题 | 处理方式 |
|---|---|
| 收藏后列表状态不亮 | 收藏关系以 RDB 为准,Preferences 只做入口缓存 |
| 最近浏览和详情页不一致 | 最近浏览先写 Preferences,再异步补 RDB 明细 |
| 购物清单 checked 状态刷新后错 | 明细 checked 写 RDB,页面从查询结果回填 |
| 怎么判断两边没有写乱 | 建一个对账方法,启动和返回时做轻量校验 |
环境和文件边界
| 项目 | 内容 |
|---|---|
| 开发工具 | DevEco Studio |
| 技术栈 | HarmonyOS Stage 模型、ArkTS、ArkUI |
| 快速状态 | entry/src/main/ets/data/local/AppPreferenceStore.ets |
| 明细数据 | entry/src/main/ets/data/repository/FavoriteRepository.ets |
| 购物清单 | entry/src/main/ets/data/repository/ShoppingListRepository.ets |
| 页面入口 | entry/src/main/ets/features/home/HomeViewModel.ets |
先分清:Preferences 不是小型数据库
Preferences 很适合放“轻状态”:最后一次筛选条件、最近打开的 tab、最近浏览 id 列表、首屏缓存标记。它的优势是快,读写也简单。但它不适合承担复杂查询,也不适合当唯一事实来源。
| 数据 | 放哪里 | 原因 |
|---|---|---|
| 最近浏览 recipeId 列表 | Preferences | 数量少,读得快,丢了也能从 RDB 或行为日志重建 |
| 收藏关系 | RDB | 要查询、排序、统计、和笔记/推荐关联 |
| 购物清单 checked | RDB | 用户动作要可靠保存,刷新和重启都不能丢 |
| 当前筛选条件 | Preferences 或页面状态 | 只是恢复入口,不是业务事实 |
一个容易出错的写法
最常见的问题是收藏时只改 Preferences,页面看起来很快,但 RDB 没同步。过一会儿页面重新从 RDB 查,状态就变回去了。
// 不建议:只改快速状态,明细库没有事实记录asyncfunctiontoggleFavorite(recipeId:string,favorite:boolean):Promise<void>{constids=awaitpreferenceStore.getFavoriteIds()constnextIds=favorite?[...ids,recipeId]:ids.filter(id=>id!==recipeId)awaitpreferenceStore.setFavoriteIds(nextIds)}这个写法在 Demo 里没问题,在真实项目里会出事。收藏不只是一个按钮状态,后面还会影响列表筛选、推荐排序、详情页入口、用户行为分析。只存在 Preferences 里,后面很难查。
正确做法:事实进 RDB,快速入口进 Preferences
收藏这类状态,我会让 RDB 做事实来源。Preferences 可以保留最近几个 id,用来优化首屏或恢复入口,但不能反过来当最终数据。
exportclassFavoriteRepository{asyncsetFavorite(recipeId:string,favorite:boolean):Promise<void>{if(favorite){awaitthis.rdbStore.insert('favorite_recipe',{recipe_id:recipeId,created_at:Date.now()})return}constpredicates=newrelationalStore.RdbPredicates('favorite_recipe')predicates.equalTo('recipe_id',recipeId)awaitthis.rdbStore.delete(predicates)}asyncisFavorite(recipeId:string):Promise<boolean>{constpredicates=newrelationalStore.RdbPredicates('favorite_recipe')predicates.equalTo('recipe_id',recipeId)returnawaitthis.exists(predicates)}}页面要快,可以在 ViewModel 里先做乐观更新,但最终要以 Repository 的结果为准。
exportclassRecipeDetailViewModel{favorite=falseasynctoggleFavorite(recipeId:string):Promise<void>{constnext=!this.favoritethis.favorite=nexttry{awaitthis.favoriteRepository.setFavorite(recipeId,next)awaitthis.preferenceStore.touchRecentFavorite(recipeId)}catch(err){this.favorite=!nextthrowerr}}}这段逻辑的关键不是“写了两处”,而是两处的职责不同:RDB 负责事实,Preferences 负责快速入口和体验优化。写失败时要回滚页面状态,不然用户会以为收藏成功了。
最近浏览可以先轻后重
最近浏览的场景不一样。用户进详情页时,首页可能马上要展示“刚看过”。这里可以先写 Preferences,再异步补一条 RDB 行为明细。
exportclassViewedRecipeService{asyncrecordViewed(recipeId:string):Promise<void>{awaitthis.preferenceStore.prependRecentViewed(recipeId,20)try{awaitthis.viewedRepository.insertViewed({recipeId,viewedAt:Date.now()})}catch(err){// 行为明细失败不阻塞用户浏览,但要留下日志,方便后续排查hilog.warn(0x0000,'ViewedRecipeService',`record viewed failed:${JSON.stringify(err)}`)}}}这里我接受“RDB 行为明细偶尔晚一点”,因为最近浏览不是强一致交易。但收藏、购物清单不一样,它们是用户明确动作,不能随便丢。
购物清单 checked 要按明细回填
购物清单的 checked 状态更接近任务进度。用户勾选“番茄”,刷新后还应该是番茄已勾选。这个状态要写回 RDB,页面每次加载时从查询结果回填。
exportclassShoppingListRepository{asyncupdateChecked(rowId:string,checked:boolean):Promise<void>{constpredicates=newrelationalStore.RdbPredicates('shopping_ingredient')predicates.equalTo('row_id',rowId)awaitthis.rdbStore.update({checked:checked?1:0,updated_at:Date.now()},predicates)}asyncqueryCurrentList():Promise<ShoppingIngredientRow[]>{constpredicates=newrelationalStore.RdbPredicates('shopping_ingredient')predicates.orderByAsc('checked')predicates.orderByAsc('display_name')returnthis.queryRows(predicates)}}如果业务要按食材合并,就把rowId换成稳定的ingredientKey。但边界必须说清楚,不能一会儿按行,一会儿按食材。
做一个轻量对账方法
对账不是每天扫全库,而是在关键节点做小范围校验。比如启动首页时检查最近浏览 id 是否还存在;进入收藏页时检查 Preferences 里的快速 id 是否和 RDB 收藏表矛盾;进入购物清单时检查 checked 数量是否能从明细查出来。
| 对账点 | 检查什么 | 发现问题怎么处理 |
|---|---|---|
| 首页启动 | 最近浏览 id 是否还能查到菜谱 | 清掉无效 id |
| 收藏页打开 | Preferences 快速 id 是否仍在收藏表 | 以 RDB 为准刷新 |
| 详情页返回 | 当前 recipeId 收藏状态是否一致 | 重新查 RDB 并回写页面 |
| 购物清单打开 | checked 统计是否等于明细查询结果 | 以明细表重新计算 |
exportclassLocalStateReconcileService{asyncreconcileRecentViewed():Promise<void>{constids=awaitthis.preferenceStore.getRecentViewedIds()constexistingIds=awaitthis.recipeRepository.filterExistingRecipeIds(ids)if(existingIds.length!==ids.length){awaitthis.preferenceStore.setRecentViewedIds(existingIds)}}asyncreconcileFavoriteFastEntry():Promise<void>{constfastIds=awaitthis.preferenceStore.getRecentFavoriteIds()constvalidIds=awaitthis.favoriteRepository.filterFavoriteIds(fastIds)awaitthis.preferenceStore.setRecentFavoriteIds(validIds)}}这样做的好处是,页面不会长期带着脏状态跑。即便某次写入失败,下一次进入关键页面也能把状态拉回正确轨道。
回归验证怎么做
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 收藏一道菜,返回列表 | 列表收藏状态立即亮起 |
| 2 | 关闭应用再打开收藏页 | 收藏仍然存在 |
| 3 | 进入三道菜详情页 | 首页最近浏览顺序正确 |
| 4 | 删除或隐藏一条菜谱后重启 | 最近浏览自动清掉无效 id |
| 5 | 勾选购物清单食材后刷新 | checked 状态从 RDB 回填 |
| 6 | 切换筛选条件再返回 | 统计数量和明细一致 |
本章小结
Preferences 和 RDB 不要互相抢工作。我的判断标准是:
- 需要查、排、关联、统计的,放 RDB。
- 只是恢复入口、加快首屏、保存轻量偏好的,放 Preferences。
- 用户明确动作,比如收藏和购物清单 checked,要能落到事实数据里。
- 页面显示和数据层不一致时,要以事实来源为准做对账。
中式美食后面还会继续加收藏、最近浏览、购物清单和推荐排序,这些功能只要进入同一个本地数据体系,就必须先把边界讲清楚。否则文章里看起来每个模块都能跑,真实用户一来回切页面,状态就会开始互相打架。