鸿蒙应用开发UI基础第三十一节:长列表LazyForEach 懒加载渲染核心与踩坑-基础篇
本文介绍了鸿蒙开发中LazyForEach的核心原理与使用方法。LazyForEach是专为长列表设计的性能优化方案,通过按需渲染、自动回收机制、键值管理和数据监听器三大原理,实现高效内存管理和流畅滑动体验。文章详细解析了LazyForEach的底层机制,包括仅渲染可视区域组件、键值唯一性要求以及数据变更通知机制。同时提供了基础使用指南,重点说明了必须配合List等容器使用,并展示了如何实现通用的
一、为什么必须掌握LazyForEach?
上一节我们学习的ForEach,核心逻辑是全量创建组件,即便配合List组件使用,当数据量超过100条甚至更多时,对组件数据的增删查改依然会触发组件的销毁与重建,性能表现较差。严重时会导致页面打开慢、内存占用暴涨、滑动列表卡顿掉帧,甚至应用崩溃。
而LazyForEach就是鸿蒙针对长列表场景推出的专属性能优化方案之一,它的核心价值用一句话概括:按需创建组件、自动回收资源,用极低的内存占用实现千条/万条数据的流畅滑动。
二、LazyForEach 三大核心底层原理
LazyForEach的所有用法、规则、坑点,都来自这三个核心原理,必须先吃透底层逻辑。示例代码作为定义验证
2.1 按需渲染+自动回收机制
这是LazyForEach高性能的核心来源:
- 首次渲染:页面打开时,仅计算滚动容器(List/Grid等)的可视区域,再加上
cachedCount设置的预加载条数,只创建这部分组件,其余数据完全不处理; - 滑动渲染:列表向上滑动时,仅创建即将划入屏幕的组件;向下滑动时,预加载下方的组件;
- 自动回收:组件完全滑出可视区域后,不会立即销毁,框架会在主线程空闲时,自动销毁这些组件、回收内存,保证应用内存始终处于低占用状态。
2.2 键值(Key)机制
LazyForEach完全依赖键值判断组件的创建、复用、销毁,是保证渲染正确的核心:
- 键值的作用:给每个列表项一个唯一的“身份证”,框架通过这个身份证识别哪个数据对应哪个组件;
- 键值的要求:唯一性(每个数据的键值不能重复)、稳定性(数据不变,键值绝对不能变);
- 键值的默认规则:如果不自定义键值,框架会用
viewId + '-' + index生成键值,仅和索引绑定,数据增删后索引变化,会导致渲染错乱,不推荐使用默认键值。
2.3 数据监听器机制
LazyForEach不会监听数据源数组的直接变化,必须通过DataChangeListener监听器,主动通知框架数据发生了什么变化,框架才会更新UI。
- 简单说:你改了数组里的数据,必须告诉框架“我新增了一条、删除了一条、修改了一条”,框架才会对应更新组件;
- 直接修改数组、直接给数据源重新赋值,都不会触发UI刷新,甚至会导致渲染异常。
2.4 核心语法说明
LazyForEach的语法固定为三个参数,缺一不可:
LazyForEach(
dataSource: IDataSource, // 必选:实现了IDataSource的数据源
itemGenerator: (item: T, index?: number) => void, // 必选:组件生成函数
keyGenerator?: (item: T, index?: number) => string // 强烈建议必选:键值生成函数
)
三、LazyForEach 基础使用
3.1 前置要求
LazyForEach必须配合支持懒加载的滚动容器使用,鸿蒙仅支持5个容器:List、ListItemGroup、Grid、Swiper、WaterFlow,最常用的是List。
重要规范:数据增删改查提供了两种通知方式,
onDatasetChange批量操作接口 与onDataxxx单操作接口不可混用,否则会导致渲染异常甚至程序崩溃。
3.2 步骤1:实现基础数据源
我们需要先实现IDataSource接口,封装一个通用的数据源类,负责管理数据、注册监听器、通知框架数据变化。
这里我们直接封装一个可复用的泛型数据源,支持任意数据类型,后续项目可以直接用。核心方法必须实现,其他方法可根据需求扩展。
// datasource/BaseDataSource.ets
// 通用泛型数据源,支持任意数据类型
export class BaseDataSource<T> implements IDataSource {
// 存储数据监听器(框架自动注册)
private listeners: DataChangeListener[] = [];
// 实际存储的数据源数组
private dataList: T[] = [];
// ========== 必须实现的4个核心方法 ==========
// 1. 返回数据总条数,框架用来计算列表总长度
totalCount(): number {
return this.dataList.length;
}
// 2. 返回对应索引的数据,框架渲染组件时会调用
getData(index: number): T {
return this.dataList[index];
}
// 3. 注册数据监听器,框架自动调用,不用手动管
registerDataChangeListener(listener: DataChangeListener): void {
if (!this.listeners.includes(listener)) {
this.listeners.push(listener);
}
}
// 4. 注销数据监听器,框架自动调用,不用手动管
unregisterDataChangeListener(listener: DataChangeListener): void {
const index = this.listeners.indexOf(listener);
if (index >= 0) {
this.listeners.splice(index, 1);
}
}
// 新增数据
pushData(data: T): void {
this.dataList.push(data);
this.notifyDataAdd(this.dataList.length - 1)
}
// 删除指定索引的数据
deleteData(index: number): void {
if (index < 0 || index >= this.dataList.length) return;
this.dataList.splice(index, 1);
this.notifyDataDelete(index)
}
// 修改指定索引的数据
updateData(index: number, newData: T): void {
if (index < 0 || index >= this.dataList.length) return;
this.dataList.splice(index,1,newData);
this.notifyDataChange(index)
}
// 批量操作数据(新增/删除/修改/移动一次性完成,性能最优)
notifyDatasetChange(operations: DataOperation[]): void {
this.listeners.forEach(listener => {
listener.onDatasetChange(operations);
});
}
batchDelete(start: number, count: number): void {
// 1. 空数据直接返回
if (this.dataList.length === 0) return;
// 2. start 不能小于 0
if (start < 0) start = 0;
// 3. start 不能超过数组最大索引
if (start >= this.dataList.length) return;
// 4. count 不能小于 1
if (count < 1) return;
// 5. 确保删除范围不越界(核心)
const maxDeleteCount = this.dataList.length - start;
if (count > maxDeleteCount) {
count = maxDeleteCount;
}
// 执行删除
this.dataList.splice(start, count);
// 批量操作
// this.notifyDatasetChange([{
// type: DataOperationType.DELETE,
// index: start,
// count: count
// }]);
// 全局刷新
this.reloadAll()
}
// 通知LazyForEach组件需要重载所有子组件
notifyDataReload(): void {
this.listeners.forEach(listener => {
listener.onDataReloaded();
});
}
// 通知LazyForEach组件需要在index对应索引处添加子组件
notifyDataAdd(index: number): void {
this.listeners.forEach(listener => {
listener.onDataAdd(index);
// listener.onDatasetChange([{type: DataOperationType.ADD, index: index}]);
});
}
// 通知LazyForEach组件在index对应索引处数据有变化,需要重建该子组件
notifyDataChange(index: number): void {
this.listeners.forEach(listener => {
listener.onDataChange(index);
// listener.onDatasetChange([{type: DataOperationType.CHANGE, index: index}]);
});
}
// 通知LazyForEach组件需要在index对应索引处删除该子组件
notifyDataDelete(index: number): void {
this.listeners.forEach(listener => {
listener.onDataDelete(index);
// listener.onDatasetChange([{type: DataOperationType.DELETE, index: index}]);
});
}
// 通知LazyForEach组件将from索引和to索引处的子组件进行交换
notifyDataMove(from: number, to: number): void {
this.listeners.forEach(listener => {
listener.onDataMove(from, to);
// listener.onDatasetChange([{type: DataOperationType.EXCHANGE, index: {start: from, end: to}}]);
});
}
// 移动某一条数据到某一个位置
moveData(from: number, to: number): void {
if (from < 0 || from >= this.dataList.length || to < 0 || to >= this.dataList.length) return;
const items = this.dataList.splice(from, 1);
this.dataList.splice(to, 0, ...items);
this.notifyDataMove(from,to)
}
// 全量刷新列表(非必要不使用,会重建所有组件)
reloadAll(): void {
this.listeners.forEach(listener => {
listener.onDataReloaded();
// listener.onDatasetChange([{type: DataOperationType.RELOAD}]);
});
}
// 获取全部数据
getAllData(): T[] {
return this.dataList;
}
}
3.3 步骤2:定义数据模型
// model/GoodsInfo.ets
import { util } from "@kit.ArkTS";
// 定义商品数据类型
@Observed
export class GoodsInfo {
// 商品唯一ID,用来做键值,readonly 保证不可变,确保键值稳定
readonly id: string;
@Track name: string;
@Track price: number;
constructor(name: string, price: number, id: string = util.generateRandomUUID(true)) {
this.name = name;
this.price = price;
this.id = id;
}
}
3.4 步骤3:创建列表项组件
封装一个独立的列表项组件,方便后续做组件复用。重点:@Reusable 用于开启组件复用,大幅提升长列表滑动性能。
// components/GoodsListItem.ets
import { GoodsInfo } from "../model/GoodsInfo";
// @Reusable 开启组件复用,提升长列表滑动性能
@Component
export struct GoodsListItem {
@ObjectLink goods: GoodsInfo;
// 事件回调定义
onDelete?: (goods: GoodsInfo) => void;
onTop?: (goods: GoodsInfo) => void;
onPriceChange?: (goods: GoodsInfo) => void;
// 生命周期日志
aboutToAppear(): void {
console.info(`[GoodsListItem] 组件创建 -> ${this.goods.name}`);
}
aboutToDisappear(): void {
console.info(`[GoodsListItem] 组件消失 -> ${this.goods.name}`);
}
aboutToRecycle(): void {
console.info(`[GoodsListItem] 组件回收 -> ${this.goods.name}`);
}
// 复用时更新数据
// 注意:在aboutToReuse中对@Link、@ObjectLink等自动更新的状态变量赋值,可能触发不必要的组件刷新,无需手动处理
// aboutToReuse(params: Record<string, ESObject>): void {
// console.info(`[GoodsListItem] 组件复用 -> 名字:${params.goods.name} -> 价格:${params.goods.price}`);
// }
build() {
Row({ space: 12 }) {
Text(this.goods.name)
.fontSize(18)
.fontWeight(FontWeight.Medium)
.flexGrow(1)
Text(`¥${this.goods.price}`)
.fontSize(16)
.fontColor('#FF4400')
.fontWeight(FontWeight.Bold)
// 涨价按钮
Text("涨价10元")
.fontSize(14)
.fontColor(Color.White)
.padding(10)
.backgroundColor('#007AFF')
.borderRadius(4)
.onClick(() => {
console.info(`[GoodsListItem] 点击涨价 -> ${this.goods.name}`);
if (this.onPriceChange) {
// 方式1: 直接使用状态管理变量 局部精准修改数据 this.goods.price += 10。父组件仅做刷新,这样会分散逻辑。
// 方式2: 父组件修改价格和通知数据变动逻辑放在一起。
// 注意天坑来了:this.onPriceChange(this.goods); 通过回调把goods传递到父组件,但是代理对象Proxy会被解包变成普通对象Object,父组件修改goods子属性装饰器无法监听到数据变化。
// 解决方案: const goods = this.goods; 创建临时变量,此时临时变量引用相同的地址,通过回调传递给父组件代理对象Proxy不会被解包。
const goods = this.goods;
this.onPriceChange(goods);
}
})
// 上移按钮
Text("上移")
.fontSize(14)
.fontColor(Color.White)
.padding(10)
.backgroundColor('#FF9500')
.borderRadius(4)
.onClick(() => {
console.info(`[GoodsListItem] 点击上移 -> ${this.goods.name}`);
if (this.onTop) {
const goods = this.goods;
this.onTop(goods);
}
})
// 删除按钮
Text("删除")
.fontSize(14)
.fontColor(Color.White)
.padding(10)
.backgroundColor('#FF3B30')
.borderRadius(4)
.onClick(() => {
console.info(`[GoodsListItem] 点击删除 -> ${this.goods.name}`);
if (this.onDelete) {
const goods = this.goods;
this.onDelete(goods);
}
})
}
.width('100%')
.padding(16)
.backgroundColor('#F7F8FA')
.borderRadius(8)
.margin({ bottom: 8 })
}
}
3.5 步骤4:实现懒加载列表页面
// pages/LazyForEachDemo.ets
import { GoodsListItem } from '../components/GoodsListItem';
import { GoodsInfo } from '../model/GoodsInfo';
import { BaseDataSource } from '../datasource/BaseDataSource';
@Entry
@Component
struct Index {
// 初始化数据源,指定数据类型为GoodsInfo
private goodsDataSource: BaseDataSource<GoodsInfo> = new BaseDataSource<GoodsInfo>();
// 预加载条数:可视区域上下各预加载5条,滑动更流畅,默认值为5
private cachedCount: number = 5;
// 页面加载时,初始化50条测试数据
aboutToAppear(): void {
for (let i = 0; i < 50; i++) {
this.goodsDataSource.pushData(new GoodsInfo(`测试商品 ${i + 1}`, Math.floor(Math.random() * 100 + 1)));
}
}
// 处理删除:通过ID查找真实索引,避免闭包index过时问题
handleDelete(goods: GoodsInfo): void {
const allData = this.goodsDataSource.getAllData();
const index = allData.findIndex(item => item.id === goods.id);
if (index !== -1) {
this.goodsDataSource.deleteData(index);
}
}
// 处理上移:通过ID查找真实索引
handleMoveUp(goods: GoodsInfo): void {
const allData = this.goodsDataSource.getAllData();
const currentIndex = allData.findIndex(item => item.id === goods.id);
// 如果不是第一条,则执行上移
if (currentIndex > 0) {
this.goodsDataSource.moveData(currentIndex, currentIndex - 1);
}
}
// 处理价格修改:通过ID查找真实索引并通知刷新
handlePriceChange(goods: GoodsInfo): void {
const allData = this.goodsDataSource.getAllData();
const index = allData.findIndex(item => item.id === goods.id);
if (index !== -1) {
// 方案1:子组件用@ObjectLink直接修改属性,这里仅做通知刷新(性能最优,局部刷新)
// 方案2:父组件统一修改数据,再通知刷新(逻辑更集中)
// goods.price += 10;
this.goodsDataSource.notifyDataChange(index);
}
}
build() {
Column({ space: 12 }) {
Text('LazyForEach 千条数据长列表示例')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.margin({ top: 16 })
List({ space: 8 }) {
LazyForEach(
this.goodsDataSource,
(item: GoodsInfo, index: number) => {
ListItem() {
GoodsListItem({
goods: item,
onDelete: (goods) => this.handleDelete(goods),
onTop: (goods) => this.handleMoveUp(goods),
onPriceChange: (goods) => this.handlePriceChange(goods)
})
}
// 【测试写法】ListItem直接绑定点击事件修改数据,这里拿到的也是代理对象可正常刷新
// .onClick(()=>{
// item.price += 10;
// this.goodsDataSource.notifyDataChange(index);
// })
},
// 第三个参数:键值生成函数,必须返回唯一稳定的值,这里用商品唯一id
(item: GoodsInfo) => item.id
)
}
// 设置预加载条数
.cachedCount(this.cachedCount)
.width('100%')
.layoutWeight(1)
.backgroundColor($r('sys.color.comp_background_list_card'))
Row({ space: 16 }) {
Button("新增一条数据")
.onClick(() => {
// 新增商品时,id由构造函数自动生成
this.goodsDataSource.pushData(
new GoodsInfo(`新增商品 ${this.goodsDataSource.totalCount() + 1}`, Math.floor(Math.random() * 100 + 1))
);
})
Button("删除前五条")
.onClick(() => {
// 批量删除前五条数据
this.goodsDataSource.batchDelete(0, 5);
})
}
.padding({ bottom: 16 })
}
.width('100%')
.height('100%')
.backgroundColor($r('sys.color.point_color_checked'))
}
}
四、数据更新核心操作(增删改查)
4.1 首次创建
初始化50条数据,通过控制台日志可看到,首屏仅创建了14条组件,其中可视区域9条,缓存区域5条,与cachedCount = 5的设置完全符合预期。

