鸿蒙 ArkTS 长列表性能:ForEach 与 LazyForEach 怎么选
一、先看现象
鸿蒙应用里最常见的性能问题,是长列表滚动卡顿。一个几百条数据的 List,用 ForEach 渲染出来,滑动起来一顿一顿的,数据越多越明显。
多数人第一反应是"数据太多",于是做分页。但根因往往不在数据量,而在渲染方式。
二、ForEach 做了什么
```typescript List() { ForEach(this.items, (item: Item) => { ListItem() { ItemCard({ data: item }) } }, (item: Item) => item.id) } ```
`ForEach` 的语义是"把这个数组里的每一项都变成一个子组件"。它在构建阶段就一次性创建所有子组件——哪怕这个列表有一千条,屏幕只看得到十条,剩下九百九十条也全部创建了。
带来的后果:
- 首屏构建慢,进入页面有明显顿挫;
- 内存占用高,每个子组件都有自己的状态和布局树;
- 数据变化时重建范围大,容易出现整列表闪烁。
分页能缓解,但只是把问题摊薄,没有解决"看不见的也在渲染"这件事。
三、LazyForEach 的机制
`LazyForEach` 只创建当前可见区域及其附近的子组件,滚出屏幕的会被回收复用。这正是长列表该有的行为。
它的用法不同:不能直接传数组,必须传一个实现了 `IDataSource` 接口的数据源对象。
```typescript List() { LazyForEach(this.dataSource, (item: Item) => { ListItem() { ItemCard({ data: item }) } }, (item: Item) => item.id) } ```
三个参数的含义和 `ForEach` 一致(数据源、子组件生成器、键生成器),区别只在第一个:数组换成了数据源。
四、IDataSource 怎么写
接口有四个必须实现的方法:
```typescript class BasicDataSource implements IDataSource { private listeners: DataChangeListener[] = [] private data: Item[] = []
totalCount(): number { return this.data.length }
getData(index: number): Item { return this.data[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) } }
// 以下是自己加的操作方法,改完要通知监听者 addItem(item: Item): void { this.data.push(item) this.listeners.forEach(l => l.onDataAdd(this.data.length - 1)) }
changeItem(index: number, item: Item): void { this.data[index] = item this.listeners.forEach(l => l.onDataChange(index)) }
deleteItem(index: number): void { this.data.splice(index, 1) this.listeners.forEach(l => l.onDataDelete(index)) }
reload(): void { this.listeners.forEach(l => l.onDataReloaded()) } } ```
关键在最后那四个方法:改完数据必须主动通知。`LazyForEach` 不会去监听数组本身,它只知道框架通过 `onDataAdd` / `onDataChange` / `onDataDelete` / `onDataReloaded` 告诉它的变化。
常见错误就是改了数据但忘了通知,界面不动,然后怀疑是 LazyForEach 不好用。
五、键生成器不能省
```typescript LazyForEach(this.dataSource, (item: Item) => { ListItem() { ItemCard({ data: item }) } }, (item: Item) => item.id) // ← 这个必须有 ```
键生成器返回每一项的唯一标识。框架靠它判断"这一项是不是同一项",从而决定复用哪个已回收的组件。
如果键不稳定(比如用数组下标 `index.toString()`),删除中间一项后,后面所有项的键都变了,框架会认为"全都变了",于是整段重建——性能退回到 ForEach 的水平,还可能引发状态错乱(复用了不该复用的组件)。
判断标准很简单:这个键在数据整个生命周期内是否始终指向同一条记录。用主键或 UUID,不要用下标。
六、ForEach 还有用吗
有。判断依据是列表长度和是否固定:
| 场景 | 建议 |
|---|---|
| 少于约 30 条、内容固定 | `ForEach` 更简单,没必要上数据源 |
| 长列表、会滚动 | `LazyForEach` |
| 列表项高度差异极大 | `LazyForEach` + 合理估算高度 |
| 需要频繁增删改 | `LazyForEach`(靠通知精确更新,比整体重建便宜) |
为几行数据写一套 `IDataSource` 是过度设计。但一旦列表会滚到屏幕外,收益就非常明显。
七、一个容易忽略的细节
`LazyForEach` 的 `itemGenerator` 会被多次调用——滚出去的项再滚回来,会重新执行这个函数。所以不要在生成器里做副作用(发请求、写日志、改外部状态),它应该是一个纯的"给数据渲染组件"的函数。
需要一次性初始化的工作,放在页面的 `aboutToAppear` 里,按数据 id 缓存在数据源对象上。
八、小结
两个 API 的区别可以概括成一句话:
- `ForEach`:把所有数据都变成组件,简单但全量;
- `LazyForEach`:只把看得见的数据变成组件,需要数据源,但可复用。
判断要不要换,就问一句:这个列表会滚出屏幕吗? 会,就该用 `LazyForEach`。
真正容易出错的地方不在 API 本身,而在两处细节:数据改了要通知、键要稳定。这两点做对,长列表的性能问题基本就消失了。
- 点赞
- 收藏
- 关注作者
评论(0)