鸿蒙开发之 LazyForEach 长列表加载与下拉刷新实践
在移动应用开发中,长列表的渲染性能和交互体验是决定应用品质的关键因素。HarmonyOS 的 ArkUI 框架为长列表场景提供了 LazyForEach 数据懒加载、Refresh 下拉刷新和 onReachEnd 触底加载等核心能力。本文将结合一个完整的「新闻列表」Demo,带你实战这些技术的组合使用。
一、效果预览
本文 Demo 包含以下功能:
-
LazyForEach 实现数据懒加载,仅渲染可视区域的列表项
-
下拉刷新,模拟网络请求获取最新数据
-
触底加载更多,自动请求分页数据
-
SwipeAction 侧滑操作,支持左滑删除
-
加载状态 Toast 提示
![]() |
![]() |
![]() |
| ▲ 侧滑删除 | ▲ 列表主页 | ▲ 下拉刷新 |
二、核心概念速览
| API | 作用 | 关键点 |
|---|---|---|
LazyForEach |
按需渲染列表项,只创建可见区域内的组件 | 必须配合 IDataSource 接口实现数据源 |
IDataSource |
定义数据源接口 | 需实现 totalCount()、getData()、registerDataChangeListener() 等方法 |
Refresh |
下拉刷新容器 | 包裹 List,通过 $$refreshing 双向绑定刷新状态 |
onReachEnd |
列表滚动到底部回调 | 触发加载更多逻辑 |
cachedCount |
预加载屏幕外 N 条 item | 防止快速滚动时白屏 |
ListItem.swipeAction |
侧滑操作 | 常用于删除、置顶等场景 |
三、完整代码
以下代码为完整的 @Entry 页面,包含数据源、列表渲染、下拉刷新、加载更多和侧滑删除功能。
// pages/Index.ets
// 鸿蒙 LazyForEach + 下拉刷新 + 加载更多 实战 Demo
// API 24 Release (HarmonyOS 6.1.1) 适用
import { promptAction } from '@kit.ArkUI';
// ============================================================
// 数据模型
// ============================================================
class NewsItem {
id: number;
title: string;
summary: string;
source: string;
time: string;
imageColor: string; // 模拟封面图颜色
constructor(id: number, title: string, summary: string,
source: string, time: string, imageColor: string) {
this.id = id;
this.title = title;
this.summary = summary;
this.source = source;
this.time = time;
this.imageColor = imageColor;
}
}
// ============================================================
// 模拟数据生成器
// ============================================================
const NEWS_TITLES: string[] = [
'HarmonyOS 7 开发者预览版发布',
'ArkUI 声明式 UI 最佳实践总结',
'DevEco Studio 6.1.1 新特性解读',
'TaskPool 多线程性能优化指南',
'Navigation 路由导航深度解析',
'ArkWeb 混合开发踩坑记录',
'Camera Kit 相机开发完整指南',
'AppGallery Connect 上架流程',
'Audio Kit 音频播放架构设计',
'Form Kit 服务卡片开发实战',
];
const NEWS_SUMMARIES: string[] = [
'本次更新带来了全新的 Material Design 视觉风格,API 26 新增了多个系统级 Kit 能力...',
'从 V1 到 V2 状态管理迁移过程中,需要重点关注 @ObservedV2 和 @Trace 的配合使用...',
'新增了 Hot Reload for C++、ComMemory 内存分析、strictCheckerOnly 快速语法检查等实用功能...',
'使用 TaskPool.execute 替代 Worker 处理短时并发任务,可以降低线程创建开销约 60%...',
'NavPathStack 提供了 pushPath、pop、replacePath 等完整的路由栈操作,配合 navDestination 实现灵活跳转...',
'在 Web 组件中使用 javaScriptProxy 实现 ArkTS 与 JS 的互相调用,需要注意线程安全问题...',
'通过 CameraInput + PreviewOutput + PhotoOutput 构建完整的拍照流程,支持前后摄像头切换...',
'应用上架前需要完成 App Signing、Content Rating、Privacy Policy 等配置,审核周期通常 1-3 个工作日...',
'AVSession 是后台音频播放的必需组件,缺少它系统会在应用退后台时强制暂停音频播放...',
'服务卡片开发需要 FormExtensionAbility + 卡片 UI 页面配合,支持 1×2、2×2、2×4 等多种尺寸...',
];
const SOURCES: string[] = [
'华为开发者官网', '鸿蒙技术社区', 'DevEco 博客',
'ArkUI 团队', '开发者日报', 'HarmonyOS 周刊',
];
const COLORS: string[] = [
'#007DFF', '#FF6B35', '#00B578', '#FF8F1F',
'#0A59F7', '#E84026', '#6B3DE8', '#1EA89E',
];
function generateMockNews(startId: number, count: number): NewsItem[] {
const items: NewsItem[] = [];
for (let i = 0; i < count; i++) {
const id = startId + i;
items.push(new NewsItem(
id,
NEWS_TITLES[id % NEWS_TITLES.length],
NEWS_SUMMARIES[id % NEWS_SUMMARIES.length],
SOURCES[id % SOURCES.length],
`${Math.floor(Math.random() * 60)}分钟前`,
COLORS[id % COLORS.length]
));
}
return items;
}
// ============================================================
// IDataSource 实现(LazyForEach 的数据源适配器)
// ============================================================
class NewsDataSource implements IDataSource {
private dataArray: NewsItem[] = [];
private listeners: DataChangeListener[] = [];
// 获取总数据量
totalCount(): number {
return this.dataArray.length;
}
// 获取指定位置的数据
getData(index: number): NewsItem {
return this.dataArray[index];
}
// 追加数据(用于加载更多)
appendData(items: NewsItem[]): void {
this.dataArray.push(...items);
this.notifyDataReload();
}
// 重置数据(用于下拉刷新)
resetData(items: NewsItem[]): void {
this.dataArray = items;
this.notifyDataReload();
}
// 删除数据
deleteData(index: number): void {
this.dataArray.splice(index, 1);
this.notifyDataReload();
}
// 获取所有数据(用于调试或进一步处理)
getAllData(): NewsItem[] {
return this.dataArray;
}
// 注册数据变更监听
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
// 注销数据变更监听
unregisterDataChangeListener(listener: DataChangeListener): void {
const idx = this.listeners.indexOf(listener);
if (idx >= 0) {
this.listeners.splice(idx, 1);
}
}
// 通知所有监听器:数据已重新加载
private notifyDataReload(): void {
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDataReloaded();
}
}
// ======== DataChangeListener 接口必需实现的方法 ========
// 当前方法名(API 21+)
// onDataReloaded() — 上面已实现
// onDataAdd / onDataDelete / onDataChange / onDataMove — 下面实现
onDataAdd(index: number): void {
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDataAdd(index);
}
}
onDataDelete(index: number): void {
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDataDelete(index);
}
}
onDataChange(index: number): void {
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDataChange(index);
}
}
onDataMove(from: number, to: number): void {
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDataMove(from, to);
}
}
// ======== 废弃方法名(API 21 仍需实现,否则编译报错)========
onDataAdded(index: number): void {
this.onDataAdd(index);
}
onDataDeleted(index: number): void {
this.onDataDelete(index);
}
onDataChanged(index: number): void {
this.onDataChange(index);
}
onDataMoved(from: number, to: number): void {
this.onDataMove(from, to);
}
onDatasetChange(dataOperations: DataOperation[]): void {
for (let i = 0; i < this.listeners.length; i++) {
this.listeners[i].onDatasetChange(dataOperations);
}
}
}
// ============================================================
// 列表项组件
// ============================================================
@Component
struct NewsListItem {
@Prop item: NewsItem;
build() {
Row({ space: 12 }) {
// 左侧封面色块
Row() {
Text(this.item.title.charAt(0))
.fontSize(20)
.fontColor(Color.White)
.fontWeight(FontWeight.Bold)
}
.width(72)
.height(72)
.borderRadius(8)
.backgroundColor(this.item.imageColor)
.justifyContent(FlexAlign.Center)
// 右侧文本内容
Column({ space: 6 }) {
Text(this.item.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#1A1A1A')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.item.summary)
.fontSize(13)
.fontColor('#666666')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(18)
Row({ space: 8 }) {
Text(this.item.source)
.fontSize(11)
.fontColor('#007DFF')
Text(this.item.time)
.fontSize(11)
.fontColor('#999999')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
}
}
// ============================================================
// 主页面 @Entry
// ============================================================
@Entry
@Component
struct NewsListPage {
@State isRefreshing: boolean = false;
@State isLoading: boolean = false;
@State hasMore: boolean = true;
private dataSource: NewsDataSource = new NewsDataSource();
private nextId: number = 10;
private pageSize: number = 10;
aboutToAppear(): void {
// 初始加载第一页数据
this.dataSource.resetData(generateMockNews(0, 10));
this.nextId = 10;
}
// 下拉刷新
private async onRefresh(): Promise<void> {
this.isRefreshing = true;
// 模拟网络请求延迟
await this.delay(1500);
// 模拟:获取最新数据插入到头部
const freshData = generateMockNews(1000 + Math.floor(Math.random() * 1000), 3);
const allData = freshData.concat(this.dataSource.getAllData());
this.dataSource.resetData(allData);
this.hasMore = true;
this.isRefreshing = false;
const promptAction = this.getUIContext().getPromptAction();
promptAction.showToast({ message: `为您推荐了 ${freshData.length} 条新内容`, duration: 2000 });
}
// 加载更多
private async loadMore(): Promise<void> {
if (this.isLoading || !this.hasMore) {
return;
}
this.isLoading = true;
// 模拟网络请求延迟
await this.delay(1000);
const moreData = generateMockNews(this.nextId, this.pageSize);
this.dataSource.appendData(moreData);
this.nextId += this.pageSize;
// 模拟总共 50 条数据后无更多
if (this.nextId >= 50) {
this.hasMore = false;
}
this.isLoading = false;
}
// 删除列表项
private onDeleteItem(index: number): void {
const item = this.dataSource.getData(index);
this.dataSource.deleteData(index);
const promptAction = this.getUIContext().getPromptAction();
promptAction.showToast({ message: `已删除: ${item.title}`, duration: 2000 });
}
// 延迟函数
private delay(ms: number): Promise<void> {
return new Promise<void>((resolve: Function) => {
setTimeout(() => { resolve(); }, ms);
});
}
// 侧滑删除按钮构建器
@Builder
SwipeDeleteBtn(index: number) {
Row() {
SymbolGlyph($r('sys.symbol.trash'))
.fontSize(20)
.fontColor([Color.White])
}
.width(72)
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#FF4444')
.borderRadius({ topRight: 12, bottomRight: 12 })
.onClick(() => {
this.onDeleteItem(index);
})
}
build() {
Column() {
// 下拉刷新容器包裹 List
Refresh({ refreshing: $$this.isRefreshing }) {
List({ space: 10 }) {
LazyForEach(this.dataSource, (item: NewsItem, index: number) => {
ListItem() {
NewsListItem({ item: item })
}
.swipeAction({
end: {
builder: () => { this.SwipeDeleteBtn(index); },
actionAreaDistance: 72
},
edgeEffect: SwipeEdgeEffect.Spring
})
}, (item: NewsItem) => item.id.toString())
// 底部加载状态
ListItem() {
Row() {
if (this.isLoading) {
LoadingProgress()
.width(20)
.height(20)
.color('#007DFF')
Text('正在加载更多...')
.fontSize(13)
.fontColor('#999999')
.margin({ left: 8 })
} else if (!this.hasMore) {
Text('— 已经到底了 —')
.fontSize(13)
.fontColor('#CCCCCC')
} else {
Text('上拉加载更多')
.fontSize(13)
.fontColor('#CCCCCC')
}
}
.width('100%')
.height(50)
.justifyContent(FlexAlign.Center)
}
}
.width('100%')
.height('100%')
.padding({ left: 14, right: 14, top: 8 })
.edgeEffect(EdgeEffect.Spring)
.cachedCount(3) // 预加载屏幕外 3 条,防止快速滚动白屏
.onReachEnd(() => {
// 触底加载更多
this.loadMore();
})
.scrollBar(BarState.Auto)
.alignListItem(ListItemAlign.Center)
}
.onRefreshing(() => {
this.onRefresh();
})
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
}
四、代码拆解说明
4.1 IDataSource 数据源实现
LazyForEach 不直接操作数组,而是通过 IDataSource 接口代理数据访问:
class NewsDataSource implements IDataSource {
private dataArray: NewsItem[] = [];
private listeners: DataChangeListener[] = [];
totalCount(): number {
return this.dataArray.length; // 告诉框架总数据量
}
getData(index: number): NewsItem {
return this.dataArray[index]; // 按索引获取数据
}
// ...
}
关键规则:
totalCount()和getData()每次重渲染都会调用,必须保持 O(1) 复杂度- 数据变更后必须通过
listeners回调通知框架,否则 UI 不会更新 - API 21+ 必须同时实现新旧两套方法名(如
onDataReloaded和onDataReloaded— 实际上废弃方法是onDataAdded/onDataDeleted/onDataChanged/onDataMoved)
4.2 LazyForEach 使用
LazyForEach(this.dataSource, (item: NewsItem, index: number) => {
ListItem() { /* 渲染 item */ }
}, (item: NewsItem) => item.id.toString())
三个参数:
- 数据源:
IDataSource实例 - 渲染函数:每个 item 的 UI 构建
- 键生成器:返回唯一 key(必须保证唯一性,否则会导致渲染错乱)
4.3 下拉刷新 Refresh 组件
Refresh({ refreshing: $$this.isRefreshing }) {
List() { /* ... */ }
}
.onRefreshing(() => {
this.onRefresh(); // 刷新回调
})
$$this.isRefreshing是双向绑定语法,Refresh组件结束时自动重置为falseonRefreshing回调中执行数据请求,请求完成后刷新状态会自动还原
4.4 触底加载 onReachEnd
List()
.onReachEnd(() => {
this.loadMore(); // 触发加载更多
})
注意添加防抖保护:
private async loadMore(): Promise<void> {
if (this.isLoading || !this.hasMore) {
return; // 正在加载中或无更多数据,直接返回
}
this.isLoading = true;
// ... 请求数据
this.isLoading = false;
}
4.5 cachedCount 预加载
List()
.cachedCount(3) // 额外预加载 3 条屏幕外的 item
cachedCount 会在可视区域外提前创建 N 条 item,用户快速滚动时不易出现白屏。建议值:1-5,过大反而浪费内存。
4.6 侧滑删除 SwipeAction
swipeAction 是 ListItem 组件的属性,不能用在 Row 或其他容器上。在 @Entry 页面中定义 @Builder 方法,然后通过 swipeAction 绑定到 ListItem:
// 在 @Entry 组件中定义侧滑按钮 Builder
@Builder
SwipeDeleteBtn(index: number) {
Row() {
SymbolGlyph($r('sys.symbol.trash'))
.fontSize(20).fontColor([Color.White])
}
.width(72).height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#FF4444')
.borderRadius({ topRight: 12, bottomRight: 12 })
.onClick(() => { this.onDeleteItem(index); })
}
// 在 ListItem 上挂载
ListItem() {
NewsListItem({ item: item })
}
.swipeAction({
end: {
builder: () => { this.SwipeDeleteBtn(index); },
actionAreaDistance: 72, // 滑动触发距离
},
edgeEffect: SwipeEdgeEffect.Spring // 回弹效果
})
左滑后显示红色删除按钮,点击删除后调用 dataSource.deleteData() → 内部调用 notifyDataReload() → LazyForEach 自动更新 UI。
五、性能优化要点
| 优化点 | 做法 | 效果 |
|---|---|---|
| 避免 item 层级过深 | 列表项组件控制在 3 层以内 | 减少布局计算耗时 |
使用 cachedCount |
设置为 2-5 | 快速滚动不白屏 |
| 图片懒加载 | 在 onVisibleAreaChange 中加载图片 |
减少首屏内存占用 |
| 复用组件 | 给 ListItem 的组件加 @Reusable |
约 69% 更快的组件创建 |
| key 生成器保持稳定 | 使用 item.id.toString() 而非 index.toString() |
避免列表项错位和闪烁 |
| 深色模式适配 | 颜色使用系统资源 $r('sys.color.xxx') |
自动跟随系统主题 |
六、常见踩坑
- IDataSource 方法不全导致编译报错:API 21+ 必须同时实现新旧两套
DataChangeListener方法名,参考上文完整实现。 - key 生成器用 index 导致删除错乱:删除第 0 条后,原来的第 1 条变成第 0 条,key 相同会导致组件复用错误。务必使用业务唯一 ID。
- onReachEnd 反复触发:不加
isLoading防抖会在滚动到底部时连续触发多次请求。 - Refresh 与 List 的嵌套:
Refresh必须直接包裹List(或Scroll/Grid),中间不能有其他容器。 - LazyForEach 不支持非 List/Grid/WaterFlow/Swiper 容器:不能在
Column内直接使用LazyForEach。
七、运行效果
将此代码复制到 DevEco Studio 项目的 entry/src/main/ets/pages/Index.ets 中,运行后将看到:
- 10 条新闻列表正常渲染
- 下拉刷新:出现刷新指示器,模拟延迟后插入 3 条新数据并 Toast 提示
- 触底加载:滚动到底部自动追加 10 条数据,底部显示「正在加载…」
- 无更多数据:加载到 40+ 条后底部显示「已经到底了」
- 左滑删除:列表项左滑出现红色删除按钮,点击后 item 动画消失并 Toast 提示
- LoadingProgress:加载中状态显示旋转指示器
八、扩展方向
| 场景 | 实现方案 |
|---|---|
| 分组列表 + 粘性标题 | 使用 ListItemGroup + sticky(StickyStyle.Header) |
| 瀑布流布局 | 将 List 替换为 WaterFlow + FlowItem |
| 拖拽排序 | 使用 List.onItemDragStart + onItemDragMove |
| 网络真实数据 | 用 @ohos/axios 请求分页接口,替换模拟数据 |
| 多选模式 | 给 NewsItem 加 isSelected 字段 + Checkbox 组件 |
| 下拉刷新二级样式 | Refresh 组件绑定 Prompt 提示文本 |
希望本文能帮助你快速掌握 LazyForEach 长列表开发的核心要点。完整代码经过 API 24 Release SDK 编译验证,可直接运行截图。
如果你对鸿蒙开发有任何疑问,欢迎下方咨询我们,一对一给你解答。
更多推荐




所有评论(0)