文档管理中心
指南应用框架ArkWeb(方舟Web)同层渲染同层渲染原生组件

同层渲染原生组件

概述

在使用Web组件加载H5页面时,经常会有输入框、视频的场景,这些场景在H5中的组件性能体验欠佳。想要更加流畅的体验,必须要将原生组件放到Web组件上。在以下场景应在Web组件上使用原生组件:

  • 需要高性能,流畅体验。
  • 需要使用原生组件功能。
  • 原生组件已经实现,复用以减少开发成本。

目前要实现在Web组件上使用原生组件(详情查看组件介绍)有两种方案:

方案一:直接使用Stack将组件堆叠到H5页面上。

方案二:使用同层渲染,使用Web组件和原生组件交互的方式,将原生组件替代Web组件中部分组件,提升交互体验和性能。

以上两种方案经过性能对比后,同层渲染比非同层渲染的性能要更好。

什么是同层渲染

同层渲染是一种混合渲染技术,通过将原生组件嵌入到Web组件的DOM树中同一层级,实现原生组件与Web组件的无缝集成。

同层渲染和非同层渲染的区别如下:

  • 非同层渲染:通过Z轴的层级关系堆叠在Web组件页面上。此方式实现方式简单,用于原生组件大小位置固定场景。
  • 同层渲染:通过同层渲染的方式直接渲染到H5页面Embed标签区域上。此方式实现相对复杂,用于原生组件大小位置需要跟随Web组件页面变化场景。

图1 同层渲染和非同层渲染区别

场景示例

以下分别采用非同层渲染和同层渲染的两种方式,加载相同的商城组件到相同的H5页面上,并抓取Trace对比两者之间的区别,页面效果与场景实例源码的核心部分如下:

图2 页面效果图

提供承载的H5页面代码如下:

收起
自动换行
深色代码主题
复制
  1. <div>
  2. <div id="bodyId">
  3. <!-- On the H5 interface, the same layer elements are identified by the embedded tag, and the native components are rendered to the location of the embedded tag on the H5 page on the application side.-->
  4. <embed id="nativeSearch" type = "native/component" width="100%" height="100%" src="view"/>
  5. </div>
  6. </div>

商品数据代码如下:

收起
自动换行
深色代码主题
复制
  1. export const PRODUCT_DATA: Array<ProductDataModel> = [
  2. new ProductDataModel(0, $r('app.media.nativeembed_product000'), $r('app.string.nativeembed_product_title000'),
  3. $r("app.string.nativeembed_product_price000")),
  4. new ProductDataModel(1, $r('app.media.nativeembed_product001'), $r('app.string.nativeembed_product_title001'),
  5. $r('app.string.nativeembed_product_price001')),
  6. new ProductDataModel(2, $r('app.media.nativeembed_product002'), $r('app.string.nativeembed_product_title002'),
  7. $r('app.string.nativeembed_product_price002')),
  8. new ProductDataModel(4, $r('app.media.nativeembed_product003'), $r('app.string.nativeembed_product_title004'),
  9. $r('app.string.nativeembed_product_price004')),
  10. new ProductDataModel(0, $r('app.media.nativeembed_product000'), $r('app.string.nativeembed_product_title000'),
  11. $r("app.string.nativeembed_product_price000")),
  12. new ProductDataModel(1, $r('app.media.nativeembed_product001'), $r('app.string.nativeembed_product_title001'),
  13. $r('app.string.nativeembed_product_price001')),
  14. new ProductDataModel(2, $r('app.media.nativeembed_product002'), $r('app.string.nativeembed_product_title002'),
  15. $r('app.string.nativeembed_product_price002')),
  16. new ProductDataModel(4, $r('app.media.nativeembed_product003'), $r('app.string.nativeembed_product_title004'),
  17. $r('app.string.nativeembed_product_price004')),
  18. new ProductDataModel(0, $r('app.media.nativeembed_product000'), $r('app.string.nativeembed_product_title000'),
  19. $r("app.string.nativeembed_product_price000")),
  20. new ProductDataModel(1, $r('app.media.nativeembed_product001'), $r('app.string.nativeembed_product_title001'),
  21. $r('app.string.nativeembed_product_price001')),
  22. new ProductDataModel(2, $r('app.media.nativeembed_product002'), $r('app.string.nativeembed_product_title002'),
  23. $r('app.string.nativeembed_product_price002')),
  24. new ProductDataModel(4, $r('app.media.nativeembed_product003'), $r('app.string.nativeembed_product_title004'),
  25. $r('app.string.nativeembed_product_price004')),
  26. new ProductDataModel(0, $r('app.media.nativeembed_product000'), $r('app.string.nativeembed_product_title000'),
  27. $r("app.string.nativeembed_product_price000")),
  28. new ProductDataModel(1, $r('app.media.nativeembed_product001'), $r('app.string.nativeembed_product_title001'),
  29. $r('app.string.nativeembed_product_price001')),
  30. new ProductDataModel(2, $r('app.media.nativeembed_product002'), $r('app.string.nativeembed_product_title002'),
  31. $r('app.string.nativeembed_product_price002')),
  32. new ProductDataModel(4, $r('app.media.nativeembed_product003'), $r('app.string.nativeembed_product_title004'),
  33. $r('app.string.nativeembed_product_price004')),
  34. ];

