写给那些在 ArkUI 里摸爬滚打的开发者们


三、组件复用:让列表滑动更流畅

3.1 什么是组件复用

组件复用是说,自定义组件从组件树上移除后,会被放入缓存池。后续创建相同类型的组件节点时,直接从缓存池里拿,不用重新创建。

image.png

这事儿的好处很明显:

  • 避免频繁创建和销毁对象,减少内存回收的频率
  • 复用缓存中的组件可以直接绑定数据,降低了计算开销

最典型的应用场景就是长列表滑动。你想想,一个列表有几百条数据,用户快速滑动的时候,如果每个列表项都要创建销毁,那肯定卡。

3.2 复用的核心机制

ArkUI 提供了 @Reusable 装饰器来实现组件复用。原理是这样的:

image.png

  1. 标记了 @Reusable 的自定义组件,在滑动出屏幕一定范围后,从组件树上被移除,组件对象实例被放入 CustomNode 虚拟结点
  2. 列表的 RecycleManager 将这些 CustomNode 虚拟结点回收,根据复用标识 reuseId 分组,形成缓存池
  3. 继续滑动,新的列表项需要显示时,RecycleManager 优先从缓存池中查找对应 reuseId 的视图对象,然后绑定新数据,重用该节点

3.3 怎么实现组件复用

实现组件复用就三步:

第一步:用@Reusable 装饰器修饰可复用的自定义组件

@Reusable
@Component
struct ReusableComponent {
  @State text: string = ''

  aboutToReuse(params: Record<string, Object>): void {
    this.text = params.text as string;
  }

  build() {
    Text(this.text)
  }
}

第二步:实现 aboutToReuse() 生命周期回调

当组件从缓存中重新加入到节点树时,会触发这个回调。组件的构造参数会传递进来,你在这里处理数据刷新。

第三步:设置 reuseId

@Entry
@Component
struct Index {
  build() {
    Column() {
      ReusableComponent({ text: 'hello' })
        .reuseId('my_component')
    }
  }
}

reuseId 用来划分组件的复用组别。没设置的话,组件名会默认作为 reuseId

3.4 注意事项

  • @Reusable 修饰的组件需要布局在同一个父自定义组件下才能实现缓存复用
  • 不建议在 @Reusable 修饰的组件中嵌套使用另一个 @Reusable 组件

3.5 同一列表内的组件复用

场景一:列表项结构类型相同

这种场景最简单,列表中的每一项都是由相同类型的元素和布局构成。

image.png

@Reusable
@Component
struct ItemView {
  @State title: string | Resource = '';
  @State from: string | Resource = '';
  @State tail: string | Resource = '';

  aboutToReuse(params: Record<string, Object>): void {
    this.title = params.title as string;
    this.from = params.from as string;
    this.tail = params.tail as string;
  }

  build() {
    // 列表项布局
  }
}

@Component
export struct OneTypeItemPage {
  build() {
    NavDestination() {
      Column() {
        List() {
          LazyForEach(this.dataSource, (item: ItemData) => {
            ItemView({ title: item.title, from: item.from, tail: item.tail })
              .reuseId('item_id')
          }, (item: ItemData) => item.id.toString())
        }
      }
    }
  }
}
场景二:列表项结构类型不同

列表中有多种类型的列表项,比如文本、单图、多图等。

image.png

image.png

@Reusable
@Component
struct TextTypeItemView { /* ... */ }

@Reusable
@Component
struct ImageTypeItemView { /* ... */ }

@Reusable
@Component
struct ThreeImageTypeItemView { /* ... */ }

@Component
export struct MultiTypeItemPage {
  build() {
    NavDestination() {
      Column() {
        List() {
          LazyForEach(this.dataSource, (item: ItemData) => {
            if (item.type === 0) {
              TextTypeItemView({ item: item }).reuseId('text_item_id')
            } else if (item.type === 1) {
              ImageTypeItemView({ item: item }).reuseId('image_item_id')
            } else if (item.type === 2) {
              ThreeImageTypeItemView({ item: item }).reuseId('three_image_item_id')
            }
          }, (item: ItemData) => item.id.toString())
        }
      }
    }
  }
}
场景三:列表项内子组件可拆分组合

列表项有多种结构类型,但内部子组件可以拆分组合。比如顶部都是文本标题,底部都是发布时间,中间区域有单图、多图、视频三种情况。

