1# 使用ArkUI的FrameNode扩展实现动态布局类框架
2## 简介
3在特定的节假日或活动节点,应用通常需要推送相应主题或内容到首页,但又不希望通过程序更新方式来实现。因此,一般会采用动态布局类框架。动态布局类框架是一种动态生成原生组件树的轻量级框架,可以根据运营需求,在无需重新上架应用的情况下也可以动态地向用户推送新内容。该框架使用了类似于CSS的语法,通过设置不同的样式属性来控制视图的位置、大小、对齐方式等。本文将介绍如何使用ArkUI的FrameNode扩展来实现动态布局类框架,并探讨其带来的性能收益。
4## ArkUI的声明式扩展在动态框架对接场景下的优势
5### 组件创建更快
6在采用声明式前端开发模式时,若使用ArkUI的自定义组件对节点树中的每个节点进行定义,往往会遇到节点创建效率低下的问题。这主要是因为每个节点在JS引擎中都需要分配内存空间来存储应用程序的自定义组件和状态变量。此外,在节点创建过程中,还必须执行组件ID、组件闭包以及状态变量之间的依赖关系收集等操作。相比之下,使用ArkUI的FrameNode扩展,则可以避免创建自定义组件对象和状态变量对象,也无需进行依赖收集,从而显著提升组件创建的速度。
7### 组件更新更快
8在动态布局类框架的更新场景中,通常存在一个由树形数据结构ViewModelA创建的UI组件树TreeA。当需要使用新的数据结构ViewModelB来更新TreeA时,尽管声明式前端可以实现数据驱动的自动更新,但这一过程中却伴随着大量的diff操作,如图一所示。对于JS引擎而言,在对一个复杂组件树(深度超过30层,包含100至200个组件)执行diff算法时,几乎无法在120Hz的刷新率下保持满帧运行。然而,使用ArkUI的FrameNode扩展,框架能够自主掌控更新流程,实现高效的按需剪枝。特别是针对那些仅服务于少数特定业务的动态布局框架,利用这一扩展,可以实现极其迅速的更新操作。
9
10图一
11![图一](./figures/imperative_dynamic_layouts_diff.jpg)
12
13### 直接操作组件树
14使用声明式前端还存在组件树结构更新操作困难的痛点,比如将组件树中的一个子树从当前子节点完整移到另一个子节点,如图二所示。使用声明式前端无法直接调整组件实例的结构关系,只能通过重新渲染整棵组件树的方式实现上述操作。而使用ArkUI的FrameNode扩展,则可以通过操作FrameNode来很方便的操控该子树,将其移植到另一个节点,这样只会进行局部渲染刷新,性能更优。
15
16图二
17![图二](./figures/imperative_dynamic_layouts_component_tree.jpg)
18
19## 场景示例
20下面使用视频首页刷新图片资源作为场景,如图三所示,来介绍如何使用ArkUI的FrameNode扩展来实现。
21
22图三
23
24![图三](./figures/imperative_dynamic_layouts.gif)
25### ArkUI的声明式扩展使用
26一个简化的动态布局类框架的DSL一般会使用JSON、XML等数据交换格式来描述UI,下面使用JSON为例进行说明。
27本案例相关核心字段含义如下表所示:
28| 标签     | 含义                                                                      |
29|---------|---------------------------------------------------------------------------|
30| type    |描述UI组件的类型,通常与原生组件存在一一对应的关系,也可能是框架基于原生能力封装的某种组件|
31| content |文本,图片类组件的内容                                                         |
32| css     |描述UI组件的布局特性                                                          |
33
341. 定义视频首页UI描述数据如下:
35```json
36{
37  "type": "Column",
38  "css": {
39    "width": "100%"
40  },
41  "children": [
42    {
43      "type": "Row",
44      "css": {
45        "width": "100%",
46        "padding": {
47          "left": 15,
48          "right": 15
49        },
50        "margin": {
51          "top": 5,
52          "bottom": 5
53        },
54        "justifyContent": "FlexAlign.SpaceBetween"
55      },
56      "children": [
57        {
58          "type": "Text",
59          "css": {
60            "fontSize": 24,
61            "fontColor": "#ffffff"
62          },
63          "content": "首页"
64        },
65        {
66          "type": "Image",
67          "css": {
68            "width": 24,
69            "height": 24
70          },
71          "content": "app.media.search"
72        }
73      ]
74    },
75    {
76      "type": "Swiper",
77      "css": {
78        "width": "100%"
79      },
80      "children": [
81        {
82          "type": "Image",
83          "css": {
84            "height": "40%",
85            "width": "100%"
86          },
87          "content": "app.media.movie1"
88        },
89        {
90          "type": "Image",
91          "css": {
92            "height": "40%",
93            "width": "100%"
94          },
95          "content": "app.media.movie2"
96        },
97        {
98          "type": "Image",
99          "css": {
100            "height": "40%",
101            "width": "100%"
102          },
103          "content": "app.media.movie3"
104        }
105      ]
106    },
107    {
108      "type": "Row",
109      "css": {
110        "width": "100%",
111        "padding": {
112          "left": 15,
113          "right": 15
114        },
115        "margin": {
116          "top": 15,
117          "bottom": 15
118        },
119        "justifyContent": "FlexAlign.SpaceBetween"
120      },
121      "children": [
122        {
123          "type": "Text",
124          "css": {
125            "width": 75,
126            "height": 40,
127            "borderRadius": 60,
128            "fontColor": "#000000",
129            "backgroundColor": "#ffffff"
130          },
131          "content": "精选"
132        },
133        {
134          "type": "Text",
135          "css": {
136            "width": 75,
137            "height": 40,
138            "borderRadius": 60,
139            "fontColor": "#000000",
140            "backgroundColor": "#808080"
141          },
142          "content": "电视剧"
143        },
144        {
145          "type": "Text",
146          "css": {
147            "width": 75,
148            "height": 40,
149            "borderRadius": 60,
150            "fontColor": "#000000",
151            "backgroundColor": "#808080"
152          },
153          "content": "电影"
154        },
155        {
156          "type": "Text",
157          "css": {
158            "width": 75,
159            "height": 40,
160            "borderRadius": 60,
161            "fontColor": "#000000",
162            "backgroundColor": "#808080"
163          },
164          "content": "综艺"
165        }
166      ]
167    },
168    {
169      "type": "Row",
170      "css": {
171        "width": "100%",
172        "padding": {
173          "left": 15,
174          "right": 15
175        },
176        "margin": {
177          "top": 5,
178          "bottom": 5
179        },
180        "justifyContent": "FlexAlign.SpaceBetween"
181      },
182      "children": [
183        {
184          "type": "Text",
185          "css": {
186            "fontSize": 24,
187            "fontColor": "#ffffff"
188          },
189          "content": "每日推荐"
190        },
191        {
192          "type": "Text",
193          "css": {
194            "fontSize": 20,
195            "fontColor": "#ffffff",
196            "opacity": 0.5
197          },
198          "content": "更多"
199        }
200      ]
201    },
202    {
203      "type": "Row",
204      "css": {
205        "width": "100%",
206        "padding": {
207          "left": 15,
208          "right": 15
209        },
210        "margin": {
211          "top": 5,
212          "bottom": 5
213        },
214        "justifyContent": "FlexAlign.SpaceBetween"
215      },
216      "children": [
217        {
218          "type": "Column",
219          "css": {
220            "alignItems": "HorizontalAlign.Start"
221          },
222          "children": [
223            {
224              "type": "Image",
225              "css": {
226                "height": 120,
227                "width": 170,
228                "borderRadius": 10
229              },
230              "content": "app.media.movie4"
231            },
232            {
233              "type": "Text",
234              "css": {
235                "fontColor": "#ffffff"
236              },
237              "content": "电影1"
238            }
239          ]
240        },
241        {
242          "type": "Column",
243          "css": {
244            "alignItems": "HorizontalAlign.Start"
245          },
246          "children": [
247            {
248              "type": "Image",
249              "css": {
250                "height": 120,
251                "width": 170,
252                "borderRadius": 10
253              },
254              "content": "app.media.movie5"
255            },
256            {
257              "type": "Text",
258              "css": {
259                "fontColor": "#ffffff"
260              },
261              "content": "电影2"
262            }
263          ]
264        }
265      ]
266    },
267    {
268      "id": "refreshImage",
269      "type": "Text",
270      "css": {
271        "width": 180,
272        "height": 40,
273        "borderRadius": 60,
274        "fontColor": "#ffffff",
275        "backgroundColor": "#0000FF"
276      },
277      "content": "刷新"
278    }
279  ]
280}
281```
2822. 定义相应数据结构用于接收UI描述数据,如下:
283```ts
284class VM {
285  type?: string;
286  content?: string;
287  css?: ESObject;
288  children?: VM[];
289  id?: string;
290}
291```
2923. 自定义DSL解析逻辑,且使用carouselNodes保存轮播图节点,方便后续操作节点更新,如下:
293```ts
294// 存储图片节点,方便后续直接操作节点
295let carouselNodes: typeNode.Image[] = [];
296
297/**
298 * 自定义DSL解析逻辑,将UI描述数据解析为组件
299 *
300 * @param vm
301 * @param context
302 * @returns
303 */
304function FrameNodeFactory(vm: VM, context: UIContext): FrameNode | null {
305  if (vm.type === "Column") {
306    let node = typeNode.createNode(context, "Column");
307    setColumnNodeAttr(node, vm.css);
308    vm.children?.forEach(kid => {
309      let child = FrameNodeFactory(kid, context);
310      node.appendChild(child);
311    });
312    return node;
313  } else if (vm.type === "Row") {
314    let node = typeNode.createNode(context, "Row");
315    setRowNodeAttr(node, vm.css);
316    vm.children?.forEach(kid => {
317      let child = FrameNodeFactory(kid, context);
318      node.appendChild(child);
319    });
320    return node;
321  } else if (vm.type === "Swiper") {
322    let node = typeNode.createNode(context, "Swiper");
323    node.attribute.width(vm.css.width);
324    node.attribute.height(vm.css.height);
325    vm.children?.forEach(kid => {
326      let child = FrameNodeFactory(kid, context);
327      node.appendChild(child);
328    });
329    return node;
330  } else if (vm.type === "Image") {
331    let node = typeNode.createNode(context, "Image");
332    node.attribute.width(vm.css.width);
333    node.attribute.height(vm.css.height);
334    node.attribute.borderRadius(vm.css.borderRadius);
335    node.attribute.objectFit(ImageFit.Fill);
336    node.initialize($r(vm.content));
337    carouselNodes.push(node);
338    return node;
339  } else if (vm.type === "Text") {
340    let node = typeNode.createNode(context, "Text");
341    node.attribute.fontSize(vm.css.fontSize);
342    node.attribute.width(vm.css.width);
343    node.attribute.height(vm.css.height);
344    node.attribute.width(vm.css.width);
345    node.attribute.borderRadius(vm.css.borderRadius);
346    node.attribute.backgroundColor(vm.css.backgroundColor);
347    node.attribute.fontColor(vm.css.fontColor);
348    node.attribute.opacity(vm.css.opacity);
349    node.attribute.textAlign(TextAlign.Center);
350    // 使用id来标识特殊节点,方便抽出来单独操作
351    if (vm.id === 'refreshImage') {
352      // 因为frameNode暂时没有Button组件,因此使用Text代替,给该组件绑定点击事件
353      node.attribute.onClick(() => {
354        carouselNodes[1].initialize($r('app.media.movie6'));
355        carouselNodes[2].initialize($r('app.media.movie7'));
356        carouselNodes[3].initialize($r('app.media.movie8'));
357        carouselNodes[4].initialize($r('app.media.movie9'));
358        carouselNodes[5].initialize($r('app.media.movie10'));
359        node.attribute.visibility(Visibility.Hidden);
360      })
361    }
362    node.initialize(vm.content);
363    return node;
364  }
365  return null;
366}
367
368function setColumnNodeAttr(node: typeNode.Column, css: ESObject) {
369  node.attribute.width(css.width);
370  node.attribute.height(css.height);
371  node.attribute.backgroundColor(css.backgroundColor);
372  if (css.alignItems === "HorizontalAlign.Start") {
373    node.attribute.alignItems(HorizontalAlign.Start);
374  }
375}
376
377function setRowNodeAttr(node: typeNode.Row, css: ESObject) {
378  node.attribute.width(css.width);
379  if (css.padding !== undefined) {
380    node.attribute.padding(css.padding as Padding);
381  }
382  if (css.margin !== undefined) {
383    node.attribute.margin(css.margin as Padding);
384  }
385  node.attribute.justifyContent(FlexAlign.SpaceBetween);
386}
387```
3884. 使用NodeContainer组件嵌套ArkUI的FrameNode扩展和ArkUI的声明式语法,如下:
389```ts
390/**
391 * 继承NodeController,用于绘制组件树
392 */
393class ImperativeController extends NodeController {
394  makeNode(uiContext: UIContext): FrameNode | null {
395    return FrameNodeFactory(data, uiContext);
396  }
397}
398
399@Entry
400@Component
401struct ImperativePage {
402  controller: ImperativeController = new ImperativeController();
403
404  build() {
405    Column() {
406      NodeContainer(this.controller)
407    }
408    .height('100%')
409    .width('100%')
410    .backgroundColor(Color.Black)
411  }
412}
413```
414## 性能对比
415下面以场景示例中的两种方案实现,通过DevEcho Studio的profile工具抓取Trace进行性能分析比对。
4161. 声明式前端开发模式下刷新图片资源场景的完成时延为9.8ms(根据设备和场景不同,数据会有差异,本数据仅供参考),如图四所示。
417
418图四
419![图四](./figures/imperative_dynamic_layouts_trace_1.png)
420
4212. FrameNode扩展模式下刷新图片资源场景的完成时延为7.6ms(根据设备和场景不同,数据会有差异,本数据仅供参考),如图五所示。
422
423图五
424![图五](./figures/imperative_dynamic_layouts_trace_2.png)
425## 总结
426综上所述,在动态布局类场景下,相对于声明式写法,使用ArkUI的FrameNode扩展更具有优势,能缩短响应时延,带来的性能收益更高。因此对于需要使用动态布局类框架的场景,建议优先使用ArkUI的FrameNode扩展来实现。
427
428