1# 组件复用实践
2
3若开发者的应用中存在以下场景,并成为UI线程的帧率瓶颈,应该考虑使用组件复用机制提升应用性能:
4
51. 滑动场景下对同一类自定义组件的实例进行频繁的创建与销毁。
62. 反复切换条件渲染的控制分支,且控制分支中的组件子树结构比较复杂。
7
8组件复用生效的条件是:
9
101. 自定义组件被@Reusable装饰器修饰,即表示其具备组件复用的能力。
112. 在一个自定义组件(父)下创建出来的具备组件复用能力的自定义组件(子),在可复用自定义组件从组件树上移除之后,会被加入到其父自定义组件的可复用组件缓存中。
123. 在一个自定义组件(父)下创建可复用的子组件时,若可复用子节点缓存中有对应类型的可复用子组件的实例,会通过更新可复用子组件的方式,快速创建可复用子组件。
13
14## 约束限制
15
161. @Reusable标识自定义组件具备可复用的能力,它可以被添加到任意的自定义组件上,但是开发者需要小心处理自定义组件的**创建流程**和**更新流程**以确保自定义组件在复用之后能展示出正确的行为。
172. 可复用自定义组件的缓存和复用只能发生在同一父组件下,无法在不同的父组件下复用同一自定义组件的实例。例如,A组件是可复用组件,其也是B组件的子组件,并进入了B组件的可复用组件缓存中,但是在C组件中创建A组件时,无法使用B组件缓存的A组件。
183. @Reusable装饰器只需要对复用子树的根节点进行标记。例如:自定义组件A中有一个自定义子组件B,若需要复用A与B的子树,只需要对A组件添加@Reusable装饰器。
194. 可复用自定义组件中嵌套自定义组件,如果想要对嵌套的子组件的内容进行更新,需要实现对应子组件的aboutToReuse生命周期回调。例如:A组件是可复用的组件,B是A中嵌套的子组件,要想实现对A组件中的B组件内容进行更新,需要在B组件中实现aboutToReuse生命周期回调。
205. 自定义组件的复用带来的性能提升主要体现在节省了自定义组件的JS对象的创建时间并复用了自定义组件的组件树结构,若应用开发者在自定义组件复用的前后使用渲染控制语法显著的改变了自定义组件的组件树结构,那么将无法享受到组件复用带来的性能提升。
216. 组件复用仅发生在存在可复用组件从组件树上移除并再次加入到组件树的场景中,若不存在上述场景,将无法触发组件复用。例如,使用ForEach渲染控制语法创建可复用的自定义组件,由于ForEach渲染控制语法的全展开属性,不能触发组件复用。
227. 组件复用当前不支持嵌套使用。即在可复用的组件的子树中存在可复用的组件,可能导致未定义的结果。
23
24## 生命周期
25
26可复用组件从C++侧的组件树上移除时,自定义组件在ArkUI框架native侧的CustomNode会被挂载到其对应的JSView上。复用发生之后,CustomNode被JSView引用,并触发ViewPU上的aboutToRecycle方法,ViewPU的实例将会被RecycleManager引用。
27
28可复用组件从RecycleManager中重新加入组件树时,会调用前端ViewPU对象上的aboutToReuse生命周期回调。
29
30## 接口说明
31
32组件的生命周期回调,在可复用组件从复用缓存中加入到组件树之前调用,可在其中更新组件的状态变量以展示正确的内容,入参的类型与自定义组件的构造函数入参相同。
33
34```ts
35aboutToReuse?(params: { [key: string]: unknown }): void;
36```
37
38组件的生命周期回调,在可复用组件从组件树上被加入到复用缓存之前调用。
39
40```ts
41aboutToRecycle?(): void;
42```
43
44开发者可以使用reuseId为复用组件分配复用组,相同reuseId的组件会在同一个复用组中复用。
45
46```ts
47reuseId(id: string);
48```
49
50Reusable装饰器,用于声明组件具备可复用的能力。
51
52```ts
53declare const Reusable: ClassDecorator;
54```
55
56**示例:**
57
58```ts
59// xxx.ets
60class MyDataSource implements IDataSource {
61  private dataArray: string[] = [];
62  private listener: DataChangeListener | undefined;
63
64  public totalCount(): number {
65    return this.dataArray.length;
66  }
67
68  public getData(index: number): string {
69    return this.dataArray[index];
70  }
71
72  public pushData(data: string): void {
73    this.dataArray.push(data);
74  }
75
76  public reloadListener(): void {
77    this.listener?.onDataReloaded();
78  }
79
80  public registerDataChangeListener(listener: DataChangeListener): void {
81    this.listener = listener;
82  }
83
84  public unregisterDataChangeListener(listener: DataChangeListener): void {
85    this.listener = undefined;
86  }
87}
88
89@Entry
90@Component
91struct MyComponent {
92  private data: MyDataSource = new MyDataSource();
93
94  aboutToAppear() {
95    for (let i = 0; i < 1000; i++) {
96      this.data.pushData(i.toString())
97    }
98  }
99
100  build() {
101    List({ space: 3 }) {
102      LazyForEach(this.data, (item: string) => {
103        ListItem() {
104          ReusableChildComponent({ item: item })
105        }
106      }, (item: string) => item)
107    }
108    .width('100%')
109    .height('100%')
110  }
111}
112
113@Reusable
114@Component
115struct ReusableChildComponent {
116  @State item: string = ''
117
118  aboutToReuse(params: ESObject) {
119    this.item = params.item;
120  }
121
122  build() {
123    Row() {
124      Text(this.item)
125        .fontSize(20)
126        .margin({ left: 10 })
127    }.margin({ left: 10, right: 10 })
128  }
129}
130```
131
132## 相关实例
133
134以下为购物片段示例代码,对比使用组件复用前后,应用侧创建自定义组件的收益以及前后的代码写法对比。
135
136### 复用前后代码对比
137
138**复用前:**
139
140```ts
141LazyForEach(this.GoodDataOne, (item, index) => {
142  GridItem() {
143    Column() {
144      Image(item.img)
145        .height(item.hei)
146        .width('100%')
147        .objectFit(ImageFit.Fill)
148
149      Text(item.introduce)
150        .fontSize(14)
151        .padding({ left: 5, right: 5 })
152        .margin({ top: 5 })
153      Row() {
154        Row() {
155          Text('¥')
156            .fontSize(10)
157            .fontColor(Color.Red)
158            .baselineOffset(-4)
159          Text(item.price)
160            .fontSize(16)
161            .fontColor(Color.Red)
162          Text(item.numb)
163            .fontSize(10)
164            .fontColor(Color.Gray)
165            .baselineOffset(-4)
166            .margin({ left: 5 })
167        }
168
169        Image($r('app.media.photo63'))
170          .width(20)
171          .height(10)
172          .margin({ bottom: -8 })
173      }
174      .width('100%')
175      .justifyContent(FlexAlign.SpaceBetween)
176      .padding({ left: 5, right: 5 })
177      .margin({ top: 15 })
178    }
179    .borderRadius(10)
180    .backgroundColor(Color.White)
181    .clip(true)
182    .width('100%')
183    .height(290)
184  }
185}, (item) => JSON.stringify(item))
186```
187
188**复用后:**
189
190组件被复用后,ArkUI框架会将组件构造对应的参数输入给aboutToReuse生命周期回调,开发者需要在aboutToReuse生命周期中对需要进行更新的状态变量进行赋值,ArkUI框架将会基于最新的状态变量值对UI进行展示。
191
192如果同一种自定义组件的不同实例之间存在较大的结构差异,建议使用reuseId对不同的自定义组件实例分别标注复用组,以达到最佳的复用效果。
193
194如果一个自定义组件中,持有对某个大对象或者其他非必要资源的引用,可以在aboutToRecycle生命周期中释放,以免造成内存泄漏。
195
196```ts
197LazyForEach(this.GoodDataOne, (item, index) => {
198  GridItem() {
199    GoodItems({
200      boo:item.data.boo,
201      img:item.data.img,
202      webimg:item.data.webimg,
203      hei:item.data.hei,
204      introduce:item.data.introduce,
205      price:item.data.price,
206      numb:item.data.numb,
207      index:index
208    })
209    .reuseId(this.CombineStr(item.type))
210  }
211}, (item) => JSON.stringify(item))
212
213
214@Reusable
215@Component
216struct GoodItems {
217  @State img: Resource = $r("app.media.photo61")
218  @State webimg?: string = ''
219  @State hei: number = 0
220  @State introduce: string = ''
221  @State price: string = ''
222  @State numb: string = ''
223  @LocalStorageLink('storageSimpleProp') simpleVarName: string = ''
224  boo: boolean = true
225  index: number = 0
226  controllerVideo: VideoController = new VideoController();
227
228  aboutToReuse(params)
229  {
230    this.webimg = params.webimg
231    this.img = params.img
232    this.hei = params.hei
233    this.introduce = params.introduce
234    this.price = params.price
235    this.numb = params.numb
236  }
237
238  build() {
239    // ...
240  }
241}
242```
243
244### 性能收益
245
246通过DevEco Studio的profiler工具分析复用前后的组件创建时间,可以得到应用使能组件复用后的优化情况,组件创建的时间平均从1800us降低到了570us。
247
248![before reuse](./figures/before-recycle.png)
249
250![using reuse](./figures/using-recycle.png)
251
252|                | 创建组件时间 |
253| -------------- | ------------ |
254| 不使能组件复用 | 1813us       |
255| 使能组件复用   | 570us        |
256
257## 开发建议
258
2591.建议复用自定义组件时避免一切可能改变自定义组件的组件树结构和可能使可复用组件中产生重新布局的操作以将组件复用的性能提升到最高。
260
2612.建议列表滑动场景下组件复用能力和LazyForEach渲染控制语法搭配使用以达到性能最优效果。
262
2633.开发者需要区分好自定义组件的创建和更新过程中的行为,并注意到自定义组件的复用本质上是一种特殊的组件更新行为,组件创建过程中的流程与生命周期将不会在组件复用中发生,自定义组件的构造参数将通过aboutToReuse生命周期回调传递给自定义组件。例如,aboutToAppear生命周期和自定义组件的初始化传参将不会在组件复用中发生。
264
2654.避免在aboutToReuse生命周期回调中产生耗时操作,最佳实践是仅在aboutToReuse中做自定义组件更新所需的状态变量值的更新。
266
2675.避免在aboutToReuse中对@Link、@StorageLink、@ObjectLink、@Consume等自动更新值的状态变量进行更新,可能触发不必要的组件刷新。
268
2696.避免使用函数作为复用的自定义组件创建时的入参:
270
271由于在组件复用的场景下,每次复用都需要重新创建组件关联的数据对象,导致重复执行入参中的函数来获取入参结果。如果函数中存在耗时操作,会严重影响性能。正反例如下所示:
272
273【反例】
274
275```ts
276// 下文中BasicDateSource是实现IDataSource接口的类,具体可参考LazyForEach用法指导
277// 此处为复用的自定义组件
278@Reusable
279@Component
280struct ChildComponent {
281  @State desc: string = '';
282  @State sum: number = 0;
283
284  aboutToReuse(params: Record<string, Object>): void {
285    this.desc = params.desc as string;
286    this.sum = params.sum as number;
287  }
288
289  build() {
290    Column() {
291      Text('子组件' + this.desc)
292        .fontSize(30)
293        .fontWeight(30)
294      Text('结果' + this.sum)
295        .fontSize(30)
296        .fontWeight(30)
297    }
298  }
299}
300
301@Entry
302@Component
303struct Reuse {
304  private data: BasicDateSource = new BasicDateSource();
305
306  aboutToAppear(): void {
307    for (let index = 0; index < 20; index++) {
308      this.data.pushData(index.toString())
309    }
310  }
311
312  // 真实场景的函数中可能存在未知的耗时操作逻辑,此处用循环函数模拟耗时操作
313  count(): number {
314    let temp: number = 0;
315    for (let index = 0; index < 10000; index++) {
316      temp += index;
317    }
318    return temp;
319  }
320
321  build() {
322    Column() {
323      List() {
324        LazyForEach(this.data, (item: string) => {
325          ListItem() {
326            // 此处sum参数是函数获取的,实际开发场景无法预料该函数可能出现的耗时操作,每次进行组件复用都会重复触发此函数的调用
327            ChildComponent({ desc: item, sum: this.count() })
328          }
329          .width('100%')
330          .height(100)
331        }, (item: string) => item)
332      }
333    }
334  }
335}
336```
337
338上述反例操作中,复用的子组件参数sum是通过耗时函数生成。该函数在每次组件复用时都需要执行,会造成性能问题,甚至是列表滑动过程中的卡顿丢帧现象。
339
340【正例】
341
342```ts
343// 下文中BasicDateSource是实现IDataSource接口的类,具体可参考LazyForEach用法指导
344// 此处为复用的自定义组件
345@Reusable
346@Component
347struct ChildComponent {
348  @State desc: string = '';
349  @State sum: number = 0;
350
351  aboutToReuse(params: Record<string, Object>): void {
352    this.desc = params.desc as string;
353    this.sum = params.sum as number;
354  }
355
356  build() {
357    Column() {
358      Text('子组件' + this.desc)
359        .fontSize(30)
360        .fontWeight(30)
361      Text('结果' + this.sum)
362        .fontSize(30)
363        .fontWeight(30)
364    }
365  }
366}
367
368@Entry
369@Component
370struct Reuse {
371  private data: BasicDateSource = new BasicDateSource();
372  @State sum: number = 0;
373
374  aboutToAppear(): void {
375    for (let index = 0; index < 20; index++) {
376      this.data.pushData(index.toString())
377    }
378    // 执行该异步函数
379    this.count();
380  }
381
382  // 模拟耗时操作逻辑
383  async count() {
384    let temp: number = 0;
385    for (let index = 0; index < 10000; index++) {
386      temp += index;
387    }
388    // 将结果放入状态变量中
389    this.sum = temp;
390  }
391
392  build() {
393    Column() {
394      List() {
395        LazyForEach(this.data, (item: string) => {
396          ListItem() {
397            // 子组件的传参通过状态变量进行
398            ChildComponent({ desc: item, sum: this.sum })
399          }
400          .width('100%')
401          .height(100)
402        }, (item: string) => item)
403      }
404    }
405  }
406}
407```
408
409上述正例操作中,通过耗时函数count生成的结果不变,可以将其放到页面初始渲染时执行一次,将结果赋值给this.sum。在复用组件的参数传递时,通过this.sum来进行。
410