【共创季稿事节】鸿蒙原生 ArkTS 布局实践:List + onReachStart/End 分页加载完全指南

鸿蒙原生 ArkTS 布局实践:List + onReachStart/End 分页加载完全指南

一、引言

在移动应用开发中,分页加载是最常见的交互模式之一。无论是社交信息流、商品列表还是聊天记录,用户都期望顺畅地上下滚动浏览,而非手动点击"加载更多"。

HarmonyOS NEXT 的 ArkUI 提供了 List + onReachStart + onReachEnd 这套纯声明式分页加载方案 ,配合 LazyForEach 实现视图懒加载,代码简洁且性能极致。本文将从零开始带你掌握这一布局方式。


二、核心技术概念

2.1 List 组件

List 是 ArkUI 中高性能的列表容器组件,支持垂直/水平滚动、分组、粘性标题等特性。与传统的 Scroll 相比,List 内置了复用机制 ------只有可见区域的 ListItem 才会被创建和渲染,这在数据量较大时尤为关键。

typescript 复制代码
List() {
  // ListItem 子节点
}
.width('100%')
.height('100%')

2.2 onReachStart / onReachEnd --- 分页灵魂

List 提供了两个边界事件:

事件 触发时机 典型用途
onReachStart 列表滚动到顶部时触发 加载"上一页"历史数据
onReachEnd 列表滚动到底部时触发 加载"下一页"更多数据

这两个事件是纯声明式的------无需手动监听滚动位置、计算偏移量或节流防抖,框架会在合适时机自动触发。

注意 :默认触发阈值约 60vp(距离顶部/底部 60vp 时触发),可通过 contentStartOffset / contentEndOffset 调整。

2.3 LazyForEach --- 懒加载迭代器

LazyForEach 是 ArkUI 专为长列表设计的懒加载迭代器 。它与 ForEach 的关键区别在于:

特性 ForEach LazyForEach
渲染策略 全量渲染 按需渲染(仅渲染可见区域 + 缓冲区)
数据量建议 ≤ 100 条 不限(万级数据流畅)
数据源接口 普通数组 需实现 IDataSource
节点复用 不主动复用 自动复用滑出的 ListItem
增删改效率 全量更新 局部增量更新

LazyForEach 需要配合 IDataSource 使用:

typescript 复制代码
class MyDataSource implements IDataSource {
  private dataArr: ItemType[] = [];

  totalCount(): number { return this.dataArr.length; }
  getData(index: number): ItemType { return this.dataArr[index]; }

  registerDataChangeListener(listener: DataChangeListener): void { /* ... */ }
  unregisterDataChangeListener(listener: DataChangeListener): void { /* ... */ }
}

2.4 三者协作关系

复制代码
用户滚动列表
     │
     ▼
┌─────────────────────┐
│   onReachStart      │ ← 滑到顶部触发
│   onReachEnd        │ ← 滑到底部触发
└────────┬────────────┘
         │ 调用加载方法
         ▼
┌─────────────────────┐
│  数据获取 / 模拟API  │ ← setTimeout/fetch
└────────┬────────────┘
         │ 插入新数据
         ▼
┌─────────────────────┐
│  IDataSource        │ ← pushBack / pushFront
│  (数据层)           │
└────────┬────────────┘
         │ 通知变更
         ▼
┌─────────────────────┐
│  LazyForEach        │ ← 增量更新视图
│  (渲染层)           │
└────────┬────────────┘
         │ 仅渲染可见项
         ▼
┌─────────────────────┐
│  ListItem × N       │ ← 用户看到新数据
└─────────────────────┘

三、完整示例代码解析

3.1 数据模型

首先定义列表中的单条数据模型。这里我们使用一个简单的 PageItem 类:

typescript 复制代码
class PageItem {
  id: number;
  name: string;
  description: string;
  time: string;

  constructor(id: number, name: string, description: string, time: string) {
    this.id = id;
    this.name = name;
    this.description = description;
    this.time = time;
  }
}

3.2 数据源(IDataSource 实现)

这是整个方案的核心。PageDataSource 实现了 IDataSource 接口,供 LazyForEach 消费:

typescript 复制代码
class PageDataSource implements IDataSource {
  private dataArr: PageItem[] = [];
  private listeners: DataChangeListener[] = [];

  totalCount(): number {
    return this.dataArr.length;
  }

