鸿蒙NEXT开发【全局自定义组件复用实现】性能

简介

默认的组件复用行为,是将子组件放在父组件的缓存池里,受到这个限制,不同父组件中的相同子组件无法复用,推荐的解决方案是将父组件改为builder函数,让子组件共享组件复用池,但是由于在一些应用场景下,父组件承载了复杂的带状态的业务逻辑,而builder是无状态的,修改会导致难以维护,因此开发者可以使用BuilderNode自行管理组件复用池。

实现思路

  1. 将要生成自定义组件地方用[NodeContainer]占位,将NodeContainer内部的[NodeController]按照组件类型分别存储在NodePool中。
  2. 每次需要创建子组件时,优先从NodePool中取出一个组件,如果NodePool中没有可复用的组件则重新创建一个,否则就更新一下数据。当NodeController销毁时,回收到NodePool中,供下次使用。

数据结构

NodeItem继承NodeController,并实现makeNode方法,创建组件。NodePool通过HashMap管理NodeItem的复用和回收。

1

创建和销毁过程中复用池的流程

复用池中NodeItem的复用过程跟随NodeContainer组件的创建与销毁。

2

应用场景

在应用开发中,会遇到需要页面切换的场景,比如某些视频APP的首页,就是一个List(标题)+Swiper(列表页面)实现的Tabs切换场景。Swiper中每个页面都使用瀑布流加载视频列表,各个瀑布流中的子组件有可能是相同的布局,为了提升应用性能,就会有跨页面复用子组件的需求。但是在ArkUI提供的常规复用中,复用池是放在父组件中的,这就导致跨页面时无法复用上一个页面瀑布流中的子组件。此时就可以使用[BuilderNode]自定义一个全局的组件复用池,根据页面状态创建、回收、复用子组件,实现组件的跨页面复用。

组件复用性能对比

下面通过常规复用和自定义组件复用池两种方式,对比组件复用的性能。

常规复用

  • 使用List+Swiper实现Tabs页面切换。
List() {
  ForEach(this.arrayTitle, (title: Title, index: number) => {
    ListItem() {
      TitleView({
        title: title, clickListener: () => {
          if (title.isSelected) {
            return;
          }
          // 点击标题时,Swiper组件跳转到对应的页面
          this.swiperController.changeIndex(index, true);
          // 设置标题为选中状态
          this.arrayTitle[index].isSelected = true;
          this.arrayTitle[this.selectIndex].isSelected = false;
          this.selectIndex = index;
        }
      })
        // ...
    }
  })
}
.height(30)
.listDirection(Axis.Horizontal)

Swiper(this.swiperController) {
  // 使用LazyForEach,使Swiper页面按需加载,而不是一次全部创建
  LazyForEach(this.array, (item: string, index: number) => {
    // 常规复用代码,可按需注释运行
    // TabComp({ index: index })
    // 自定义组件复用池代码
    TabNode({ index: index })
  }, (title: string) => title)
}
.loop(false)
.onChange((index: number) => {
  // Swiper滑动切换页面时,改变标题栏的选中状态
  if (this.selectIndex !== index) {
    this.arrayTitle[index].isSelected = true;
    this.arrayTitle[this.selectIndex].isSelected = false;
    this.selectIndex = index;
  }
})
.cachedCount(0) // 此处设置cachedCount为0,便于性能对比,实际开发中可按需设置
  • 使用Swiper组件实现轮播图,使用WaterFlow组件实现瀑布流加载数据,并给自定义组件设置reuseId,用于组件复用。
Swiper() {
  ForEach(this.index % 2 === 0 ? banners1 : banners2, (res: Resource) => {
    Image(res)
      .width('100%')
      .height('100%')
  })
}
.loop(true)
.width('100%')
.height(200)
.nestedScroll(SwiperNestedScrollMode.SELF_FIRST)

WaterFlow() {
  LazyForEach(this.dataSource, (item: ViewItem, index: number) => {
    FlowItemComp({
      item: item,
      updater: (item: ViewItem) => {
        this.fillNewData(item)
      },
    })
      .reuseId('reuse_type_')
  }, (item: string) => item)
}
.columnsTemplate("1fr 1fr")
.columnsGap(10)
.rowsGap(5)
.backgroundColor(0xFAEEE0)
.width('100%')
.height('100%')
  • 实现瀑布流的子组件。
