先把问题说清楚
中式美食的菜谱列表不是一次性全量展示。首页、分类页、搜索页都会从 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 就只能整页重写。
最后还有一个小经验:分页问题一定要用连续操作压,不要只测一次正常下拉。至少要测“快速触底两次、加载中切分类、加载中返回详情、收藏后继续加载、清空关键词再加载”。这些动作都过了,才算分页状态真的稳。