商城组件代码如下:

收起
自动换行
深色代码主题
复制
  1. @Component
  2. struct SearchComponent {
  3. @Prop searchWidth: number;
  4. @Prop searchHeight: number;
  5. build() {
  6. Column({ space: 8 }) {
  7. Text($r('app.string.nativeembed_mall'))
  8. .fontSize(16)
  9. Row() {
  10. Image($r('app.media.nativeembed_search_icon'))
  11. .width(14)
  12. .margin({ left: 14 })
  13. Text($r('app.string.nativeembed_search_text_placeholder'))
  14. .fontSize(14)
  15. .opacity(0.6)
  16. .fontColor('#000000')
  17. .margin({ left: 14})
  18. }
  19. .width('100%')
  20. .margin(4)
  21. .height(36)
  22. .backgroundColor(Color.White)
  23. .borderRadius(18)
  24. .onClick(() => {
  25. this.getUIContext().getPromptAction().showToast({
  26. message: $r('app.string.nativeembed_prompt_text')
  27. });
  28. })
  29. Grid() {
  30. ForEach(PRODUCT_DATA, (item: ProductDataModel, index: number) => {
  31. GridItem() {
  32. Column({ space: 8 }) {
  33. Image(item.uri)
  34. .width(100)
  35. .height(100)
  36. Row({ space: 8 }) {
  37. Text(item.title)
  38. .fontSize(12)
  39. Text(item.price)
  40. .fontSize(12)
  41. }
  42. }
  43. .backgroundColor(Color.White)
  44. .alignItems(HorizontalAlign.Center)
  45. .justifyContent(FlexAlign.Center)
  46. .width('100%')
  47. .borderRadius(12)
  48. .padding({ bottom: 12 })
  49. }
  50. }, (item: ProductDataModel) => `${item.id}`)
  51. }
  52. .columnsTemplate('1fr 1fr')
  53. .rowsGap(8)
  54. .columnsGap(8)
  55. .width('100%')
  56. .height('90%')
  57. .backgroundColor('#F1F3F5')
  58. }
  59. .padding(10)
  60. .width(this.searchWidth)
  61. .height(this.searchHeight)
  62. }
  63. }

Web组件首次加载原生组件方案对比

首先的想法是,将原生组件内容使用H5实现,直接用Web组件加载页面。但是,用H5开发页面时,需要使用到JS和CSS,甚至一些前端框架进行页面的开发,并且动效和体验都不如原生组件。因此采用同层渲染和非同层渲染两种方案进行对比。

使用非同层渲染

底层使用空白的H5页面,用任意标签进行占位,然后在H5页面上方层叠一个原生组件。原生组件需要在Web组件加载完成后,获取到标签大小位置后,在对应位置展示。

需要在H5侧添加getEmbedSize()方法来获取元素大小,代码如下:

