HarmonyOS RDB 分页重复加载怎么办:中式美食菜谱列表怎么用游标、防抖和去重稳住下一页
2026/7/17 4:32:03 网站建设 项目流程

先把问题说清楚

中式美食的菜谱列表不是一次性全量展示。首页、分类页、搜索页都会从 RDB 里按条件取一批数据,用户继续往下滑,再取下一批。这个逻辑看起来很普通,但真正写起来容易出两个问题:一是同一道菜重复出现,二是切换分类或关键词以后,上一轮分页结果还会塞回当前列表。

我一开始也想过用简单页码处理:第一页查offset=0,第二页查offset=20。但在真实页面里,列表不是静止的。收藏状态会变,分类条件会变,搜索词会变,返回页面时还要恢复现场。只盯着页码,很容易把“当前查询条件下的下一页”和“上一轮查询残留的下一页”混成一件事。

排查点真实表现
连续触底一次滚动触发两次加载,第二次拿到重复数据
切换分类分类已经变了,旧分类的下一页还在回来
返回列表页面恢复了滚动位置,但分页游标没有恢复
收藏刷新单条状态更新后重新合并,recipeId 重复

我最后拆成三个状态

分页不能只看pageIndex。在中式美食里,我更愿意把它拆成三个状态:当前查询条件、当前游标、正在执行的请求版本。

typeRecipeQuery={keyword:stringcategoryId:stringsortMode:'default'|'favorite'|'recent'}typePageCursor={lastScore?:numberlastRecipeId?:stringpageSize:numberhasMore:boolean}typePageRequestState={queryKey:stringrequestVersion:numberloading:boolean}

queryKey是关键。只要关键词、分类或排序变了,就生成新的queryKey。旧请求即使回来,也不能再改当前列表。

RDB 查询边界不要只靠 offset

如果数据排序稳定,offset能用;但推荐排序、收藏优先、最近浏览这些排序会让列表顺序发生变化。中式美食里我更倾向于用“上一条记录的排序值 + recipeId”当游标,这样下一页知道自己从哪里继续。

asyncfunctionqueryNextRecipes(db:relationalStore.RdbStore,query:RecipeQuery,cursor:PageCursor){constpredicates=newrelationalStore.RdbPredicates('recipe').equalTo('category_id',query.categoryId).limit(cursor.pageSize)if(query.keyword.trim()){predicates.like('name',`%${query.keyword.trim()}%`)}if(cursor.lastScore!==undefined&&cursor.lastRecipeId){predicates.beginWrap().lessThan('score',cursor.lastScore).or().beginWrap().equalTo('score',cursor.lastScore).greaterThan('recipe_id',cursor.lastRecipeId).endWrap().endWrap()}predicates.orderByDesc('score').orderByAsc('recipe_id')returndb.query(predicates)}

这段代码的重点不是某个 API 多高级,而是排序必须稳定。只按score排,如果两道菜分数一样,下一页边界就会晃;加上recipe_id以后,边界才稳定。

合并列表前先按 recipeId 去重

分页结果回来后不要直接list.push(...next)。中式美食里同一道菜可能从搜索、推荐、收藏状态刷新里回来,所以合并前要按业务主键去重。

functionmergeRecipePage(current:RecipeItem[],next:RecipeItem[]){constmap=newMap<string,RecipeItem>()for(constitemofcurrent){map.set(item.recipeId,item)}for(constitemofnext){constold=map.get(item.recipeId)map.set(item.recipeId,old?{...old,...item}:item)}returnArray.from(map.values())}

这里不要用下标去重。下标只说明它在当前列表第几位,不说明它是哪一道菜。列表一排序,下标马上就失效。

请求版本用来挡住旧结果

下拉加载和切换分类经常会撞在一起。我的处理方式是每次条件变化都让requestVersion加一,接口回来后先比版本,不是当前版本就丢掉。

classRecipeListViewModel{privaterequestVersion=0privaterecipes:RecipeItem[]=[]privatecursor:PageCursor={pageSize:20,hasMore:true}asyncreload(query:RecipeQuery){constversion=++this.requestVersionthis.cursor={pageSize:20,hasMore:true}constpage=awaitthis.repository.queryFirstPage(query,this.cursor)if(version!==this.requestVersion)returnthis.recipes=page.itemsthis.cursor=page.cursor}asyncloadMore(query:RecipeQuery){if(!this.cursor.hasMore)returnconstversion=this.requestVersionconstpage=awaitthis.repository.queryNextPage(query,this.cursor)if(version!==this.requestVersion)returnthis.recipes=mergeRecipePage(this.recipes,page.items)this.cursor=page.cursor}}

