HarmonyOS 列表分页稳定性实战:游标分页、去重合并与加载状态治理
HarmonyOS 列表分页稳定性实战:游标分页、去重合并与加载状态治理
列表分页的问题往往不是“接口没返回数据”,而是状态没管住:下拉刷新和加载更多同时触发,旧请求覆盖新列表,分页数据重复,游标丢失后底部一直转圈,删除一条数据后下一页出现错位。列表页越核心,分页稳定性越重要。

本文围绕一个目标展开:在 HarmonyOS 应用中设计一套可复用的分页状态模型,让游标、请求状态、去重合并、刷新重置和错误恢复都有明确规则。
一、分页先选口径:pageNo 还是 cursor
如果列表数据变化频繁,游标分页通常比 pageNo 更稳。pageNo 适合静态列表,cursor 更适合动态流。
| 分页方式 | 适合场景 | 风险 |
|---|---|---|
| pageNo | 后台管理、静态列表 | 数据插入后可能重复或漏数据 |
| cursor | 动态内容流、消息、路线列表 | 需要保存 nextCursor |
| offset | 简单查询列表 | 大数据量性能差 |
| local slice | 本地缓存展示 | 刷新和远端同步要额外处理 |

本文示例使用 cursor,因为它更适合移动端动态内容流。
二、资料与版本边界:本文写应用层分页治理
本文示例面向 HarmonyOS NEXT / ArkTS / ArkUI 工程,重点在应用层分页治理:分页状态、游标请求、去重合并、刷新重置、加载错误和验收排查。真实项目需要结合后端分页协议、列表组件、缓存策略和业务排序字段。
| 分页治理层 | 本文覆盖内容 | 项目确认点 |
|---|---|---|
| 请求层 | cursor、pageSize、请求序号 | 后端分页协议 |
| 状态层 | idle、refreshing、loadingMore、failed | 页面状态管理 |
| 数据层 | 去重、合并、hasMore | 唯一 id 和排序规则 |
| 交互层 | 下拉刷新、触底加载、重试 | ArkUI List 触发点 |
| 验收层 | 重复、漏数据、乱序、弱网 | 测试数据和真机 |

