概述
在使用Web组件加载H5页面时,经常会有输入框、视频的场景,这些场景在H5中的组件性能体验欠佳。想要更加流畅的体验,必须要将原生组件放到Web组件上。在什么场景下应该在Web组件上使用原生组件:
- 需要高性能,流畅体验。
- 需要使用原生组件功能。
- 原生组件已经实现,复用以减少开发成本。
目前要实现在Web组件上使用原生组件有两种方案:
方案一:直接使用Stack将组件堆叠到H5页面上。
方案二:使用同层渲染,使用Web组件和原生组件交互的方式,将原生组件替代Web组件中部分组件,提升交互体验和性能。
以上两种方案经过性能对比后,同层渲染比非同层渲染的性能要更好。
什么是同层渲染
同层渲染是一种优化技术,用于提高Web组件页面的渲染性能。同层渲染会将位于同一个图层的元素一起渲染,以减少重绘和重排的次数,从而提高页面的渲染效率。
同层渲染和非同层渲染的区别如下:
- 非同层渲染:通过Z轴的层级关系堆叠在Web组件页面上。此方式实现方式简单,用于原生组件大小位置固定场景。
- 同层渲染:通过同层渲染的方式直接渲染到H5页面Embed标签区域上。此方式实现相对复杂,用于原生组件大小位置需要跟随Web组件页面变化场景。
图1 同层渲染和非同层渲染区别
场景示例
以下分别采用非同层渲染和同层渲染的两种方式,加载相同的商城组件到相同的H5页面上,并抓取Trace对比两者之间的区别,页面效果与场景实例源码的核心部分如下:
图2 页面效果图
提供承载的H5页面代码如下:
<!-- nativeembed_view.html -->
...
<div>
<div id="bodyId">
<!-- 在H5界面上通过embed标签标识同层元素,在应用侧将原生组件渲染到H5页面embed标签所在位置-->
<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组件
Text('商城')
.fontSize(16)
Row() {
Image($r('app.media.nativeembed_search_icon'))
.width(14)
.margin({ left: 14 })
Text('搜索相关宝贝')
.fontSize(14)
.opacity(0.6)
.fontColor('#000000')
.margin({ left: 14})
}
.width('100%')
.margin(4)
.height(36)
.backgroundColor(Color.White)
.borderRadius(18)
.onClick(() => {
promptAction.showToast({
message: '仅演示,可自行实现业务功能'
});
})
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)
}
}
Web组件首次加载原生组件方案对比
首先的想法是,将原生组件内容使用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 { promptAction } from '@kit.ArkUI';
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控制器
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() {
// 由于需要根据Web实际加载的尺寸进行展示,需要等Web初始化后获取宽高,之后层叠到Web上
SearchComponent({ searchWidth: this.searchWidth, searchHeight: this.searchHeight })
}
.zIndex(10)
}
}
}
}
/**
* 设置项的数据类
*/
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组件
Text('商城')
.fontSize(16)
Row() {
Image($r('app.media.nativeembed_search_icon'))
.width(14)
.margin({ left: 14 })
Text('搜索相关宝贝')
.fontSize(14)
.opacity(0.6)
.fontColor('#000000')
.margin({ left: 14})
}
.width('100%')
.margin(4)
.height(36)
.backgroundColor(Color.White)
.borderRadius(18)
.onClick(() => {
promptAction.showToast({
message: '仅演示,可自行实现业务功能'
});
})
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标签进行占位,原生使用NodeContainer来占位,最后将Web侧的surfaceId和原生组件绑定,渲染在NodeContainer上。这里给出一些大致步骤:
- 用Stack组件层叠NodeContainer和Web组件,并开启enableNativeEmbedMode模式。
- 因为要使用NodeContainer,所以封装一个继承自NodeController的类SearchNodeController。
- 使用Web组件加载nativeembed_view.html文件,Web组件解析到Embed标签后,通过onNativeEmbedLifecycleChange接口上报Embed标签创建消息通知到应用侧。
- 在步骤3的回调内,根据embed.status,将配置传入searchNodeController后,执行rebuild方法重新触发其makeNode方法。
- makeNode方法触发后,NodeContainer组件获取到BuilderNode对象,页面出现原生组件。
import { BuilderNode, FrameNode, NodeController, NodeRenderType } from '@kit.ArkUI';
import { webview } from '@kit.ArkWeb';
import { PRODUCT_DATA } from '../viewmodel/GoodsViewModel';
import { ProductDataModel } from '../model/GoodsModel';
const MARGIN_VERTICAL: number = 8;
const FONT_WEIGHT: number = 500;
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 = '';
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;
}
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.uri).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: px2vp(embed.info?.width),
height: 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: px2vp(embed.info?.width),
height: 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);
}
})
})
}
}
}
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 非同层渲染滑动时单帧图。
使用同层渲染
图6 同层渲染滑动时单帧图。
上述两张图经过对比也可以发现,非同层渲染的render_service每一帧的耗时大幅增加,其中的RSUniRender::Process耗时也大幅增加,结论和上述保持一致,再次验证了同样的结果。
总结
在Web组件中渲染原生组件时,采用同层渲染方式比起非同层渲染可以将图片渲染提前到原生组件加载完成前,且同层渲染将位于同一个图层的元素一起渲染,降低绘制任务,提升了性能。同时使用同层渲染可以实现更多功能,比如根据尺寸调整组件大小等功能,从而避免繁琐操作。