这样做以后,页面不会因为旧请求回来而闪一下,也不会把旧分类的数据塞进新分类。

ArkUI 触底也要做防抖

ArkUI 列表触底时,滚动状态可能连续触发。这里不要把所有保护都压给 Repository,页面层也要挡住重复触发。

List({scroller:this.scroller}){ForEach(this.recipes,(recipe:RecipeItem)=>{RecipeCard({recipe,onOpen:this.openDetail})},(recipe:RecipeItem)=>recipe.recipeId)}.onReachEnd(()=>{if(this.loadingMore||!this.hasMore)returnthis.loadingMore=truethis.viewModel.loadMore(this.query).finally(()=>{this.loadingMore=false})})

这里的ForEach key也要用recipeId,不能用 index。分页列表一合并,用 index 做 key 会让旧卡片复用到新菜谱上,最后表现出来就是图片、收藏状态或者点击事件串行。

我会怎么验收

操作预期结果
连续快速滑到底部只进入一次加载中,不重复追加同一页
加载中切换分类旧请求回来后不改新分类列表
收藏一条菜谱再继续加载收藏状态保留,列表不重复
从详情页返回滚动位置和已有分页结果不乱
清空关键词游标重置,列表从默认条件重新查

最后我把这个问题归到一句话:分页不是简单的“下一页”。它其实是查询条件、排序边界、请求时序和列表合并共同决定的结果。中式美食这种菜谱应用,只要有分类、搜索、收藏和推荐排序,就必须把这几块拆清楚,不然后面页面越做越多,重复加载会越来越难查。

为什么不能只在 UI 层挡一下

这个问题最容易被临时修成“按钮防连点”或者“触底时加一个 loading”。这两个保护要有,但它们只能挡住一部分重复触发,挡不住旧请求回来,也挡不住排序边界漂移。中式美食的列表来源比较多,首页推荐、分类筛选、搜索结果和收藏状态都会影响同一组菜谱,只有 UI 层加锁,最后还是会在数据合并时出问题。

临时做法看起来解决了什么后面为什么还会出问题
触底时加 loading连续触底少触发一次旧请求回来仍然可能覆盖新列表
只用 pageIndex能取到下一页排序变化后 offset 边界不稳定
合并时直接 push代码最短同一道菜从不同入口回来会重复
切换分类时清数组页面看起来刷新了未完成请求仍然可能把旧数据塞回来

所以我的处理顺序是:先让查询条件可比较,再让下一页边界稳定,最后在合并时按recipeId做兜底。这样就算其中一层漏了一次,也不至于直接把重复数据展示给用户。

为什么用 recipeId 做最后兜底

菜谱列表里的下标不是身份。今天这道菜排第 3,明天因为收藏、评分、最近浏览变成第 8,这都很正常。真正能代表同一道菜的只有业务主键。中式美食里我把recipeId当成列表合并、收藏回写、详情跳转和最近浏览记录的同一条线,这样排查时不会每个模块都用一套身份判断。

字段能不能当身份原因
index不能列表一筛选、一排序就变
name不建议同名菜谱或别名会冲突
coverUrl不能图片可以替换,不能代表业务对象
recipeId可以详情、收藏、购物清单都能按它回到同一道菜

这也是为什么我在ForEach里坚持用recipeId当 key。列表分页本身已经有时序问题,如果 key 再不稳定,页面复用会把问题放大:看起来是滚动错位,实际可能是组件复用了旧状态。

以后怎么避免再踩

新增列表功能先问自己什么
新增排序排序字段是否稳定,是否需要第二排序键
新增筛选条件变化时是否重置游标和请求版本
新增收藏刷新单条更新是否会破坏当前分页结果
新增搜索联想搜索词变化是否会让旧分页结果回流
新增推荐混排推荐结果和普通列表是否用同一套 recipeId 去重

我现在更倾向于把分页逻辑写得啰嗦一点,也不要把所有判断散在页面里。页面只负责触发“我要下一页”,ViewModel 判断这次请求是否还有效,Repository 负责按稳定边界查数据。三层职责清楚以后,后面再加搜索、分类、推荐,问题也能定位到具体层,不会一出 bug 就只能整页重写。

最后还有一个小经验:分页问题一定要用连续操作压,不要只测一次正常下拉。至少要测“快速触底两次、加载中切分类、加载中返回详情、收藏后继续加载、清空关键词再加载”。这些动作都过了,才算分页状态真的稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询