ArkTS 组件复用与性能优化:从原理到实战,打造丝滑万级长列表
文章目录

每日一句正能量
未经磨砺的灵魂没有深度、没有风暴的海洋是为池塘。
别去计较你现在付出了什么,别去怀疑你现在有没有收获,当你全身心地去投入你喜欢的事业时,一切美好的结果,都会在你未来的时光给你最优美的答案!早安!加油!
一、前言:为什么组件复用是性能优化的核心
在鸿蒙应用开发中,长列表是最常见的 UI 场景之一——从电商商品列表到社交信息流,从新闻资讯到聊天记录,几乎无处不在。然而,当数据量达到数千甚至上万条时,传统的 ForEach 循环渲染方式会面临严重的性能瓶颈:内存占用线性增长、首屏加载缓慢、滑动频繁丢帧。
根据华为官方测试数据,在万级数据场景下,ForEach 相比 LazyForEach 内存消耗高出约 22 倍,首屏加载时间延长约 30 倍,丢帧率从接近 0 飙升至 58.2%。这意味着用户在滑动列表时会明显感受到卡顿,严重影响用户体验。
本文将从 LazyForEach 懒加载、@Reusable 组件复用、cachedCount 预加载 三大核心机制入手,结合完整代码示例与性能实测数据,系统讲解 ArkTS 长列表性能优化的完整方法论。
二、LazyForEach:按需渲染的基石
2.1 ForEach 的致命缺陷
ForEach 接收一个数组,为每个元素创建对应的组件,然后一次性挂载到组件树上。数据量小的时候没有问题,但随着数据增长,内存占用和首屏加载时间都会线性增长。一万条数据意味着一万个组件实例同时驻留内存,不管用户能看到几条。

图 1:ForEach 与 LazyForEach 在万级数据场景下的性能对比
2.2 LazyForEach 的工作原理
LazyForEach 的策略完全不同。它只为当前可见区域的数据项创建组件,屏幕外的数据项不会触发组件创建。用户滚动列表时,滑出屏幕的组件被回收或缓存,滑入屏幕的新数据项才触发组件创建或从缓存中复用。
LazyForEach 要求数据源实现 IDataSource 接口,提供四个核心方法:
class ArticleDataSource implements IDataSource {
private data: Article[] = []
private listeners: DataChangeListener[] = []
totalCount(): number {
return this.data.length
}
getData(index: number): Article {
return this.data[index]
}
registerDataChangeListener(listener: DataChangeListener): void {
this.listeners.push(listener)
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const idx = this.listeners.indexOf(listener)
if (idx >= 0) {
this.listeners.splice(idx, 1)
}
}
// 精确通知数据变化,避免全量刷新
notifyDataAdd(index: number): void {
this.listeners.forEach(listener => listener.onDataAdd(index))
}
notifyDataChange(index: number): void {
this.listeners.forEach(listener => listener.onDataChange(index))
}
notifyDataDelete(index: number): void {
this.listeners.forEach(listener => listener.onDataDelete(index))
}
// 加载更多数据
loadMore(newData: Article[]): void {
const startIndex = this.data.length
this.data.push(...newData)
for (let i = 0; i < newData.length; i++) {
this.notifyDataAdd(startIndex + i)
}
}
}
2.3 关键配置:cachedCount
cachedCount 控制可见区域之外预加载的组件数量,默认值是 1。如果列表项高度较小、用户滑动较快,默认值可能不够,导致快速滚动时出现白屏。一般建议设置为可见项数量的 1 到 2 倍。
@Entry
@Component
struct ArticleListPage {
private dataSource: ArticleDataSource = new ArticleDataSource()
aboutToAppear() {
// 初始化加载数据
this.dataSource.loadMore(fetchArticles(0, 50))
}
build() {
List() {
LazyForEach(this.dataSource, (item: Article, index: number) => {
ListItem() {
ArticleCard({ article: item })
}
}, (item: Article) => item.id) // 用业务唯一 ID 作为 key,绝不用 index
}
.cachedCount(8) // 预加载 8 个屏外组件,根据实际可见项数量调整
.onReachEnd(() => {
// 触底加载更多
this.dataSource.loadMore(fetchArticles(this.dataSource.totalCount(), 20))
})
}
}
⚠️ 关键提醒:
keyGenerator必须使用数据的业务唯一标识(如item.id),绝不能用数组下标。数据插入或删除后下标会偏移,框架会误判组件状态,导致渲染错乱甚至崩溃。
三、@Reusable:组件复用的灵魂
3.1 为什么需要组件复用?
LazyForEach 解决了"创建多少个组件"的问题,但没有解决"创建组件本身的开销"。每次新数据项进入可见区域,框架仍然需要从零构建一个组件实例——创建节点、解析布局、绑定数据。如果组件结构复杂(多层嵌套、包含图片和富文本),这个创建过程的耗时会直接反映到帧率上。
@Reusable 装饰器的作用是告诉框架:这个组件可以被缓存和复用。当一个 @Reusable 组件滑出可见区域时,框架不会销毁它,而是把它的组件节点连同对应的 JSView 对象一起存入复用缓存池。当新的数据项需要显示时,框架先到缓存池里找同类型的组件,找到了就直接复用,只需要更新数据;找不到才会新建。

