HarmonyOS Preferences 最近浏览越存越乱怎么办:中式美食怎么做去重、上限和 RDB 回写
最近浏览很容易被当成一个小功能打开详情页把菜谱 id 塞进数组下次首页再拿出来展示。中式美食里我不想这么写因为这个功能一旦跑起来问题会很快出现同一道菜反复出现数组越来越长用户清空后又被旧数据写回来首页读取还要等数据库。这里按 DevEco Studio 6.x、HarmonyOS SDK API 12、ArkTS Stage 模型来拆让 Preferences 负责快读快写RDB 负责后续统计和推荐两边边界先说清楚。项目说明应用名称中式美食这篇解决什么最近浏览重复、无限增长、读取慢、Preferences 和 RDB 职责不清谁会搜这篇做 HarmonyOS 菜谱详情页、最近浏览、收藏、推荐入口的 ArkTS 开发者点进来能拿到什么Preferences 快速写入、去重置顶、上限截断、RDB 异步回写和验收用例当前项目证据中式美食有首页推荐、详情页、收藏、搜索、购物清单这些入口都会用到最近浏览本章导读这篇不把最近浏览写成“数据持久化方案”只讲一个真实页面问题用户频繁打开菜谱详情页时最近浏览怎么做到快、准、不乱。快是因为页面返回首页时马上能看到刚看过的菜准是同一道菜只出现一次不乱是旧数据、重复数据和 RDB 回写不能互相打架。章节重点为什么不用纯 RDB最近浏览是高频小数据页面启动要快Preferences 放什么只放最近浏览摘要不放完整菜谱详情怎么去重置顶同一recipeId移到第一位而不是追加一条怎么控制上限最多保留 20 条尾部截断RDB 怎么回写异步写行为记录给推荐和统计用验证环境和文件边界项目说明DevEco Studio6.x 系列HarmonyOS SDKAPI 12 目标工程UI 层ArkUI 声明式页面Stage 模型语言ArkTS快速存储ohos.data.preferences稳定存储ohos.data.relationalStore、RDB、RdbPredicates我会把最近浏览拆成三个模块RecentViewStore管 PreferencesRecentViewRepository负责规则RecipeVisitLogRepository负责 RDB 回写。页面不要直接写 Preferences否则后面改上限、改回写策略会很麻烦。模块负责什么不应该做什么RecipeDetailPage打开详情页时通知 ViewModel不直接写本地存储RecentViewViewModel触发记录、刷新首页状态不关心 Preferences keyRecentViewRepository去重、置顶、截断、读写摘要不做 UI 展示RecipeVisitLogRepositoryRDB 行为日志回写不阻塞页面响应为了避免这篇变成空泛思路先把文件边界也列出来。真实项目里我会按这个粒度落文件而不是把读写逻辑塞进页面。文件作用关键 APIfeatures/recent/RecentViewStore.etsPreferences 读写preferences.getPreferences、put、flushfeatures/recent/RecentViewRepository.ets去重置顶、上限截断、异步回写upsertRecentRecipe、recordVisitfeatures/recent/RecipeVisitLogRepository.etsRDB 浏览日志relationalStore.RdbStore、insert、queryfeatures/detail/RecipeDetailViewModel.ets详情页触发记录onRecipeOpenedfeatures/home/RecentRecipeStrip.ets首页最近浏览展示ForEach、recipeIdkeyPreferences 只放轻量摘要最近浏览不是菜谱详情缓存。它只需要让首页、个人页、搜索页快速展示“刚看过什么”。完整详情还是应该从菜谱库或 RDB 查。exportinterfaceRecentRecipeSnapshot{recipeId:stringtitle:stringcoverUrl?:stringcategoryName?:stringvisitedAt:number}constRECENT_VIEW_KEYrecent_recipe_viewsconstRECENT_VIEW_LIMIT20这里我不会把步骤、食材、营养数据都放进去。Preferences 适合轻量、频繁、结构稳定的小数据。如果把完整菜谱塞进去后面菜谱详情更新了最近浏览里的旧数据还要同步麻烦会成倍增加。数据放 Preferences 吗原因recipeId放后续跳详情必须用title放首页快速展示coverUrl放摘要展示卡片需要完整步骤不放数据大更新麻烦食材明细不放详情页再查visitedAt放排序和回写都要用去重置顶要写成纯函数最近浏览最核心的规则其实就两句同一菜谱只保留一次新打开的放最前面超过上限就截断。这个逻辑最好写成纯函数方便单测也方便后面在别的入口复用。exportfunctionupsertRecentRecipe(current:RecentRecipeSnapshot[],next:RecentRecipeSnapshot,limit:numberRECENT_VIEW_LIMIT,):RecentRecipeSnapshot[]{constwithoutSamecurrent.filter((item)item.recipeId!next.recipeId)return[next,...withoutSame].slice(0,limit)}不要在页面里边遍历边写存储。页面只负责告诉 ViewModel“用户打开了这道菜”。真正的去重规则放 Repository才不会出现首页和详情页各写一套。当前列表新打开结果A, B, CDD, A, B, CA, B, CBB, A, C20 条新菜谱新菜谱置顶最后一条丢掉空列表AAPreferences 读写封装起来HarmonyOS 里用 Preferences 时别让每个页面都拿同一个 key 操作。封装一下后面换 key、加版本、加迁移都更稳。importpreferencesfromohos.data.preferencesexportclassRecentViewStore{privatepref?:preferences.Preferencesasyncinit(context:Context):Promisevoid{this.prefawaitpreferences.getPreferences(context,zhongshi_recent_view)}asyncread():PromiseRecentRecipeSnapshot[]{constrawawaitthis.pref?.get(RECENT_VIEW_KEY,[])if(typeofraw!string){return[]}try{returnJSON.parse(raw)asRecentRecipeSnapshot[]}catch{return[]}}asyncwrite(items:RecentRecipeSnapshot[]):Promisevoid{awaitthis.pref?.put(RECENT_VIEW_KEY,JSON.stringify(items))awaitthis.pref?.flush()}}这里有个小边界JSON 解析失败时不要让页面崩。最近浏览不是核心资产坏了就丢弃重建比一直卡在异常页更合理。如果你想让版本迁移更稳可以给 Preferences 里存一个版本号。这样后面从 v1 的数组结构升级到 v2 的对象结构时不用猜当前数据是哪一版。constRECENT_VIEW_VERSION_KEYrecent_recipe_views_versionconstRECENT_VIEW_VERSION2asyncfunctionensureRecentViewVersion(pref:preferences.Preferences):Promisevoid{constversionawaitpref.get(RECENT_VIEW_VERSION_KEY,1)if(versionRECENT_VIEW_VERSION){return}awaitpref.put(RECENT_VIEW_KEY,[])awaitpref.put(RECENT_VIEW_VERSION_KEY,RECENT_VIEW_VERSION)awaitpref.flush()}Repository 做快写和慢回写打开详情页时用户最先感知的是页面流畅度。最近浏览写 Preferences 应该尽快完成RDB 行为日志可以异步补。exportclassRecentViewRepository{constructor(privatereadonlystore:RecentViewStore,privatereadonlyvisitLogRepository:RecipeVisitLogRepository,){}asyncrecordVisit(snapshot:RecentRecipeSnapshot):PromiseRecentRecipeSnapshot[]{constcurrentawaitthis.store.read()constnextupsertRecentRecipe(current,snapshot)awaitthis.store.write(next)this.visitLogRepository.appendVisit(snapshot.recipeId,snapshot.visitedAt).catch((error)console.warn(append visit log failed,error))returnnext}}这段代码的重点是RDB 回写失败不应该影响用户打开详情页。它可以影响统计和推荐但不应该让详情页卡住。详情页触发时也不要直接拼 snapshot。ViewModel 可以从当前菜谱详情里取必要字段缺图、缺分类时允许为空保证最近浏览不是强依赖完整详情。exportclassRecipeDetailViewModel{constructor(privatereadonlyrecentViewRepository:RecentViewRepository){}asynconRecipeOpened(recipe:RecipeDetail):Promisevoid{awaitthis.recentViewRepository.recordVisit({recipeId:recipe.id,title:recipe.title,coverUrl:recipe.coverUrl,categoryName:recipe.categoryName,visitedAt:Date.now(),})}}写入目标同步还是异步失败影响Preferences 最近浏览同步等待影响当前页面的最近浏览展示RDB 行为日志异步补写只影响后续统计/推荐首页推荐列表下次读取时更新不在详情页里强刷收藏状态单独模块管理不和最近浏览混写RDB 回写只做稳定记录RDB 里我会存访问日志不把它当最近浏览的唯一来源。这样后面做推荐分数、浏览次数、最近活跃菜谱时数据有地方查。constCREATE_RECIPE_VISIT_LOG_TABLECREATE TABLE IF NOT EXISTS recipe_visit_log ( id TEXT PRIMARY KEY, recipe_id TEXT NOT NULL, visited_at INTEGER NOT NULL, source TEXT )asyncfunctionappendVisit(store:relationalStore.RdbStore,recipeId:string,visitedAt:number,):Promisevoid{awaitstore.insert(recipe_visit_log,{id:${recipeId}_${visitedAt},recipe_id:recipeId,visited_at:visitedAt,source:detail_page,})}如果你想做“最近 7 天看得最多的菜”就从 RDB 统计如果只是首页展示最近浏览就从 Preferences 读。两个需求不要互相拖。建表和查询最好也写得明确一点别只在代码里口头说“回写 RDB”。这张表只存行为日志不替代最近浏览列表。asyncfunctioncreateVisitLogTable(store:relationalStore.RdbStore):Promisevoid{awaitstore.executeSql(CREATE_RECIPE_VISIT_LOG_TABLE)awaitstore.executeSql(CREATE INDEX IF NOT EXISTS idx_visit_recipe_time ON recipe_visit_log(recipe_id, visited_at))}asyncfunctionqueryHotVisitedRecipes(store:relationalStore.RdbStore):Promisestring[]{constpredicatesnewrelationalStore.RdbPredicates(recipe_visit_log)constsevenDaysAgoDate.now()-7*24*60*60*1000predicates.greaterThan(visited_at,sevenDaysAgo)predicates.orderByDesc(visited_at)predicates.limitAs(50)constresultSetawaitstore.query(predicates,[recipe_id,visited_at])returncollectRecipeIds(resultSet)}ArkUI 页面只读状态页面里不要每次 build 都去读 Preferences。正确做法是 ViewModel 先加载状态页面只消费状态。Componentstruct RecentRecipeStrip{Propitems:RecentRecipeSnapshot[]onOpenRecipe:(recipeId:string)void(){}build(){if(this.items.length0){Column({space:10}){Text(最近浏览).fontSize(18).fontWeight(FontWeight.Bold)Scroll(){Row({space:12}){ForEach(this.items,(item:RecentRecipeSnapshot){RecipeMiniCard({title:item.title,coverUrl:item.coverUrl,onClick:()this.onOpenRecipe(item.recipeId),})},(item:RecentRecipeSnapshot)item.recipeId)}}.scrollable(ScrollDirection.Horizontal)}}}}这里ForEach的 key 用recipeId。如果用 index最近浏览置顶时容易出现卡片状态串行尤其是图片加载、收藏按钮或点击态还挂在原位置上。错误和边界怎么收口最近浏览的坑不大但很细。边界提前写出来后面不会反复踩。边界处理方式原因JSON 损坏返回空列表并重建最近浏览不是核心资产同一菜谱重复打开去重后置顶符合用户直觉超过上限截断尾部避免无限增长RDB 回写失败记录日志不影响页面保护详情页打开速度用户清空最近浏览清空 Preferences同时可选写 RDB 操作日志避免旧数据马上恢复ViewModel 也要提供一个明确的清空入口别让页面自己删 key。exportclassRecentViewViewModel{items:RecentRecipeSnapshot[][]constructor(privatereadonlyrepository:RecentViewRepository){}asyncload():Promisevoid{this.itemsawaitthis.repository.readRecent()}asyncclear():Promisevoid{awaitthis.repository.clearRecent()this.items[]}}工程验收记录我会用这几组用例验收而不是只看页面有没有列表。用例操作预期首次打开打开 A 菜谱详情最近浏览出现 A重复打开A、B、A 连续打开列表是 A、B不出现两个 A超过上限连续打开 25 道菜只保留最近 20 条JSON 损坏手动写入错误 JSON页面不崩最近浏览重建RDB 失败模拟访问日志写入失败详情页正常返回Preferences 已更新这些用例过了以后最近浏览才算能放进首页入口。否则它很容易在初期看着能用真实用户多点几次就乱。本章小结最近浏览不是无限数组也不应该一股脑塞进 RDB。中式美食这里的做法是Preferences 负责轻量快读快写ViewModel/Repository 负责去重置顶和上限截断RDB 负责后续统计和推荐回写。这样页面响应快数据也不会越存越脏。最后留一个实际问题最近浏览你会保留 10 条、20 条还是 50 条我现在更倾向于 20 条首页足够用Preferences 也不会变成一个越来越大的历史仓库。