收起
自动换行
深色代码主题
复制
  1. function getEmbedSize() {
  2. let doc = document.getElementById('nativeSearch');
  3. return {
  4. width: doc.offsetWidth,
  5. height: doc.offsetHeight,
  6. }
  7. }

使用Stack层叠Web组件和SearchComponent组件,代码如下:

收起
自动换行
深色代码主题
复制
  1. import { PRODUCT_DATA } from '../mock/GoodsMock';
  2. import { webview } from '@kit.ArkWeb';
  3. @Entry
  4. @Component
  5. struct NonSameLayerRendering {
  6. @State searchWidth: number = 0;
  7. @State searchHeight: number = 0;
  8. @State isWebInit: boolean = false;
  9. browserTabController: WebviewController = new webview.WebviewController(); // WebviewController controller
  10. build() {
  11. Stack() {
  12. Web({ src: $rawfile('nativeembed_view.html'), controller: this.browserTabController })
  13. .backgroundColor('#F1F3F5')
  14. .onPageEnd(() => {
  15. this.browserTabController.runJavaScript(
  16. 'getEmbedSize()',
  17. (error, result) => {
  18. if (result) {
  19. interface EmbedSize {
  20. width: number,
  21. height: number
  22. }
  23. let embedSize = JSON.parse(result) as EmbedSize;
  24. this.searchWidth = embedSize.width;
  25. this.searchHeight = embedSize.height;
  26. this.isWebInit = true;
  27. }
  28. });
  29. })
  30. if (this.isWebInit){
  31. Column() {
  32. // Because it needs to be displayed according to the actual size of the Web, it needs to wait for the width and height to be obtained after the Web is initialized, and then it needs to be layered on the Web
  33. SearchComponent({ searchWidth: this.searchWidth, searchHeight: this.searchHeight })
  34. }
  35. .zIndex(10)
  36. }
  37. }
  38. }
  39. }
  40. /**
  41. * Set the data class of the item
  42. */
  43. class ProductDataModel {
  44. id: number;
  45. uri: ResourceStr;
  46. title: ResourceStr;
  47. price: ResourceStr;
  48. constructor(id: number, uri: ResourceStr, title: ResourceStr, price: ResourceStr) {
  49. this.id = id;
  50. this.uri = uri;
  51. this.title = title;
  52. this.price = price;
  53. }
  54. }
  55. @Component
  56. struct SearchComponent {
  57. @Prop searchWidth: number;
  58. @Prop searchHeight: number;
  59. build() {
  60. Column({ space: 8 }) {
  61. Text($r('app.string.nativeembed_mall'))
  62. .fontSize(16)
  63. Row() {
  64. Image($r('app.media.nativeembed_search_icon'))
  65. .width(14)
  66. .margin({ left: 14 })
  67. Text($r('app.string.nativeembed_search_text_placeholder'))
  68. .fontSize(14)
  69. .opacity(0.6)
  70. .fontColor('#000000')
  71. .margin({ left: 14})
  72. }
  73. .width('100%')
  74. .margin(4)
  75. .height(36)
  76. .backgroundColor(Color.White)
  77. .borderRadius(18)
  78. .onClick(() => {
  79. this.getUIContext().getPromptAction().showToast({
  80. message: $r('app.string.nativeembed_prompt_text')
  81. });
  82. })
  83. Grid() {
  84. ForEach(PRODUCT_DATA, (item: ProductDataModel, index: number) => {
  85. GridItem() {
  86. Column({ space: 8 }) {
  87. Image(item.uri)
  88. .width(100)
  89. .height(100)
  90. Row({ space: 8 }) {
  91. Text(item.title)
  92. .fontSize(12)
  93. Text(item.price)
  94. .fontSize(12)
  95. }
  96. }
  97. .backgroundColor(Color.White)
  98. .alignItems(HorizontalAlign.Center)
  99. .justifyContent(FlexAlign.Center)
  100. .width('100%')
  101. .borderRadius(12)
  102. .padding({ bottom: 12 })
  103. }
  104. }, (item: ProductDataModel) => `${item.id}`)
  105. }
  106. .columnsTemplate('1fr 1fr')
  107. .rowsGap(8)
  108. .columnsGap(8)
  109. .width('100%')
  110. .height('90%')
  111. .backgroundColor('#F1F3F5')
  112. }
  113. .padding(10)
  114. .width(this.searchWidth)
  115. .height(this.searchHeight)
  116. }
  117. }

