1# 使用同层渲染在Web上渲染原生组件 2 3在使用Web组件加载H5页面时,经常会有长列表、视频等场景。由于Web目前最高只有60帧,想要更加流畅的体验,必须要将原生组件放到Web上。 4 5在什么场景下应该在Web上使用原生组件: 6- 需要高性能,流畅体验。如长列表,视频等场景 7- 需要使用原生组件功能 8- 原生组件已经实现,复用以减少开发成本 9 10目前要实现在Web上使用原生组件有两种方案: 11方案一:直接将组件堆叠到H5页面上。 12方案二:使用**同层渲染**,使用Web和原生组件交互的方式,将原生组件替代Web中部分组件,提升交互体验和性能。 13 14## 什么是同层渲染 15 16同层渲染是一种优化技术,用于提高Web页面的渲染性能。同层渲染会将位于同一个图层的元素一起渲染,以减少重绘和重排的次数,从而提高页面的渲染效率。 17 18同层渲染和非同层渲染的区别如下: 19 20- 非同层渲染:通过Z序的层级关系堆叠在Web页面上。此方式实现方式简单,用于原生组件大小位置固定场景。 21- 同层渲染:通过同层渲染的方式直接渲染到H5页面Embed标签区域上。此方式实现相对复杂,用于原生组件大小位置需要跟随Web页面变化场景。 22 23**图一:同层渲染和非同层渲染区别** 24 25 26同层渲染的大致开发流程可以参考[同层渲染绘制](../web/web-same-layer.md)。 27 28## 场景示例 29 30以下分别采用纯H5、非同层渲染和同层渲染的三种方式,加载相同的商城组件到相同的H5页面上,并抓取trace对比三者之间的区别,其中商城页面大致如图二所示: 31 32**图二:商城页面场景** 33 34 35场景实例源码的核心部分如下: 36 37上图中,背景的空白提供承载的H5页面如下: 38 39```html 40<!-- nativeembed_view.html --> 41... 42 <body> 43 <div> 44 <div id="bodyId"> 45 <!-- 在H5界面上通过embed标签标识同层元素,在应用侧将原生组件渲染到H5页面embed标签所在位置--> 46 <embed id="nativeSearch" type="native/component" width="100%" height="100%" src="view" /> 47 </div> 48 </div> 49 </body> 50... 51``` 52 53上图中,搜索框+下方列表的原生商城组件如下: 54 55```typescript 56// SearchComponent.ets 57 58// API以及模块引入 59// ... 60 61@Component 62export struct SearchComponent { 63 @Prop params: Params; 64 @State myData: MyData = new MyData(); 65 66 build() { 67 Column({ space: 8 }) { 68 Text('商城').fontSize(16) 69 Row() { 70 Image($r("app.media.nativeembed_search_icon")) 71 .width(14) 72 .margin({ left: 14 }) 73 Text("搜索相关宝贝") 74 .fontSize(14) 75 .opacity(0.6) 76 .fontColor('#000000') 77 .margin({ left: 14 }) 78 } 79 .width("100%") 80 .margin(4) 81 .height(36) 82 .backgroundColor('#FFFFFF') 83 .borderRadius(18) 84 .onClick(() => { 85 // 点击搜索框提示 86 promptAction.showToast({ 87 message: "仅演示" 88 }); 89 }) 90 91 Grid() { 92 LazyForEach(this.myData, (item: ProductDataModel, index: number) => { 93 GridItem() { 94 Column({ space: 8 }) { 95 Image(item.uri).width(100) 96 Row({ space: 8 }) { 97 Text(item.title).fontSize(12) 98 Text(item.price).fontSize(12) 99 } 100 } 101 .backgroundColor('#FFFFFF') 102 .alignItems(HorizontalAlign.Center) 103 .justifyContent(FlexAlign.Center) 104 .width("100%") 105 .height(140) 106 .borderRadius(12) 107 } 108 }, (item: ProductDataModel) => `${item.id}`) 109 } 110 .cachedCount(2) 111 .columnsTemplate('1fr 1fr') 112 .rowsGap(8) 113 .columnsGap(8) 114 .width("100%") 115 .height("90%") 116 .backgroundColor('#F1F3F5') 117 } 118 .padding(10) 119 .width(this.params.width) 120 .height(this.params.height) 121 } 122} 123 124``` 125 126## Web加载原生组件三种方案的对比 127 128### 直接使用H5加载 129首先的想法是,将搜索框和列表组件使用原生H5实现,直接用web加载页面。数据交互的部分则需要与原生交互部分通过WebMessagePort与Web交互。关键代码步骤如下: 130 1311. 应用侧使用单Web组件挂在H5页面,但是同时需要设置javaScriptProxy传入参数,并在PageEnd回调中建立WebMessagePort通道传输数据。 132 133 ```typescript 134 Web({ src: $rawfile("web.html"), controller: this.browserTabController }) 135 .zoomAccess(false) 136 .javaScriptProxy({ 137 object: this.mockData, 138 name: 'mockData', 139 methodList: ["getMockData"], 140 controller: this.browserTabController 141 }) 142 .onPageEnd(() => { 143 // 1. 创建消息端口 144 this.ports = this.browserTabController.createWebMessagePorts(true); 145 // 2. 发送端口1到HTML5 146 this.browserTabController.postMessage("init_web_messageport", [this.ports[1]], "*"); 147 // 3. 保存端口0到本地 148 this.nativePort = this.ports[0]; 149 // 4. 设置回调函数 150 this.nativePort.onMessageEventExt((result) => { 151 try { 152 const type = result.getType(); 153 switch (type) { 154 case webview.WebMessageType.STRING: { 155 if (result.getString() === 'shop_search_click') { 156 // 点击搜索框提示 157 promptAction.showToast({ 158 message: $r("app.string.nativeembed_prompt_text") 159 }); 160 } 161 break; 162 } 163 } 164 } catch (error) { 165 console.error(`ErrorCode: ${error.code}, Message: ${error.message}`); 166 } 167 }); 168 hiTraceMeter.finishTrace('START_WEB_WEB', 2002); 169 }) 170 ``` 1712. 此时,样式和组件需要单独通过js和css文件进行控制,这里仅展示js主要代码 172 173 ```javascript 174 let h5Port; // 获取应用侧的端口 175 window.addEventListener('message', function (event) { 176 if (event.data == 'init_web_messageport') { 177 if (event.ports[0] != null) { 178 h5Port = event.ports[0]; // 1. 保存从ets侧发送过来的端口 179 } 180 } 181 }) 182 183 function postStringToApp(str) { 184 if (h5Port) { 185 h5Port.postMessage(str); 186 } else { 187 console.error("In html h5port is null, please init first"); 188 } 189 } 190 191 192 // 获取应用侧的数据对象 193 let imageNodeData = mockData.getMockData(); 194 195 // 搜索框 196 let searchNode = document.createElement('div'); 197 searchNode.classList.add('shop-input'); 198 searchNode.addEventListener('click', () => { 199 postStringToApp('shop_search_click') 200 }) 201 202 // ... 203 // 其余相关节点 204 // ... 205 206 let imageNodeList = []; // 商城node节点列表 207 imageNodeData.forEach(item => { 208 // 商品div 209 let node = document.createElement("div"); 210 node.classList.add('shop-container'); 211 // 图片img 212 let imageNode = document.createElement('img'); 213 imageNode.classList.add('shop-img'); 214 imageNode.src = item.uri; 215 // 文字 216 let textNode = document.createElement("p"); 217 textNode.innerText = `${item.title}\u00A0\u00A0\u00A0\u00A0${item.price}`; 218 // 组合商品图 219 node.append(imageNode, textNode); 220 imageNodeList.push(node); 221 }) 222 223 shopNode.append(...imageNodeList); 224 225 document.querySelector("#my-app").append(titleNode, searchNode, shopNode); 226 ``` 227 228在上述的方案中可以发现,用H5开发页面时,需要使用到JS和CSS,甚至一些前端框架进行页面的开发。并且动效和体验都不如原生组件。既然Web也是一个组件,可以想到直接把原生组件堆叠到Web页面上,方案如下: 229 230### 使用非同层渲染 231底层使用空白的H5页面,用任意标签进行占位,然后在H5页面上方层叠一个原生组件。原生组件需要在Web加载完成后,获取到标签大小位置后,在对应位置展示。 232 2331. 使用Stack层叠Web和searchBuilder组件。 234 235 ```typescript 236 build() { 237 Stack() { 238 Web({ src: $rawfile("nativeembed_view.html"), controller: this.browserTabController }) 239 .backgroundColor('#F1F3F5') 240 .zoomAccess(false)// 不允许执行缩放 241 .onPageEnd(() => { 242 // ... 243 // 里面放下一步的内容 244 // ... 245 }) 246 if (this.isWebInit) { 247 Column() { 248 // 由于需要根据Web实际加载的尺寸进行展示,需要等Web初始化后获取宽高,之后层叠到Web上 249 searchBuilder({ width: this.searchWidth, height: this.searchHeight }) 250 } 251 .zIndex(10) 252 } 253 } 254 .alignContent(Alignment.Top) 255 } 256 ``` 2572. 用Web加载nativeembed_view.html文件,在加载完成后的onPageEnd回调中,获取Web侧预留的Embed元素大小,并通过px2vp方法转换为组件大小。 258需要在H5侧添加getEmbedSize方法来获取元素大小,如下: 259 260 ```javascript 261 // H5侧 262 function getEmbedSize() { 263 let doc = document.getElementById('nativeSearch'); 264 return { 265 width: doc.offsetWidth, 266 height: doc.offsetHeight, 267 } 268 } 269 ``` 270 在应用侧,步骤1的onPageEnd回调中: 271 272 ```typescript 273 // 从web侧获取组件大小 274 this.browserTabController.runJavaScript( 275 'getEmbedSize()', 276 (error, result) => { 277 if (result) { 278 interface EmbedSize { 279 width: number, 280 height: number 281 } 282 let embedSize = JSON.parse(result) as EmbedSize; 283 this.searchWidth = px2vp(embedSize.width); 284 this.searchHeight = px2vp(embedSize.height); 285 this.isWebInit = true; 286 } 287 }); 288 ``` 2893. 获取到步骤2的尺寸之后,传入searchBuilder中,通过显隐控制展示SearchComponent组件。 290 291在上述的方案中,实现方法非常简单。但是这只是限于底层H5网页比较简单,不会滚动的情况。如果H5页面可以上下滑动或者放大缩小比较复杂,此方案就会出现问题,就会发现原生组件是很难去定位,很难跟随H5页面一起滚动。而且在性能上,Web是整体渲染的,即使被原生组件遮住的部分也会消耗性能。于是我们可以通过同层渲染来完美解决这个问题,方案如下: 292 293### 同层渲染实现 294同层渲染简单来说就是,底层使用空白的H5页面,用**Embed标签**进行占位,原生使用**NodeContainer**来站位,最后将Web侧的surfaceId和原生组件绑定,渲染在**NodeContainer**上。详细的步骤可以参考前面[什么是同层渲染](#什么是同层渲染)中的链接,这里给出一些大致步骤。 295 2961. 用Stack组件层叠NodeContainer和Web组件,并开启enableNativeEmbedMode模式。 297 ```typescript 298 build() { 299 Stack() { 300 NodeContainer(this.searchNodeController) 301 // web组件加载本地nativeembed_view.html页面 302 Web({ src: $rawfile("nativeembed_view.html"), controller: this.browserTabController }) 303 .backgroundColor('#F1F3F5') 304 .zoomAccess(false)// 不允许执行缩放 305 .enableNativeEmbedMode(true) // 开启同层渲染模式 306 } 307 } 308 ``` 3092. 因为要使用NodeContainer,所以封装一个继承自NodeController的类SearchNodeController。 310 ```typescript 311 type Node = BuilderNode<[Params]> | undefined | null; 312 313 /** 314 * 用于控制和反馈对应的NodeContainer上的节点的行为,需要与NodeContainer一起使用 315 */ 316 class SearchNodeController extends NodeController { 317 private surfaceId: string = ""; // 当前的surfaceId 318 private embedId: string = ""; // 当前的embedId 319 private type: string = ""; // 当前的节点类型 320 private renderType: NodeRenderType = NodeRenderType.RENDER_TYPE_DISPLAY; // 渲染模式 321 private componentWidth: number = 0; // 原生组件宽 322 private componentHeight: number = 0; // 原生组件高 323 private nodeMap: Map<string, Node> = new Map<string, Node>(); // 存放与surfaceId关联的BuilderNode 324 325 /** 326 * 设置surfaceId等渲染选项 327 */ 328 setRenderOption(params: NodeControllerParams): void { 329 this.surfaceId = params.surfaceId; 330 this.embedId = params.embedId; 331 this.type = params.type; 332 this.renderType = params.renderType; 333 this.componentWidth = params.width; 334 this.componentHeight = params.height; 335 } 336 337 /** 338 * 在对应NodeContainer创建的时候调用、或者通过rebuild方法调用刷新 339 */ 340 makeNode(uiContext: UIContext): FrameNode | null { 341 if (!this.surfaceId) { // 当前没有surfaceId时直接返回null 342 return null; 343 } 344 let getNode: Node = this.nodeMap.get(this.surfaceId); 345 if (getNode) { // 根据surfaceId获取BuilderNode 346 return getNode.getFrameNode(); 347 } else { // 没有获取到则创建一个BuilderNode并与nodeMap关联后返回 348 let newNode: Node = new BuilderNode(uiContext, { surfaceId: this.surfaceId, type: this.renderType }) 349 newNode.build(wrapBuilder(searchBuilder), { width: this.componentWidth, height: this.componentHeight }); 350 this.nodeMap.set(this.surfaceId, newNode); 351 return newNode.getFrameNode(); 352 } 353 } 354 355 /** 356 * 将触摸事件派发到rootNode创建出的FrameNode上 357 */ 358 postEvent(event: TouchEvent | undefined): boolean { 359 if (!this.surfaceId) { 360 return false; 361 } else { 362 let getNode: Node = this.nodeMap.get(this.surfaceId); 363 return getNode?.postTouchEvent(event) as boolean; 364 } 365 } 366 } 367 ``` 3683. 使用Web加载nativeembed_view.html文件,web解析到Embed标签后,通过onNativeEmbedLifecycleChange接口上报Embed标签创建消息通知到应用侧。 369 ```typescript 370 Web({ src: $rawfile("nativeembed_view.html"), controller: this.browserTabController }) 371 .backgroundColor('#F1F3F5') 372 .zoomAccess(false)// 不允许执行缩放 373 .enableNativeEmbedMode(true) // 开启同层渲染模式 374 .onNativeEmbedLifecycleChange((embed) => { 375 ... 376 // 此处进行下一步 377 ... 378 }) 379 .onNativeEmbedGestureEvent((touch) => { 380 // 获取同层渲染组件触摸事件信息 381 this.searchNodeController.postEvent(touch.touchEvent); 382 }) 383 ``` 3844. 在步骤3的回调内,根据embed.status,将配置传入searchNodeController后,执行rebuild方法重新触发其makeNode方法。 385 ```typescript 386 if (embed.status === NativeEmbedStatus.CREATE) { 387 // 获取embed标签的surfaceId等信息,传入searchNodeController 388 this.searchNodeController.setRenderOption({ 389 surfaceId: embed.surfaceId as string, 390 type: embed.info?.type as string, 391 renderType: NodeRenderType.RENDER_TYPE_TEXTURE, 392 embedId: embed.embedId as string, 393 width: px2vp(embed.info?.width), 394 height: px2vp(embed.info?.height) 395 }); 396 } 397 this.searchNodeController.rebuild(); 398 ``` 3995. makeNode方法触发后,NodeContainer组件获取到BuilderNode对象,页面出现原生组件。 4006. Embed标签大小变化是onNativeEmbedLifecycleChange接口上报Embed标签更新消息。 401 402## 页面启动场景性能收益对比 403 404本节以Navigation页面跳转到Web页面的场景,抓取Trace图进行分析。下面的Trace图上的红线处Web页面加载完成,蓝线处原生组件加载显示出来。 405 406### 直接使用H5加载 407 408**图三:H5的Trace图** 409 410H5的分析: 411- 在应用侧,情况比较特殊,因为H5页面是在web侧渲染,所以app侧只有开始加载web之前的js处理阶段,在PageEnd后应用侧没有什么处理。 412- 在render_service侧,每一帧ReceiveVsync的耗时无明显变化。 413 414### 使用非同层渲染加载 415 416**图四:非同层渲染的Trace图** 417 418非同层渲染的分析: 419- 在应用侧,红蓝线之间为测量和计算布局,图片加载被延后到了蓝线之外。 420- 在render_service侧,蓝线之后每一帧ReceiveVsync的耗时大幅增加。 421**图五:非同层渲染情况下的单帧放大图** 422 423从图五可以明显的看到,其中的RSUniRender::Process耗时比起其他帧大幅增加,说明是应用侧组件层叠导致render_service侧的绘任务过重。 424 425### 使用同层渲染加载 426 427**图六:同层渲染的Trace图** 428 429同层渲染的分析: 430- 在应用侧,红蓝线之间由于NodeContainer的原因,组件布局的测量和绘制划分成了两部分,同时将图片加载提前到了红蓝线之间。 431- 在render_service侧,每一帧ReceiveVsync的耗时无明显变化。 432 433### 页面启动场景总结 434 435下表为各种方法完成原生组件加载(蓝线)前后几帧render_service侧的耗时对比(-1为完成前一帧,1为完成后一帧,以此类推): 436 437| | 非同层渲染 | 同层渲染 | 438| ---- | ---- | ---- | 439| -2 | 3ms 682μs 292ns | 3ms 561μs 979ns| 440| -1 | 3ms 796μs 355ns | 3ms 866μs 145ns| 441| 1 | 6ms 741μs 146ns | 4ms 192μs 187ns| 442| 2 | 7ms 974μs 479ns | 3ms 439μs 584ns| 443| 3 | 10ms 543μs 750ns | 3ms 350μs 1ns | 444| 4 | 4ms 706μs 250ns | 3ms 573μs 958ns| 445| **平均** | 6ms 240μs 712ns | 3ms 663μs 975ns| 446 447 448从此表格可以看出,非同层渲染会导致render_service侧每帧耗时大幅提升,同层渲染相比起非同层渲染,并不影响render_service侧的每帧耗时。 449 450## 列表滑动场景性能收益对比 451 452本节以列表滑动场景,抓取Trace图进行分析。在此场景下,由于纯H5实现的Web端由于帧率计算不一样,所以第二个场景不考虑纯H5的情况,对比同层渲染和非同层渲染的每一帧的结构如下所示: 453 454### 使用非同层渲染 455 456 **图七:非同层渲染滑动时单帧图** 457  458 459### 使用同层渲染 460 461 **图八:同层渲染滑动时单帧图** 462  463 上述两张图经过对比也可以发现,render_service每一帧的耗时大幅增加,其中的RSUniRender::Process耗时也大幅增加,结论和上述保持一致,再次验证了同样的结果。 464 465## 总结 466 467通过上述的分析,可以得出下表的结论。 468 469| | H5 | 非同层渲染 | 同层渲染 | 470| ---- | ---- | ---- | ---- | 471| 体验 | 低于原生 | 原生体验 | 原生体验 | 472| 性能 | 低 | 中 | 高 | 473| 功能 | 低于原生 | 完整原生功能 | 完整原生功能 | 474 475在Web中渲染原生组件时,采用同层渲染方式比起非同层渲染可以降低绘制任务,提升了性能。同时使用同层渲染可以实现更多功能,比如根据尺寸调整组件大小等功能,从而避免繁琐操作。 476