鸿蒙 ArkUI 深水区:LazyForEach 大列表虚拟滚动,1 万条数据滑得飞的秘密
鸿蒙 ArkUI 深水区:LazyForEach 大列表虚拟滚动,1 万条数据滑得飞的秘密
写在前面
如果你写 ArkUI 写过购物车、消息列表、聊天记录之类的页面,大概率遇到过这个场景:
列表里 1 千条数据,你用
ForEach渲染,滑得滑得生不如死——每滑一格卡半秒,滑到底还要白屏三秒。用户当场给你一星。
你查文档发现ForEach是「全量渲染」,1 千条全部构造 DOM 节点,内存爆炸、渲染卡死。文档说「大列表用LazyForEach」,你点进去发现要写IDataSource接口、注册监听者、手动通知增删改——比ForEach复杂十倍,一脸懵。
这是「全量渲染」和「虚拟滚动」的分水岭。鸿蒙 ArkUI 给的答案是 LazyForEach + IDataSource——只渲染可视区 + 缓冲区,离屏自动销毁,1 万条数据滑得飞。
本文就用一个真机可跑的「1 万条数据 + 增删改」demo,把 LazyForEach + IDataSource 从「听名字一脸懵」讲到「下个项目直接抄」。代码托管在 AtomGit,文末有链接,真机实拍截图作证。
适合人群:写过 ArkUI、被
ForEach大列表卡死折磨过的同学。
不适合人群:还在学@State的同学——出门左转看我的入门篇。
一、先讲清楚:LazyForEach 到底是啥
一句话:LazyForEach 是虚拟滚动的列表渲染器,只渲染可视区 + 缓冲区,离屏销毁。
对比 ForEach:
| 维度 | ForEach |
LazyForEach |
|---|---|---|
| 渲染策略 | 全量渲染,N 条全部构造 DOM | 虚拟滚动,只渲染可视区 + 缓冲区 |
| 数据源 | 直接传数组 | 必须传 IDataSource 实现 |
| 增删改 | 改数组重新渲染 | 调 IDataSource 的 notify 方法局部渲染 |
| 1 万条性能 | 卡死 | 滑得飞 |
| 何时用 | 小列表(< 100 条) | 大列表(≥ 100 条) |
一句话决策:小列表用 ForEach,大列表用 LazyForEach。
二、动手:一个 1 万条数据的虚拟滚动 demo
2.1 数据模型
class Item {
id: number = 0
label: string = ''
Item() {}
set(id: number, label: string): Item {
this.id = id
this.label = label
return this
}
}
用 class + 工厂方法 set,不用 interface 字面量——ArkTS 强约束 arkts-no-untyped-obj-literals,且 LazyForEach 的 keyGenerator 要稳定的 key,class 实例带 id 字段正好做 key。
2.2 IDataSource 实现:ListDataSource
LazyForEach 不直接读数组,要通过 IDataSource 接口读写。这个接口要你实现 4 个方法:
class ListDataSource implements IDataSource {
private items: Item[] = []
private listeners: DataChangeListener[] = []
constructor(count: number) {
for (let i = 0; i < count; i++) {
this.items.push(new Item().set(i, `第 ${i + 1} 条数据`))
}
}
// ① 总数:LazyForEach 问「这条数据有几条」
totalCount(): number { return this.items.length }
// ② 取一条:LazyForEach 问「第 index 条是啥」
getData(index: number): Item { return this.items[index] }
// ③ 注册监听者:LazyForEach 挂上来,你增删改要通知它
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) }
}
// ④ 增删改后通知所有监听者,LazyForEach 自动局部重渲染
addItem(item: Item): void {
this.items.push(item)
for (const l of this.listeners) { l.onDataAdd(this.items.length - 1) }
}
deleteItem(index: number): void {
if (index < 0 || index >= this.items.length) { return }
this.items.splice(index, 1)
for (const l of this.listeners) { l.onDataDelete(index) }
}
}
4 个接口方法的职责:
| 方法 | 职责 | 一句话理解 |
|---|---|---|
totalCount() |
返回总数 | 「LazyForEach 问数据有几条」 |
getData(index) |
取第 index 条 | 「LazyForEach 问第 N 条是啥」 |
register/unregister |
监听者管理 | 「LazyForEach 挂上来/摘下去」 |
notifyXxx |
增删改通知 | 「数据变了通知 LazyForEach 局部重渲染」 |
新手最容易忘的就是 notify——只改数组不通知,界面不动,以为装饰器没装上。
2.3 主页面:LazyForEach 装在 List 里
@Entry
@Component
struct Index {
// 1 万条数据(ForEach 直接卡死,LazyForEach 滑得飞)
private dataSource: ListDataSource = new ListDataSource(10000)
@State counter: number = 0
build() {
Column({ space: 12 }) {
Text('LazyForEach 虚拟滚动 Demo').fontSize(22).fontWeight(FontWeight.Bold).margin({ top: 16 })
// 操作区:增删数据,LazyForEach 自动局部重渲染
Row({ space: 10 }) {
Button('+ 一条').backgroundColor('#007DFF').fontColor('#fff').height(36)
.onClick(() => {
this.counter++
this.dataSource.addItem(new Item().set(10000 + this.counter, `新增第 ${this.counter} 条`))
})
Button('删末条').backgroundColor('#FF4D4F').fontColor('#fff').height(36)
.onClick(() => { this.dataSource.deleteItem(this.dataSource.totalCount() - 1) })
}
// LazyForEach 虚拟滚动:滑到哪渲染哪,离屏销毁
// keyGenerator 用 id 做复用 key
List({ space: 8 }) {
LazyForEach(this.dataSource, (item: Item) => {
ListItem() {
Row({ space: 12 }) {
Stack({ alignContent: Alignment.Center }) {
Text(`${item.id}`).fontSize(12).fontColor('#fff')
}.width(36).height(36).backgroundColor('#007DFF').borderRadius(18)
Text(item.label).fontSize(16).fontColor('#222').layoutWeight(1)
Text('›').fontSize(22).fontColor('#bbb')
}
.width('100%').padding(12).backgroundColor('#fff').borderRadius(8)
}
}, (item: Item) => `item-${item.id}`)
}
.height('78%').scrollBar(BarState.Auto)
.divider({ strokeWidth: 1, color: '#F0F0F0', startMargin: 12, endMargin: 12 })
}
.padding(16).backgroundColor('#F5F6F8').height('100%').width('100%')
}
}
LazyForEach 三个参数:
dataSource:IDataSource实例itemGenerator:每条数据的 UI 模板(ListItem里写)keyGenerator:每条数据的复用 key(用item.id拼,稳定唯一)
三、这段代码的三个关键点
① IDataSource 是 LazyForEach 的「数据门面」
LazyForEach 不直接读你的数组,所有读写都过 IDataSource。这隔离了数据存储实现——你后面可以把数组换成数据库分页查询,IDataSource 接口不变,LazyForEach 调用不变。
② notifyXxx 是局部重渲染的触发器
addItem(item: Item): void {
this.items.push(item)
for (const l of this.listeners) { l.onDataAdd(this.items.length - 1) }
// ↑↑↑↑↑ 忘了这行界面不动
}
只改数组不调 notify,LazyForEach 不知道数据变了,界面不动。这是新手第二坑(以为装饰器没装上,其实是忘通知)。
DataChangeListener 的通知方法对照表:
| 方法 | 触发啥 | 何时调 |
|---|---|---|
onDataAdd(index) |
第 index 处新增 | 末尾追加/中间插入 |
onDataDelete(index) |
第 index 处删除 | 删一条 |
onDataChange(index) |
第 index 处改了 | 改一条内容 |
onDataReload() |
全量重载 | 大批改后整体刷 |
onDataMove(from, to) |
从 from 挪到 to | 排序/拖拽换位 |
③ keyGenerator 是复用 key,稳定唯一
}, (item: Item) => `item-${item.id}`)
key 用 item.id 拼,稳定唯一——同一 id 的数据复用同一 DOM 节点,滑回去不重新构造。如果 key 用 index(数组下标),删一条所有 key 错位,会全量重渲染,虚拟滚动失效。这是新手第三坑。
四、真机实拍:1 万条数据滑得飞
我把这个 demo 装到真机上跑(鸿蒙 6.1.1.125, API 24),1 万条数据,下面这张是真机实拍,没有任何 P 图。
整体效果:标题 + 操作区(+一条/删末条)+ 列表区(可见第 1、2、3 条数据,圆形序号 + label + ›),1 万条数据滑得飞:

