鸿蒙新特性:LazyForEach 懒加载与性能优化深度解析
如果你用
ForEach渲染 1000 条数据,ArkUI 会在首次渲染时创建 1000 个组件节点——即使屏幕上只能看到 10 条。这会导致首帧渲染延迟、滚动掉帧、内存飙升。LazyForEach解决了这个问题:只创建屏幕可见区域的组件节点,滑出屏幕的节点被回收复用。本文用它构建一个 80 件商品的"创意市集",展示懒加载的完整实现模式。
一、ForEach 为什么不够用?
ForEach 是 ArkUI 中最常用的列表渲染方式,但它有一个根本性的性能问题——全量渲染。
// ForEach 会把所有 1000 个产品一次性创建为组件节点
ForEach(this.allProducts, (product: Product) => {
ListItem() { /* 商品卡片 */ }
})
当你调用 ForEach 时,框架遍历整个数组,为每个元素创建对应的 UI 节点。数组有 1000 条,就创建 1000 个节点;有 10000 条,就创建 10000 个节点。
这带来的三个问题:
- 首帧渲染延迟:创建 1000 个组件节点需要时间,用户从点击到看到内容之间有明显的延迟。
- 滚动掉帧:虽然 List 组件有内置的可见性裁剪(不显示屏幕外节点),但 1000 个节点占用的内存是实实在在的,GC 压力大。
- 内存占用高:每个节点都持有状态、样式、事件处理器等数据,1000 个节点轻松占用数十 MB 内存。
LazyForEach 解决了这三个问题:它只创建屏幕可见的几个节点(通常 10~15 个),当用户滑动时,滑出屏幕的节点被回收,滑入屏幕的位置创建新节点。无论数据有 100 条还是 100000 条,组件节点数量始终维持在 ~15 个左右。

二、LazyForEach 核心 API
2.1 基本语法
LazyForEach(
dataSource: IDataSource, // 数据源
itemGenerator: (item: T) => void, // 组件生成器
keyGenerator: (item: T) => string // 唯一键生成器
)
与 ForEach 的最大区别:第一个参数不是数组,而是一个实现了 IDataSource 接口的对象。
2.2 IDataSource 接口
interface IDataSource {
totalCount(): number; // 数据总数
getData(index: number): any; // 按索引获取数据
registerDataChangeListener(listener: DataChangeListener): void; // 注册监听器
unregisterDataChangeListener(listener: DataChangeListener): void; // 注销监听器
}
框架通过这 4 个方法与数据源交互:先调用 totalCount() 知道有多少条,然后根据需要调用 getData(index) 获取特定索引的数据。
registerDataChangeListener 是连接数据源和 UI 的桥梁——当数据变化时(增、删、改),你需要通过 listener 通知框架刷新对应的 UI。
2.3 DataChangeListener:通知框架数据变更
interface DataChangeListener {
onDataReloaded(): void; // 数据全部刷新
onDataAdd(index: number): void; // 在 index 位置新增了数据
onDataChange(index: number): void; // index 位置的数据被修改
onDataDelete(index: number): void; // index 位置的数据被删除
onDataMove(from: number, to: number): void; // 数据从 from 移动到了 to
}
这些方法不是让你手动调用的——它们是框架传入的回调对象。当你的数据发生变化时,你通过 listener 的这些方法告诉框架"哪里的数据变了",框架就知道只更新受影响的那几个 UI 节点。
2.4 最简单的 IDataSource 实现
class ProductDataSource implements IDataSource {
private dataArray: Product[] = [];
private listeners: DataChangeListener[] = [];
totalCount(): number {
return this.dataArray.length;
}
getData(index: number): Product {
return this.dataArray[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);
}
}
// 自定义方法:设置数据并通知框架刷新
setData(data: Product[]): void {
this.dataArray = data;
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataReloaded();
});
}
}
核心思路:
dataArray是实际存储数据的数组listeners是框架注册的监听器列表(通常只有 1 个)setData()是我们自定义的方法,更新数据后调用onDataReloaded()通知框架
框架收到 onDataReloaded() 后,会重新调用 totalCount() 和 getData(),只创建可见区域的组件节点。

