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