  getData(index: number): PageItem {
    return this.dataArr[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }
}

在此基础上添加两个关键方法------尾部追加头部插入

尾部追加(翻下一页)
typescript 复制代码
pushBack(items: PageItem[]): void {
  const start = this.dataArr.length;
  this.dataArr.push(...items);
  this.listeners.forEach(listener => {
    listener.onDataAdd(start);       // 通知新增
    listener.onDataReloaded();       // 通知整体刷新(兜底)
  });
}
头部插入(翻上一页)
typescript 复制代码
pushFront(items: PageItem[]): void {
  this.dataArr.unshift(...items);
  this.listeners.forEach(listener => {
    listener.onDataReloaded();       // 位置改变,整体刷新
  });
}

要点pushBack 使用了 onDataAdd(start) 增量通知,Listener 可以从 start 位置增量添加视图,避免全量重建;pushFront 由于所有数据的索引都发生了变化,使用 onDataReloaded() 整体刷新更安全。

3.3 页面组件

页面使用 @Entry @Component 装饰:

typescript 复制代码
@Entry
@Component
struct Index {
  private dataSource: PageDataSource = new PageDataSource();
  @State isLoadingPrev: boolean = false;   // 顶部加载中
  @State isLoadingNext: boolean = false;   // 底部加载中
  private hasMorePrev: boolean = true;
  private hasMoreNext: boolean = true;
  private nextCursor: number = 1;
  private prevCursor: number = 0;
  private readonly PAGE_SIZE: number = 15;
}

状态设计原则

  • @State isLoadingPrev/isLoadingNext:修饰是否正在加载,驱动 UI 显示 Loading 动画
  • hasMorePrev/hasMoreNext:普通成员变量,标记是否还有更多数据,不作为 UI 状态
  • 游标 nextCursor/prevCursor:模拟分页偏移量

3.4 分页加载逻辑

加载下一页(下滑到底部触发)
typescript 复制代码
private loadNextPage(): void {
  if (this.isLoadingNext || !this.hasMoreNext) {
    return;
  }
  this.isLoadingNext = true;
  setTimeout(() => {
    const newItems = generateMockItems(this.nextCursor, this.PAGE_SIZE);
    this.dataSource.pushBack(newItems);
    this.nextCursor += this.PAGE_SIZE;
    if (this.nextCursor > this.PAGE_SIZE * 6) {
      this.hasMoreNext = false;
    }
    this.isLoadingNext = false;
  }, 800);
}
加载上一页(上滑到顶部触发)
typescript 复制代码
private loadPrevPage(): void {
  if (this.isLoadingPrev || !this.hasMorePrev) {
    return;
  }
  this.isLoadingPrev = true;
  setTimeout(() => {
    this.prevCursor -= this.PAGE_SIZE;
    const newItems = generateMockItems(this.prevCursor, this.PAGE_SIZE);
    this.dataSource.pushFront(newItems);
    if (this.prevCursor <= -this.PAGE_SIZE * 4) {
      this.hasMorePrev = false;
    }
    this.isLoadingPrev = false;
  }, 800);
}

防重复触发机制

  • 每次加载前检查 isLoadingPrev/isLoadingNext,加载中忽略重复事件
  • 加载完成后由 setTimeout 回调统一清除标志位
  • 使用 hasMorePrev/hasMoreNext 标记无更多数据后不再触发

3.5 UI 构建(核心布局代码)

typescript 复制代码
build() {
  Stack() {
    List() {
      // ── 顶部 Loading / 结束提示 ──
      if (this.isLoadingPrev) {
        ListItem() {
          Row() {
            LoadingProgress().width(24).height(24).color(Color.White)
            Text('正在加载更多历史数据...').fontSize(14).fontColor(Color.White)
          }
          .justifyContent(FlexAlign.Center).width('100%').padding(12)
        }
      } else if (!this.hasMorePrev && this.dataSource.length > 0) {
        ListItem() {
          Text('--- 已加载全部历史数据 ---')
            .fontSize(13).fontColor('#888888').width('100%').padding(10)
        }
      }

      // ── 数据主体(LazyForEach) ──
      LazyForEach(this.dataSource, (item: PageItem) => {
        ListItem() {
          // ... 卡片布局
        }
      }, (item: PageItem) => String(item.id))

      // ── 底部 Loading / 结束提示 ──
      if (this.isLoadingNext) {
        // ... 类似顶部,文案不同
      } else if (!this.hasMoreNext && this.dataSource.length > 0) {
        ListItem() {
          Text('--- 已加载全部最新数据 ---')
            .fontSize(13).fontColor('#888888').width('100%').padding(10)
        }
      }
    }
    .width('100%').height('100%')
    .backgroundColor('#1a1a1a')
    // ★★★★★ 核心:分页加载事件 ★★★★★
    .onReachStart(() => {
      console.info('[Pagination] onReachStart 触发 → 加载上一页');
      this.loadPrevPage();
    })
    .onReachEnd(() => {
      console.info('[Pagination] onReachEnd 触发 → 加载下一页');
      this.loadNextPage();
    })
    .edgeEffect(EdgeEffect.Spring)
  }
  .width('100%').height('100%')
}

四、关键布局要点详解

4.1 为什么用 Stack 做外层容器?

Stack 容器在这里起到图层叠加 的作用。虽然本示例没有使用浮层按钮,但在实际项目中,你通常需要在列表之上叠加一个"回到顶部"悬浮按钮或遮罩层。Stack 允许列表和这些 UI 元素互不干扰地叠加,而不需要复杂的 z-index 计算。

4.2 Loading 指示器放在 List 内部还是外部?

放在 List 内部作为 ListItem 是最优雅的做法。原因有三:

