大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

前言

长列表的性能问题,很多时候并不是“数据太多”这么简单。真正容易把滑动拖慢的,是列表项本身足够复杂,同时又在滚动、条件切换或父组件变化过程中反复创建和销毁。

HarmonyOS 的 ArkUI 提供了组件复用机制,而在 HarmonyOS 7(API 26)中,又增加了全局复用池能力,可以让原本局限在父组件内部的复用实例跨父组件共享。官方资料明确说明,全局复用池从 API 26.0.0 开始支持;华为开发者官网当前也将 HarmonyOS 7 与 API 26 对应。

不过这里有一个很容易理解错的地方:长列表不能简单理解成“List + @ReusableV2 + 全局复用池全部加上就更快”。

对于标准长列表,当前 V2 状态管理下首先应该考虑 Repeat(...).virtualScroll();全局复用池真正解决的是另一个问题——同一种可复用组件分散在不同父组件下时,默认复用池无法跨父组件共享实例。

下面就围绕这个边界搭一个可以复现的最小场景。

一、先把长列表里的三层问题分开

假设页面需要展示上千条信息流卡片,每张卡片内部又包含标题、描述、标签、状态区等多层 UI。

如果直接一次性创建全部列表项,节点数量和首次构建成本都会增加。ArkUI 官方 List 文档明确区分了几种加载方式:ForEach 会一次性创建所有子组件;LazyForEach 会按显示区域进行创建和销毁;带 virtualScrollRepeat 具有与 LazyForEach 类似的懒加载行为。官方也更推荐在大量子组件场景中使用 LazyForEachRepeat

因此,长列表优化至少要区分三个层面:

  1. 数据很多,不能一次性把所有节点都创建出来:使用懒加载/虚拟滚动。
  2. 列表项反复出现,避免重复构建相同结构:利用组件复用。
  3. 相同复用组件位于不同父组件下:API 26 可以进一步使用全局复用池。

这三个问题相关,但不是一回事。

二、HarmonyOS 7 下先确认版本和能力边界

本文采用 HarmonyOS 7 / API 26 作为开发基线。华为开发者联盟在 2026 年发布的官方资料中已经明确标注 HarmonyOS 7 对应 API 26。

与本文直接相关的能力可以整理如下:

能力本文使用方式版本/限制
List长列表容器List 本身从 API 7 开始支持
Repeat(...).virtualScroll()V2 长列表懒加载与复用基础当前 V2 组件复用迁移文档推荐用 Repeat 替代 LazyForEach
@ReusableV2声明 V2 可复用自定义组件@ComponentV2 配合
.reuse({ reuseId })给 V2 复用组件划分复用组V1 的 reuseId() 迁移到 V2 后改用 reuse()
reusePool / poolAccepts声明全局复用池API 26.0.0 开始支持
getReusePool()获取全局复用池通过 UIUtils.getCustomComponentContext(this) 获取
getReusableInfo()查询指定组件/reuseId 的池状态仅全局复用池可用
IReusableInfo.maxCount限制指定分区缓存数量API 26 全局复用池能力
权限本文场景不需要额外权限无需修改 module.json5 权限

全局复用池需要在 @Component@ComponentV2 中同时配置 reusePoolpoolAccepts,只写其中一个会导致编译错误;启用全局复用池时还需要配置 freezeWhenInactivepoolAccepts 不能为空,其中只能放 @Reusable@ReusableV2 装饰的自定义组件。

这里还有一个代码组织上的限制:放入 poolAccepts 的组件必须已经在其上方定义,或者从其他文件导入,否则会因为类在声明前被使用而产生编译错误。

三、标准长列表先从 Repeat.virtualScroll 入手

先看最常见的列表。

下面用一个简单的数据模型构造 1000 条卡片数据:

class CardData {
  id: number;
  title: string;
  description: string;