三、LazyForEach vs ForEach:关键区别
| ForEach | LazyForEach | |
|---|---|---|
| 数据源 | Array<T> |
IDataSource 接口实现 |
| 渲染策略 | 全量创建组件节点 | 按需创建,约 15 个节点 |
| 首帧性能 | 数据多时慢 | 与数据量无关,始终快 |
| 滚动性能 | 数据多时掉帧 | 与数据量无关,始终流畅 |
| 内存占用 | O(n),n = 数据量 | O(1),约 15 个节点 |
| 代码复杂度 | 极简(一行代码) | 中等(需实现 IDataSource) |
选择建议:
- 数据 < 50 条:ForEach 即可,简单直接
- 数据 50~200 条:可选用 LazyForEach,但差异不明显
- 数据 > 200 条:强烈推荐 LazyForEach,性能差异明显
- 数据 > 1000 条:必须用 LazyForEach,ForEach 会导致明显卡顿
四、Demo:创意市集
本 Demo 构建一个创意市集商品目录——80 件商品跨越 5 个分类,使用 LazyForEach 按需渲染,顶部横向分类筛选。
页面结构
ProductCatalogPage (~190行)
├── Header(标题 + 商品总数)
├── 分类筛选栏(横向滚动的 6 个分类按钮)
│ └── 全部 / 数码 / 图书 / 服饰 / 家居 / 美食
├── List
│ └── LazyForEach(dataSource)
│ └── ListItem × N
│ └── 商品行(图标 + 名称 + 分类标签 + 价格 + 销量)
└── onClick → 商品详情弹窗 + 加入购物车
ProductDataSource 实现
class ProductDataSource implements IDataSource {
private dataArray: Product[] = [];
private listeners: DataChangeListener[] = [];
totalCount(): number { return this.dataArray.length; }
getData(index: number): Product { return this.dataArray[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); }
}
setData(data: Product[]): void {
this.dataArray = data;
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataReloaded();
});
}
}
setData() 是核心方法——它接收一个新数组(可能是筛选后的结果),替换内部数据,然后通过 listener.onDataReloaded() 通知框架"数据全部变了,请重新按需渲染"。
分类筛选与数据刷新
applyFilter(category: string): void {
this.selectedCategory = category;
const filtered = category === '全部' ? [...ALL_PRODUCTS] :
ALL_PRODUCTS.filter((p: Product) => p.category === category);
this.dataSource.setData(filtered);
this.productCount = filtered.length;
}
筛选逻辑:
- 如果选"全部",展示所有 80 件商品
- 如果选特定分类,用
filter()筛选出该分类的商品 - 调用
dataSource.setData()更新 LazyForEach 的数据源
每次切换分类,只有 ~15 个可见的商品卡片被重新创建,而不是全部 80 个。
分类按钮的选中态
Text(cat)
.fontColor(this.selectedCategory === cat ? Color.White : AppColors.TEXT_SECONDARY)
.fontWeight(this.selectedCategory === cat ? FontWeight.Medium : FontWeight.Regular)
.backgroundColor(this.selectedCategory === cat ? AppColors.PRIMARY : '#F0F0F0')
选中:蓝色填充 + 白色文字;未选中:灰色填充 + 灰色文字。视觉上非常清晰。
商品分类标签
每个商品行右侧有一个彩色分类标签:
getCatColor(category: string): string {
switch (category) {
case '数码': return '#1677FF'; // 蓝色
case '图书': return '#52C41A'; // 绿色
case '服饰': return '#FAAD14'; // 橙色
case '家居': return '#FF7A45'; // 红色
case '美食': return '#EB2F96'; // 粉色
default: return AppColors.PRIMARY;
}
}
不同分类用不同颜色标记,用户扫一眼就能区分商品类型。
三个交互点
- 分类筛选 — 顶部 6 个分类按钮,切换后 LazyForEach 自动刷新
- 商品点击 — 弹窗展示详情 + "加入购物车"按钮
- 滚动浏览 — 80 件商品流畅滚动(LazyForEach 保证性能)