分页接口接入前先确认排序字段
列表分页要稳定,前端和后端必须共享同一种排序口径。很多重复、漏数据、刷新错位,都来自“第一页按更新时间排,下一页按创建时间游标取”的接口不一致。
| 接口字段 | 推荐含义 | 页面使用方式 |
|---|---|---|
items |
当前页数据 | 合并到列表状态 |
nextCursor |
下一页游标 | 作为下一次请求入参 |
hasMore |
是否还有数据 | 控制触底加载 |
sortKey |
当前排序口径 | 判断刷新后是否需要清空旧列表 |
requestId |
请求追踪 | 排查乱序和重复触发 |
读者接入时要特别确认:游标是否和筛选条件绑定。如果用户切换筛选项后仍然使用旧游标,列表很容易混入其他条件的数据。
三、分页状态模型:不要只保存 items
分页至少要保存列表数据、游标、是否还有更多、加载状态和请求序号。
export type PagingStatus = 'idle' | 'refreshing' | 'loadingMore' | 'failed';
export interface PagingItem {
id: string;
title: string;
updatedAt: number;
}
export interface CursorPagingState {
items: PagingItem[];
nextCursor: string;
hasMore: boolean;
status: PagingStatus;
requestSeq: number;
errorMessage: string;
}
export function createPagingState(): CursorPagingState {
return {
items: [],
nextCursor: '',
hasMore: true,
status: 'idle',
requestSeq: 0,
errorMessage: ''
};
}
这段状态模型解决的是页面分页事实,不直接关心 UI 怎么画。requestSeq 用来保护请求乱序,nextCursor 决定下一页从哪里开始。
四、触发加载前先判断能不能请求
触底加载经常被重复触发。如果不做状态判断,一次滑动可能发出多个加载更多请求。
export function canLoadMore(state: CursorPagingState): boolean {
if (!state.hasMore) {
return false;
}
if (state.status === 'refreshing' || state.status === 'loadingMore') {
return false;
}
return true;
}
export function beginLoadMore(state: CursorPagingState): CursorPagingState {
return {
...state,
status: 'loadingMore',
requestSeq: state.requestSeq + 1,
errorMessage: ''
};
}
这段代码把加载条件集中在一起。页面触底时先调用 canLoadMore,能有效避免底部加载器反复触发。
五、去重合并:新页数据不能直接拼接
分页接口可能返回重复数据,尤其在列表刷新或数据动态插入时。合并时要按 id 去重。
export interface PageResponse {
items: PagingItem[];
nextCursor: string;
hasMore: boolean;
}
export function mergePageItems(oldItems: PagingItem[], newItems: PagingItem[]): PagingItem[] {
const map = new Map<string, PagingItem>();
for (const item of oldItems) {
map.set(item.id, item);
}
for (const item of newItems) {
map.set(item.id, item);
}
return Array.from(map.values()).sort((a, b) => b.updatedAt - a.updatedAt);
}
export function finishLoadMore(state: CursorPagingState, response: PageResponse, requestSeq: number): CursorPagingState {
if (requestSeq !== state.requestSeq) {
return state;
}
return {
...state,
items: mergePageItems(state.items, response.items),
nextCursor: response.nextCursor,
hasMore: response.hasMore,
status: 'idle'
};
}
这段代码做了两件事:按 id 去重,并用请求序号避免旧响应覆盖当前状态。排序字段要和业务列表一致,不能随便改。
六、刷新重置:刷新和加载更多不是同一种请求
下拉刷新应该重置 cursor 和错误状态,但不能立刻清空旧列表,否则弱网下页面会闪空。
export function beginRefresh(state: CursorPagingState): CursorPagingState {
return {
...state,
status: 'refreshing',
requestSeq: state.requestSeq + 1,
errorMessage: ''
};
}
export function finishRefresh(state: CursorPagingState, response: PageResponse, requestSeq: number): CursorPagingState {
if (requestSeq !== state.requestSeq) {
return state;
}
return {
...state,
items: response.items,
nextCursor: response.nextCursor,
hasMore: response.hasMore,
status: 'idle'
};
}
刷新成功后替换列表,加载更多成功后合并列表。把这两个动作分开,能减少“刷新后列表重复”的问题。
七、失败恢复:首屏失败和加载更多失败不同
首屏失败可以展示错误页;加载更多失败则应该保留当前列表,只在底部提供重试。
export interface PagingFailureView {
showFullPageError: boolean;
footerMessage: string;
canRetry: boolean;
}
export function buildPagingFailureView(state: CursorPagingState): PagingFailureView {
if (state.items.length === 0) {
return {
showFullPageError: true,
footerMessage: '',
canRetry: true
};
}
return {
showFullPageError: false,
footerMessage: state.errorMessage.length > 0 ? state.errorMessage : '加载失败,点击重试',
canRetry: true
};
}
这段代码把失败视图和数据状态绑定起来。用户已经看到列表时,不要因为下一页失败把整个页面替换成错误页。
八、列表分页问题排查表
| 列表异常表现 | 优先怀疑的分页状态 | 排查方式 | 修复动作 |
|---|---|---|---|
| 列表出现重复项 | 新页直接拼接 | 检查 mergePageItems |
按唯一 id 去重 |
| 底部一直加载 | hasMore 或 status 未更新 |
查看 CursorPagingState |
成功和失败都要结束状态 |
| 刷新后数据错乱 | 刷新和加载更多同时返回 | 检查 requestSeq |
只接受最新请求 |
| 下一页漏数据 | cursor 没保存或传错 | 打印 nextCursor |
使用后端返回游标 |
| 弱网失败页面清空 | 加载更多失败替换全页 | 查看 buildPagingFailureView |
保留旧列表,只展示底部错误 |
| 删除后分页错位 | 本地删除和远端 cursor 不一致 | 对比删除后的列表 id | 删除后刷新首屏或重置游标 |
分页排查不要只看接口返回。要同时看触发条件、请求序号、合并规则和页面失败状态。
九、分页上线前验收表
| 分页验收场景 | 通过条件 |
|---|---|
| 首屏加载 | 成功、失败、空列表都有明确 UI |
| 下拉刷新 | 不与加载更多互相覆盖 |
| 触底加载 | 不会重复发起加载更多 |
| 数据合并 | 重复 id 不会出现两次 |
| 请求乱序 | 旧响应不会覆盖新列表 |
| 弱网失败 | 保留已有列表并允许重试 |
| 数据变动 | 插入、删除、刷新后列表仍稳定 |
验收分页要准备会变动的数据。只用静态假数据,很难发现重复和漏数据。
用列表快照复盘重复和漏数据
分页问题发生时,光看页面截图很难判断原因。建议在开发环境保留一次合并前后的快照,记录旧列表、新页、去重数量和游标变化。
export interface PagingMergeSnapshot {
beforeCount: number;
incomingCount: number;
afterCount: number;
duplicateCount: number;
previousCursor: string;
nextCursor: string;
}
export function createPagingMergeSnapshot(
beforeIds: string[],
incomingIds: string[],
afterIds: string[],
previousCursor: string,
nextCursor: string
): PagingMergeSnapshot {
const incomingSet = new Set(incomingIds);
const duplicateCount = beforeIds.filter(id => incomingSet.has(id)).length;
return { beforeCount: beforeIds.length, incomingCount: incomingIds.length, afterCount: afterIds.length, duplicateCount, previousCursor, nextCursor };
}
这段快照不影响生产逻辑,但很适合定位分页异常。如果 duplicateCount 持续偏高,要看后端游标;如果 afterCount 没增加但 hasMore 仍为真,要看合并和状态更新。
分页页要分阶段接,不要一次写完
第一阶段只做首屏:成功、失败、空列表三种 UI 必须清楚。首屏没有稳定之前,不要急着加触底加载。
第二阶段接加载更多:触底前先判断 hasMore、loadingMore 和 nextCursor。只要有一个条件不满足,就不要发请求。
第三阶段接刷新:刷新不是加载更多,刷新成功后要重置游标和列表;刷新失败时保留旧列表,不要把页面清空。
第四阶段处理去重:合并时按业务唯一 id 去重,不要用数组下标。数据变化频繁的列表,后端插入新数据后更容易出现重复。
第五阶段加弱网测试:请求返回顺序不可控时,只允许最新请求更新列表。读者可以用本地 sequence 或请求时间戳保护页面状态。
十、列表分页相关官方资料
- 华为开发者文档:ArkUI List
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-list - 华为开发者文档:ArkUI 滚动容器
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-scroll - 华为开发者文档:网络管理
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/net-connection-overview - 华为开发者文档:Stage 模型应用开发
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/stage-model-development-overview
十一、把列表分页做成状态机
分页稳定性的本质是状态机。请求前判断能不能加载,请求中记录序号,请求后按刷新或加载更多分别处理,失败时按首屏和非首屏区分展示。
| 分页状态问题 | 稳定处理方式 |
|---|---|
| 什么时候能加载更多 | hasMore 为真且没有正在加载 |
| 下一页从哪里开始 | 使用后端返回的 nextCursor |
| 数据怎么合并 | 按 id 去重,再按业务排序 |
| 请求乱序怎么办 | 用 requestSeq 只接受最新响应 |
| 加载更多失败怎么办 | 保留旧列表,底部显示重试 |
更多推荐




所有评论(0)