在使用Web组件加载H5页面时,经常会有长列表、视频等场景。由于Web目前最高只有60帧,想要更加流畅的体验,必须要将原生组件放到Web上。
在什么场景下应该在Web上使用原生组件:
- 需要高性能,流畅体验。如长列表,视频等场景
- 需要使用原生组件功能
- 原生组件已经实现,复用以减少开发成本
目前要实现在Web上使用原生组件有两种方案:
方案一:直接将组件堆叠到H5页面上。
方案二:使用同层渲染,使用Web和原生组件交互的方式,将原生组件替代Web中部分组件,提升交互体验和性能。
场景示例
以下分别采用纯H5、非同层渲染和同层渲染的三种方式,加载相同的商城组件到相同的H5页面上,并抓取trace对比三者之间的区别,其中商城页面大致如图一所示:
图一:商城页面场景
场景实例源码的核心部分如下:
上图中,背景的空白提供承载的H5页面如下:
<!-- nativeembed_view.html -->
...
<body>
<div>
<div id="bodyId">
<!-- 在H5界面上通过embed标签标识同层元素,在应用侧将原生组件渲染到H5页面embed标签所在位置-->
<embed id="nativeSearch" type="native/component" width="100%" height="100%" src="view" />
</div>
</div>
<script>
// 判断设备是否支持touch事件
let nativeEmbed = {
// 通过id名获取embed标签
nativeSearch: document.getElementById('nativeSearch'),
// 事件
events: {},
// 初始化
init: function () {
let self = this;
// 添加touch的监听事件
self.nativeSearch.addEventListener('touchstart', self.events, false);
}
};
nativeEmbed.init();
// 调用无参函数时实现。
function getEmbedSize() {
let doc = document.getElementById('nativeSearch');
return {
width: doc.offsetWidth,
height: doc.offsetHeight,
}
}
</script>
</body>
...
上图中,搜索框+下方列表的原生商城组件如下:
// SearchComponent.ets
...
@Component
export struct SearchComponent {
@Prop params: Params;
@State myData: MyData = new MyData();
build() {
Column({ space: 8 }) {
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('#FFFFFF')
.borderRadius(18)
.onClick(() => {
// 点击搜索框提示
promptAction.showToast({
message: "仅演示"
});
})
Grid() {
LazyForEach(this.myData, (item: ProductDataModel, index: number) => {
GridItem() {
Column({ space: 8 }) {
Image(item.uri).width(100)
Row({ space: 8 }) {
Text(item.title).fontSize(12)
Text(item.price).fontSize(12)
}
}
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.width("100%")
.height(140)
.borderRadius(12)
}
}, (item: ProductDataModel) => `${item.id}`)
}
.cachedCount(2)
.columnsTemplate('1fr 1fr')
.rowsGap(8)
.columnsGap(8)
.width("100%")
.height("90%")
.backgroundColor('#F1F3F5')
}
.padding(10)
.width(this.params.width)
.height(this.params.height)
}
}
...
Web加载原生组件三种方案的对比
方案一:直接使用H5
首先的想法是,将搜索框和列表组件使用原生H5实现,直接用web加载页面。数据交互的部分则需要与原生交互部分通过WebMessagePort与Web交互。关键代码步骤如下:
-
应用侧使用单Web组件挂在H5页面,但是同时需要设置javaScriptProxy传入参数,并在PageEnd回调中建立WebMessagePort通道传输数据。
Web({ src: $rawfile("web.html"), controller: this.browserTabController }) .zoomAccess(false) .javaScriptProxy({ object: this.mockData, name: 'mockData', methodList: ["getMockData"], controller: this.browserTabController }) .onPageEnd(() => { // 1. 创建消息端口 this.ports = this.browserTabController.createWebMessagePorts(true); // 2. 发送端口1到HTML5 this.browserTabController.postMessage("init_web_messageport", [this.ports[1]], "*"); // 3. 保存端口0到本地 this.nativePort = this.ports[0]; // 4. 设置回调函数 this.nativePort.onMessageEventExt((result) => { try { const type = result.getType(); switch (type) { case webview.WebMessageType.STRING: { if (result.getString() === 'shop_search_click') { // 点击搜索框提示 promptAction.showToast({ message: $r("app.string.nativeembed_prompt_text") }); } break; } } } catch (error) { console.error(`ErrorCode: ${error.code}, Message: ${error.message}`); } }); hiTraceMeter.finishTrace('START_WEB_WEB', 2002); })
-
此时,样式和组件需要单独通过js和css文件进行控制,这里仅展示js主要代码
let h5Port; // 获取应用侧的端口 window.addEventListener('message', function (event) { if (event.data == 'init_web_messageport') { if (event.ports[0] != null) { h5Port = event.ports[0]; // 1. 保存从ets侧发送过来的端口 } } }) function postStringToApp(str) { if (h5Port) { h5Port.postMessage(str); } else { console.error("In html h5port is null, please init first"); } } // 获取应用侧的数据对象 let imageNodeData = mockData.getMockData(); // 搜索框 let searchNode = document.createElement('div'); searchNode.classList.add('shop-input'); searchNode.addEventListener('click', () => { postStringToApp('shop_search_click') }) ... // 其余相关节点 ... let imageNodeList = []; // 商城node节点列表 imageNodeData.forEach(item => { // 商品div let node = document.createElement("div"); node.classList.add('shop-container'); // 图片img let imageNode = document.createElement('img'); imageNode.classList.add('shop-img'); imageNode.src = item.uri; // 文字 let textNode = document.createElement("p"); textNode.innerText = `${item.title}\u00A0\u00A0\u00A0\u00A0${item.price}`; // 组合商品图 node.append(imageNode, textNode); imageNodeList.push(node); }) shopNode.append(...imageNodeList); document.querySelector("#my-app").append(titleNode, searchNode, shopNode);
在上述的方案中可以发现,用H5开发页面时,需要使用到JS和CSS,甚至一些前端框架进行页面的开发。并且动效和体验都不如原生组件。既然Web也是一个组件,可以想到直接把原生组件堆叠到Web页面上,方案如下:
方案二:使用非同层渲染
底层使用空白的H5页面,用任意标签进行占位,然后在H5页面上方层叠一个原生组件。原生组件需要在Web加载完成后,获取到标签大小位置后,在对应位置展示。
-
使用Stack层叠Web和searchBuilder组件。
build() { Stack() { Web({ src: $rawfile("nativeembed_view.html"), controller: this.browserTabController }) .backgroundColor('#F1F3F5') .zoomAccess(false)// 不允许执行缩放 .onPageEnd(() => { ... // 里面放下一步的内容 ... }) if (this.isWebInit) { Column() { // 由于需要根据Web实际加载的尺寸进行展示,需要等Web初始化后获取宽高,之后层叠到Web上 searchBuilder({ width: this.searchWidth, height: this.searchHeight }) } .zIndex(10) } } .alignContent(Alignment.Top) }
-
用Web加载nativeembed_view.html文件,在加载完成后的onPageEnd回调中,获取Web侧预留的Embed元素大小,并通过px2vp方法转换为组件大小。
// 从web侧获取组件大小 this.browserTabController.runJavaScript( 'getEmbedSize()', (error, result) => { if (result) { interface EmbedSize { width: number, height: number } let embedSize = JSON.parse(result) as EmbedSize; this.searchWidth = px2vp(embedSize.width); this.searchHeight = px2vp(embedSize.height); this.isWebInit = true; } });
-
获取到步骤2的尺寸之后,传入searchBuilder中,通过显隐控制展示SearchComponent组件。
在上述的方案中,实现方法非常简单。但是这只是限于底层H5网页比较简单,不会滚动的情况。如果H5页面可以上下滑动或者放大缩小比较复杂,此方案就会出现问题,就会发现原生组件是很难去定位,很难跟随H5页面一起滚动。而且在性能上,Web是整体渲染的,即使被原生组件遮住的部分也会消耗性能。于是我们可以通过同层渲染来完美解决这个问题,方案如下:
方案三:同层渲染实现
同层渲染简单来说就是,底层使用空白的H5页面,用Embed标签进行占位,原生使用NodeContainer来站位,最后将Web侧的surfaceId和原生组件绑定,渲染在NodeContainer上。详细的步骤可以参考前面概述中的链接,这里给出一些大致步骤。
- 用Stack组件层叠NodeContainer和Web组件,并开启enableNativeEmbedMode模式。
build() { Stack() { NodeContainer(this.searchNodeController) // web组件加载本地nativeembed_view.html页面 Web({ src: $rawfile("nativeembed_view.html"), controller: this.browserTabController }) .backgroundColor('#F1F3F5') .zoomAccess(false)// 不允许执行缩放 .enableNativeEmbedMode(true) // 开启同层渲染模式 } }
- 因为要使用NodeContainer,所以封装一个继承自NodeController的类SearchNodeController。
type Node = BuilderNode<[Params]> | undefined | null; /** * 用于控制和反馈对应的NodeContainer上的节点的行为,需要与NodeContainer一起使用 */ class SearchNodeController extends NodeController { private surfaceId: string = ""; // 当前的surfaceId private renderType: NodeRenderType = NodeRenderType.RENDER_TYPE_DISPLAY; // 渲染模式 private componentWidth: number = 0; // 原生组件宽 private componentHeight: number = 0; // 原生组件高 private nodeMap: Map<string, Node> = new Map<string, Node>(); // 存放与surfaceId关联的BuilderNode /** * 设置surfaceId等渲染选项 */ setRenderOption(params: NodeControllerParams): void { this.surfaceId = params.surfaceId; this.renderType = params.renderType; this.componentWidth = params.width; this.componentHeight = params.height; } /** * 在对应NodeContainer创建的时候调用、或者通过rebuild方法调用刷新 */ makeNode(uiContext: UIContext): FrameNode | null { if (!this.surfaceId) { // 当前没有surfaceId时直接返回null return null; } let getNode: Node = this.nodeMap.get(this.surfaceId); if (getNode) { // 根据surfaceId获取BuilderNode return getNode.getFrameNode(); } else { // 没有获取到则创建一个BuilderNode并与nodeMap关联后返回 let newNode: Node = new BuilderNode(uiContext, { surfaceId: this.surfaceId, type: this.renderType }) newNode.build(wrapBuilder(searchBuilder), { width: this.componentWidth, height: this.componentHeight }); this.nodeMap.set(this.surfaceId, newNode); return newNode.getFrameNode(); } } /** * 将触摸事件派发到rootNode创建出的FrameNode上 */ postEvent(event: TouchEvent | undefined): boolean { if (!this.surfaceId) { return false; } else { let getNode: Node = this.nodeMap.get(this.surfaceId); return getNode?.postTouchEvent(event) as boolean; } } }
- 使用Web加载nativeembed_view.html文件,web解析到Embed标签后,通过onNativeEmbedLifecycleChange接口上报Embed标签创建消息通知到应用侧。
Web({ src: $rawfile("nativeembed_view.html"), controller: this.browserTabController }) .backgroundColor('#F1F3F5') .zoomAccess(false)// 不允许执行缩放 .enableNativeEmbedMode(true) // 开启同层渲染模式 .onNativeEmbedLifecycleChange((embed) => { ... // 此处进行下一步 ... }) .onNativeEmbedGestureEvent((touch) => { // 获取同层渲染组件触摸事件信息 this.searchNodeController.postEvent(touch.touchEvent); })
- 在步骤3的回调内,根据embed.status,将配置传入searchNodeController后,执行rebuild方法重新触发其makeNode方法。
if (embed.status === NativeEmbedStatus.CREATE) { // 获取embed标签的surfaceId等信息,传入searchNodeController this.searchNodeController.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) }); } this.searchNodeController.rebuild();
- makeNode方法触发后,NodeContainer组件获取到BuilderNode对象,页面出现原生组件。
- Embed标签大小变化是onNativeEmbedLifecycleChange接口上报Embed标签更新消息。
性能收益对比
本节分别以Navigation页面跳转到Web页面和列表滑动两个具体的场景,抓取Trace图进行分析。
页面启动场景
在此场景,下面的Trace图上的红线处Web页面加载完成,蓝线处原生组件加载显示出来。
方案一:使用H5加载
图二:H5的Trace图
H5的分析:
- 在应用侧,情况比较特殊,因为H5页面是在web侧渲染,所以app侧只有开始加载web之前的js处理阶段,在PageEnd后应用侧没有什么处理。
- 在render_service侧,每一帧ReceiveVsync的耗时无明显变化。
方案二:使用非同层渲染加载
图三:非同层渲染的Trace图
非同层渲染的分析:
- 在应用侧,红蓝线之间为测量和计算布局,图片加载被延后到了蓝线之外。
- 在render_service侧,蓝线之后每一帧ReceiveVsync的耗时大幅增加。
图四:非同层渲染情况下的单帧放大图
从图四可以明显的看到,其中的RSUniRender::Process耗时比起其他帧大幅增加,说明是应用侧组件层叠导致render_service侧的绘制任务过重。
方案三:使用同层渲染加载
图五:同层渲染的Trace图
同层渲染的分析:
- 在应用侧,红蓝线之间由于NodeContainer的原因,组件布局的测量和绘制划分成了两部分,同时将图片加载提前到了红蓝线之间。
- 在render_service侧,每一帧ReceiveVsync的耗时无明显变化。
总结
下表为各种方法完成原生组件加载(蓝线)前后几帧render_service侧的耗时对比(-1为完成前一帧,1为完成后一帧,以此类推):
非同层渲染 | 同层渲染 | |
---|---|---|
-2 | 3ms 682μs 292ns | 3ms 561μs 979ns |
-1 | 3ms 796μs 355ns | 3ms 866μs 145ns |
1 | 6ms 741μs 146ns | 4ms 192μs 187ns |
2 | 7ms 974μs 479ns | 3ms 439μs 584ns |
3 | 10ms 543μs 750ns | 3ms 350μs 1ns |
4 | 4ms 706μs 250ns | 3ms 573μs 958ns |
平均 | 6ms 240μs 712ns | 3ms 663μs 975ns |
从此表格可以看出,非同层渲染会导致render_service侧每帧耗时大幅提升,同层渲染相比起非同层渲染,并不影响render_service侧的每帧耗时。
列表滑动场景
在此场景下,由于纯H5实现的Web端由于帧率计算不一样,所以第二个场景不考虑纯H5的情况,对比同层渲染和非同层渲染的每一帧的结构如下所示:
方案一:非同层渲染
图六:非同层渲染滑动时单帧图
方案二:同层渲染
图七:同层渲染滑动时单帧图
上述两张图经过对比也可以发现,render_service每一帧的耗时大幅增加,其中的RSUniRender::Process耗时也大幅增加,结论和上述保持一致,再次验证了同样的结果。
总结
通过上述的分析,可以得出下表的结论。
H5 | 非同层渲染 | 同层渲染 | |
---|---|---|---|
体验 | 低于原生 | 原生体验 | 原生体验 |
性能 | 低 | 中 | 高 |
功能 | 低于原生 | 完整原生功能 | 完整原生功能 |
在Web中渲染原生组件时,采用同层渲染方式比起非同层渲染可以降低绘制任务,提升了性能。同时使用同层渲染可以实现更多功能,比如根据尺寸调整组件大小等功能,从而避免繁琐操作。
最后
有很多小伙伴不知道学习哪些鸿蒙开发技术?不知道需要重点掌握哪些鸿蒙应用开发知识点?而且学习时频繁踩坑,最终浪费大量时间。所以有一份实用的鸿蒙(HarmonyOS NEXT)资料用来跟着学习是非常有必要的。
这份鸿蒙(HarmonyOS NEXT)资料包含了鸿蒙开发必掌握的核心知识要点,内容包含了(ArkTS、ArkUI开发组件、Stage模型、多端部署、分布式应用开发、音频、视频、WebGL、OpenHarmony多媒体技术、Napi组件、OpenHarmony内核、Harmony南向开发、鸿蒙项目实战等等)鸿蒙(HarmonyOS NEXT)技术知识点。
希望这一份鸿蒙学习资料能够给大家带来帮助,有需要的小伙伴自行领取,限时开源,先到先得~无套路领取!!
如果你是一名有经验的资深Android移动开发、Java开发、前端开发、对鸿蒙感兴趣以及转行人员,可以直接领取这份资料
获取这份完整版高清学习路线,请点击→纯血版全套鸿蒙HarmonyOS学习资料
鸿蒙(HarmonyOS NEXT)最新学习路线
-
HarmonOS基础技能
- HarmonOS就业必备技能
- HarmonOS多媒体技术
- 鸿蒙NaPi组件进阶
- HarmonOS高级技能
- 初识HarmonOS内核
- 实战就业级设备开发
有了路线图,怎么能没有学习资料呢,小编也准备了一份联合鸿蒙官方发布笔记整理收纳的一套系统性的鸿蒙(OpenHarmony )学习手册(共计1236页)与鸿蒙(OpenHarmony )开发入门教学视频,内容包含:ArkTS、ArkUI、Web开发、应用模型、资源分类…等知识点。
获取以上完整版高清学习路线,请点击→纯血版全套鸿蒙HarmonyOS学习资料
《鸿蒙 (OpenHarmony)开发入门教学视频》
《鸿蒙生态应用开发V2.0白皮书》
《鸿蒙 (OpenHarmony)开发基础到实战手册》
OpenHarmony北向、南向开发环境搭建
《鸿蒙开发基础》
- ArkTS语言
- 安装DevEco Studio
- 运用你的第一个ArkTS应用
- ArkUI声明式UI开发
- .……
《鸿蒙开发进阶》
- Stage模型入门
- 网络管理
- 数据管理
- 电话服务
- 分布式应用开发
- 通知与窗口管理
- 多媒体技术
- 安全技能
- 任务管理
- WebGL
- 国际化开发
- 应用测试
- DFX面向未来设计
- 鸿蒙系统移植和裁剪定制
- ……
《鸿蒙进阶实战》
- ArkTS实践
- UIAbility应用
- 网络案例
- ……
获取以上完整鸿蒙HarmonyOS学习资料,请点击→纯血版全套鸿蒙HarmonyOS学习资料
总结
总的来说,华为鸿蒙不再兼容安卓,对中年程序员来说是一个挑战,也是一个机会。只有积极应对变化,不断学习和提升自己,他们才能在这个变革的时代中立于不败之地。