关键点:用@Builder 实现,不要用@Component 嵌套子组件

因为缓存池位于自定义组件上,嵌套子组件后会把缓存池分割,导致复用不生效。而使用 @Builder 可以使内部的自定义组件依然汇聚在同一个缓存池里。

正例:

@Reusable
@Component
struct TopView { /* ... */ }

@Reusable
@Component
struct BottomView { /* ... */ }

@Reusable
@Component
struct MiddleSingleImageView { /* ... */ }

@Reusable
@Component
struct MiddleThreeImageView { /* ... */ }

@Reusable
@Component
struct MiddleVideoView { /* ... */ }

@Component
export struct ComposableItemPage {
  @Builder
  itemBuilderSingleImage(item: ItemData) {
    TopView({ item: item }).reuseId('top_id')
    MiddleSingleImageView({ item: item }).reuseId('middle_image_id')
    BottomView({ item: item }).reuseId('bottom_id')
  }

  @Builder
  itemBuilderThreeImage(item: ItemData) {
    TopView({ item: item }).reuseId('top_id')
    MiddleThreeImageView({ item: item }).reuseId('middle_three_image_id')
    BottomView({ item: item }).reuseId('bottom_id')
  }

  build() {
    NavDestination() {
      Column() {
        List() {
          LazyForEach(this.dataSource, (item: ItemData) => {
            ListItem() {
              Column() {
                if (item.type === 0) {
                  this.itemBuilderSingleImage(item)
                } else if (item.type === 1) {
                  this.itemBuilderThreeImage(item)
                }
              }
            }
          }, (item: ItemData) => item.id.toString())
        }
      }
    }
  }
}

反例(不要用):

// 错误:@Component 嵌套会分割缓存池
@Component
struct SingleImageComponent{
  build() {
    Column() {
      TopView({ item: item }).reuseId('top_id')
      MiddleSingleImageView({ item: item }).reuseId('middle_image_id')
      BottomView({ item: item }).reuseId('bottom_id')
    }
  }
}

3.6 多个列表间的组件复用

应用里有不同的标题页面,每个页面下有个列表。页面切换时,列表与列表之间如果存在结构相同的列表项,就有组件复用的优化可能。

image.png

解决方案是自定义一个全局的复用缓存池 NodePool,利用 BuilderNode 的节点复用能力,根据页面状态创建、回收、复用子组件。

核心实现:

// 列表项占位结点类
export class NodeItem extends NodeController {
  public builder: WrappedBuilder<ESObject> | null = null;
  public node: BuilderNode<ESObject> | null = null;
  public data: ESObject = {};
  public type: string = '';

  aboutToDisappear(): void {
    NodePool.getInstance().recycleNode(this.type, this);
  }

  makeNode(uiContext: UIContext): FrameNode | null {
    if (!this.node) {
      this.node = new BuilderNode(uiContext);
      this.node.build(this.builder, this.data);
    }
    return this.node.getFrameNode();
  }
}

// 全局复用缓存池
export class NodePool {
  private static instance: NodePool;
  private nodePool: HashMap<string, LinkedList<NodeItem>>;

  private constructor() {
    this.nodePool = new HashMap();
  }

  public static getInstance() {
    if (!NodePool.instance) {
      NodePool.instance = new NodePool();
    }
    return NodePool.instance;
  }

  public getNode(type: string, item: ESObject, builder: WrappedBuilder<ESObject>): NodeItem {
    // 从缓存池查找可复用节点
    // 如果找不到,创建新节点
  }

  public recycleNode(type: string, node: NodeItem) {
    // 回收到缓存池
  }
}

3.7 使用 onIdle() 预创建组件

首次进入页面时,复用缓存池是空的,所有列表项都要新创建,这会导致耗时较高。

image.png

可以用 onIdle() 预创建组件,把组件对象提前放入复用缓存池中。

export class IdleCallback extends FrameCallback {
  private uiContext: UIContext;
  private todoCount: number = 0;
  private dataArray: ItemData[] = [];

  constructor(context: UIContext, preBuildData: ItemData[]) {
    super();
    this.uiContext = context;
    this.dataArray = preBuildData;
  }