重点看画面:列表区每条是「圆形序号 + label + ›」结构,序号 0、1、2 依次显示「第 1 条数据」「第 2 条数据」「第 3 条数据」——这就是
LazyForEach只渲染可视区 + 缓冲区的结果,1 万条数据后台待命,滑到哪渲染哪,离屏销毁,内存不爆。
五、LazyForEach vs ForEach:啥时候用哪个
新手最容易纠结的问题:既然 LazyForEach 性能好,还要 ForEach 干啥?
| 维度 | ForEach |
LazyForEach |
|---|---|---|
| 数据源 | 直接数组 | IDataSource 实现 |
| 性能 | 全量渲染,N 条全 DOM | 虘拟滚动,只渲染可视区 |
| 增删改 | 改数组重新渲染 | 调 notify 局部渲染 |
| 代码量 | 少 | 多(要写 IDataSource) |
| 何时用 | < 100 条小列表 | ≥ 100 条大列表 |
一句话决策:小列表用 ForEach 省代码,大列表用 LazyForEach 省性能。不要啥都往 LazyForEach 塞,IDataSource 写起来比 ForEach 多三倍代码,小列表用 ForEach 就够。
六、常见坑(都是血泪)
| 坑 | 症状 | 解法 |
|---|---|---|
改数组不调 notify |
界面不动 | 增删改后必须调 onDataAdd/onDataDelete/onDataChange |
keyGenerator 用 index |
删一条全量重渲染 | 用稳定唯一的 id 做 key,不用数组下标 |
IDataSource 忘写 register/unregister |
监听者漏挂通知不到 | 4 个接口方法都要实现,不能省 |
getData 返回裸 Object |
编译报错 | 返回显式 class 实例,不要裸对象字面量 |
小列表用 LazyForEach |
代码臃肿性能没差 | < 100 条用 ForEach,别过度工程 |
LazyForEach 期望跨页面状态 |
跨页面不响应 | 跨页面用 AppStorage,LazyForEach 只本组件 |
七、完整代码仓库
本文所有代码都已托管到 AtomGit,欢迎 clone、提 issue、点 star:
🔗 仓库地址:https://atomgit.com/JaneConan/arkui-lazyforeach
仓库包含:
- 完整的「1 万条数据 + 增删改」虚拟滚动 demo 工程
Index.ets主页面(LazyForEach+ 操作区)ListDataSourceIDataSource实现(4 接口方法 + 增删改 notify)Item数据模型 + 工厂方法set- 可直接用 DevEco Studio 打开运行
八、下一步该学什么?
跑通这个 demo 之后,你的 ArkUI 大列表性能就入门了。这是 ArkUI 系列第九篇,十篇凑齐只差最后一篇——下一篇讲动画体系。建议按这个顺序往下:
- 动画体系(下一篇):
animateTo显式动画、animation隐式动画、CustomDialog入场 List+Scroller控制滚动:编程式滚到某条、监听滚动事件、下拉刷新Grid/WaterFlow网格虚拟滚动:LazyForEach在网格里的用法- 大数据分页加载:
IDataSource配合网络分页,滑到底自动加载下一页
写在最后
LazyForEach + IDataSource 的本质,是**「数据存储」和「数据渲染」的隔离**——IDataSource 管「数据怎么存」,LazyForEach 管「数据怎么渲染」,两者通过接口通讯。这个思想在前端圈叫虚拟列表/Windowing,在鸿蒙圈叫 LazyForEach/IDataSource,名字不同灵魂相通。
一旦你开始用虚拟滚动思维写大列表,你会发现大部分「大列表卡死」的需求,都是 IDataSource 实现的自然结果。1 万条数据滑得飞,内存不爆,用户五星。
代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来,亲手滑一遍 1 万条数据感受下飞。
跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀
作者:JaneConan
仓库:https://atomgit.com/JaneConan/arkui-lazyforeach
协议:Apache-2.0,随便用,别告我
更多推荐



所有评论(0)