上述的方案只是限于底层H5网页比较简单,如果H5页面比较复杂,就会发现原生组件是很难去定位,而且在性能上,Web组件是整体渲染的,即使被原生组件遮住的部分也需要渲染消耗性能。

使用同层渲染

同层渲染简单来说就是,底层使用空白的H5页面,用Embed标签进行占位,ArkTS使用NodeContainer来占位,最后将Web侧的surfaceId和原生组件绑定,渲染在NodeContainer上。这里给出一些大致步骤:

  1. 用Stack组件层叠NodeContainer和Web组件,并开启enableNativeEmbedMode模式。
  2. 因为要使用NodeContainer,所以封装一个继承自NodeController的类SearchNodeController。
  3. 使用Web组件加载nativeembed_view.html文件,Web组件解析到Embed标签后,通过onNativeEmbedLifecycleChange()接口上报Embed标签创建消息通知到应用侧。
  4. 在步骤3的回调内,根据embed.status,将配置传入searchNodeController后,执行rebuild()方法重新触发其makeNode()方法。
  5. makeNode()方法触发后,NodeContainer组件获取到BuilderNode对象,页面出现原生组件。
收起
自动换行
深色代码主题
复制
  1. import { PRODUCT_DATA } from '../viewmodel/GoodsViewModel';
  2. import { ProductDataModel } from '../model/GoodsModel';
  3. import { BuilderNode, FrameNode, NodeController, NodeRenderType } from '@kit.ArkUI';
  4. import { webview } from '@kit.ArkWeb';
  5. // Margin vertical
  6. const MARGIN_VERTICAL: number = 8;
  7. // Font weight
  8. const FONT_WEIGHT: number = 500;
  9. // Placeholder
  10. const PLACEHOLDER: ResourceStr = $r('app.string.embed_search');
  11. declare class Params {
  12. width: number;
  13. height: number;
  14. }
  15. declare class NodeControllerParams {
  16. surfaceId: string;
  17. type: string;
  18. renderType: NodeRenderType;
  19. embedId: string;
  20. width: number;
  21. height: number;
  22. }
  23. class SearchNodeController extends NodeController {
  24. private rootNode: BuilderNode<[Params]> | undefined | null = null;
  25. private embedId: string = "";
  26. private surfaceId: string = "";
  27. private renderType: NodeRenderType = NodeRenderType.RENDER_TYPE_DISPLAY;
  28. private componentWidth: number = 0;
  29. private componentHeight: number = 0;
  30. private componentType: string = "";
  31. /**
  32. * 设置渲染参数
  33. *
  34. * @param params 渲染参数
  35. */
  36. setRenderOption(params: NodeControllerParams): void {
  37. this.surfaceId = params.surfaceId;
  38. this.renderType = params.renderType;
  39. this.embedId = params.embedId;
  40. this.componentWidth = params.width;
  41. this.componentHeight = params.height;
  42. this.componentType = params.type;
  43. }
  44. /**
  45. * 创建节点
  46. *
  47. * @param uiContext UIContext
  48. * @returns 节点
  49. */
  50. makeNode(uiContext: UIContext): FrameNode | null {
  51. this.rootNode = new BuilderNode(uiContext, { surfaceId: this.surfaceId, type: this.renderType });
  52. if (this.componentType === 'native/component') {
  53. this.rootNode.build(wrapBuilder(searchBuilder), { width: this.componentWidth, height: this.componentHeight });
  54. }
  55. return this.rootNode.getFrameNode();
  56. }
  57. setBuilderNode(rootNode: BuilderNode<Params[]> | null): void {
  58. this.rootNode = rootNode;
  59. }
  60. getBuilderNode(): BuilderNode<[Params]> | undefined | null {
  61. return this.rootNode;
  62. }
  63. updateNode(arg: Object): void {
  64. this.rootNode?.update(arg);
  65. }
  66. getEmbedId(): string {
  67. return this.embedId;
  68. }
  69. postEvent(event: TouchEvent | undefined): boolean {
  70. return this.rootNode?.postTouchEvent(event) as boolean;
  71. }
  72. }
  73. @Component
  74. struct SearchComponent {
  75. @Prop params: Params;
  76. controller: SearchController = new SearchController()
  77. build() {
  78. Column({ space: MARGIN_VERTICAL }) {
  79. Text($r("app.string.embed_mall"))
  80. .fontSize($r('app.string.ohos_id_text_size_body4'))
  81. .fontWeight(FONT_WEIGHT)
  82. .fontFamily('HarmonyHeiTi-Medium')
  83. Row() {
  84. Search({ placeholder: PLACEHOLDER, controller: this.controller })
  85. .backgroundColor(Color.White)
  86. }
  87. .width($r("app.string.embed_full_percent"))
  88. .margin($r("app.integer.embed_row_margin"))
  89. Grid() {
  90. ForEach(PRODUCT_DATA, (item: ProductDataModel, index: number) => {
  91. GridItem() {
  92. Column({ space: MARGIN_VERTICAL }) {
  93. Image(item.imageRes).width($r("app.integer.embed_image_size"))
  94. Row({ space: MARGIN_VERTICAL }) {
  95. Text(item.title)
  96. .fontSize($r('app.string.ohos_id_text_size_body1'))
  97. .width(100)
  98. .maxLines(1)
  99. .textOverflow({ overflow: TextOverflow.Ellipsis })
  100. Text(item.price)
  101. .fontSize($r('app.string.ohos_id_text_size_body1'))
  102. .width(50)
  103. .maxLines(1)
  104. }
  105. }
  106. .backgroundColor($r('app.color.ohos_id_color_background'))
  107. .alignItems(HorizontalAlign.Center)
  108. .justifyContent(FlexAlign.Center)
  109. .width($r("app.string.embed_full_percent"))
  110. .height($r("app.string.embed_full_percent"))
  111. .borderRadius($r('app.string.ohos_id_corner_radius_default_m'))
  112. }
  113. }, (item: ProductDataModel, index: number) => index.toString())
  114. }
  115. .columnsTemplate('1fr 1fr')
  116. .rowsTemplate('1fr 1fr 1fr')
  117. .rowsGap($r('app.string.ohos_id_elements_margin_vertical_m'))
  118. .columnsGap($r('app.string.ohos_id_elements_margin_vertical_m'))
  119. .width($r("app.string.embed_full_percent"))
  120. .height($r("app.string.embed_sixty_percent"))
  121. .backgroundColor($r('app.color.ohos_id_color_sub_background'))
  122. }
  123. .padding($r('app.string.ohos_id_card_margin_start'))
  124. .width(this.params.width)
  125. .height(this.params.height)
  126. }
  127. }
  128. @Builder
  129. function searchBuilder(params: Params) {
  130. SearchComponent({ params: params })
  131. .backgroundColor($r('app.color.ohos_id_color_sub_background'))
  132. }
  133. @Entry
  134. @Component
  135. struct Index {
  136. browserTabController: WebviewController = new webview.WebviewController();
  137. @State componentIdArr: Array<string> = [];
  138. private nodeControllerMap: Map<string, SearchNodeController> = new Map();
  139. build() {
  140. Stack() {
  141. ForEach(this.componentIdArr, (componentId: string) => {
  142. NodeContainer(this.nodeControllerMap.get(componentId));
  143. }, (embedId: string) => embedId)
  144. Web({ src: $rawfile("embed_view.html"), controller: this.browserTabController })
  145. .backgroundColor($r('app.color.ohos_id_color_sub_background'))
  146. .zoomAccess(false)
  147. .enableNativeEmbedMode(true)
  148. .onNativeEmbedLifecycleChange((embed) => {
  149. const componentId = embed.info?.id?.toString() as string
  150. if (embed.status === NativeEmbedStatus.CREATE) {
  151. let nodeController = new SearchNodeController();
  152. nodeController.setRenderOption({
  153. surfaceId: embed.surfaceId as string,
  154. type: embed.info?.type as string,
  155. renderType: NodeRenderType.RENDER_TYPE_TEXTURE,
  156. embedId: embed.embedId as string,
  157. width: this.getUIContext().px2vp(embed.info?.width),
  158. height: this.getUIContext().px2vp(embed.info?.height)
  159. });
  160. nodeController.rebuild();
  161. this.nodeControllerMap.set(componentId, nodeController);
  162. this.componentIdArr.push(componentId);
  163. } else if (embed.status === NativeEmbedStatus.UPDATE) {
  164. let nodeController = this.nodeControllerMap.get(componentId);
  165. nodeController?.updateNode({
  166. text: 'update',
  167. width: this.getUIContext().px2vp(embed.info?.width),
  168. height: this.getUIContext().px2vp(embed.info?.height)
  169. } as ESObject);
  170. nodeController?.rebuild();
  171. } else {
  172. let nodeController = this.nodeControllerMap.get(componentId);
  173. nodeController?.setBuilderNode(null);
  174. nodeController?.rebuild();
  175. }
  176. })
  177. .onNativeEmbedGestureEvent((touch) => {
  178. this.componentIdArr.forEach((componentId: string) => {
  179. let nodeController = this.nodeControllerMap.get(componentId);
  180. if (nodeController?.getEmbedId() === touch.embedId) {
  181. nodeController?.postEvent(touch.touchEvent);
  182. }
  183. })
  184. })
  185. }
  186. }
  187. }