  onIdle(idleTimeInNano: number): void {
    // 利用空闲时间预创建组件
    while (timeLeft >= 1000000) { // 1ms
      NodePool.getInstance().preBuild('reuse_type_', this.dataArray[this.todoCount], listItemWrapper, this.uiContext);
      this.todoCount++;
      if (this.todoCount >= this.dataArray.length) return;
    }
    this.uiContext.postFrameCallback(this);
  }
}

@Entry
@Component
struct Index {
  aboutToAppear(): void {
    let dataArray: ItemData[] = genMockItemData(100);
    this.getUIContext().postFrameCallback(new IdleCallback(this.getUIContext(), dataArray));
  }
}

3.8 更多优化方法

使用 attributeUpdater 实现组件属性的部分刷新

在可复用组件中使用 attributeUpdater 可以控制指定属性的刷新,避免不必要的重绘。

export class MyTextUpdater extends AttributeUpdater<TextAttribute> {
  private color: string | number | Resource | Color = '';

  constructor(color: string | number | Resource | Color) {
    super();
    this.color = color;
  }

  initializeModifier(instance: TextAttribute): void {
    instance.fontColor(this.color)
  }
}

@Reusable
@Component
export struct OneMoment {
  @State text: string = '';
  color: string | number | Resource | Color = '';
  textUpdater: MyTextUpdater | null = null;

  aboutToAppear(): void {
    this.textUpdater = new MyTextUpdater(this.color);
  }

  aboutToReuse(params: Record<string, Object>): void {
    this.color = params.color as string;
    this.text = params.text as string;
    this.textUpdater?.attribute?.fontColor(this.color);
  }

  build() {
    Column() {
      Text(this.text)
      Text('desc').attributeModifier(this.textUpdater)
    }
  }
}
使用@Link/@ObjectLink 替代@Prop 以减少深拷贝

在可复用组件中,建议用 @Link/@ObjectLink 替代 @Prop。因为 @Prop 装饰变量时会进行深拷贝,增加了创建时间及内存消耗。

反例:

@Reusable
@Component
export struct OneMoment {
  @Prop moment: FriendMoment; // 深拷贝
}

正例:

@Reusable
@Component
export struct OneMoment {
  @ObjectLink moment: FriendMoment; // 共享地址
}
避免对自动更新的状态变量在 aboutToReuse() 中重复赋值

如果可复用组件中使用了 @Link/@ObjectLink/@Prop 等自动同步父子组件数据的状态变量,则不需要在 aboutToReuse() 中对这些数据重复赋值。

反例:

@Reusable
@Component
export struct OneMoment {
  @ObjectLink moment: FriendMoment;

  aboutToReuse(params: Record<string, Object>): void {
    this.moment.id = (params.moment as FriendMoment).id  // 多余!
    this.moment.userName = (params.moment as FriendMoment).userName  // 多余!
  }
}

正例:

@Reusable
@Component
export struct OneMoment {
  @ObjectLink moment: FriendMoment;

  aboutToReuse(params: Record<string, Object>): void {
    // 不需要重复赋值
  }
}
使用 reuseId 标记布局发生变化的组件

在同一段自定义组件代码中,如果使用 if/else 条件语句控制布局结构,会导致在不同逻辑分支中创建不同布局的组件。此时可以用 reuseId 来区分发生变化的分支逻辑。

@Entry
@Component
struct WithReuseId {
  build() {
    Column() {
      List() {
        LazyForEach(this.momentData, (moment: FriendMoment) => {
          ListItem() {
            OneMoment({ moment: moment })
              .reuseId((moment.image !== '') ? 'withImage_id' : 'noImage_id')
          }
        }, (moment: FriendMoment) => moment.id)
      }
    }
  }
}

@Reusable
@Component
export struct OneMoment {
  @ObjectLink moment: FriendMoment;

  build() {
    Column() {
      Text(this.moment.text)
      if (this.moment.image !== '') {
        Flex({ wrap: FlexWrap.Wrap }) {
          Image($r(this.moment.image))
        }
      }
    }
  }
}

3.9 如何检查组件复用是否生效

有三种方法:

  1. 使用 Code Linter 扫描工具进行代码检查,重点关注 @performance/hp-arkui-use-reusable-component 规则
  2. 通过 Profiler 工具抓取 Trace,搜索组件名称,根据 BuildRecycle 字段识别是否触发复用渲染
  3. 通过 Profiler 工具抓取 Trace,识别是否发生丢帧,判断子组件创建的次数