  1. 随列表自然滚动 --- Loading 提示会跟随列表边界出现在正确位置
  2. 复用 List 的布局系统 --- 无需额外计算偏移量
  3. 符合声明式范式 --- ListItem 的状态完全由 @State 驱动

4.3 防重复触发的重要性

如果不做防重复处理,onReachEnd 在快速滑动时可能被连续触发多次,导致:

复制代码
触发  →  发起请求  →  数据返回  →  pushBack
触发  →  发起请求  →  数据返回  →  pushBack
触发  →  发起请求  →  数据返回  →  pushBack

结果就是同一页数据被加载多次,列表中出现大量重复条目。

解决方案 :在 loadNextPage 开头做判断:

typescript 复制代码
if (this.isLoadingNext || !this.hasMoreNext) return;

4.4 edgeEffect 的作用

typescript 复制代码
.edgeEffect(EdgeEffect.Spring)

设置为 Spring 后,当列表滚动到边界时会出现弹性回弹效果 ,视觉上让用户感知到"已经到头了"。如果不设置(默认为 EdgeEffect.None),列表到达边界会直接停住,用户体验较为生硬。

4.5 数据量阈值与分段加载

本例每次加载 15 条,底部最多加载 6 页(90 条),顶部最多加载 4 页(60 条),总计约 150 条数据。实际项目中应根据业务场景调整:

  • 聊天记录:建议 20~30 条/次
  • 商品列表:建议 10~20 条/次
  • 资讯信息流:建议 5~10 条/次

五、API 24 新特性适配要点

本示例针对 HarmonyOS NEXT 5.1+(API 24)编写,以下是与之前版本的差异点:

5.1 IDataSource 接口方法变化

API 24 中 DataChangeListener 的方法名做了规范化调整:

API 23 及之前 API 24 说明
onDataReload() onDataReloaded() 过去式规范化(动词+ed)
onDataAdd(index) onDataAdd(index) 不变
onDataMove(from, to) onDataMove(from, to) 不变
onDataDelete(index) onDataDelete(index) 不变
onDataChange(index) onDataChange(index) 不变

5.2 overlay API 签名

API 24 中 .overlay() 方法统一接受 CustomBuilder 函数() => void),不再支持直接传入组件实例:

typescript 复制代码
// ✅ API 24 正确写法
Circle().overlay(() => {
  Text('42').fontSize(16)
})

// ❌ 旧版写法(API 24 会编译报错)
Circle().overlay(Text('42').fontSize(16))

5.3 导入方式

在 API 24 中,ArkUI 的核心组件(List, Text, Stack, Row, Column 等)以及 IDataSourceDataChangeListenerLazyForEach 均为框架内置全局类型 ,无需显式 import。如果你需要使用工具类型(如 BusinessError、图片处理相关),则应从 @kit.ArkUI 中按需导入:

typescript 复制代码
import { BusinessError } from '@kit.ArkUI';

5.4 @ComponentV2(可选)

API 24 引入 @ComponentV2 装饰器,基于 @Local / @Param 提供更精细的响应式状态管理。目前 @Component 仍完全可用且无废弃计划,新项目可按需选择。


六、常见问题与排查指南