图 2:组件复用机制流程对比——无复用 vs @Reusable 复用
3.2 基础复用实现
使用 @Reusable 的方式很简单,在自定义组件上加装饰器,然后实现 aboutToReuse 回调来接收新数据:
@Reusable
@Component
struct ArticleCard {
@State title: string = ''
@State summary: string = ''
@State coverUrl: string = ''
@State author: string = ''
@State readCount: number = 0
@State isExpanded: boolean = false // 内部状态,复用时需要重置
aboutToReuse(params: Record<string, Object>): void {
// 从复用池取出时,用新数据更新组件状态
this.title = params.title as string
this.summary = params.summary as string
this.coverUrl = params.coverUrl as string
this.author = params.author as string
this.readCount = params.readCount as number
// ★ 关键:重置内部状态,避免旧数据污染
this.isExpanded = false
}
aboutToAppear(): void {
// 首次创建时也会触发,aboutToReuse 先于 aboutToAppear 执行
console.info('ArticleCard created or reused')
}
build() {
Column() {
Image(this.coverUrl)
.width('100%')
.height(180)
.objectFit(ImageFit.Cover)
.borderRadius(8)
Text(this.title)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.maxLines(this.isExpanded ? undefined : 2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 8 })
Text(this.summary)
.fontSize(14)
.fontColor(Color.Gray)
.maxLines(this.isExpanded ? undefined : 3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
Row() {
Text(this.author)
.fontSize(12)
.fontColor('#666')
Text(`${this.readCount} 阅读`)
.fontSize(12)
.fontColor('#999')
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.08)' })
.onClick(() => {
this.isExpanded = !this.isExpanded
})
}
}
3.3 复用池工作原理

图 3:@Reusable 复用池工作原理——组件滑出屏幕时放入复用池,滑入时取出复用
aboutToReuse 在组件从缓存池取出、即将重新挂载到组件树之前被调用。它的参数是父组件传入的新数据。这个回调里应该只做数据赋值,不要做耗时操作。
与 aboutToAppear 的区别至关重要:
| 生命周期 | 触发时机 | 使用场景 |
|---|---|---|
aboutToReuse |
仅复用时触发,且先于 aboutToAppear |
重置状态、更新数据、释放旧资源 |
aboutToAppear |
首次创建和复用时都会触发 | 初始化仅执行一次的逻辑 |
3.4 多类型列表项的复用策略
当列表中有多种不同类型的条目时(如纯文本、带图片、视频卡片),需要使用 reuseId 区分不同类型的缓存池:
@Reusable
@Component
struct TextItemView {
@State content: string = ''
aboutToReuse(params: Record<string, Object>): void {
this.content = params.content as string
}
build() {
Column() {
Text(this.content)
.fontSize(16)
.fontColor(Color.Black)
}
.padding(12)
.height(60)
}
}
@Reusable
@Component
struct ImageItemView {
@State imageUrl: string = ''
@State title: string = ''
aboutToReuse(params: Record<string, Object>): void {
this.imageUrl = params.imageUrl as string
this.title = params.title as string
}
build() {
Row() {
Image(this.imageUrl)
.width(80)
.height(80)
.borderRadius(8)
Text(this.title)
.fontSize(16)
.margin({ left: 12 })
}
.padding(12)
.height(100)
}
}
@Reusable
@Component
struct VideoItemView {
@State videoUrl: string = ''
@State duration: number = 0
private player: MediaPlayer | null = null
aboutToReuse(params: Record<string, Object>): void {
this.videoUrl = params.videoUrl as string
this.duration = params.duration as number
// 释放旧播放器,创建新播放器
this.player?.release()
this.player = new MediaPlayer(this.videoUrl)
}
aboutToDisappear(): void {
// 滑出缓存区时暂停,而非彻底销毁
this.player?.pause()
}
build() {
Column() {
// 视频播放器 UI
Text(`视频时长: ${this.duration}秒`)
.fontSize(14)
}
.padding(12)
.height(200)
}
}
// 在列表中使用
@Entry
@Component
struct MixedListPage {
private dataSource: MixedDataSource = new MixedDataSource()
build() {
List() {
LazyForEach(this.dataSource, (item: MixedItem, index: number) => {
ListItem() {
if (item.type === 'text') {
TextItemView({ content: item.content })
.reuseId('text_item') // ★ 不同类型用不同 reuseId
} else if (item.type === 'image') {
ImageItemView({ imageUrl: item.imageUrl, title: item.title })
.reuseId('image_item')
} else if (item.type === 'video') {
VideoItemView({ videoUrl: item.videoUrl, duration: item.duration })
.reuseId('video_item')
}
}
}, (item: MixedItem) => item.id)
}
.cachedCount(10)
}
}
关键规则:不同类型的组件需要不同的
reuseId,每种类型都有自己的缓存池。如果混用相同的reuseId,框架会尝试复用不兼容的组件结构,导致渲染异常。
3.5 poolSize:精细控制复用池大小
API 12+ 为 @Reusable 新增了 poolSize 配置项,用于控制该类型组件在复用池中最多保留的实例数量:
@Reusable({ poolSize: 20 })
@Component
struct HeavyListItem {
// 此类型组件在复用池中最多保留 20 个实例
// 适合包含 Canvas、WebView、播放器等重量级资源的场景
}
@Reusable({ poolSize: 5 })
@Component
struct LightListItem {
// 轻量级组件,复用池小一些,减少内存占用
}
poolSize 默认为 10。对于图片展示等「轻量复用」场景,保持默认即可;对于包含 Canvas、WebView、播放器等重量级资源的组件,适当降低 poolSize 以减少内存占用。
四、性能实测:数据说话
4.1 测试环境
- 设备:HUAWEI Mate 60 Pro
- 系统:HarmonyOS NEXT 5.0.0
- API 版本:API 12
- 测试场景:400 条复杂列表项(包含图片、文本、富文本)匀速滑动
4.2 关键指标对比
| 指标 | 无复用 (cachedCount=0) | LazyForEach + cachedCount | + @Reusable 组件复用 |
|---|---|---|---|
| 丢帧率 | 12.1% | 3.7% | 0% |
| BuildLazyItem 耗时 | 10.277ms | 8.5ms | 0.749ms |
| 总帧耗时 | 13.430ms | 11.2ms | 7.310ms |
| 内存占用 | 45.1MB | 42.3MB | 40.2MB |

图 4:滑动场景帧率对比——优化前频繁丢帧,优化后稳定 60fps
从实测数据可以看出,组件复用将 BuildLazyItem 耗时从 10.277ms 降低到 0.749ms,降幅超过 92%;丢帧率从 3.7% 降至 0%,实现了真正的丝滑滑动体验。
五、高级优化策略
5.1 避免嵌套使用 @Reusable
@Reusable 不建议嵌套使用,会降低复用效率、增加内存占用与维护成本,还会导致缓存冗余、生命周期管理混乱。正确的做法是将复用标记放在列表项的最外层组件上,内部子组件通过常规方式组织。
5.2 图片加载优化
在复用组件中,图片加载是最常见的性能瓶颈。建议:
@Reusable
@Component
struct ImageCard {
@State imageUrl: string = ''
aboutToReuse(params: Record<string, Object>): void {
this.imageUrl = params.imageUrl as string
}
build() {
Column() {
Image(this.imageUrl)
.width('100%')
.height(200)
.objectFit(ImageFit.Cover)
.borderRadius(8)
// 使用 alt 占位图,避免空白闪烁
.alt($r('app.media.placeholder'))
// 设置图片解码尺寸,避免内存浪费
.sourceSize({ width: 400, height: 300 })
}
}
}
5.3 数据精确通知
数据变更时,使用精确的 onDataAdd/Change/Delete 方法通知框架,绝对避免使用 onDataReloaded。全量刷新会导致框架重新计算所有已有项的布局,在数据多的时候计算量非常大,容易造成明显卡顿。
// ❌ 错误:全量刷新,性能极差
reloadAllData(): void {
this.data = fetchAllData()
this.notifyDataReload() // 避免使用!
}
// ✅ 正确:精确通知,只更新变化的部分
addItem(item: Article): void {
const index = this.data.length
this.data.push(item)
this.notifyDataAdd(index) // 只通知新增项
}
updateItem(index: number, newItem: Article): void {
this.data[index] = newItem
this.notifyDataChange(index) // 只通知变更项
}
removeItem(index: number): void {
this.data.splice(index, 1)
this.notifyDataDelete(index) // 只通知删除项
}
5.4 @Observed + @ObjectLink 细粒度刷新
当列表项内部的嵌套对象属性发生变化时,仅靠 LazyForEach 的刷新机制需要销毁重建整个子组件,性能较低。框架提供了 @Observed 与 @ObjectLink 机制进行深度观测,可以做到仅刷新使用了该属性的组件:
@Observed
class ArticleDetail {
title: string = ''
likeCount: number = 0
commentList: Comment[] = []
}
@Reusable
@Component
struct ArticleCard {
@ObjectLink detail: ArticleDetail // 深度观测嵌套对象
aboutToReuse(params: Record<string, Object>): void {
// detail 对象会被整体替换,不需要手动同步每个属性
}
build() {
Column() {
Text(this.detail.title)
Text(`${this.detail.likeCount} 赞`)
// 当 likeCount 变化时,只有这个 Text 会刷新
}
}
}
六、性能优化策略金字塔

图 5:ArkTS 长列表性能优化策略金字塔——从基础到进阶的完整方法论
6.1 核心要点速查表
| 序号 | 原则 | 说明 | 优先级 |
|---|---|---|---|
| 1 | 数据量超过 100 条时必须使用 LazyForEach | 绝不用 ForEach |
⭐⭐⭐ |
| 2 | 必须设置 cachedCount | 推荐值 1~2 倍可见项数量 | ⭐⭐⭐ |
| 3 | 每个 ListItem 封装为独立 @Component | 不封装就无法复用 | ⭐⭐⭐ |
| 4 | 使用业务唯一 ID 作为 key | 绝不用数组 index | ⭐⭐⭐ |
| 5 | 数据变更时精确通知 | 使用 onDataAdd/Change/Delete |
⭐⭐ |
| 6 | aboutToReuse 中重置所有内部状态 | 避免旧数据污染 | ⭐⭐ |
| 7 | 图片/媒体列表配合 @Reusable | 精细控制重量级组件 | ⭐ |
| 8 | 用 Profiler 实测,不要靠感觉 | 数据驱动优化 | ⭐⭐⭐ |
七、常见问题与踩坑实录
Q1:组件复用后 UI 状态没有更新?
组件复用时会自动更新传入的参数,但在 aboutToAppear 中如果使用了 @State 变量保存了数据的副本,需要手动同步。正确做法是在 aboutToReuse 中同步所有状态。
Q2:滑动一段距离后列表突然变卡?
这种现象通常是因为新的数据段包含更复杂的组件(例如前 1000 条是纯文本,第 1001 条开始包含图片)。解决方案:
- 针对复杂区域增大
cachedCount - 使用
@Reusable的poolSize根据组件类型精细控制 - 后端对列表数据进行预计算,将简单和复杂的条目混合排列
Q3:复用组件中图片显示旧数据?
这是最常见的复用问题。原因是在 aboutToReuse 中没有及时重置图片状态。解决方案:
- 在
aboutToReuse中先重置图片 URL 为空或占位图,再赋值新 URL - 使用
Image组件的alt属性设置占位图 - 对于网络图片,在
aboutToDisappear中取消正在进行的下载请求
Q4:不同类型组件混用时出现渲染错乱?
检查 reuseId 是否正确设置。不同类型的组件必须使用不同的 reuseId,否则框架会从错误的缓存池中取出不兼容的组件结构。
八、总结与最佳实践
一句话总结:LazyForEach 解决「不看的别创建」,cachedCount 解决「快看的提前预备」,组件复用解决「用过的别丢」。三者配合,让万级长列表跑出秒级列表的流畅感。
性能优化不是炫技,而是对用户体验的敬畏。十万条数据固然壮观,但用户感受到的只是每一次滑动是否跟手、每一次切换是否流畅。作为开发者,我们需要的不是「我用了 LazyForEach」的满足感,而是「用户在列表里滑动时,有没有感觉到哪怕一帧的卡顿」的自我审视。
鸿蒙 ArkTS 已经为长列表提供了强大的基础设施——从 LazyForEach 的按需渲染到 @Reusable 的组件复用,从 cachedCount 的预加载到 IDataSource 的精确通知。剩下的,就是用工匠精神去打磨每一个细节,用 Profiler 数据去验证每一次优化,用用户体验去衡量每一份成果。
附录
完整源码
本文配套的完整源码结构:
entry/src/main/ets/
├── pages/
│ └── ArticleListPage.ets # 主页面
├── components/
│ ├── ArticleCard.ets # 文章卡片组件(@Reusable)
│ ├── TextItemView.ets # 文本类型组件
│ ├── ImageItemView.ets # 图片类型组件
│ └── VideoItemView.ets # 视频类型组件
├── datasource/
│ └── ArticleDataSource.ets # 数据源实现
└── model/
└── Article.ets # 数据模型
转载自:https://blog.csdn.net/u014727709/article/details/137077535
欢迎 👍点赞✍评论⭐收藏,欢迎指正
更多推荐




所有评论(0)