// 需要添加@Reusable装饰器,并实现aboutToReuse接口用于组件复用时刷新数据
@Reusable
@Component
export struct FlowItemComp {
  // ...

  build() {
    // ...
  }
  // 通过aboutToReuse接口刷新复用后的数据
  aboutToReuse(params: ESObject): void {
    this.item = params.item
  }
}

编译运行后,点击Tabs切换页面,然后抓取Trace,通过图1中选择的区域可以看到,切换Tabs时,每个页面的首帧耗时(从DispatchTouchEvent标签开始,到sendCommands标签结束)都在20-30ms左右。这是因为使用@Reusable的组件复用,是使用了父组件的复用池。FlowItemComp的父组件是WaterFlow,Tab切换时新页面的WaterFlow会被重新创建,这就导致前一个页面的复用池是无法使用的,只能重新创建所有的子组件。

图1 常规复用Trace图

3

自定义组件复用池

  • 使用List+Swiper实现Tabs页面切换。
List() {
  ForEach(this.arrayTitle, (title: Title, index: number) => {
    ListItem() {
      TitleView({
        title: title, clickListener: () => {
          if (title.isSelected) {
            return;
          }
          // 点击标题时,Swiper组件跳转到对应的页面
          this.swiperController.changeIndex(index, true);
          // 设置标题为选中状态
          this.arrayTitle[index].isSelected = true;
          this.arrayTitle[this.selectIndex].isSelected = false;
          this.selectIndex = index;
        }
      })
        // ...
    }
  })
}
.height(30)
.listDirection(Axis.Horizontal)

Swiper(this.swiperController) {
  // 使用LazyForEach,使Swiper页面按需加载,而不是一次全部创建
  LazyForEach(this.array, (item: string, index: number) => {
    // 常规复用代码,可按需注释运行
    // TabComp({ index: index })
    // 自定义组件复用池代码
    TabNode({ index: index })
  }, (title: string) => title)
}
.loop(false)
.onChange((index: number) => {
  // Swiper滑动切换页面时,改变标题栏的选中状态
  if (this.selectIndex !== index) {
    this.arrayTitle[index].isSelected = true;
    this.arrayTitle[this.selectIndex].isSelected = false;
    this.selectIndex = index;
  }
})
.cachedCount(0) // 此处设置cachedCount为0,便于性能对比,实际开发中可按需设置
  • 继承NodeController,实现makeNode,用于组件的创建或刷新,并在组件隐藏时(aboutToDisappear)回收组件。
export class NodeItem extends NodeController {
  private callback: UpdaterCallback | null = null;
  // ...
  // 父类方法,用于创建子组件
  makeNode(uiContext: UIContext): FrameNode | null {
    if (!this.node) {
      this.node = new BuilderNode(uiContext);
      this.node.build(this.builder, this.data);
    } else {
      this.node.update(this.data);
      this.update(this.data);
    }

    return this.node.getFrameNode();
  }

  aboutToDisappear(): void {
    // 当页面销毁时回收组件到复用池中
    NodePool.getInstance().recycleNode(this.type, this);
  }
  // ...
}
  • 使用单例模式实现复用池,应用内统一管理组件复用。
// 全局组件复用池
export class NodePool {
  private static instance: NodePool;
  // ...

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

  // 使用单例模式,用于全局管理组件复用池
  public static getInstance() {
    if (!NodePool.instance) {
      NodePool.instance = new NodePool();
    }
    return NodePool.instance;
  }
  // ...
}
  • 添加getNode方法,根据传入的type参数,获取对应的Node组件,如果未找到,则重新创建。
// 根据type获取子组件,如果有则直接复用,如果没有则创建新的子组件
public getNode(type: string, data: ESObject, builder: WrappedBuilder<ESObject>): NodeItem | undefined {
  let node: NodeItem | undefined = this.nodePool.get(type)?.pop();
  if (!node) {
    node = new NodeItem(builder, data, type);
    this.nodeHook.add(node);
  } else {
    node.data = data;
  }
  node.data.callback = (callback: UpdaterCallback) => {
    if (node) {
      node.registerUpdater(callback);
    }
  }
  return node;
}
  • 实现recycleNode方法,回收Node组件。
