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 12 13### 直接操作组件树 14使用声明式前端还存在组件树结构更新操作困难的痛点,比如将组件树中的一个子树从当前子节点完整移到另一个子节点,如图二所示。使用声明式前端无法直接调整组件实例的结构关系,只能通过重新渲染整棵组件树的方式实现上述操作。而使用ArkUI的FrameNode扩展,则可以通过操作FrameNode来很方便的操控该子树,将其移植到另一个节点,这样只会进行局部渲染刷新,性能更优。 15 16图二 17 18 19## 场景示例 20下面使用视频首页刷新图片资源作为场景,如图三所示,来介绍如何使用ArkUI的FrameNode扩展来实现。 21 22图三 23 24 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 420 4212. FrameNode扩展模式下刷新图片资源场景的完成时延为7.6ms(根据设备和场景不同,数据会有差异,本数据仅供参考),如图五所示。 422 423图五 424 425## 总结 426综上所述,在动态布局类场景下,相对于声明式写法,使用ArkUI的FrameNode扩展更具有优势,能缩短响应时延,带来的性能收益更高。因此对于需要使用动态布局类框架的场景,建议优先使用ArkUI的FrameNode扩展来实现。 427 428