四、组件复用问题诊断与分析

4.1 核心概念回顾

组件复用的本质是自定义组件的缓存池。使用 @Reusable 装饰器标记自定义组件后,当该组件进入销毁流程时,将改为进入缓存池。当需要创建此自定义组件时,优先尝试从缓存池中寻找对应 reuseId 的自定义组件,若未找到再进行创建。

此缓存池的实例位于 @Reusable 标记的自定义组件的父自定义组件上,会随父自定义组件的销毁一并销毁。

image.png

image.png

关键概念:

  • 缓存池:用于存放自定义组件,位于父自定义组件对象上
  • @Reusable 装饰器:标记自定义组件,使其在释放时进入缓存池
  • reuseId:自定义组件在缓存池中的键,用于区分池中的自定义组件类型

4.2 未使用组件复用的场景

image.png

场景一:列表项结构类型相同

反例:

@Component  // 缺少@Reusable
struct NewsContent {
  @Builder
  myBuilder(item: ItemData) {
    TopView({ item: item })
    MiddleSingleImageView({ item: item })
    BottomView({ item: item })
  }
}

优化建议:给父组件 NewsContent 添加 @Reusable 装饰器。

场景二:列表项结构类型不同

image.png

子场景①:"总 - 分式"子组件可变结构

反例:

@Component  // 缺少@Reusable 和 reuseId
struct NewsContent {
  @Builder
  myBuilder(item: ItemData) {
    TopView({ item: item })
    if (item.type === 0) {
      MiddleSingleImageView({ item: item })
    } else if (item.type === 1) {
      MiddleThreeImageView({ item: item })
    }
    BottomView({ item: item })
  }
}

优化建议:添加 @Reusable 装饰器,同时根据 if-else 分支合理配置 reuseId

正例:

List() {
  LazyForEach(this.dataSource, (item: ItemData) => {
    ListItem() {
      NewsContent({ item: item }).reuseId(`${item.type}`)
    }
  }, (item: ItemData) => item.id.toString())
}

@Reusable
@Component
struct NewsContent { /* ... */ }
场景三:列表项内子组件可拆分组合

反例:

@Builder
itemBuilderVideo(item: ItemData) {
  Column() {
    TopView({ item: item })  // 缺少@Reusable
    MiddleVideoView({ item: item })  // 缺少@Reusable
    BottomView({ item: item })  // 缺少@Reusable
  }
}

优化建议:将 @Builder 内的自定义组件添加 @Reusable 修饰。

4.3 组件复用使用不当的场景

image.png

场景一:父组件未使用复用,子组件使用复用

反例:

@Component  // 父组件未复用
struct NewsContent {
  @Builder
  myBuilder(item: ItemData) {
    TopView({ item: item })  // 子组件复用
  }
}

@Reusable
@Component
struct TopView { /* ... */ }

问题:父组件未使用复用,但子组件使用了复用,会导致复用机制失效。因为父组件销毁时,子组件及对应的复用池也随之销毁。

优化建议:提升复用层级,在父组件启用复用,取消子组件复用。

场景二:复用嵌套

反例:

@Reusable
@Component
struct NewsContent {
  @Builder
  myBuilder(item: ItemData) {
    TopView({ item: item })  // 子组件也复用了
  }
}

@Reusable
@Component
struct TopView { /* ... */ }

问题:父组件和子组件都用了 @Reusable,形成"复用嵌套",会导致复用机制失效。

优化建议:父组件已启用复用,子组件就不要再复用了。

场景三:reuseId 分类过粗

反例:

@Reusable
@Component
struct NewsContent {
  @Builder
  myBuilder(item: ItemData) {
    TopView({ item: item })
    if (item.type === 0) {
      MiddleSingleImageView({ item: item })
    } else if (item.type === 1) {
      MiddleThreeImageView({ item: item })
    } else {
      MiddleVideoView({ item: item })
    }
    BottomView({ item: item })
  }
}
// 未设置 reuseId,默认使用组件名

问题:未根据 if-else 分支中的不同类型设置差异化的 reuseId,导致复用分类粒度过粗。

优化建议:应根据组件的结构、布局和特征,为不同类别分别配置独立稳定的 reuseId