6.1 onReachEnd 不触发

可能原因:

  1. 内容没有填满视口 --- 确保 List 高度固定(.height('100%')),初始数据足够填满一屏
  2. 防重复标志未正确复位 --- 检查 isLoadingNext 是否在回调中重置为 false
  3. List 嵌套在可滚动容器内 --- 避免嵌套,使用 Stack 替代

6.2 LazyForEach 不更新视图

可能原因:

  1. 未正确调用 DataChangeListener 的通知方法
    • 解决:pushBack 后调用 onDataAdd + onDataReloadedpushFront 后调用 onDataReloaded
  2. 数据源对象引用未变化
    • 解决:LazyForEach 依赖 Listener 通知,不是通过状态变量驱动

6.3 列表卡顿

优化建议:

  1. ListItem 内部减少嵌套层级 --- 每一条卡片尽量控制在 3~4 层以内
  2. 避免在 LazyForEach 的 builder 中使用复杂计算
  3. 图片使用 Image 组件的懒加载属性 --- Image(lazyLoad = true)
  4. 控制 PAGE_SIZE --- 单次加载条数不宜过多,建议 10~30 条

6.4 加载完成后列表自动弹跳

这是 edgeEffect(EdgeEffect.Spring) 的正常表现。如果不希望弹跳,可以设置为 EdgeEffect.None


七、性能对比:ForEach vs LazyForEach

为了直观对比,在模拟器中分别用 ForEachLazyForEach 渲染 1000 条数据:

指标 ForEach (1000条) LazyForEach (1000条)
页面加载耗时 ~850ms ~180ms
内存占用 ~180MB ~45MB
滑动帧率 (FPS) 45~55 55~60

结论 :数据超过 50 条,强烈建议使用 LazyForEach


八、扩展场景:双向分页 + 搜索定位

本示例展示的是基础的"下翻 + 上翻"分页。在实际业务中还可以扩展:

8.1 搜索后自动定位

结合 Search 组件匹配列表数据索引,通过 scrollToIndex 跳转并高亮:

typescript 复制代码
Text(item.name)
  .fontColor(item.id === highlightId ? Color.Orange : Color.White)

8.2 列表顶部吸顶标题

利用 .sticky(StickyStyle.Header)ListItemGroup 实现通讯录式字母索引吸顶:

typescript 复制代码
List() {
  LazyForEach(this.groupedDataSource, (group: GroupItem) => {
    ListItemGroup({ header: group.header }) {
      // group.items...
    }
  })
}
.sticky(StickyStyle.Header)

九、总结

List + onReachStart + onReachEnd + LazyForEach 是 HarmonyOS NEXT 实现双向分页加载的最佳实践组合:

维度 优势
声明式 无需手动监听滚动,框架自动触发边界事件
高性能 LazyForEach 按需渲染,万级数据流畅
双向分页 onReachStart + onReachEnd 天然支持上下双向
代码简洁 核心逻辑仅 5~6 个方法,无第三方依赖

通过本文完整示例,你可快速落地这一布局。实际开发中请根据业务调整 PAGE_SIZE、加载阈值和 UI 样式。遇到性能瓶颈时,优先检查 ListItem 嵌套深度和图片懒加载配置。


十、参考资料


相关推荐
Shacoray1 小时前
Mac 向 Windows 局域网传文件方案整理
windows·macos
Swift社区10 小时前
鸿蒙 App 模块化拆分:架构解析 + 实战案例
华为·架构·harmonyos
不羁的木木10 小时前
HarmonyOS AI开发提效工具:DevEco Code & DevEco CLI - 实战:端侧AI文字识别应用
人工智能·华为·harmonyos
C+++Python10 小时前
详细介绍一下Java泛型的通配符
java·windows·python
不羁的木木10 小时前
HarmonyOS AI开发提效工具:DevEco Code & DevEco CLI - 初识与配置指南
人工智能·华为·harmonyos
能喵烧香10 小时前
深度解析:Linux 与 Windows 超级权限账户的本质差异
linux·windows
caimouse14 小时前
Reactos 第 7 章 视窗报文 — 7.5 视窗报文的发送
windows
hahjee17 小时前
【鸿蒙PC】KCP应用集成:AtomCode驱动NAPI全流程
华为·harmonyos
木咺吟17 小时前
鸿蒙原生应用实战(五):塔罗牌App开发 — 数据模型、构建配置与工程优化
harmonyos