  constructor(id: number, title: string, description: string) {
    this.id = id;
    this.title = title;
    this.description = description;
  }
}

@ReusableV2
@ComponentV2
struct FeedCard {
  @Require @Param data: CardData;

  build() {
    Column({ space: 8 }) {
      Row() {
        Text(`#${this.data.id}`)
          .fontSize(14)

        Text(this.data.title)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
      }
      .width('100%')

      Text(this.data.description)
        .fontSize(14)
        .width('100%')

      Row({ space: 8 }) {
        Text('ArkUI')
          .fontSize(12)
        Text('HarmonyOS')
          .fontSize(12)
        Text('Performance')
          .fontSize(12)
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .border({ width: 1, color: Color.Gray })
  }
}

@Entry
@ComponentV2
struct LongListPage {
  private data: CardData[] = [];

  aboutToAppear() {
    for (let i = 0; i < 1000; i++) {
      this.data.push(new CardData(
        i,
        `Card ${i}`,
        `This is the description of card ${i}.`
      ));
    }
  }

  build() {
    List({ space: 8 }) {
      Repeat(this.data)
        .virtualScroll()
        .each((ri) => {
          ListItem() {
            FeedCard({ data: ri.item })
          }
        })
    }
    .cachedCount(4)
    .width('100%')
    .height('100%')
  }
}

这段代码解决的第一个问题不是全局复用,而是不要一次创建 1000 张卡片的完整 UI 节点

官方 List 文档明确说明,List + ForEach 会一次创建所有子组件,而 List + Repeat.virtualScroll() 按懒加载方式工作;cachedCount 则可以提前加载显示区域之外的一部分列表项。

但这里还有一个很关键的细节。

华为官方的 V1 → V2 组件复用迁移文档给出的长列表示例,同样使用:

Repeat(this.data)
  .virtualScroll()

并明确指出,Repeat 自身能够进行组件复用,因此在这一场景中不会进入自定义组件的 aboutToReuse 生命周期。

也就是说,如果你的页面只是一个普通 List + Repeat.virtualScroll() 长列表,不应该看到 @ReusableV2 就立刻继续叠一个全局复用池。

先把 Repeat 的虚拟滚动、列表缓存和布局复杂度处理好,通常更符合问题本身。

四、为什么默认复用池还有局限

真正适合全局复用池的场景,是组件边界发生变化。

例如一个信息流页面存在“推荐模式”和“关注模式”,两个区域由不同父组件负责,但内部都使用同一种复杂卡片:

Index
├── RecommendContainer
│   └── ReusableCard
└── FollowContainer
    └── ReusableCard

如果通过 if 在两个父组件之间切换,ReusableCard 虽然本身可以复用,但默认复用池属于它的父组件。

官方对默认复用池的定义就是:@Reusable / @ReusableV2 组件创建和销毁时,会使用父组件维护的本地复用池。一个父组件池中的回收实例不能直接被另一个父组件使用。父组件一起销毁时,其默认复用池也可能随之销毁。

这才是 API 26 全局复用池要解决的问题。

它允许我们把复用池放到更高层:

Index(全局复用池)
├── RecommendContainer
│   └── ReusableCard ──┐
└── FollowContainer    │
    └── ReusableCard ──┴── 共享 Index 的复用池

当框架创建或回收复用组件时,会向组件树上方寻找能够接纳该组件类型的全局复用池;如果找到,就优先使用它。没有找到匹配的全局池时,才继续使用默认复用机制。

五、搭一个跨父组件复用的最小示例

为了避免把 Repeat 自己的复用机制和全局复用池混在一起,这里单独做一个最小验证场景。

我们让两个不同父组件都创建 ReusableCard,然后通过按钮反复切换父组件。

1. 定义可复用卡片

import { UIUtils, IReusableInfo } from '@kit.ArkUI';

class ReuseCardData {
  title: string;
  detail: string;

  constructor(title: string, detail: string) {
    this.title = title;
    this.detail = detail;
  }
}

@ReusableV2
@ComponentV2
struct ReusableCard {
  @Require @Param data: ReuseCardData;

  aboutToAppear() {
    console.info(`ReusableCard appear: ${this.data.title}`);
  }

  aboutToReuse() {
    console.info(`ReusableCard reuse: ${this.data.title}`);
  }

  aboutToRecycle() {
    console.info(`ReusableCard recycle: ${this.data.title}`);
  }

  aboutToDisappear() {
    console.info(`ReusableCard disappear: ${this.data.title}`);
  }

  build() {
    Column({ space: 8 }) {
      Text(this.data.title)
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      Text(this.data.detail)
        .fontSize(14)

      Row({ space: 8 }) {
        Text('HarmonyOS')
        Text('ArkUI')
        Text('ReusableV2')
      }
    }
    .width('100%')
    .padding(16)
    .border({ width: 1, color: Color.Gray })
  }
}

@ReusableV2@ComponentV2 配合使用。V2 组件的 aboutToReuse() 已经不再接收 V1 那样的复用参数,并且框架会在复用前处理 V2 状态变量重置。官方迁移文档特别提醒了这一变化。

因此,不应该继续把 V1 的:

aboutToReuse(params: ESObject)

原样搬到 V2。

2. 准备两个不同的父组件

@ComponentV2
struct RecommendContainer {
  build() {
    Column({ space: 12 }) {
      Text('Recommend')
        .fontSize(24)

      ReusableCard({
        data: new ReuseCardData(
          'Recommend Card',
          'Reusable component under RecommendContainer'
        )
      })
        .reuse({ reuseId: () => 'feed-card' })
    }
  }
}

@ComponentV2
struct FollowContainer {
  build() {
    Column({ space: 12 }) {
      Text('Follow')
        .fontSize(24)

      ReusableCard({
        data: new ReuseCardData(
          'Follow Card',
          'Reusable component under FollowContainer'
        )
      })
        .reuse({ reuseId: () => 'feed-card' })
    }
  }
}

这里两个组件使用同一个 reuseIdfeed-card

V1 组件复用中使用的是 .reuseId();迁移到 V2 后,官方要求改为:

.reuse({
  reuseId: () => 'feed-card'
})

只有进入相同复用分区的组件,才适合互相复用。如果两个卡片结构或使用语义不同,就应该分配不同的 reuseId

3. 把复用池提升到页面级

@Entry
@ComponentV2({
  reusePool: 'shared',
  poolAccepts: [ReusableCard],
  freezeWhenInactive: false
})
struct GlobalReusePage {
  @Local showRecommend: boolean = true;

  aboutToAppear() {
    const pool =
      UIUtils.getCustomComponentContext(this).getReusePool();

    const info =
      pool?.getReusableInfo(
        ReusableCard,
        'feed-card'
      ) as IReusableInfo;

    if (info) {
      info.maxCount = 6;
    }
  }

  build() {
    Column({ space: 16 }) {
      Button('Switch Container')
        .onClick(() => {
          this.showRecommend = !this.showRecommend;
        })

      if (this.showRecommend) {
        RecommendContainer()
      } else {
        FollowContainer()
      }
    }
    .width('100%')
    .padding(16)
  }
}

真正需要关注的是这一段:

@ComponentV2({
  reusePool: 'shared',
  poolAccepts: [ReusableCard],
  freezeWhenInactive: false
})

poolAccepts 明确告诉框架,这个全局池接纳 ReusableCard

官方提供两种所有权模式:sharedperInstanceshared 表示同一组件类的多个实例可以共享一个池;perInstance 则为每个拥有者实例维护独立池。官方文档建议开发者优先考虑 shared,同时提醒 shared 池中的实例可能累积,因此应结合 maxCount 控制缓存。

这里把 feed-card 分区的 maxCount 设置为 6:

info.maxCount = 6;

这样做的意义不是“缓存越多越快”,而是给复用率和内存占用之间设置一个明确边界。

六、reuseId 不只是“组件类型名称”

reuseId 比较容易被写成:

.reuse({
  reuseId: () => this.data.id.toString()
})

如果每一条业务数据都有唯一 id,这种写法通常会把复用池切成大量小分区,反而很难让不同数据对应的同构 UI 互相复用。

更合适的思路是按照组件结构和复用兼容性分类。

比如信息流只有两种稳定结构:

.reuse({ reuseId: () => 'text-card' })

和:

.reuse({ reuseId: () => 'rich-card' })

这样同一种结构的实例可以进入相同分区。

HarmonyOS 7 的全局复用池还能通过:

pool.getReusableInfo(ReusableCard, 'rich-card')

单独取得某个 reuseId 分区的信息,然后调整其 maxCount。不传 reuseId 时,还可以查询该组件对应的多个复用分区。官方示例正是利用这个机制分别查看和清理 A、B、C 三个 reuseId 分区。

因此,reuseId 更像是“哪些组件实例可以安全互换”的分组条件,而不是业务数据主键。

七、Profiler 怎么判断复用到底有没有价值

这里不能凭感觉写“优化后提升 XX%”。

是否真正减少了列表滑动中的创建成本,需要用 DevEco Profiler 对同一场景分别录制优化前后的 Trace。

华为官方针对 ArkUI 滑动卡顿的排查文档建议使用 Frame 分析,并给出了多个可以直接用于定位的 Trace 关键字,包括:

H:APP_LIST_FLING
H:CustomNodeUpdate
H:CreateTaskMeasure
H:CreateTaskLayout
H:Create[组件名]

官方还把“未采用组件复用、频繁创建和销毁对象”列为 ArkUI 页面滑动卡顿的可能原因之一。

另一个官方性能 FAQ 展示了更具体的分析方式:通过 H:CustomNode:BuildRecycle 检查组件复用过程,再框选单次复用 Trace,通过 H:Create 查看复用过程中是否仍然创建了大量子组件。该官方案例中就发现一次复用过程中仍创建了多个子组件,最终问题不仅在于“有没有复用”,还包括组件层级过深、复用组件内部节点过多。

因此,对本文示例进行验证时,可以固定相同设备、相同数据量和相同操作路径,分别记录:

优化前:
启动页面 → 连续滑动列表 → 切换容器 → 再次滑动

优化后:
启动页面 → 连续滑动列表 → 切换容器 → 再次滑动

重点比较 H:Create[ReusableCard] 以及其子节点创建次数,同时观察 Frame 泳道中的丢帧情况和测量、布局任务。

不要只比较一个“总耗时”。

如果组件创建次数减少,但 MeasureLayoutRenderTask 仍然很重,瓶颈可能已经转移到布局复杂度、图片处理或者状态刷新上。

八、几个特别容易理解错的地方

1. @ReusableV2 不等于 Repeat 的复用

这是本文最需要区分的一点。

Repeat(...).virtualScroll() 本身已经具有列表项复用能力,官方 V2 迁移示例甚至明确指出,在该场景下不会进入自定义组件的 aboutToReuse 生命周期。

所以发现 aboutToReuse() 没打印日志,不应该立即判断“复用失效”。

先确认组件到底由谁管理复用。

2. 全局复用池不是默认复用池的简单放大版

默认池与父组件绑定,而全局池可以放在组件树更高的位置,让不同父组件共享。

这意味着它更适合:

  • 同一种复杂组件存在于不同父容器;
  • if 切换会连父组件一起销毁;
  • 页面结构导致默认复用池生命周期过短;
  • 希望按组件类型和 reuseId 精确限制缓存数量。

如果组件始终处于同一个稳定父组件下,默认池已经能够满足需求,就没有必要为了“看起来更高级”再增加全局池。

3. shared 不是永久单例

官方特别说明,shared 全局复用池并不是一个永久存在的 static 单例。

它采用跨实例引用方式:当最后一个拥有该 shared 池的组件实例销毁后,池本身以及其中缓存的组件也会销毁。

这个生命周期不能和应用级永久缓存混为一谈。

4. 不要在 aboutToRecycle 里做会触发刷新的一堆事情

官方明确建议,不要在 aboutToRecycle 中修改会触发重新渲染的状态变量,因为组件此时正在从 UI 树移除。

这个回调更适合做与回收阶段直接相关的轻量处理,而不是重新驱动页面刷新。

5. 复用不能解决复杂布局本身的成本

组件能够被复用,不代表内部布局就没有成本。

官方性能 FAQ 已经给出过典型案例:组件确实发生了复用,但由于嵌套层级复杂、内部仍创建大量子节点,单次复用仍然可能耗时。

所以长列表优化不能只检查有没有 @ReusableV2

九、实际项目可以按这个顺序排查

碰到“列表越滑越卡”,建议把问题拆开,而不是一上来调整缓存参数。

先看列表数据是否被全量创建。如果是 ForEach 展开大量数据,优先确认是否应该改成 Repeat.virtualScroll()。官方 List 文档已经明确指出两者在节点创建策略上的区别。

再看 Profiler 的 H:APP_LIST_FLING 区间,判断主要耗时到底发生在 Create、Measure、Layout、Render 还是业务回调中。

如果存在大量重复创建,再检查组件是否具有稳定、可复用的 UI 结构。

如果已经使用 Repeat 虚拟滚动,不要仅凭 aboutToReuse() 是否调用判断复用状态,因为 Repeat 有自己的复用机制。

如果相同的 @ReusableV2 组件分散在多个父组件中,而且父组件本身频繁切换或销毁,再考虑 API 26 的全局复用池。

启用全局池后继续检查 reuseId:不要用业务唯一 ID 把复用池切得过细。

最后通过 getReusableInfo() 查看各分区的 countmaxCount,控制缓存规模,再重新录制相同操作路径的 Profiler Trace。

十、什么组件适合复用,什么组件没必要

比较适合复用的是结构稳定、创建成本较高、会频繁出现和消失的自定义组件。例如复杂信息流卡片、商品卡片、多层嵌套的搜索结果项,以及多个父容器都会使用的相同业务组件。

反过来,一个只有单个 Text 的简单组件,如果生命周期中几乎不会反复创建销毁,就没必要为了复用额外增加设计复杂度。

还有一种情况更应该先优化结构:一个所谓的“复杂卡片”内部包含十几层没有必要的容器。即使把外层组件放进复用池,布局、测量和内部节点更新成本仍然存在。

复用的目标不是让所有组件都进入缓存,而是减少有实际成本的重复创建

开发经验总结

HarmonyOS 7 做长列表优化时,可以把思路浓缩成三层。

第一层是 List + Repeat.virtualScroll()。这是解决大量数据按需加载的基础,不能用组件复用替代虚拟滚动。

第二层是 @ReusableV2。它让适合复用的 V2 自定义组件具备复用语义,同时 V2 已经调整了 aboutToReuse() 和状态重置机制,不能继续照搬 V1 写法。

第三层才是 HarmonyOS 7 / API 26 新增的全局复用池。它解决的核心不是“池不够大”,而是默认复用池被父组件边界隔开。通过 reusePoolpoolAcceptsreuseIdgetReusableInfo()maxCount,可以进一步管理跨父组件的复用范围与缓存数量。

如果正在排查一个真实长列表,可以先问自己一个问题:当前卡顿究竟来自“数据全量加载”“组件反复创建”,还是“组件虽然已经复用,但内部布局仍然太重”?

把这三个问题分清楚,通常比直接往页面上叠更多性能 API 更有价值。

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

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

更多推荐