HarmonyOS ArkUI 分页列表防重:游标推进、重复项合并与刷新隔离
长列表里“到底了”不总意味着下一页成功:列表底部事件可能在初次布局时就触发,网络重试可能重复返回上一页,用户下拉刷新时旧分页请求也可能最后才回来。若每次回调都直接 append,页面会出现重复卡片、顺序回跳,甚至刷新后的新结果又被旧结果覆盖。
这类问题的关键不是多加一个isLoading,而是让分页请求有明确身份:它属于哪个查询代次、请求哪个游标、当前是否已有同游标请求在途。响应只有通过这些核对,才能按稳定 ID 合并并推进游标。本文用应用侧纯逻辑模型说明这个边界;代码片段用于解释决策规则,不声称已完成 HarmonyOS 编译、系统联调或设备验收。
HarmonyOS 的ListAPI 文档说明了列表容器和到达末尾回调,也提醒了内容未撑满视口时首次加载可能触发到达末尾事件;因此回调本身不能当成“用户只触发了一次”的保证。ArkUI List API 官方列表流示例还展示了在列表底部加载更多的典型交互。List 组件信息流场景
1. 把分页状态建成显式状态机
列表至少需要区分idle、loading、exhausted和failed。布尔值容易出现矛盾组合,例如loading=true同时hasMore=false,或请求失败后游标已经提前推进。状态机可以明确规定:失败保留已提交游标,重试仍请求同一页;只有成功响应通过校验后才推进游标。
分页状态还要绑定查询代次。用户更改关键词、筛选条件或显式刷新时,开启新代次并清理旧请求资格。这样无需指望底层请求一定可取消,迟到响应也不能写回新列表。
typeLoadStatus='idle'|'loading'|'failed'|'exhausted';interfacePageState<T>{generation:number;queryKey:string;cursor:string|null;status:LoadStatus;rows:T[];inFlightCursor:string|null;requestId:number;}interfacePageToken{generation:number;queryKey:string;cursor:string|null;requestId:number;}cursor表示下一次尚未提交的分页位置,而不是“最后一次发出的页码”。inFlightCursor用来避免重复触发产生并行的同页请求;requestId则进一步区分同一代次里失败重试的响应。
2. 先规范查询键,再谈同页去重
底部回调、用户快速上拉和自动预加载可能几乎同时到达。如果系统只检查loading的可见状态,状态更新时序仍可能让两个调用都启动请求。协调层应在发请求之前同步登记inFlightCursor和请求令牌。
查询键需要包含会改变结果集或排序的条件。字符串做规范化,集合筛选排序去重,分页大小也纳入键;否则不同查询共享一组页面,或者同一个查询被误当成多个请求范围。
interfaceQueryInput{keyword:string;filterIds:string[];order:'newest'|'popular';pageSize:number;}exportfunctionmakeQueryKey(input:QueryInput):string{if(!Number.isSafeInteger(input.pageSize)||input.pageSize<1){thrownewError('pageSize must be a positive safe integer');}constfilterIds=[...newSet(input.filterIds.map((id)=>id.trim()).filter(Boolean))].sort();returnJSON.stringify({keyword:input.keyword.trim(),filterIds,order:input.order,pageSize:input.pageSize,});}不要把用户输入里的临时空格、筛选器点击顺序放进键;但任何会影响服务端结果的字段都必须进入键。条件契约改变时同步更新回归用例。
3. 到达末尾事件只是提示,不是分页命令
列表布局变化、初始索引、窗口尺寸变化或数据不足一屏,都可能让“已到末尾”回调在没有真实滚动动作时发生。官方 List API 对这类触发时机有说明。因此事件处理器应只向状态协调器发出“可以尝试加载”的信号,由协调器检查 loading、hasMore、游标和代次后决定是否发请求。
exportfunctioncanStartNextPage<T>(state:PageState<T>):boolean{returnstate.status!=='loading'&&state.status!=='exhausted'&&state.inFlightCursor===null;}exportfunctionbeginNextPage<T>(state:PageState<T>):{state:PageState<T>;token:PageToken}|null{if(!canStartNextPage(state))returnnull;constrequestId=state.requestId+1;consttoken={generation:state.generation,queryKey:state.queryKey,cursor:state.cursor,requestId,};return{token,state:{...state,status:'loading',inFlightCursor:state.cursor,requestId},};}实际页面回调应当能安全地重复调用。无论事件来自用户滚动还是首屏布局,重复信号都只会触发一次状态转换;状态已是exhausted时不额外请求。首次请求可以使用cursor=null,必须由状态区分它不是已结束。
4. 刷新要开新代次,而不是和下一页共用状态
刷新与 load more 是两种不同的并发操作。刷新通常改变数据基线,应增加generation、清空旧列表并从首页游标开始;之后旧代次的分页结果即使返回成功,也必须被丢弃。不能只把loading设置回 false,因为旧 Promise 仍然存在。
exportfunctionstartRefresh<T>(state:PageState<T>,queryKey:string):{state:PageState<T>;token:PageToken}{constgeneration=state.generation+1;constrequestId=state.requestId+1;consttoken={generation,queryKey,cursor:null,requestId};return{token,state:{generation,queryKey,cursor:null,status:'loading',rows:[],inFlightCursor:null,requestId,},};}刷新是否保留旧内容作为过渡 UI,是产品体验选择;但“旧内容暂时展示”和“旧请求可以更新新状态”是两回事。过渡期可以保留旧视图快照,数据提交仍要由新代次控制。
5. 响应提交前核对代次、查询键和游标
响应是否成功,不等于是否仍然有效。请求可能属于旧筛选条件,或同一游标已经由一次更新的重试替代。提交前应同时匹配 generation、queryKey、requestId 和 in-flight cursor;任何一项不一致就忽略该响应,不推进状态。
exportfunctionisCurrentPageResponse<T>(state:PageState<T>,token:PageToken):boolean{returntoken.generation===state.generation&&token.queryKey===state.queryKey&&token.requestId===state.requestId&&token.cursor===state.inFlightCursor;}对被忽略的迟到结果可以记录低敏诊断字段,例如代次和响应类别,但避免把完整关键词或个人筛选值写入日志。若可以取消请求,也仍保留响应门禁,因为取消与响应到达之间存在竞态。
6. 用稳定 ID 合并页面,保留已有顺序
分页接口偶尔会重叠返回边界记录,重试也可能重放上一页。以数组索引或对象引用判断重复都不稳;选择不可变、业务唯一的 ID 做键。合并策略应明确处理“列表中已有”的记录:更新字段但保留原位置,除非产品明确要求重新排序并刷新整个结果集。
exportfunctionmergeById<Textends{id:string}>(existing:T[],incoming:T[]):T[]{constpositions=newMap<string,number>();constmerged=existing.map((item,index)=>{if(!item.id||positions.has(item.id))thrownewError('existing IDs must be unique');positions.set(item.id,index);returnitem;});for(constitemofincoming){if(!item.id)thrownewError('incoming item ID is required');constindex=positions.get(item.id);if(index===undefined){positions.set(item.id,merged.length);merged.push(item);}else{merged[index]=item;}}returnmerged;}此实现对重复 ID 的后到版本执行更新,对新 ID 追加到末尾。若排序字段可能变化,更新但不移动位置只适用于“当前页顺序在会话内冻结”的产品规则;否则要从受影响位置重新排序,不能静默改变合并语义。
7. 游标必须前进,失败时绝不提前消费
偏移页码可以用递增整数,服务端游标则通常是不透明 token。客户端不应解析 token 猜页数,但仍能做两项通用检查:返回的nextCursor不应等于本次请求 cursor;同一个响应不能被再次提交。遇到游标不推进,应进入错误状态并停止自动加载,避免onReachEnd反复触发形成请求风暴。
更重要的是提交顺序:先校验令牌与响应,再合并数据,最后才把cursor更新为nextCursor。网络错误或数据解码失败时保留请求用的旧 cursor,以便在明确重试策略下请求同一页。
interfacePageResponse<T>{requestedCursor:string|null;nextCursor:string|null;items:T[];}exportfunctionvalidatePageResponse<T>(token:PageToken,response:PageResponse<T>):void{if(response.requestedCursor!==token.cursor)thrownewError('response cursor does not match request');if(!Array.isArray(response.items))thrownewError('items must be an array');if(response.nextCursor!==null&&response.nextCursor===token.cursor){thrownewError('cursor did not advance');}}不要把空数组直接解释为结束,也不要只靠totalCount决定末页。以接口契约定义终止语义:例如nextCursor=null明确代表无后续页;若服务端提供 hasMore,则交叉校验字段并处理不一致响应。
8. 把失败、重试和结束语义分开
failed表示这次加载没有完成,exhausted表示服务端明确给出终止条件。两者的 UI 和重试行为不同:失败可显示重试入口且保留旧 cursor;结束状态显示列表尾部,不继续监听信号发请求。
重试应重新分配 requestId,但继续使用相同 cursor 和 generation;如果此期间用户刷新或更改查询,旧的重试按钮不能用旧 token 发请求。页面层只调用协调器,不自行拼 URL、合并数组或推进游标。
| 结果 | 游标处理 | 列表处理 | 后续状态 |
|---|---|---|---|
| 请求成功且 token 当前 | 提交nextCursor | 按 ID 合并 | idle或exhausted |
| 网络/解析失败 | 保留请求 cursor | 保留已提交行 | failed |
| 代次或请求 ID 不匹配 | 不推进 | 不写入 | 忽略旧响应 |
| nextCursor 与请求相同 | 不推进 | 不自动重复合并 | failed并停自动加载 |
| 用户显式刷新 | 重置为首页游标 | 建立新基线 | 新代次loading |
9. ArkUI 列表层只负责呈现与发出意图
ArkUIList适合呈现连续同类数据,官方资料说明可配合懒加载机制处理较多子项。视图层的职责是展示rows/loading/error/exhausted,处理滚动和刷新交互,再把loadMore或refresh意图交给控制器。不要把onReachEnd直接连到未经门控的网络函数。
状态层应输出稳定 ID 和不可变列表快照。对 LazyForEach 或其他数据源适配器,key 需要稳定且唯一;列表组件的实际构建、复用策略和 API 细节要遵循目标 SDK 文档,并通过项目自己的设备/系统验证。本文纯逻辑例子不等同于真实 ArkUI 列表实现。
10. 测试重点放在重复、乱序和边界
最容易漏掉的不是正常第一页,而是多个事件紧挨着触发:初始布局底部回调、用户滚动回调和重试同时到达;某页返回时触发刷新;相邻页包含重复 ID;服务端游标返回原值;或旧查询响应晚于新查询。
- 同一 cursor 被快速请求两次,只产生一个有效 token。
- 刷新后旧 generation 响应被拒绝,不能清空或覆盖新结果。
- 不同请求代次中同一 ID 的更新规则确定且顺序稳定。
- 游标不前进时阻止自动继续请求。
- 空列表、空页、有下一游标、无下一游标分别验证。
- 失败重试使用同 cursor、新 requestId;刷新则使用新 generation。
纯函数和状态机断言能验证策略,但不能证明 ArkUI 回调时序、网络库取消能力或真实设备滚动体验。需要分别记录纯逻辑测试、目标 API 页面集成验证和设备验证,未实际执行的项保持“待验证”。
11. 常见症状与定位顺序
| 症状 | 首先检查 | 处理方式 |
|---|---|---|
| 底部连续重复请求 | 初次布局回调、inFlight 状态登记时机 | 状态转换同步门控;避免把事件次数当业务意图 |
| 每页重复几条 | 合并键是否稳定唯一 | 用业务 ID 去重,定义覆盖但不移位的规则 |
| 刷新完成后旧内容回归 | 响应是否携带 generation | 刷新开启新代次,旧响应一律忽略 |
| 失败后重试总是跳页 | 是否请求前就推进 cursor | 成功校验后才提交下一游标 |
| 列表卡在加载中 | 异常路径是否清理 inFlight | 成功、失败、取消都结束本次加载状态 |
| 到底后一直转圈 | 空页/重复 cursor 的终止条件 | 检测无进展并停止自动加载,给出可诊断错误 |
排查时先看请求日志中的 queryKey 摘要、generation、requestId、cursor 摘要和结果数量,再对照状态转换。不要记录完整敏感查询参数,也不要仅凭 UI 上的 loading 图标判断请求是否重复。
12. 落地清单:事件可重复,状态提交要严格
- 查询键覆盖影响结果的字段,并有确定的规范化规则。
- load more 开始前同步登记 generation、cursor 与 requestId。
- 同游标请求被门控,终态不再触发自动加载。
- 刷新提升 generation;旧分页响应不能写入新列表。
- 响应校验请求游标、返回数据结构和 nextCursor 推进情况。
- 稳定 ID 合并策略明确决定更新时保序还是重排。
- 游标只在成功校验并提交数据后推进。
- 失败、取消、结束态都有独立恢复路径。
- 纯逻辑、ArkUI 集成和设备验证分别记录,不相互冒充。
把列表底部回调视为可重复信号,把游标和请求令牌视为提交凭据,分页就不再依赖“希望每次事件恰好只来一次”。再配合稳定 ID 合并和刷新代次隔离,重复事件、重叠页面与乱序响应都有明确处置策略。
正确的分页不是收到一包数据就 append,而是先证明响应属于当前查询、当前代次和当前游标,再以稳定 ID 合并、最后推进位置。失败不消费 cursor,刷新另起代次,过期响应没有写权限。
这套规则可以先在纯逻辑层形成可重复测试的契约,再接入 ArkUI 列表和仓库适配层。List的懒加载与末尾回调负责交互便利,业务状态机负责避免重复和乱序;两层职责分开后,列表故障也更容易定位和回归。