4.2 非首次创建
滚动列表:每向上滑动一行,观察日志可看到,向上滑动5行时,会连续输出5条组件创建日志,且没有销毁旧组件——这是因为缓存机制,cachedCount会预创建上下不可见区域的组件,此时刚好满足上下各5条的缓存要求。
继续向上滑动一行:会创建第20条组件,同时销毁第1条组件,日志如下:
[GoodsListItem] 组件创建 (aboutToAppear) -> 商品: 测试商品 20
[GoodsListItem] 组件消失 (aboutToDisappear) -> 商品: 测试商品 1
4.3 删除数据
点击删除第一行,调用deleteData方法,内部通过onDataDelete通知框架,框架仅销毁对应索引的组件,更新后续组件的位置。
注意:键值必须唯一且固定,否则可能出现删除错乱、渲染异常的问题。
4.4 新增数据
点击「新增一条数据」,调用pushData方法,内部通过onDataAdd通知框架,框架仅在对应索引创建新组件,不会刷新整个列表。
注意:点击新增后,控制台不会立即输出组件创建日志,因为新增的数据不在可视区域内,只有滑动到底部时,才会创建对应组件。
4.5 修改数据
- 推荐方案:使用
@ObjectLink在子组件直接修改属性,配合notifyDataChange通知刷新,可实现仅刷新使用了该属性的组件,性能更优,还能避免因组件重建导致的图片闪烁问题。
4.6 批量操作
调用batchDelete,通过onDatasetChange一次性通知框架删除范围,仅重建受影响的后续组件,避免多次通知导致的重复渲染,性能最优。
4.7 全量刷新
调用reloadAll,通过onDataReloaded通知框架重建所有组件,非必要不使用,会造成严重的性能损耗和屏幕闪烁。
4.8 局部刷新:避免组件全量重建
当我们只需要修改列表项的某一个属性时,不需要重建整个列表项,通过@Observed+@ObjectLink可以实现仅刷新使用了该属性的组件,大幅降低渲染开销。
五、高级优化核心
5.1 组件复用:减少创建销毁开销
通过@Reusable装饰器标记列表项为可复用,组件滑出屏幕后不会被销毁,而是放入缓存池;新的列表项进入屏幕时,直接复用缓存的组件,仅更新数据,大幅减少组件创建销毁的性能开销。
5.2 验证步骤
取消对@Reusable和aboutToReuse的注释,观察控制台日志,会发现aboutToAppear调用次数大幅减少,组件销毁变成了回收,组件创建变成了复用。
5.3 不建议嵌套使用
@Reusable 不建议嵌套使用,会降低复用效率、增加内存占用与维护成本,还会导致缓存冗余、生命周期管理混乱。
六、高频踩坑与解决方案
6.1 数据修改后,组件不刷新
- 原因1:没有自定义键值,默认键值仅和索引绑定,数据变化键值不变,框架认为组件不需要刷新;
- 解决方案:自定义键值生成函数,绑定数据的唯一ID或稳定不变的内容;
- 原因2:修改了数据,但没有调用notify方法通知框架;
- 解决方案:所有数据修改,都必须通过数据源的方法,调用对应的notify通知。
6.2 删除数据后,渲染错乱,删错了组件
- 原因:在
LazyForEach的itemGenerator中直接使用了闭包的index进行删除。当列表滑动或数据删除后,这个index是未更新的; - 解决方案:通过数据的唯一
id在getAllData()中查找当前真实索引(如代码中的handleDelete所示)。
6.3 列表滚动到底部加载更多时,屏幕闪烁
- 原因:加载更多数据后,调用了
reloadAll全量刷新,导致整个列表重建; - 解决方案:用
onDataAdd或onDatasetChange精准通知新增的数据,不要全量刷新。
6.4 子组件回调修改数据后,UI不刷新
- 原因:
@ObjectLink包装的是响应式代理对象(Proxy Object)。当在子组件中直接将this.goods传递给父组件回调时,在传递过程中会对代理对象进行自动解包(Unwrap),使其变回普通的原始对象。此时,父组件修改的是普通对象,无法触发@Observed装饰器的响应式监听,导致UI不刷新。 - 解决方案:在子组件回调触发前,先通过
const goods = this.goods;将代理对象临时赋值给一个常量。这一操作会保留对代理对象的引用,再通过这个常量传递给父组件,从而保证父组件修改数据时能够被正确监听并触发 UI 更新。
七、仓库代码
- 工程名称:LazyForEachBaseDemo
- 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git
八、下节预告
下一节我们将利用LazyForEach改造之前微信联系人列表通过双重LazyForEach分别对列表的组和行进行懒加载,并完成侧滑菜单删除修改备注彻底掌握LazyForEach。
更多推荐


所有评论(0)