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 必须清楚。首屏没有稳定之前,不要急着加触底加载。

第二阶段接加载更多:触底前先判断 hasMoreloadingMorenextCursor。只要有一个条件不满足,就不要发请求。

第三阶段接刷新:刷新不是加载更多,刷新成功后要重置游标和列表;刷新失败时保留旧列表,不要把页面清空。

第四阶段处理去重:合并时按业务唯一 id 去重,不要用数组下标。数据变化频繁的列表,后端插入新数据后更容易出现重复。

第五阶段加弱网测试:请求返回顺序不可控时,只允许最新请求更新列表。读者可以用本地 sequence 或请求时间戳保护页面状态。

十、列表分页相关官方资料

  1. 华为开发者文档:ArkUI List
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-list
  2. 华为开发者文档:ArkUI 滚动容器
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-scroll
  3. 华为开发者文档:网络管理
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/net-connection-overview
  4. 华为开发者文档:Stage 模型应用开发
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/stage-model-development-overview

十一、把列表分页做成状态机

分页稳定性的本质是状态机。请求前判断能不能加载,请求中记录序号,请求后按刷新或加载更多分别处理,失败时按首屏和非首屏区分展示。

分页状态问题 稳定处理方式
什么时候能加载更多 hasMore 为真且没有正在加载
下一页从哪里开始 使用后端返回的 nextCursor
数据怎么合并 按 id 去重,再按业务排序
请求乱序怎么办 requestSeq 只接受最新响应
加载更多失败怎么办 保留旧列表,底部显示重试
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