// 回收子组件到复用池中
public recycleNode(type: string, node: NodeItem) {
  let nodeArray: Array<NodeItem> = this.nodePool.get(type);
  if (!nodeArray) {
    nodeArray = new Array();
    this.nodePool.set(type, nodeArray);
  }
  nodeArray.push(node);
}
  • 使用NodeContainer占位轮播图组件和瀑布流子组件的位置,在最外层的Swiper切换时,会根据LazyForEach的懒加载机制回收页面,此时会触发NodeItem中的aboutToDisappear方法,将组件回收到复用池中。而新加载的页面则可以通过自定义的组件复用池获取可用的子组件,如果未获取到对应type类型的组件,则会重新创建新的组件,否则直接获取之前回收的子组件进行复用。
@Builder
function FlowItemBuilder(data: ESObject) {
  FlowItemNode({
    item: data.item,
    itemColor: data.itemColor,
    updater: data.updater,
    callback: data.callback
  })
}

// 瀑布流子组件WrappedBuilder对象
let flowItemWrapper: WrappedBuilder<ESObject> = wrapBuilder<ESObject>(FlowItemBuilder);
// 瀑布流轮播图WrappedBuilder对象
let swiperWrapper: WrappedBuilder<ESObject> = wrapBuilder<ESObject>(SwiperBuilder);

// 自定义组件复用池Swiper页面s
@Component
export struct TabNode {
  // ...
  build() {
    Scroll(this.scroller) {
      Column({ space: 2 }) {
        NodeContainer(NodePool.getInstance().getNode(REUSE_VIEW_TYPE_SWIPER, {
          images: this.index % 2 === 0 ? banners1 : banners2
        }, swiperWrapper))
        WaterFlow() {
          LazyForEach(this.dataSource, (item: ViewItem, index: number) => {
            FlowItem() {
              NodeContainer(NodePool.getInstance().getNode(REUSE_VIEW_TYPE_ITEM, {
                item: item,
                itemColor: Color.White,
                updater: (item: ViewItem) => {
                  this.fillNewData(item);
                },
                callback: null
              }, flowItemWrapper))
            }
            .width('100%')
          }, (item: string) => item)
        }
        // ...
      }
    }
    // ...
  }
}

编译运行后,点击Tabs切换页面,然后抓取Trace,通过图2中的选择区域可以看到,第一个页面的首帧耗时和常规复用是差不多的,但是后面3个页面的耗时大幅减少,只有12ms和10ms左右。这是因为第一个页面创建时自定义复用池里没有被回收的子组件,所以会和常规复用一样,需要直接创建新的子组件。而切换到第三个页面时,第一个页面中的子组件被回收到了自定义复用池NodePool中,当第三个页面被创建时,会先去复用池中查找可用的子组件直接使用,减少了创建子组件的时间。

图2 自定义组件复用池Trace图
6

性能数据对比

页面首页风景商品旅游头像
创建耗时(优化前)49.2ms23.8ms29.4ms28.9ms30.3ms
创建耗时(优化后)58.1ms21.1ms12.9ms10.6ms12.4ms

总结

在父组件内部进行组件复用时,使用常规复用是可以解决问题的,而且使用简单,只需要添加@Reusable装饰器并且实现aboutToReuse。但是由于复用池的局限性,不同的父组件想要复用相同子组件时就会失效。而自定义组件复用池,可以实现跨页面的组件复用,但是实现起来也比较复杂,需要开发者自己维护复用池。

FAQ

Q: 示例代码中为什么不使用ArkUI提供的Tabs+TabContent组件,而是要用List+Swiper组件实现?

A: Tabs中不支持使用LazyForEach,只能使用ForEach。如果使用ForEach,那么在页面创建时会将所有的TabContent全部创建,并且切换时无法回收子组件(不会执行aboutToDisappear),这就导致自定义复用池NodePool中是空的,每次创建时都获取不到组件,只能重新创建,使组件复用失去了效果。并且因为多创建了一个NodeContainer组件,耗时会比常规复用更长。

Q: 如果想要优化第一个页面的首帧耗时,应该怎么做?

A: 可以根据业务需求,提前在复用池中创建一些子组件(比如应用启动时),这样在第一个页面加载时可以略过创建子组件的过程,减少首帧耗时。

Q: NodeController中aboutToDisappear接口,是否和自定义组件生命周期中的aboutToDisappear相同?

A: NodeController中aboutToDisappear与自定义组件生命周期的aboutToDisappear含义不同,如果在两个生命周期中都进行复用,会导致重复挂载。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值