五、完整代码
import { AppColors, BorderRadius, FontSize, Spacing } from '../common/Constants';
import { promptAction } from '@kit.ArkUI';
class Product {
id: number;
name: string;
price: number;
category: string;
icon: string;
sold: number;
constructor(id: number, name: string, price: number, category: string,
icon: string, sold: number) {
this.id = id;
this.name = name;
this.price = price;
this.category = category;
this.icon = icon;
this.sold = sold;
}
}
class ProductDataSource implements IDataSource {
private dataArray: Product[] = [];
private listeners: DataChangeListener[] = [];
totalCount(): number { return this.dataArray.length; }
getData(index: number): Product { return this.dataArray[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); }
}
setData(data: Product[]): void {
this.dataArray = data;
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataReloaded();
});
}
}
const CATEGORIES: string[] = ['全部', '数码', '图书', '服饰', '家居', '美食'];
function generateProducts(): Product[] {
const digitalNames = ['降噪耳机Pro', '机械键盘RGB', '超宽显示器', '无线鼠标',
'蓝牙音箱', '智能手表', '平板支架', '充电宝', 'USB集线器', '摄像头', '麦克风', '数位板'];
const digitalIcons = ['🎧', '⌨️', '🖥️', '🖱️', '🔊', '⌚', '📱', '🔋', '🔌', '📷', '🎤', '✍️'];
const bookNames = ['设计模式', '代码整洁之道', '深入理解计算机系统', '算法导论',
'人月神话', '重构', '程序员修炼之道', '领域驱动设计', '微服务架构',
'SRE工作手册', '架构整洁之道', '软技能'];
const bookIcons = ['📖', '📚', '💻', '📐', '📕', '🔧', '🛠️', '🏗️', '☁️', '📋', '🏰', '💡'];
const fashionNames = ['运动跑鞋', '牛仔夹克', '帆布背包', '太阳镜', '羊绒围巾',
'真皮钱包', '棒球帽', '帆布鞋', '休闲T恤', '羊毛衫'];
const fashionIcons = ['👟', '🧥', '🎒', '🕶️', '🧣', '👛', '🧢', '👟', '👕', '🧶'];
const homeNames = ['香薰蜡烛', '多肉盆栽', '挂墙时钟', '收纳盒', '桌垫',
'抱枕', '地毯', '台灯', '花瓶', '置物架'];
const homeIcons = ['🕯️', '🪴', '⏰', '📦', '🟫', '🛏️', '🏠', '💡', '🏺', '🗄️'];
const foodNames = ['手工巧克力', '精品咖啡豆', '有机绿茶', '进口蜂蜜', '日式抹茶',
'坚果礼盒', '黑松露酱', '橄榄油', '意面套装', '和牛牛排'];
const foodIcons = ['🍫', '☕', '🍵', '🍯', '🍃', '🥜', '🖤', '🫒', '🍝', '🥩'];
const buildProducts = (names: string[], icons: string[], category: string,
startPrice: number, startSold: number, startId: number): Product[] => {
const products: Product[] = [];
for (let i = 0; i < names.length; i++) {
products.push(new Product(startId + i, names[i],
startPrice + Math.floor(Math.random() * 500), category, icons[i],
startSold + Math.floor(Math.random() * 5000)));
}
return products;
};
let all: Product[] = [];
all = all.concat(buildProducts(digitalNames, digitalIcons, '数码', 199, 1200, 1));
all = all.concat(buildProducts(bookNames, bookIcons, '图书', 29, 800, 100));
all = all.concat(buildProducts(fashionNames, fashionIcons, '服饰', 59, 600, 200));
all = all.concat(buildProducts(homeNames, homeIcons, '家居', 39, 400, 300));
all = all.concat(buildProducts(foodNames, foodIcons, '美食', 19, 300, 400));
return all;
}
const ALL_PRODUCTS: Product[] = generateProducts();
@Entry
@Component
struct ProductCatalogPage {
@State selectedCategory: string = '全部';
@State productCount: number = 0;
private dataSource: ProductDataSource = new ProductDataSource();
aboutToAppear(): void {
this.applyFilter('全部');
}
applyFilter(category: string): void {
this.selectedCategory = category;
const filtered = category === '全部' ? [...ALL_PRODUCTS] :
ALL_PRODUCTS.filter((p: Product) => p.category === category);
this.dataSource.setData(filtered);
this.productCount = filtered.length;
}
build() {
Column() {
Row() {
Text('创意市集')
.fontSize(FontSize.HEADLINE)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.layoutWeight(1)
Text(`${this.productCount}件`)
.fontSize(FontSize.CAPTION)
.fontColor('#FFFFFFAA')
}
.width('100%').height(52)
.backgroundColor(AppColors.PRIMARY)
.padding({ left: Spacing.XXL, right: Spacing.XXL })
Row() {
Scroll() {
Row() {
ForEach(CATEGORIES, (cat: string, index: number) => {
Text(cat)
.fontSize(FontSize.CAPTION)
.fontColor(this.selectedCategory === cat ? Color.White :
AppColors.TEXT_SECONDARY)
.fontWeight(this.selectedCategory === cat ? FontWeight.Medium :
FontWeight.Regular)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(9999)
.backgroundColor(this.selectedCategory === cat ?
AppColors.PRIMARY : '#F0F0F0')
.margin({ right: Spacing.SM })
.onClick(() => { this.applyFilter(cat); })
})
}
.padding({ left: Spacing.LG, right: Spacing.LG })
}
.scrollBar(BarState.Off).width('100%')
}
.width('100%')
.padding({ top: Spacing.MD, bottom: Spacing.MD })
.backgroundColor(Color.White)
.border({ width: { bottom: 1 }, color: '#F0F0F0' })
List() {
LazyForEach(this.dataSource, (product: Product) => {
ListItem() {
Row() {
Column() {
Text(product.icon).fontSize(32)
}
.width(56).height(56)
.borderRadius(BorderRadius.SM)
.backgroundColor(this.getCatBgColor(product.category))
.justifyContent(FlexAlign.Center)
Column() {
Row() {
Text(product.name)
.fontSize(FontSize.BODY)
.fontColor(AppColors.TEXT_PRIMARY)
.fontWeight(FontWeight.Medium)
.layoutWeight(1).maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(product.category)
.fontSize(10)
.fontColor(this.getCatColor(product.category))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(this.getCatBgColor(product.category))
.borderRadius(4)
}
.width('100%')
Row() {
Text(`¥${product.price}`)
.fontSize(FontSize.MEDIUM)
.fontColor('#E74C3C')
.fontWeight(FontWeight.Bold)
Text(` 已售${product.sold}`)
.fontSize(FontSize.CAPTION)
.fontColor(AppColors.TEXT_DISABLED)
}
.width('100%').margin({ top: 2 })
}
.layoutWeight(1).margin({ left: Spacing.MD })
}
.width('100%')
.padding({ left: Spacing.LG, right: Spacing.LG,
top: Spacing.MD, bottom: Spacing.MD })
.backgroundColor(Color.White)
.onClick(() => {
promptAction.showDialog({
title: `${product.icon} ${product.name}`,
message: `价格:¥${product.price}\n` +
`分类:${product.category}\n已售:${product.sold} 件` +
`\n\n商品详情请查看商品页面`,
buttons: [{ text: '加入购物车', color: AppColors.PRIMARY },
{ text: '关闭', color: AppColors.TEXT_TERTIARY }]
});
})
}
}, (product: Product) => product.id.toString())
}
.layoutWeight(1).scrollBar(BarState.Off).backgroundColor('#F5F6FA')
.divider({ strokeWidth: 1, color: '#F0F0F0',
startMargin: Spacing.LG, endMargin: Spacing.LG })
}
.width('100%').height('100%')
}
getCatColor(category: string): string {
switch (category) {
case '数码': return '#1677FF';
case '图书': return '#52C41A';
case '服饰': return '#FAAD14';
case '家居': return '#FF7A45';
case '美食': return '#EB2F96';
default: return AppColors.PRIMARY;
}
}
getCatBgColor(category: string): string {
switch (category) {
case '数码': return '#1677FF20';
case '图书': return '#52C41A20';
case '服饰': return '#FAAD1420';
case '家居': return '#FF7A4520';
case '美食': return '#EB2F9620';
default: return AppColors.PRIMARY + '20';
}
}
}
六、常见面试题 / 踩坑点
6.1 LazyForEach 的 keyGenerator 为什么是必须的?
LazyForEach(this.dataSource, (product: Product) => { ... },
(product: Product) => product.id.toString()) // ← 这个参数是必须的
keyGenerator 是 LazyForEach 的第三个参数,它告诉框架每个数据项的唯一标识。当数据变化时,框架通过 key 来判断哪些组件可以复用、哪些需要重建。
如果 keyGenerator 返回重复的值,框架会混淆两个不同的数据项,导致 UI 显示错误(两个卡片显示相同内容)。
如果 keyGenerator 返回的 key 在数据变化前后不匹配,框架会认为所有数据都变了,全部重建——这会让 LazyForEach 退化为"一次性创建所有节点",丢失懒加载的性能优势。
最佳实践:使用数据实体的唯一 ID(通常是数据库主键)。
6.2 IDataSource 的监听器需要保存多个吗?
通常情况下,registerDataChangeListener 只会被调用一次——框架注册一个监听器。所以你只需要用一个数组保存即可。在实际开发中,很少出现多个监听器的场景,但按接口规范用数组实现是最安全的做法。
6.3 LazyForEach 只能用在 List 中吗?
不。LazyForEach 可以用于任何支持滚动的容器:List、Scroll、Grid、WaterFlow 等。核心条件是容器必须支持"按需创建节点"——而滚动容器天然支持,因为它们知道当前可见区域的边界。
6.4 如何给 LazyForEach 数据源动态新增 / 删除数据?
// 新增
addData(item: Product): void {
this.dataArray.push(item);
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataAdd(this.dataArray.length - 1);
});
}
// 删除
deleteData(index: number): void {
this.dataArray.splice(index, 1);
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataDelete(index);
});
}
关键点:增/删后必须通过 listener 通知框架。onDataAdd(index) 告诉框架"在 index 位置插入了一个新项",框架只创建这一项的 UI 节点,不影响其他已有节点。
6.5 数据刷新(如筛选)用 onDataReloaded 还是逐一通知?
如果数据全部变了(筛选、排序、重新加载),用 onDataReloaded() 一次性通知。
如果只是个别项变了(修改一个属性、删除一项),用对应的精确通知方法(onDataChange、onDataDelete 等)。
不要为了"简单"在所有场景都用 onDataReloaded()——这会让 LazyForEach 重建所有可见节点,失去增量更新的优势。
七、总结
LazyForEach 是 ArkUI 中处理大量数据列表的核心方案。它的设计哲学很清晰:框架管理"何时创建/何时回收",开发者管理"数据是什么/数据怎么变"。
1. 组件节点数量从 O(n) 降到 O(1)。 无论数据是 100 条还是 10000 条,屏幕上始终只有约 15 个组件节点。这意味着首帧加载时间、滚动帧率、内存占用都不随数据量增长而恶化。
2. IDataSource 是连接数据与 UI 的桥梁。 它不是一个"最佳实践"建议,而是一个硬性接口——实现了这 4 个方法(totalCount、getData、registerListener、unregisterListener),LazyForEach 就能工作。setData 是自定义方法,不是接口要求。
3. 数据变更通知决定 UI 刷新粒度。 onDataReloaded() 全量刷新,onDataAdd/Delete/Change 增量刷新。选对通知方法,能让 LazyForEach 在增删改场景下也只重建受影响的节点。
LazyForEach 特别适合以下场景:
- 商品列表/商品目录(分页加载 + 筛选 + 懒渲染)— 本文 Demo
- 社交媒体 Feed(无限滚动 + 动态增删)
- 文件管理器(成千上万的文件列表)
- 通讯录/联系人(按字母分组的大量联系人)
- 任何数据量 > 200 条且需要流畅滚动的列表
LazyForEach 的代码比 ForEach 多一些(需要实现 IDataSource),但这个"多一点"的代价换来的是:无论数据量多大,列表始终像原生一样流畅。
更多推荐




所有评论(0)