在使用Web组件加载H5页面时,经常会有输入框、视频的场景,这些场景在H5中的组件性能体验欠佳。想要更加流畅的体验,必须要将原生组件放到Web组件上。在以下场景应在Web组件上使用原生组件:
目前要实现在Web组件上使用原生组件(详情查看组件介绍)有两种方案:
方案一:直接使用Stack将组件堆叠到H5页面上。
方案二:使用同层渲染,使用Web组件和原生组件交互的方式,将原生组件替代Web组件中部分组件,提升交互体验和性能。
以上两种方案经过性能对比后,同层渲染比非同层渲染的性能要更好。
同层渲染是一种混合渲染技术,通过将原生组件嵌入到Web组件的DOM树中同一层级,实现原生组件与Web组件的无缝集成。
同层渲染和非同层渲染的区别如下:
图1 同层渲染和非同层渲染区别

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

提供承载的H5页面代码如下:
<div>
<div id="bodyId">
<!-- 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.-->
<embed id="nativeSearch" type = "native/component" width="100%" height="100%" src="view"/>
</div>
</div> 商品数据代码如下:
export const PRODUCT_DATA: Array<ProductDataModel> = [
new ProductDataModel(0, $r('app.media.nativeembed_product000'), $r('app.string.nativeembed_product_title000'),
$r("app.string.nativeembed_product_price000")),
new ProductDataModel(1, $r('app.media.nativeembed_product001'), $r('app.string.nativeembed_product_title001'),
$r('app.string.nativeembed_product_price001')),
new ProductDataModel(2, $r('app.media.nativeembed_product002'), $r('app.string.nativeembed_product_title002'),
$r('app.string.nativeembed_product_price002')),
new ProductDataModel(4, $r('app.media.nativeembed_product003'), $r('app.string.nativeembed_product_title004'),
$r('app.string.nativeembed_product_price004')),
new ProductDataModel(0, $r('app.media.nativeembed_product000'), $r('app.string.nativeembed_product_title000'),
$r("app.string.nativeembed_product_price000")),
new ProductDataModel(1, $r('app.media.nativeembed_product001'), $r('app.string.nativeembed_product_title001'),
$r('app.string.nativeembed_product_price001')),
new ProductDataModel(2, $r('app.media.nativeembed_product002'), $r('app.string.nativeembed_product_title002'),
$r('app.string.nativeembed_product_price002')),
new ProductDataModel(4, $r('app.media.nativeembed_product003'), $r('app.string.nativeembed_product_title004'),
$r('app.string.nativeembed_product_price004')),
new ProductDataModel(0, $r('app.media.nativeembed_product000'), $r('app.string.nativeembed_product_title000'),
$r("app.string.nativeembed_product_price000")),
new ProductDataModel(1, $r('app.media.nativeembed_product001'), $r('app.string.nativeembed_product_title001'),
$r('app.string.nativeembed_product_price001')),
new ProductDataModel(2, $r('app.media.nativeembed_product002'), $r('app.string.nativeembed_product_title002'),
$r('app.string.nativeembed_product_price002')),
new ProductDataModel(4, $r('app.media.nativeembed_product003'), $r('app.string.nativeembed_product_title004'),
$r('app.string.nativeembed_product_price004')),
new ProductDataModel(0, $r('app.media.nativeembed_product000'), $r('app.string.nativeembed_product_title000'),
$r("app.string.nativeembed_product_price000")),
new ProductDataModel(1, $r('app.media.nativeembed_product001'), $r('app.string.nativeembed_product_title001'),
$r('app.string.nativeembed_product_price001')),
new ProductDataModel(2, $r('app.media.nativeembed_product002'), $r('app.string.nativeembed_product_title002'),
$r('app.string.nativeembed_product_price002')),
new ProductDataModel(4, $r('app.media.nativeembed_product003'), $r('app.string.nativeembed_product_title004'),
$r('app.string.nativeembed_product_price004')),
]; 商城组件代码如下:
@Component
struct SearchComponent {
@Prop searchWidth: number;
@Prop searchHeight: number;
build() {
Column({ space: 8 }) {
Text($r('app.string.nativeembed_mall'))
.fontSize(16)
Row() {
Image($r('app.media.nativeembed_search_icon'))
.width(14)
.margin({ left: 14 })
Text($r('app.string.nativeembed_search_text_placeholder'))
.fontSize(14)
.opacity(0.6)
.fontColor('#000000')
.margin({ left: 14})
}
.width('100%')
.margin(4)
.height(36)
.backgroundColor(Color.White)
.borderRadius(18)
.onClick(() => {
this.getUIContext().getPromptAction().showToast({
message: $r('app.string.nativeembed_prompt_text')
});
})
Grid() {
ForEach(PRODUCT_DATA, (item: ProductDataModel, index: number) => {
GridItem() {
Column({ space: 8 }) {
Image(item.uri)
.width(100)
.height(100)
Row({ space: 8 }) {
Text(item.title)
.fontSize(12)
Text(item.price)
.fontSize(12)
}
}
.backgroundColor(Color.White)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.width('100%')
.borderRadius(12)
.padding({ bottom: 12 })
}
}, (item: ProductDataModel) => `${item.id}`)
}
.columnsTemplate('1fr 1fr')
.rowsGap(8)
.columnsGap(8)
.width('100%')
.height('90%')
.backgroundColor('#F1F3F5')
}
.padding(10)
.width(this.searchWidth)
.height(this.searchHeight)
}
} 首先的想法是,将原生组件内容使用H5实现,直接用Web组件加载页面。但是,用H5开发页面时,需要使用到JS和CSS,甚至一些前端框架进行页面的开发,并且动效和体验都不如原生组件。因此采用同层渲染和非同层渲染两种方案进行对比。
底层使用空白的H5页面,用任意标签进行占位,然后在H5页面上方层叠一个原生组件。原生组件需要在Web组件加载完成后,获取到标签大小位置后,在对应位置展示。
需要在H5侧添加getEmbedSize()方法来获取元素大小,代码如下:
function getEmbedSize() {
let doc = document.getElementById('nativeSearch');
return {
width: doc.offsetWidth,
height: doc.offsetHeight,
}
} 使用Stack层叠Web组件和SearchComponent组件,代码如下:
import { PRODUCT_DATA } from '../mock/GoodsMock';
import { webview } from '@kit.ArkWeb';
@Entry
@Component
struct NonSameLayerRendering {
@State searchWidth: number = 0;
@State searchHeight: number = 0;
@State isWebInit: boolean = false;
browserTabController: WebviewController = new webview.WebviewController(); // WebviewController controller
build() {
Stack() {
Web({ src: $rawfile('nativeembed_view.html'), controller: this.browserTabController })
.backgroundColor('#F1F3F5')
.onPageEnd(() => {
this.browserTabController.runJavaScript(
'getEmbedSize()',
(error, result) => {
if (result) {
interface EmbedSize {
width: number,
height: number
}
let embedSize = JSON.parse(result) as EmbedSize;
this.searchWidth = embedSize.width;
this.searchHeight = embedSize.height;
this.isWebInit = true;
}
});
})
if (this.isWebInit){
Column() {
// 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
SearchComponent({ searchWidth: this.searchWidth, searchHeight: this.searchHeight })
}
.zIndex(10)
}
}
}
}
/**
* Set the data class of the item
*/
class ProductDataModel {
id: number;
uri: ResourceStr;
title: ResourceStr;
price: ResourceStr;
constructor(id: number, uri: ResourceStr, title: ResourceStr, price: ResourceStr) {
this.id = id;
this.uri = uri;
this.title = title;
this.price = price;
}
}
@Component
struct SearchComponent {
@Prop searchWidth: number;
@Prop searchHeight: number;
build() {
Column({ space: 8 }) {
Text($r('app.string.nativeembed_mall'))
.fontSize(16)
Row() {
Image($r('app.media.nativeembed_search_icon'))
.width(14)
.margin({ left: 14 })
Text($r('app.string.nativeembed_search_text_placeholder'))
.fontSize(14)
.opacity(0.6)
.fontColor('#000000')
.margin({ left: 14})
}
.width('100%')
.margin(4)
.height(36)
.backgroundColor(Color.White)
.borderRadius(18)
.onClick(() => {
this.getUIContext().getPromptAction().showToast({
message: $r('app.string.nativeembed_prompt_text')
});
})
Grid() {
ForEach(PRODUCT_DATA, (item: ProductDataModel, index: number) => {
GridItem() {
Column({ space: 8 }) {
Image(item.uri)
.width(100)
.height(100)
Row({ space: 8 }) {
Text(item.title)
.fontSize(12)
Text(item.price)
.fontSize(12)
}
}
.backgroundColor(Color.White)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.width('100%')
.borderRadius(12)
.padding({ bottom: 12 })
}
}, (item: ProductDataModel) => `${item.id}`)
}
.columnsTemplate('1fr 1fr')
.rowsGap(8)
.columnsGap(8)
.width('100%')
.height('90%')
.backgroundColor('#F1F3F5')
}
.padding(10)
.width(this.searchWidth)
.height(this.searchHeight)
}
} 上述的方案只是限于底层H5网页比较简单,如果H5页面比较复杂,就会发现原生组件是很难去定位,而且在性能上,Web组件是整体渲染的,即使被原生组件遮住的部分也需要渲染消耗性能。
同层渲染简单来说就是,底层使用空白的H5页面,用Embed标签进行占位,ArkTS使用NodeContainer来占位,最后将Web侧的surfaceId和原生组件绑定,渲染在NodeContainer上。这里给出一些大致步骤:
import { PRODUCT_DATA } from '../viewmodel/GoodsViewModel';
import { ProductDataModel } from '../model/GoodsModel';
import { BuilderNode, FrameNode, NodeController, NodeRenderType } from '@kit.ArkUI';
import { webview } from '@kit.ArkWeb';
// Margin vertical
const MARGIN_VERTICAL: number = 8;
// Font weight
const FONT_WEIGHT: number = 500;
// Placeholder
const PLACEHOLDER: ResourceStr = $r('app.string.embed_search');
declare class Params {
width: number;
height: number;
}
declare class NodeControllerParams {
surfaceId: string;
type: string;
renderType: NodeRenderType;
embedId: string;
width: number;
height: number;
}
class SearchNodeController extends NodeController {
private rootNode: BuilderNode<[Params]> | undefined | null = null;
private embedId: string = "";
private surfaceId: string = "";
private renderType: NodeRenderType = NodeRenderType.RENDER_TYPE_DISPLAY;
private componentWidth: number = 0;
private componentHeight: number = 0;
private componentType: string = "";
/**
* 设置渲染参数
*
* @param params 渲染参数
*/
setRenderOption(params: NodeControllerParams): void {
this.surfaceId = params.surfaceId;
this.renderType = params.renderType;
this.embedId = params.embedId;
this.componentWidth = params.width;
this.componentHeight = params.height;
this.componentType = params.type;
}
/**
* 创建节点
*
* @param uiContext UIContext
* @returns 节点
*/
makeNode(uiContext: UIContext): FrameNode | null {
this.rootNode = new BuilderNode(uiContext, { surfaceId: this.surfaceId, type: this.renderType });
if (this.componentType === 'native/component') {
this.rootNode.build(wrapBuilder(searchBuilder), { width: this.componentWidth, height: this.componentHeight });
}
return this.rootNode.getFrameNode();
}
setBuilderNode(rootNode: BuilderNode<Params[]> | null): void {
this.rootNode = rootNode;
}
getBuilderNode(): BuilderNode<[Params]> | undefined | null {
return this.rootNode;
}
updateNode(arg: Object): void {
this.rootNode?.update(arg);
}
getEmbedId(): string {
return this.embedId;
}
postEvent(event: TouchEvent | undefined): boolean {
return this.rootNode?.postTouchEvent(event) as boolean;
}
}
@Component
struct SearchComponent {
@Prop params: Params;
controller: SearchController = new SearchController()
build() {
Column({ space: MARGIN_VERTICAL }) {
Text($r("app.string.embed_mall"))
.fontSize($r('app.string.ohos_id_text_size_body4'))
.fontWeight(FONT_WEIGHT)
.fontFamily('HarmonyHeiTi-Medium')
Row() {
Search({ placeholder: PLACEHOLDER, controller: this.controller })
.backgroundColor(Color.White)
}
.width($r("app.string.embed_full_percent"))
.margin($r("app.integer.embed_row_margin"))
Grid() {
ForEach(PRODUCT_DATA, (item: ProductDataModel, index: number) => {
GridItem() {
Column({ space: MARGIN_VERTICAL }) {
Image(item.imageRes).width($r("app.integer.embed_image_size"))
Row({ space: MARGIN_VERTICAL }) {
Text(item.title)
.fontSize($r('app.string.ohos_id_text_size_body1'))
.width(100)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(item.price)
.fontSize($r('app.string.ohos_id_text_size_body1'))
.width(50)
.maxLines(1)
}
}
.backgroundColor($r('app.color.ohos_id_color_background'))
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.width($r("app.string.embed_full_percent"))
.height($r("app.string.embed_full_percent"))
.borderRadius($r('app.string.ohos_id_corner_radius_default_m'))
}
}, (item: ProductDataModel, index: number) => index.toString())
}
.columnsTemplate('1fr 1fr')
.rowsTemplate('1fr 1fr 1fr')
.rowsGap($r('app.string.ohos_id_elements_margin_vertical_m'))
.columnsGap($r('app.string.ohos_id_elements_margin_vertical_m'))
.width($r("app.string.embed_full_percent"))
.height($r("app.string.embed_sixty_percent"))
.backgroundColor($r('app.color.ohos_id_color_sub_background'))
}
.padding($r('app.string.ohos_id_card_margin_start'))
.width(this.params.width)
.height(this.params.height)
}
}
@Builder
function searchBuilder(params: Params) {
SearchComponent({ params: params })
.backgroundColor($r('app.color.ohos_id_color_sub_background'))
}
@Entry
@Component
struct Index {
browserTabController: WebviewController = new webview.WebviewController();
@State componentIdArr: Array<string> = [];
private nodeControllerMap: Map<string, SearchNodeController> = new Map();
build() {
Stack() {
ForEach(this.componentIdArr, (componentId: string) => {
NodeContainer(this.nodeControllerMap.get(componentId));
}, (embedId: string) => embedId)
Web({ src: $rawfile("embed_view.html"), controller: this.browserTabController })
.backgroundColor($r('app.color.ohos_id_color_sub_background'))
.zoomAccess(false)
.enableNativeEmbedMode(true)
.onNativeEmbedLifecycleChange((embed) => {
const componentId = embed.info?.id?.toString() as string
if (embed.status === NativeEmbedStatus.CREATE) {
let nodeController = new SearchNodeController();
nodeController.setRenderOption({
surfaceId: embed.surfaceId as string,
type: embed.info?.type as string,
renderType: NodeRenderType.RENDER_TYPE_TEXTURE,
embedId: embed.embedId as string,
width: this.getUIContext().px2vp(embed.info?.width),
height: this.getUIContext().px2vp(embed.info?.height)
});
nodeController.rebuild();
this.nodeControllerMap.set(componentId, nodeController);
this.componentIdArr.push(componentId);
} else if (embed.status === NativeEmbedStatus.UPDATE) {
let nodeController = this.nodeControllerMap.get(componentId);
nodeController?.updateNode({
text: 'update',
width: this.getUIContext().px2vp(embed.info?.width),
height: this.getUIContext().px2vp(embed.info?.height)
} as ESObject);
nodeController?.rebuild();
} else {
let nodeController = this.nodeControllerMap.get(componentId);
nodeController?.setBuilderNode(null);
nodeController?.rebuild();
}
})
.onNativeEmbedGestureEvent((touch) => {
this.componentIdArr.forEach((componentId: string) => {
let nodeController = this.nodeControllerMap.get(componentId);
if (nodeController?.getEmbedId() === touch.embedId) {
nodeController?.postEvent(touch.touchEvent);
}
})
})
}
}
} **图3 **非同层渲染的Trace图

非同层渲染的分析:
图4 同层渲染的Trace图

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

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

非同层渲染的分析: