HarmonyOS 6 列表性能实战:1000 条数据为什么用 LazyForEach 就不卡了
前言
上周我在一个数据大屏的工程里填列表,随手往里塞了一千条数据,一滚动就开始掉帧,卡片边缘能看到明显的锯齿感。第一反应是“是不是动画开太多了”,关掉几个动画还是卡。后来才想明白,问题根本不在动画,在于这一千条数据被一次性全部创建成了组件——屏幕上一屏只能看到十几条,剩下九百多条也在白白占着渲染的预算。
HarmonyOS 6 的 ArkUI 里,解决这个问题最直接的方式就是 LazyForEach。这篇我把踩坑过程完整记下来:先复现“全量创建”是怎么卡的,再看 LazyForEach 为什么能按需创建,最后用一个小实验把两种写法的差距直接量化出来。工程代码是完整的,你在 DevEco Studio 6.1.1 里建一个 Empty Ability 工程,把 Index.ets 换成文末的完整代码,就能在模拟器上亲自点着玩。

一、一千条数据放进去,为什么会卡
先看最朴素的写法:用一个 Scroll 包一个 Column,里面用 ForEach 遍历数据,每条数据渲染成一个卡片。代码看着很干净:
Scroll() {
Column({ space: 8 }) {
ForEach(this.items, (item: Item) => {
this.row(item)
}, (item: Item) => item.id.toString())
}
}
问题就出在 ForEach 的语义上:它会一次性遍历全部数据,把每一个数据项都创建成对应的组件。一千条数据,就是一千个 Row、一千个 Text、一千个圆角背景,全部进组件树。组件树越大,布局和渲染要算的东西越多,滚动的时候每一帧都要重新计算可见区域内的布局,自然就卡了。
这里有个关键点:用户一屏只能看到十几条数据,剩下的几百条在屏幕外面,用户根本看不见,但它们照样被创建、照样参与布局计算。这些“看不见的组件”就是性能的大头。
数据量小的时候感觉不明显,几十条、一百条,ForEach 全量创建也够快。一旦到几百上千条,特别是每条数据里还有图片、复杂样式的时候,卡顿就藏不住了。我这次是一千条纯文本卡片,已经能明显感觉到掉帧,要是每条再带个图,会更难受。
二、LazyForEach 是怎么“偷懒”的
LazyForEach 和 ForEach 最大的区别,就是“按需创建”:它不会一上来把所有数据全部变成组件,而是只创建当前可见区域内的那几条,滚动到哪,就取哪条的数据来创建。滚过去的组件离开可视区之后,框架还能回收复用,把预算留给当前屏幕上的内容。

要实现这个“按需取数”,LazyForEach 需要配一个数据源,实现 IDataSource 接口。这个接口一共四个方法,语义很直白:
- totalCount():告诉列表一共有多少条数据
- getData(index):列表要创建第 index 条时来取数据
- registerDataChangeListener():注册数据变化监听
- unregisterDataChangeListener():注销监听
getData 是整个机制的核心:它只在框架真正需要创建某个位置的组件时才会被调用。换句话说,getData 被调用的次数,基本就等于“实际创建的组件数”。这正是我们后面做实验量化对比的抓手——数一数 getData 被调了多少次,就知道两种写法到底各创建了多少组件。
除了数据源,LazyForEach 的第三个参数 keyGenerator 也要给一个稳定且唯一的 key。它用来在数据变化时定位组件,key 写得不稳定,列表就可能出现错位、闪烁这类奇怪问题。这个我后面在踩坑那节会再展开。
三、实验设计:两个列表、一把开关、一个计数器
光讲原理没有说服力,我做了个小实验,把两种写法的差距直接摆在界面上:
- 固定生成一千条测试数据
- 页面上一把开关,可以在 List + LazyForEach 和 Scroll + ForEach 两种模式之间来回切换
- 顶部实时显示“已创建组件:xx / 1000”,这个数字就是 getData 被调用的次数
ForEach 模式切过去,计数器会直接跳成 1000,因为一百个组件一次性全建了;LazyForEach 模式切过去,计数器只会停在可视区能容纳的数量附近,滚动的时候才慢慢往上涨。同样的数据、同样的卡片样式,唯一区别就是渲染方式,数字会说明一切。
环境用的是固定的一套:DevEco Studio 6.1.1 Release(Build #6.1.1.300),HarmonyOS 6.1.1 Release SDK(API Version 24),模拟器是 MateBook Pro 2in1(3120×2080 的 14.2 英寸屏),工程模板 Empty Ability。全程只改一个 Index.ets 文件。
四、数据模型和数据源
先定义一个最简单的数据模型,每条数据就三个字段:编号、标题、描述:

class Item {
id: number;
title: string;
desc: string;
constructor(id: number, title: string, desc: string) {
this.id = id;
this.title = title;
this.desc = desc;
}
}
然后是 LazyForEach 的数据源。除了实现 IDataSource 的四个方法,我给它加了一个 requestedCount 计数器,每次 getData 被调用就加一,方便把“实际创建了多少组件”暴露给界面:

class ItemDataSource implements IDataSource {
private items: Item[] = [];
private listeners: DataChangeListener[] = [];
requestedCount: number = 0;
init(items: Item[]): void {
this.items = items;
}
totalCount(): number {
return this.items.length;
}
getData(index: number): Item {
// 每被 UI 请求一次,说明要创建一个 ListItem
this.requestedCount++;
return this.items[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);
}
}
}
requestedCount 是实验的关键。对 LazyForEach 来说,getData 只在框架要真正创建某个位置的组件时才被调用,所以这个计数就是“当前实际存在的组件数量”;而 ForEach 那套是另一个维度,我们直接用数据总量 1000 来对比。
五、列表、开关和计数器
页面主体用一个 @State useLazy 开关控制当前模式。约到开始前先造好一千条数据,再用一个 300ms 的定时器不停把 requestedCount 刷到界面上,这样滚动的时候计数器是实时变化的:

@State useLazy: boolean = true;
@State createdCount: number = 0;
private items: Item[] = [];
private source: ItemDataSource = new ItemDataSource();
private timerId: number = -1;
aboutToAppear() {
for (let i = 0; i < 1000; i++) {
this.items.push(new Item(i, `第 ${i + 1} 条数据`, '列表性能实战:1000 条也能流畅滚动'));
}
this.source.init(this.items);
this.createdCount = this.source.requestedCount;
this.timerId = setInterval(() => {
this.createdCount = this.source.requestedCount;
}, 300);
}
列表区按开关走两条分支:LazyForEach 用 List + ListItem,ForEach 用 Scroll + Column。卡片行渲染抽成了一个 @Builder row(),两种模式共用同一套样式,保证对比公平:
if (this.useLazy) {
List({ space: 8 }) {
LazyForEach(this.source, (item: Item) => {
ListItem() {
this.row(item)
}
}, (item: Item) => item.id.toString())
}
.width('100%')
.layoutWeight(1)
} else {
Scroll() {
Column({ space: 8 }) {
ForEach(this.items, (item: Item) => {
this.row(item)
}, (item: Item) => item.id.toString())
}
}
.width('100%')
.layoutWeight(1)
}
切开关的时候,把计数改成对应模式的语义:切到 LazyForEach 显示当前实际创建的条数,切到 ForEach 直接显示 1000,因为 ForEach 是一次性全量创建的。
六、完整代码——直接运行
下面是整个 Index.ets 的完整代码,和工程里跑的是同一个版本,拷进 Empty Ability 工程就能运行:

// 数据模型
class Item {
id: number;
title: string;
desc: string;
constructor(id: number, title: string, desc: string) {
this.id = id;
this.title = title;
this.desc = desc;
}
}
// LazyForEach 数据源:数据按需取,取到才创建对应组件
class ItemDataSource implements IDataSource {
private items: Item[] = [];
private listeners: DataChangeListener[] = [];
requestedCount: number = 0;
init(items: Item[]): void {
this.items = items;
}
totalCount(): number {
return this.items.length;
}
getData(index: number): Item {
// 每被 UI 请求一次,说明要创建一个 ListItem
this.requestedCount++;
return this.items[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);
}
}
}
@Entry
@Component
struct Index {
@State useLazy: boolean = true;
@State createdCount: number = 0;
private items: Item[] = [];
private source: ItemDataSource = new ItemDataSource();
private timerId: number = -1;
aboutToAppear() {
// 生成 1000 条测试数据
for (let i = 0; i < 1000; i++) {
this.items.push(new Item(i, `第 ${i + 1} 条数据`, '列表性能实战:1000 条也能流畅滚动'));
}
this.source.init(this.items);
this.createdCount = this.source.requestedCount;
// 定时把"实际创建组件数"刷到界面(LazyForEach 滚动时会持续增长)
this.timerId = setInterval(() => {
this.createdCount = this.source.requestedCount;
}, 300);
}
aboutToDisappear() {
if (this.timerId !== -1) {
clearInterval(this.timerId);
this.timerId = -1;
}
}
build() {
Column() {
// 顶栏
Row() {
Text('1000 条列表性能对比')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1A1A1A')
Blank()
Text(this.useLazy ? 'LazyForEach' : 'ForEach')
.fontSize(13)
.fontColor(Color.White)
.backgroundColor(this.useLazy ? '#2B5CE6' : '#FA8C16')
.borderRadius(10)
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
// 统计 + 切换按钮
Row({ space: 12 }) {
Text(`已创建组件:${this.createdCount} / 1000`)
.fontSize(13)
.fontColor('#666666')
Blank()
Button(this.useLazy ? '切到 ForEach' : '切到 LazyForEach')
.fontSize(12)
.height(32)
.backgroundColor('#2B5CE6')
.onClick(() => {
this.useLazy = !this.useLazy;
if (this.useLazy) {
this.createdCount = this.source.requestedCount;
} else {
// ForEach 会一次性创建全部子组件
this.createdCount = 1000;
}
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 8 })
// 当前模式说明
Text(this.useLazy ? 'LazyForEach:只创建可见项,滚动时按需取数' : 'ForEach:一次性创建全部子组件')
.fontSize(12)
.fontColor('#888888')
.width('100%')
.padding({ left: 16, bottom: 8 })
// 列表区:两种实现方式切换
if (this.useLazy) {
List({ space: 8 }) {
LazyForEach(this.source, (item: Item) => {
ListItem() {
this.row(item)
}
}, (item: Item) => item.id.toString())
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16 })
} else {
Scroll() {
Column({ space: 8 }) {
ForEach(this.items, (item: Item) => {
this.row(item)
}, (item: Item) => item.id.toString())
}
.width('100%')
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16 })
}
}
.width('100%')
.height('100%')
.backgroundColor('#F5F7FA')
}
@Builder
row(item: Item) {
Row() {
Text(`${item.id + 1}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
.width(36)
.height(36)
.textAlign(TextAlign.Center)
.backgroundColor(item.id % 2 === 0 ? '#4A80F0' : '#52C41A')
.borderRadius(18)
Column({ space: 2 }) {
Text(item.title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#1A1A1A')
Text(item.desc)
.fontSize(12)
.fontColor('#888888')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('>')
.fontSize(14)
.fontColor('#CCCCCC')
}
.width('100%')
.height(64)
.padding({ left: 12, right: 12 })
.backgroundColor(Color.White)
.borderRadius(12)
}
}
七、运行效果:数字说话
在模拟器上跑起来,两边各点一次,差距非常直观:
- 默认是 LazyForEach 模式:顶部“已创建组件”停在个位数,十四英寸屏一屏能显示十来个卡片,计数器就在这个量级附近。上下快速滚动,计数器才缓慢往上爬,滚过的组件离开可视区后会被回收,计数器基本保持稳定。
- 点“切到 ForEach”:计数器瞬间跳到 1000,一千个卡片全部创建完成。这时候再上下滚动,明显能感觉到帧率下降,列表滚动没有 LazyForEach 模式顺滑。
同样是 1000 条数据、同一套卡片样式,唯一的区别就是“创建策略”:一个只创建看得见的,一个把一千条全部建出来。滚动体验的差距,就是这两种策略的成本差。
页面上的“已创建组件:xx / 1000”是刻意暴露给用户的细节。有了这个数字,LazyForEach 为什么快就不再是玄学,而是可以直接看到的:它创建的量级,始终只有你屏幕上需要的那一点点。


八、我在真实项目里踩过的坑
8.1 别在 getData 里做耗时操作
getData 会在滚动过程中被高频调用,如果在里面做网络请求、复杂计算或者字符串拼接,滚动就会一顿一顿的。数据源应该提前把数据准备好,getData 只做一件事:把第 index 条数据取出来返回。
8.2 keyGenerator 必须稳定且唯一
key 用来在数据变化时定位组件。如果 key 不稳定(比如用了随机数),列表刷新时组件会被错误复用,出现错位、内容串行这种诡异问题。数据里如果有 id,直接用 id 做 key 最稳。
8.3 子组件尺寸要确定
懒加载是按需创建的,子组件如果高度不确定,列表在滚动时可能反复调整布局,出现跳动。固定行高,或者让内容撑满确定的结构,体验才稳定。
8.4 LazyForEach 不只在 List 里能用
Grid、WaterFlow 这些滚动容器同样支持 LazyForEach。这次用的是列表,思路一样:凡是“数据多、一屏显示不下”的地方,都优先考虑懒加载。
8.5 再进一步:组件复用
LazyForEach 解决了“创建过多”的问题,如果卡片本身很重,还可以配合 List 的组件复用机制进一步优化。这篇先不展开,等后面单独写一篇列表性能深挖。
总结
这篇把一个很常见的列表卡顿问题拆到了底:卡顿来自 ForEach 的一次性全量创建,解决思路是 LazyForEach 的按需创建,然后用一个“已创建组件”计数器把差距量化出来——1000 对十几,差距摆在那里,滚动手感也摆在那里。
写这篇最大的收获是:列表性能问题,不要靠感觉去调,先想办法把“创建了多少组件”这种关键数字暴露出来,问题在哪、优化有没有效,一眼就清楚了。
参考资料
更多推荐


所有评论(0)