场景四:reuseId 分类过细

反例:

List() {
  LazyForEach(this.dataSource, (item: ItemData) => {
    ListItem() {
      NewsContent({ item: item }).reuseId(`${item.type}`)  // 每种类型都独立
    }
  }, (item: ItemData) => item.id.toString())
}

问题reuseId 设置过于细化,会导致复用缓存池被过度拆分,形成多个小型缓存桶,降低组件复用率。

优化建议:将结构相近的列表项合并到一个类型中,以提升复用的命中率。

4.4 复用缓存池为空的情况

列表的首次滑动

在列表首次滑动过程中,复用缓存池为空属于正常现象。初始渲染时,所有可见组件均需通过新建(BuildItem)方式创建组件实例,尚未有组件被移出屏幕并回收至缓存池。

列表滑动速度突然变快

当列表项快速滑动时,若观察到大量组件以 BuildItem 方式创建,且复用行为(BuildRecycle)显著减少或消失,表明复用机制未能及时响应高频滚动事件。

可能出现的问题:

问题 原因 表现
回收滞后 滚动速度过快,系统来不及回收移出的组件 缓存池无法填充,新项只能通过新建方式进行创建
缓存池溢出 短时间内产生大量待回收项,超出缓存容量 旧实例被丢弃,新项仍需重建

4.5 组件复用后的卡顿问题

image.png

复用过程函数耗时长

在复用过程中会触发应用复用生命周期回调和内容刷新,若复用成功但仍有丢帧时,优先检查复用过程中是否存在耗时函数。

典型 Trace 分析中,BuildRecycle 是典型的复用成功回调,此处执行耗时过长造成了应用丢帧。

复用过程刷新耗时长

复用过程的更新与状态管理的标准更新一致,可参考状态管理最佳实践文档。


五、避坑指南

写了这么多,最后总结几个最容易踩的坑:

坑一:忘了加@Reusable

这是最常见的。你把组件封装得好好的,列表一滑动就卡。一查,忘了加 @Reusable 装饰器。

记住:只要是想复用的自定义组件,必须加 @Reusable

坑二:reuseId 设置不当

reuseId 设置过粗,不同类型的组件混在一起复用,会导致显示错误。

reuseId 设置过细,每种类型都单独一个 reuseId,会导致缓存池被过度拆分,复用率降低。

怎么把握这个度?看组件的结构差异。结构差异大的,分开;结构差异小的,合并。

坑三:复用嵌套

父组件用了 @Reusable,子组件也用了 @Reusable。这看起来挺合理,但实际上会导致复用机制失效。

记住:父组件已经复用,子组件就不要再复用了。

坑四:在 aboutToReuse() 中重复赋值

@Link/@ObjectLink/@Prop 等状态变量会自动同步父子组件数据,不需要在 aboutToReuse() 中重复赋值。重复赋值会导致额外的计算更新耗时。

坑五:用函数方法作为复用组件的入参

可复用组件的入参如果用了函数方法,每次复用都会执行这个函数,造成性能问题。

解决办法:先把函数执行结果存到状态变量里,然后传状态变量。

坑六:在@Builder 里嵌套@Component

列表项内子组件可拆分组合的场景下,如果用 @Component 嵌套子组件,会把缓存池分割,导致复用不生效。

正确做法:用 @Builder 实现,让内部的自定义组件汇聚在同一个缓存池里。

坑七:首次滑动卡顿

列表首次滑动时,复用缓存池是空的,所有组件都要新建,这会导致卡顿。

解决办法:用 onIdle() 预创建组件,把组件提前放入缓存池。


六、最后说两句

组件封装和复用这事儿,说难不难,说简单也不简单。

难的地方在于,你得理解 ArkUI 的复用机制,知道缓存池是怎么工作的,知道 reuseId 是怎么分组的。

简单的地方在于,一旦你理解了这些,用起来其实就那几招:加 @Reusable、设 reuseId、实现 aboutToReuse()

但真正要做好,还得在实际项目中多练。每个项目的情况都不一样,列表项的结构、数据类型、性能要求都不同,你得根据具体情况调整复用策略。

最后,记住一点:性能优化是个平衡的艺术。不是复用越多越好,也不是封装越细越好。得根据实际情况,找到那个平衡点。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