Web组件加载原生组件性能收益对比

本节以Web组件加载原生组件的场景,抓取Trace图进行分析。下面的Trace图上的红线处Web组件加载完成,蓝线处原生组件加载显示完成。

使用非同层渲染加载

**图3 **非同层渲染的Trace图

非同层渲染的分析:

  • 在应用侧,红蓝线之间为测量和计算布局,图片加载被延后到了蓝线之外。
  • 在render_service侧,蓝线之后每一帧ReceiveVsync的耗时大幅增加。

使用同层渲染加载

图4 同层渲染的Trace图

同层渲染的分析:

  • 在应用侧,红蓝线之间由于NodeContainer的原因,组件布局的测量和绘制划分成了两部分,同时将图片加载提前到了红蓝线之间。
  • 在render_service侧,每一帧ReceiveVsync的耗时无明显变化。

页面加载场景总结

下表为各种方法完成原生组件加载(蓝线)前后几帧render_service侧的耗时对比(-1为完成前一帧,1为完成后一帧,以此类推)

从此表格可以看出,非同层渲染会导致render_service侧每帧耗时大幅提升,同层渲染相比起非同层渲染,并不影响render_service侧的每帧耗时。

列表滑动场景性能收益对比

本节以列表滑动场景,抓取Trace图进行分析。在此场景下,对比同层渲染和非同层渲染的每一帧的结构如下所示:

使用非同层渲染

图5 非同层渲染滑动时单帧图

非同层渲染的分析:

  • ReceiveVsync表示渲染服务接收到垂直同步信号(Vsync)的事件,此标记的出现意味着渲染服务开始响应新一帧的绘制任务。所以上图表示单帧的渲染绘制情况。
  • 单帧ReceiveVsync渲染耗时5ms。

使用同层渲染

图6 同层渲染滑动时单帧图

同层渲染的分析:

  • 同场景下,单帧ReceiveVsync渲染耗时1ms。

列表滑动场景总结

非同层渲染的render_service每一帧的耗时大幅增加,结论与“页面加载场景总结”一致,再次验证了同样的结果。

总结

在Web组件中渲染原生组件时,采用同层渲染方式比起非同层渲染可以将图片渲染提前到原生组件加载完成前,且同层渲染将位于同一个图层的元素一起渲染,降低绘制任务,提升了性能。同时使用同层渲染可以实现更多功能,比如根据尺寸调整组件大小等功能,从而避免繁琐操作。

在 指南 中进行搜索
请输入您想要搜索的关键词