在这里插入图片描述

每日一句正能量

未经磨砺的灵魂没有深度、没有风暴的海洋是为池塘。
别去计较你现在付出了什么,别去怀疑你现在有没有收获,当你全身心地去投入你喜欢的事业时,一切美好的结果,都会在你未来的时光给你最优美的答案!早安!加油!


一、前言:为什么组件复用是性能优化的核心

在鸿蒙应用开发中,长列表是最常见的 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 条开始包含图片)。解决方案:

  1. 针对复杂区域增大 cachedCount
  2. 使用 @ReusablepoolSize 根据组件类型精细控制
  3. 后端对列表数据进行预计算,将简单和复杂的条目混合排列

Q3:复用组件中图片显示旧数据?

这是最常见的复用问题。原因是在 aboutToReuse 中没有及时重置图片状态。解决方案:

  1. aboutToReuse 中先重置图片 URL 为空或占位图,再赋值新 URL
  2. 使用 Image 组件的 alt 属性设置占位图
  3. 对于网络图片,在 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
欢迎 👍点赞✍评论⭐收藏,欢迎指正

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