• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# 全局自定义组件复用实现
2
3## 简介
4
5默认的组件复用行为,是将子组件放在父组件的缓存池里,受到这个限制,不同父组件中的相同子组件无法复用,推荐的解决方案是将父组件改为builder函数,让子组件共享组件复用池,但是由于在一些应用场景下,父组件承载了复杂的带状态的业务逻辑,而builder是无状态的,修改会导致难以维护,因此开发者可以使用BuilderNode自行管理组件复用池。
6
7
8
9## 实现思路
10
111. 将要生成自定义组件地方用[NodeContainer](../reference/apis-arkui/arkui-ts/ts-basic-components-nodecontainer.md#nodecontainer)占位,将NodeContainer内部的[NodeController](../reference/apis-arkui/js-apis-arkui-nodeController.md)按照组件类型分别存储在NodePool中。
122. 每次创建子组件时,优先通过NodePool的getNode方法尝试复用已存在的NodeController组件,若无可复用组件则调用makeNode方法新建;若复用成功,则调用update方法更新组件数据。
133. 当NodeController销毁时,NodeItem回收到NodePool中,供下次使用。
14
15### 组件复用原理
16
17在ArkUI中,当页面退出时,系统默认会销毁页面上的所有节点及其对应的NodeItem实例,以释放资源。为了提升性能和资源利用率,应用侧可以主动保存NodeItem实例。通过这种方法,能够有效延长这些NodeItem实例的生命周期,避免不必要的重建开销,从而在后续页面或组件的创建过程中实现快速复用。
18
19下图为复用池中NodeItem实例跟随NodeContainer组件创建与销毁的复用过程。
20
21![component_pool_reuse_process](figures/node_custom_component_reusable_pool_process.jpg)
22
23### 数据结构
24
25NodeItem继承NodeController,并实现makeNode方法,创建组件。NodePool通过HashMap管理NodeItem的复用和回收。
26
27![image-20240531161153519](figures/node_custom_component_reusable_pool_struct.png)
28
29## 应用场景
30
31在应用开发中,会遇到需要页面切换的场景,比如某些视频APP的首页,就是一个List(标题)+Swiper(列表页面)实现的Tabs切换场景。Swiper中每个页面都使用瀑布流加载视频列表,各个瀑布流中的子组件有可能是相同的布局,为了提升应用性能,就会有跨页面复用子组件的需求。但是在ArkUI提供的常规复用中,复用池是放在父组件中的,这就导致跨页面时无法复用上一个页面瀑布流中的子组件。此时就可以使用[BuilderNode](../reference/apis-arkui/js-apis-arkui-builderNode.md#buildernode)自定义一个全局的组件复用池,根据页面状态创建、回收、复用子组件,实现组件的跨页面复用。
32
33## 组件复用性能对比
34
35下面通过常规复用和自定义组件复用池两种方式,对比组件复用的性能。
36
37### 常规复用
38
391. 使用List+Swiper实现Tabs页面切换。
40
41   ```ts
42   List() {
43     ForEach(this.arrayTitle, (title: Title, index: number) => {
44       ListItem() {
45         TitleView({
46           title: title, clickListener: () => {
47             if (title.isSelected) {
48               return;
49             }
50             this.swiperController.changeIndex(index, true);
51             this.arrayTitle[index].isSelected = true;
52             this.arrayTitle[this.selectIndex].isSelected = false;
53             this.selectIndex = index;
54           }
55         })
56       }
57     })
58   }
59   .height(30)
60   .listDirection(Axis.Horizontal)
61
62   Swiper(this.swiperController) {
63     // 使用LazyForEach,使Swiper页面按需加载,而不是一次全部创建
64     LazyForEach(this.array, () => {
65       TabComp()
66     }, (title: string) => title)
67   }
68   .loop(false)
69   .onChange((index: number) => {
70     if (this.selectIndex !== index) {
71       this.arrayTitle[index].isSelected = true;
72       this.arrayTitle[this.selectIndex].isSelected = false;
73       this.selectIndex = index;
74     }
75   })
76   .cachedCount(0) // 此处设置cachedCount为0,便于性能对比,实际开发中可按需设置
77   ```
78
792. 使用Swiper组件实现轮播图,使用WaterFlow组件实现瀑布流加载数据,并给自定义组件设置reuseId,用于组件复用。
80
81   ```ts
82   Scroll(this.scroller) {
83     Column({ space: 2 }) {
84       SwiperBuilder({images: this.images})
85
86       WaterFlow() {
87         LazyForEach(this.dataSource, (item: ViewItem, index: number) => {
88           FlowItem() {
89             FlowItemComp({
90               item: item,
91               itemHeight: this.itemHeightArray[index % 100],
92               itemColor: Color.White,
93               updater: (item: ViewItem) => {
94                 this.fillNewData(item);
95               }
96             }).reuseId('reuse_type_')
97           }
98           .width('100%')
99         }, (item: string) => item)
100       }
101       .nestedScroll({ // 设置嵌套滑动属性
102         scrollForward: NestedScrollMode.PARENT_FIRST,
103         scrollBackward: NestedScrollMode.SELF_FIRST
104       })
105     }
106   }.width('100%')
107   .height('100%')
108   ```
109
1103. 实现瀑布流的子组件。
111
112   ```ts
113   // 需要添加@Reusable装饰器,并实现aboutToReuse接口用于组件复用时刷新数据
114   @Reusable
115   @Component
116   export struct FlowItemComp {
117     // ...
118
119     build() {
120       // ...
121     }
122     // 通过aboutToReuse接口刷新复用后的数据
123     aboutToReuse(params: ESObject): void {
124       this.item = params.item;
125       this.itemHeight = params.itemHeight;
126       this.itemColor = params.itemColor;
127     }
128   }
129   ```
130
131编译运行后,点击Tabs切换页面,然后抓取Trace,通过图1中选择的区域可以看到,切换Tabs时,每个页面的首帧耗时(从DispatchTouchEvent标签开始,到sendCommands标签结束)都在30-40ms左右。这是因为使用@Reusable的组件复用,是使用了父组件的复用池。FlowItemComp的父组件是WaterFlow,Tab切换时新页面的WaterFlow会被重新创建,这就导致前一个页面的复用池是无法使用的,只能重新创建所有的子组件。
132
133图1 常规复用Trace图
134
135![img](figures/node_custom_component_reusable_pool_trace_1.JPG)
136
137### 自定义组件复用池
138
1391. 使用List+Swiper实现Tabs页面切换。
140
141   ```ts
142   Swiper(this.swiperController) {
143     LazyForEach(this.array, () => {
144       TabNode()
145     }, (title: string) => title)
146   }
147   ```
148
1492. 继承NodeController,实现makeNode,用于组件的创建或刷新,并在组件隐藏时(aboutToDisappear)回收组件。
150
151   ```ts
152   export class NodeItem extends NodeController {
153     private callback: UpdaterCallback | null = null;
154     // 变量声明
155     // 父类方法,用于创建子组件
156     makeNode(uiContext: UIContext): FrameNode | null {
157       if (!this.node) {
158         this.node = new BuilderNode(uiContext);
159         this.node.build(this.builder, this.data);
160       } else {
161         this.node.update(this.data);
162         this.update(this.data);
163       }
164
165       return this.node.getFrameNode();
166     }
167     // 组件隐藏时回收组件
168     aboutToDisappear(): void {
169       NodePool.getInstance().recycleNode(this.type, this);
170     }
171   }
172   ```
173
1743. 使用单例模式实现复用池,应用内统一管理组件复用
175
176    ```ts
177    export class NodePool {
178      private static instance: NodePool;
179      // ...
180
181      private constructor() {
182        this.nodePool = new HashMap();
183        this.nodeHook = new HashSet();
184        this.idGen = 0;
185      }
186      // 单例模式,可以全局统一管理
187      public static getInstance() {
188        if (!NodePool.instance) {
189          NodePool.instance = new NodePool();
190        }
191        return NodePool.instance;
192      }
193    }
194    ```
195
1964. 添加getNode方法,根据传入的type参数,获取对应的Node组件,如果未找到,则重新创建
197
198    ```ts
199      // 获取Node组件,如果存在type类型的Node组件,则直接使用,否则重新创建
200      public getNode(type: string, data: ESObject, builder: WrappedBuilder<ESObject>): NodeItem | undefined {
201        let node: NodeItem | undefined = this.nodePool.get(type)?.pop();
202        if (!node) {
203          node = new NodeItem(builder, data, type);
204          this.nodeHook.add(node);
205        } else {
206          node.data = data;
207        }
208        node.data.callback = (callback: UpdaterCallback) => {
209          if (node) {
210            node.registerUpdater(callback);
211          }
212        }
213        return node;
214      }
215    ```
216
2175. 实现recycleNode方法,回收Node组件
218
219    ```ts
220      // 回收Node组件,提供给下次复用
221      public recycleNode(type: string, node: NodeItem) {
222        let nodeArray: Array<NodeItem> = this.nodePool.get(type);
223        if (!nodeArray) {
224          nodeArray = new Array();
225          this.nodePool.set(type, nodeArray);
226        }
227        nodeArray.push(node);
228      }
229    ```
230
2316. 使用NodeContainer占位轮播图组件和瀑布流子组件的位置,在最外层的Swiper切换时,会根据LazyForEach的懒加载机制回收页面,此时会触发NodeItem中的aboutToDisappear方法,将组件回收到复用池中。而新加载的页面则可以通过自定义的组件复用池获取可用的子组件,如果未获取到对应type类型的组件,则会重新创建新的组件,否则直接获取之前回收的子组件进行复用。
232
233   ```ts
234   @Builder
235   function FlowItemBuilder(data: ESObject) {
236     FlowItemNode({
237       item: data.item,
238       itemHeight: data.itemHeight,
239       itemColor: data.itemColor,
240       updater: data.updater,
241       callback: data.callback
242     })
243   }
244
245   let flowItemWrapper: WrappedBuilder<ESObject> = wrapBuilder<ESObject>(FlowItemBuilder);
246   let swiperWrapper: WrappedBuilder<ESObject> = wrapBuilder<ESObject>(SwiperBuilder);
247
248   @Component
249   export struct TabNode {
250     // 变量声明
251     // ...
252     build() {
253       Scroll(this.scroller) {
254         Column({ space: 2 }) {
255           NodeContainer(NodePool.getInstance().getNode('reuse_type_swiper_', {
256             images: this.images
257           }, swiperWrapper))
258           WaterFlow() {
259             LazyForEach(this.dataSource, (item: ViewItem, index: number) => {
260               FlowItem() {
261                 NodeContainer(NodePool.getInstance().getNode('reuse_type_', {
262                   item: item,
263                   itemHeight: this.itemHeightArray[index % 100],
264                   itemColor: this.colors[index % 5],
265                   updater: (item: ViewItem) => {
266                     this.fillNewData(item);
267                   },
268                   callback: null
269                 }, flowItemWrapper))
270               }
271               .width('100%')
272             }, (item: string) => item)
273           }
274     }
275   }
276   ```
277
278编译运行后,点击Tabs切换页面,然后抓取Trace,通过图2中的选择区域可以看到,第一个页面的首帧耗时和常规复用是差不多的,但是后面2个页面的耗时大幅减少,只有14ms和17ms左右。这是因为第一个页面创建时自定义复用池里没有被回收的子组件,所以会和常规复用一样,需要直接创建新的子组件。而切换到第三个页面时,第一个页面中的子组件被回收到了自定义复用池NodePool中,当第三个页面被创建时,会先去复用池中查找可用的子组件直接使用,减少了创建子组件的时间。
279
280图2 自定义组件复用池Trace图
281
282![img](figures/node_custom_component_reusable_pool_trace_2.JPG)
283
284### 性能数据对比
285
286| 页面             | 电影   | 电视剧 | 动画   | 体育   |
287| :-------------: | :----: | :----: | :----: | :----: |
288| 创建耗时(优化前) | 39.5ms | 35.7ms | 29.8ms | 26.5ms |
289| 创建耗时(优化后) | 40.3ms | 14.8ms | 17.8ms | 18.3ms |
290
291## 使用onIdle进行组件预创建
292
293在上一个章节的优化示例中,第一次进入首页时耗时依旧较高。这是因为第一次进入时,自定义组件复用池中没有组件可以复用,全部需要重新创建。要解决这个问题,可以提前预创建组件复用池中的组件,减少进入首页的启动耗时。目前,应用冷启动是一个比较好的预创建组件的时机。当组件数量较多时,集中预创建本身也耗时较长,容易导致主线程阻塞。ArkUI中提供了[onIdle回调接口](https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/js-apis-arkui-uicontext-V5#onidle12),可以返回每一帧帧尾的空闲时间,在帧尾空闲时逐步进行预创建是一个比较好的分摊主线程负载的方式。
294
295### 示例代码
296
297下面的代码,模拟了应用冷启动的流程,在应用启动后先进入广告页(Index页面),并在广告页进行组件预创建。
298
2991. 在自定义组件复用池中实现预创建。
300
301   ```ts
302   // 继承NodeController,创建可以复用的子组件
303   export class NodeItem extends NodeController {
304     // ...
305
306     // 预创建BuildNode
307     prebuild(uiContext: UIContext) {
308       this.node = new BuilderNode(uiContext);
309       this.node.build(this.builder, this.data);
310     }
311   }
312
313   // 全局组件复用池
314   export class NodePool {
315     // ...
316
317     public preBuild(type: string, item: ESObject, builder: WrappedBuilder<ESObject>, uiContext: UIContext) {
318       if (type) {
319         let nodeItem: NodeItem | undefined = new NodeItem();
320         nodeItem.builder = builder;
321         nodeItem.data.data = item;
322         nodeItem.type = type;
323         // 预创建组件
324         nodeItem.prebuild(uiContext);
325         // 将预创建的组件回收到复用池中,便于后续复用
326         this.recycleNode(type, nodeItem);
327       }
328     }
329
330     // ...
331   }
332   ```
333
3342. 在广告页中预创建组件。
335
336   ```ts
337   @Entry
338   @Component
339   struct Index {
340     // ...
341     aboutToAppear(): void {
342       // ...
343       // 获取模拟数据
344       let viewItems: ViewItem[] = [];
345       viewItems.push(...furnitureData());
346       viewItems.push(...natureData());
347       // 遍历模拟数据,预创建对应数量的组件
348       viewItems.forEach((item) => {
349         NodePool.getInstance()
350           .preBuild('reuse_type_', item, flowItemWrapper, this.getUIContext());
351       })
352     }
353
354     // ...
355   }
356   ```
357
3583. 通过SmartPerfHost工具抓取Trace图,从图3中可以看到提前进行组件预创建后,上一章节中前两个页面的加载耗时已经缩短到了25ms左右,和第三个页面的耗时(18ms)相比差距已明显缩小,这说明提前预创建确实能解决首页因无法复用组件而导致的长耗时问题。
359
360   图3 预创建组件Trace图
361
362   ![](figures/node_custom_component_onidle_1.png)
363
3644. 然后查看冷启动耗时,如图4所示,加载Index页面(H:load page: pages/Index(id:1))耗时大概144ms左右。
365
366   图4 预创建组件冷启动Trace图-1
367
368   ![](figures/node_custom_component_onidle_2.png)
369
3705. 如图5所示,将图4中的Trace进一步放大后可以看到,加载Index页面时主要耗时都是用于创建子组件(H:CustomNode:BuildItem \[SubFlowItem]\[self:47][parent:48])。虽然单个组件耗时并不多,只有426μs,但是当数量较多时,总的预创建耗时就会变长,导致主线程阻塞。
371
372   图5 预创建组件冷启动Trace图-2
373
374   ![](figures/node_custom_component_onidle_3.png)
375
3766. 如图6所示,能够看到从桌面点击图标,到进入广告页,有明显的卡顿,这是因为预创建耗时较长,引起了主线程的阻塞。
377
378   图6 预创建组件演示
379
380   ![](figures/node_custom_component_onidle_4.gif)
381
382### 优化方案
383
384前文中可以看到,在冷启动时进行预创建,当组件数量较多时,会引起主线程的阻塞,增加冷启动耗时。为了解决这个问题,可以通过onIdle回调方法,将组件预创建分布到每一帧帧尾的空闲时间中执行。这样一来,预创建过程就被平摊在多个周期里执行,避免对冷启动时间的过度影响,进而优化启动速度和用户体验。
385
386### 实现思路
387
388当系统执行完全部任务后,会将帧尾的空闲时间通知到onIdle回调。此时,如果组件复用池中有需要预创建的组件,则判断空闲时间是否足够进行预创建。如果时间充足,则进行组件预创建,否则将onIdle回调传递到下一帧中执行,直到所有的组件全部预创建完成。
389
390![](figures/node_custom_component_onidle_5.png)
391
392### 优化代码
393
394下面的代码,将对组件预创建进行优化,把预创建分摊到帧尾的空闲时间中进行。
395
3961. 通过常规预创建抓取Trace,获取单个组件预创建耗时,示例代码中单个组件预创建耗时最长在1ms左右。
397
3982. 继承抽象类FrameCallback,实现帧回调类,在构造器中传入预创建的数据,并实现onIdle接口。
399
400   ```ts
401   export class IdleCallback extends FrameCallback {
402     private uiContext: UIContext;
403     // 已经创建的子组件数量
404     private todoCount: number = 0;
405     private viewItems: ViewItem[] = [];
406
407     /**
408      * @param context 上下文对象,用于将帧回调传递到下一帧
409      * @param preBuildData 预创建组件的数据列表,用于确认预创建组件的数量和相关信息,可根据业务需求自行修改或设置固定值
410      */
411     constructor(context: UIContext, preBuildData: ViewItem[]) {
412       super();
413       this.uiContext = context;
414       this.viewItems = preBuildData;
415     }
416   ```
417
4183. 系统通过onIdle回调方法,将帧尾空闲时间通过参数idleTimeInNano(单位:ns)传递出来。在接收到帧尾空闲时间后,如果有需要预创建的组件,可根据单个组件的预创建耗时,设置预创建的剩余空闲时间上限(示例代码中设置了1ms)。
419
420   ```
421   // onIdle回调,返回帧尾空闲时间idleTimeInNano。
422   onIdle(idleTimeInNano: number): void {
423
424     // 当预创建的组件数量已经超过模拟数据的数量,则停止预创建
425     if (this.todoCount >= this.viewItems.length) {
426       return;
427     }
428     // 当前时间,后续用于计算本帧剩余空闲时间。
429     let cur: number = systemDateTime.getTime(true);
430     // 帧尾空闲时间,后续用于计算本帧剩余空闲时间。
431     let timeLeft = idleTimeInNano;
432     // 当帧尾空闲时间大于1ms时,执行预创建。
433     // 此处空闲时间限制设置了1ms,即空闲时间少于1ms时,本帧不再进行组件的预创建,而是将帧回调传递到下一帧,开发者可以根据自身业务、组件复杂度进行设置,预留足够的空闲时间。
434     while (timeLeft >= 1000000) {
435   ```
436
4374. 当剩余空闲时间足够创建组件时,在此帧中进行组件预创建,并不断更新当前帧的剩余空闲时间。
438
439   ```ts
440   hiTraceMeter.startTrace('onIdle_prebuild', 1);
441   // 进行组件预创建
442   NodePool.getInstance()
443     .preBuild('reuse_type_', this.viewItems[this.todoCount], flowItemWrapper, this.uiContext);
444   hiTraceMeter.finishTrace('onIdle_prebuild', 1);
445   // 预创建完成后,更新本帧剩余空闲空闲时间。
446   let now = systemDateTime.getTime(true);
447   timeLeft = timeLeft - (now - cur);
448   cur = now;
449   this.todoCount++;
450   // 当预创建的组件数量已经超过模拟数据的数量,则停止预创建
451   if (this.todoCount >= this.viewItems.length) {
452     return;
453   }
454   ```
455
4565. 当当前帧剩余空闲时间不足以创建组件时,通过postFrameCallback方法,将帧回调传递到下一帧,继续进行剩余组件的预创建。
457
458   ```ts
459   // 如果组件预创建没有完成,则将帧回调传递到下一帧,并在下一帧中继续进行组件的预创建。
460   if (this.todoCount < this.viewItems.length) {
461     this.uiContext.postFrameCallback(this);
462   }
463   ```
464
4656. 在冷启动时通过帧回调的方式预创建组件。
466
467   ```
468   @Entry
469   @Component
470   struct Index {
471     // ...
472     aboutToAppear(): void {
473       // ...
474       // 获取模拟数据
475       let viewItems: ViewItem[] = [];
476       viewItems.push(...furnitureData());
477       viewItems.push(...natureData());
478       let context = this.getUIContext();
479       // 开启帧回调
480       context.postFrameCallback(new IdleCallback(context, viewItems));
481     }
482
483     // ...
484   }
485   ```
486
4877. 通过SmartPerfHost工具抓取Trace图,可以查看冷启动耗时。如下图所示,加载Index页面(H:load page: pages/Index(id:1))耗时大概8ms左右,只有优化前耗时(144ms)的1/18,性能提升明显。而且相比于优化前,H:load page: pages/Index(id:1)标签下面并没有创建组件的耗时标签。
488
489   图7 使用onIdle预创建组件Trace图
490
491   ![](figures/node_custom_component_onidle_6.png)
492
4938. 如图8所示,组件的预创建,被放在了onIdle中执行,并且是在帧尾空闲时间中,并不会影响到帧的正常功能。
494
495   图8 使用onIdle预创建组件空闲时间Trace图
496
497   ![](figures/node_custom_component_onidle_7.png)
498
4999. 通过图9可以看到,从桌面点击图标到广告页的展示,变的更加流畅了。
500
501   图9 使用onIdle预创建组件演示
502
503   ![](figures/node_custom_component_onidle_8.gif)
504
505### 性能对比
506
507通过前两个章节可以看到,使用onIdle进行闲时组件预创建时,性能优化效果明显,能够在预创建复用池组件的前提下,减少冷启动时间。
508
509| 优化前 | 144ms |
510| ------ | ----- |
511| 优化后 | 8ms   |
512
513### 使用约束
514
5151. 开发者需要根据业务准确预估组件预创建耗时,同时将业务逻辑颗粒度拆小,以便能够拆分到多个onIdle时机中完成。例如,单个组件预创建耗时在2ms左右,帧尾空闲时间只有1ms,那么就不能在当前帧进行预创建,而是延迟到下一帧中执行。
5162. 需要合理控制自定义组件复用池中组件预创建的数量,否则内存占用较多,可能会影响性能。
517
518## 总结
519
520在父组件内部进行组件复用时,使用常规复用是可以解决问题的,而且使用简单,只需要添加@Reusable装饰器并且实现aboutToReuse。但是由于复用池的局限性,不同的父组件想要复用相同子组件时就会失效。而自定义组件复用池,可以实现跨页面的组件复用,并在闲时对组件进行预创建,加快组件的加载速度。但是实现起来也比较复杂,需要开发者自己维护复用池。
521
522## FAQ
523
524**Q:** 示例代码中为什么不使用ArkUI提供的Tabs+TabContent组件,而是要用List+Swiper组件实现?
525
526**A:** Tabs中不支持使用LazyForEach,只能使用ForEach。如果使用ForEach,那么在页面创建时会将所有的TabContent全部创建,并且切换时无法回收子组件(不会执行aboutToDisappear),这就导致自定义复用池NodePool中是空的,每次创建时都获取不到组件,只能重新创建,使组件复用失去了效果。并且因为多创建了一个NodeContainer组件,耗时会比常规复用更长。
527
528**Q:** NodeController中aboutToDisappear接口,是否和自定义组件生命周期中的aboutToDisappear相同?
529
530**A:** NodeController中aboutToDisappear与自定义组件生命周期的aboutToDisappear含义不同,在复用时也会走到aboutToDisappear,在外层复用场景,会导致重复挂载。
531
532## 参考资料
533
534[场景示例代码](https://gitee.com/harmonyos-cases/cases/tree/master/CommonAppDevelopment/feature/perfermance/customreusablepool)