HarmonyOS 长列表一次创建几千个组件会怎样?LazyForEach 实战记录【鸿蒙心迹】

大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~
本文目录:
前言
长列表有一个很容易混淆的问题:数据源里有 5000 条数据,不代表页面就应该同时创建 5000 个 UI 节点。
如果用 ForEach 把 5000 条数据全部展开,ArkUI 会一次性创建对应的子组件;换成 List + LazyForEach 后,框架按照列表可视区域按需创建节点,滑出范围的节点会离开组件树,新的节点随着滚动再创建。官方对这两种行为有明确区分,这也是长列表优化最值得先处理的一层。
这次用一个最小场景把问题拆开:准备 1000/5000 条纯文本模拟数据,对比 ForEach 和 LazyForEach 的节点创建方式,再补上数据新增、删除、修改、整体刷新、key 设计以及组件复用。
一、5000 条数据,真正需要担心的不是数组
先明确两个数量:
数据数量是 ArkTS 中数组或数据源保存了多少条记录;UI 节点数量则是 ArkUI 当前创建了多少个用于布局、渲染和交互的组件节点。
两者不是一回事。
比如下面生成 5000 条模拟数据:
class FeedItem {
id: string;
title: string;
description: string;
constructor(id: string, title: string, description: string) {
this.id = id;
this.title = title;
this.description = description;
}
}
function buildMockData(count: number): FeedItem[] {
const result: FeedItem[] = [];
for (let i = 0; i < count; i++) {
result.push(new FeedItem(
`item-${i}`,
`列表项 ${i}`,
`这是第 ${i} 条模拟数据`
));
}
return result;
}
这段代码只是得到 5000 个数据对象。如果随后这样渲染:
List() {
ForEach(this.data, (item: FeedItem) => {
ListItem() {
Column() {
Text(item.title)
Text(item.description)
}
}
}, (item: FeedItem) => item.id)
}
问题就变了。
HarmonyOS 当前 List 官方文档明确说明:List 与 ForEach 组合时,会一次性创建所有子组件,只是根据需要布局、渲染屏幕范围内的节点;节点滑出屏幕后也不会因为这种懒加载机制而下树销毁。与之对应,List + LazyForEach 会创建、布局、渲染显示范围所需节点,滚动后划出范围的节点下树,划入范围的节点再创建、布局和渲染。
所以“1000 条”和“5000 条”在这里不是某个官方性能临界值。官方也没有给出“超过多少条必卡”这样的固定数字。真正应该关注的是:是否把数据规模直接转换成了 UI 节点规模,以及每个节点本身有多复杂。
二、先把 HarmonyOS 7 下的版本关系说清楚
本文以当前 HarmonyOS 7 开发环境为背景。华为官方升级适配资料明确给出了 HarmonyOS 7.0 与 API 26.0.0 的对应关系,并建议升级开发套件、评估废弃 API 和行为变更后再进行适配。
本例涉及的核心能力可以整理如下:
| 项目 | 本文使用情况 |
|---|---|
| 系统版本背景 | HarmonyOS 7.0 |
| 对应 API 版本 | API 26.0.0 |
| UI 框架 | ArkUI |
| 核心容器 | List、ListItem |
| 渲染控制 | ForEach、LazyForEach |
| LazyForEach 数据协议 | IDataSource、DataChangeListener |
| 列表缓存 | cachedCount |
| 可选组件复用 | @Reusable(API version 10 起支持) |
| 权限 | 本示例不涉及额外系统权限 |
| module.json5 | 本示例不需要增加专用权限或能力配置 |
List 和 ListItem 都是较早已经提供的 ArkUI 组件,当前文档继续提供 LazyForEach 示例。ListItem 官方文档还特别说明:当它与 LazyForEach 配合时,其子组件在 ListItem 创建时创建。
这里还有一个与 HarmonyOS 7 很相关的变化:如果项目已经采用状态管理 V2 和组件复用 V2,官方当前推荐用 Repeat().virtualScroll() 替代过去常见的 LazyForEach + @Reusable 组合。 官方迁移文档给出的理由是 Repeat 自身可以进行复用,API 更简洁,并明确提供了 LazyForEach 向 Repeat 迁移的方案。
因此本文继续讲 LazyForEach,因为它仍然是理解 ArkUI 懒加载、维护现有 V1 工程以及分析长列表问题的重要接口;但不能把它写成 HarmonyOS 7 下所有新项目唯一推荐的方案。
三、LazyForEach 真正多出来的是一个“数据源协议”
ForEach 可以直接拿数组渲染,而 LazyForEach 的核心输入是 IDataSource。
官方 List 示例中的 ListDataSource 就是这个模式:数据源实现 totalCount()、getData()、监听器注册和注销,并在数据变化时通过 DataChangeListener 通知框架。
我们把它整理成一个可以复用的泛型数据源:
class LongListDataSource<T> implements IDataSource {
private data: T[] = [];
private listeners: DataChangeListener[] = [];
constructor(data: T[]) {
this.data = data;
}
totalCount(): number {
return this.data.length;
}
getData(index: number): T {
return this.data[index];
}
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener);
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const index = this.listeners.indexOf(listener);
if (index >= 0) {
this.listeners.splice(index, 1);
}
}
append(item: T): void {
this.data.push(item);
const index = this.data.length - 1;
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataAdd(index);
});
}
insert(index: number, item: T): void {
this.data.splice(index, 0, item);
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataAdd(index);
});
}
remove(index: number): void {
this.data.splice(index, 1);
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataDelete(index);
});
}
update(index: number, item: T): void {
this.data[index] = item;
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataChange(index);
});
}
replaceAll(data: T[]): void {
this.data = data;
this.listeners.forEach((listener: DataChangeListener) => {
listener.onDataReloaded();
});
}
}
这段代码解决的并不是“怎么存数组”,而是数组变化以后怎么告诉 LazyForEach。
华为当前官方资料中的数据源实现同样通过 onDataAdd、onDataDelete、onDataChange、onDataReloaded 等回调通知 LazyForEach 更新对应节点。
这里比较容易理解错:只执行 splice() 或给数组元素重新赋值,并不等于已经完整履行了 IDataSource 的通知职责。数据变化和 UI 更新通知应该放在同一个数据源操作里管理,否则后续很容易出现“数据明明变了,列表为什么没变”的排查问题。
【建议插图2:LongListDataSource中registerDataChangeListener与onDataAdd/onDataDelete/onDataChange/onDataReloaded的关系示意】
四、5000 条数据的 LazyForEach 最小实现
准备数据源:
@Entry
@Component
struct LongListPage {
private dataSource: LongListDataSource<FeedItem> =
new LongListDataSource<FeedItem>(buildMockData(5000));
build() {
Column() {
Text('LazyForEach - 5000 items')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.margin(12)
List({ space: 8 }) {
LazyForEach(
this.dataSource,
(item: FeedItem, index: number) => {
ListItem() {
Column({ space: 4 }) {
Text(item.title)
.fontSize(18)
Text(item.description)
.fontSize(14)
.fontColor(Color.Gray)
}
.width('100%')
.padding(12)
}
.onAppear(() => {
console.info(`item appear: ${index}`);
})
.onDisAppear(() => {
console.info(`item disappear: ${index}`);
})
},
(item: FeedItem) => item.id
)
}
.width('100%')
.layoutWeight(1)
.cachedCount(4)
}
.width('100%')
.height('100%')
}
}
这里真正需要关注四件事。
第一,LazyForEach 接收的是 IDataSource,而不是直接把 5000 条数据展开成 5000 个列表节点。
第二,ListItem 仍然是 List 管理的直接列表项。官方文档明确要求 List 的列表内容使用 ListItem、ListItemGroup 或符合要求的自定义组件结构。
第三,cachedCount(4) 表示除了显示区域外,还给懒加载列表保留一定范围的预加载。官方说明 List + LazyForEach 设置 cachedCount 后,会在空闲时隙预创建、预布局显示区域外指定范围的节点;缓存数量需要在滚动体验与 CPU、内存开销之间权衡。当前 List 文档还给出了通常可从“一屏列表项数量的一半”附近考虑的建议,而不是越大越好。
第四,示例里的 console.info 只是为了观察生命周期节奏,不应该变成正式业务中的高频日志。华为主线程优化资料明确提醒,高频回调和懒加载相关函数里应避免额外耗时操作。
【建议插图3:DevEco Studio控制台中上下滑动列表时item appear/disappear索引变化】
五、滑动时到底发生了什么
如果把 cachedCount 和预加载因素一起考虑,不能简单理解成“屏幕显示 8 行,所以永远只有 8 个节点”。
更准确的模型是:可视区域决定当前必须参与显示的节点,List 还可以在显示区域外预加载一部分节点;随着滚动,可视范围和缓存范围一起移动。
官方文档描述的 List + LazyForEach 行为是:初始阶段创建、布局和渲染显示范围所需节点;滚动时,划出范围的节点下树,划入范围的新节点被创建、布局、渲染。设置 cachedCount 后,还会预创建和预布局额外节点。
所以 5000 条数据真正带来的变化主要是可滚动的数据空间变大,而不是要求首屏同步存在 5000 份 UI。
这也是文章开头那句话的核心:
数据很多,不等于 UI 一次全部创建。
六、数据新增、删除、修改和刷新怎么写
有了前面的数据源,页面操作反而很简单。
// 追加
this.dataSource.append(
new FeedItem('item-new', '新增条目', '通过 onDataAdd 通知')
);
// 删除第10项
this.dataSource.remove(10);
// 修改第20项
this.dataSource.update(
20,
new FeedItem('item-20', '标题已修改', '通过 onDataChange 通知')
);
// 整体替换
this.dataSource.replaceAll(buildMockData(1000));
不要把所有变化都粗暴地处理成全量刷新。
新增一项就通知新增,删除一项就通知删除,某个索引的数据发生变化就通知对应项变化;只有确实整体替换数据时,再使用 onDataReloaded()。官方 ListDataSource 示例本身就是按照这种方式分别通知新增、删除和重载。
这样做的价值不只是代码语义更清楚,也让框架知道“到底发生了什么变化”,而不是每次都把整个数据集合视为未知的新状态。
七、key 不要随手拿 index 糊上去
LazyForEach 的第三个参数是 keyGenerator:
(item: FeedItem) => item.id
这里建议把业务数据中的稳定唯一 ID 作为 key。
例如删除索引 10 后,原来的索引 11 会变成 10。如果把索引本身当成“数据身份”,列表发生插入、删除、移动后,索引和业务实体的对应关系就会改变。
因此示例的数据模型专门保留:
id: string;
然后:
(item: FeedItem) => item.id
而不是:
(item: FeedItem, index: number) => index.toString()
当前 DevEco Studio 性能规则中也继续保留了与循环渲染 key 有关的检查,并特别包含“不要在 LazyForEach 组件复用的 key 生成器里使用 stringify”等性能规则。
另外,keyGenerator 本身也属于滑动过程中可能频繁执行的函数。华为主线程优化文档明确指出,itemGenerator、keyGenerator 和数据源 getData() 在懒加载滚动过程中都会频繁调用,不应在里面放耗时逻辑。
所以一个理想的 key 通常应该同时满足两个特点:能稳定标识业务实体,而且计算足够轻。
八、LazyForEach 之后,还能不能继续做组件复用
可以,但这里必须区分 V1 和当前 HarmonyOS 7 的 V2 推荐方案。
LazyForEach 解决的是不要一次性创建全部列表节点;组件复用解决的是节点需要重新出现时,尽量减少自定义组件反复创建、销毁带来的开销。这是两个不同层次的问题。
对于仍采用 @Component 状态管理 V1 的现有工程,官方 @Reusable 从 API version 10 开始支持,适用于列表滚动等反复创建和销毁自定义组件的场景。
一个最小 V1 复用组件可以写成:
@Reusable
@Component
struct ReusableFeedRow {
@State title: string = '';
@State description: string = '';
aboutToReuse(params: Record<string, Object>): void {
this.title = params.title as string;
this.description = params.description as string;
}
build() {
Column({ space: 4 }) {
Text(this.title)
.fontSize(18)
Text(this.description)
.fontSize(14)
.fontColor(Color.Gray)
}
.width('100%')
.padding(12)
}
}
放入 LazyForEach:
LazyForEach(
this.dataSource,
(item: FeedItem) => {
ListItem() {
ReusableFeedRow({
title: item.title,
description: item.description
})
}
},
(item: FeedItem) => item.id
)
V1 复用场景真正需要注意的是 aboutToReuse:组件被再次利用时,要按照复用参数更新需要更新的状态,而且这个回调里同样不应该塞耗时计算。官方性能文档专门把 aboutToReuse 列为高频回调场景。
不过,如果是 HarmonyOS 7 下新写的状态管理 V2 页面,不建议为了“组件复用”机械地继续拼 LazyForEach + @ReusableV2。官方迁移指导已经明确:V2 场景推荐 Repeat().virtualScroll(),Repeat 自身提供复用机制。
这个版本差异很关键,否则很容易把旧方案和当前推荐方案混在一篇代码里。
九、几个容易出现的理解偏差
第一个偏差是“用了 LazyForEach 就一定不会有性能问题”。LazyForEach 主要控制节点按需创建,但如果每个 Item 自身非常复杂,或者 getData()、itemGenerator、keyGenerator 中执行同步耗时任务,滚动仍然可能受影响。官方主线程优化指南明确要求避免在这些高频路径执行耗时操作。
第二个偏差是“cachedCount 越大越流畅”。缓存可以减少滚动过程中临时创建节点的压力,但缓存本身也需要 CPU 和内存。官方文档强调要综合性能与体验设置,而不是无限增加。
第三个偏差是“LazyForEach 会让 5000 条数据消失”。不会。数据源仍然有 5000 条数据,LazyForEach 优化的是 UI 节点创建策略,不是自动替开发者做网络分页、数据库分页或者业务数据淘汰。
第四个偏差是“数组改了,UI 自然就知道”。使用 IDataSource 时,需要通过 DataChangeListener 把对应变化通知出去。新增、删除、修改、重载分别表达不同的数据变化。
第五个偏差是“既然 HarmonyOS 7 还能用 LazyForEach,就说明它仍然是所有新代码的首选”。当前官方资料并不是这样表述的:在 V2 组件复用迁移场景中,官方推荐迁移到 Repeat().virtualScroll()。
十、实际项目里可以按这个顺序排查
遇到长列表首屏慢、滑动过程中创建压力大或者数据更新异常时,可以先确认当前工程到底使用 V1 还是 V2,再确认 List 内部是不是仍然使用 ForEach 全量创建;之后检查 LazyForEach 的数据源通知是否和真实的数据操作一致,再看 key 是否稳定唯一、cachedCount 是否设置得过于激进,最后检查 itemGenerator、keyGenerator、getData()、aboutToReuse() 这些高频路径有没有日志、同步计算或其他耗时业务。
如果列表本身已经是懒加载,但滑动仍然有问题,就不要继续只盯着“数据有 5000 条”这个数字。此时更应该分析单个 Item 的组件复杂度、图片加载、布局层级和主线程任务。
开发经验总结
这次把 1000/5000 条数据放进一个最小长列表场景后,真正值得留下的不是某个固定的性能数字,而是一套实现思路。
数据规模和 UI 节点规模要分开看;ForEach 在 List 中会全量创建子组件,而 LazyForEach 才把节点创建与可视范围联系起来。
动态列表不要只维护数组,还要维护 IDataSource 的变更通知;新增、删除、修改和整体刷新应该表达成对应的数据事件。
key 尽量来自稳定的业务 ID,不要把会随着插入、删除而变化的索引当成业务实体身份,同时保持 key 计算足够轻。
cachedCount 是预加载调节手段,不是越大越好;LazyForEach 也不是性能优化的终点,Item 自身复杂度和高频回调中的工作量仍然需要控制。
最后还要把版本路线分清:现有 V1 项目可以继续理解和维护 LazyForEach + @Reusable;HarmonyOS 7 下采用 V2 的新代码,则应该同时评估官方当前推荐的 Repeat().virtualScroll() 方案。
如果正在排查自己的长列表,可以先问一个很具体的问题:页面里有 5000 条数据时,当前到底是保存了 5000 条数据,还是同时创建了 5000 份 UI? 这两个问题看起来只差几个字,优化方向完全不同。
如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~
更多推荐



所有评论(0)