场景描述
Hybrid应用开发是介于Web应用和原生应用两者之间的应用开发技术,兼具“原生应用良好交互体验”的优势和“Web应用跨平台开发”的优势。其主要原理是由Native通过JSBridge通道提供统一的API,然后用Html/CSS实现界面,JS来写业务逻辑,能够调用系统API,最终的页面在Webview中显示。
Hybrid应用鸿蒙化方案
整体架构
- Ark进程:由ArkTS引擎提供运行时,具备调用系统API的能力。应用启动从Ark进程进入,完成EntryAbility的初始化并创建鸿蒙应用页面。Ark进程可以动态或者静态创建Webview运行时环境,并加载html/css/js资源文件。
- Webview进程:默认支持标准W3C API,对原生侧资源的访问有限制。Webview渲染能力主要由鸿蒙Web组件提供。用户可以通过Web组件的属性配置是否开启同层渲染能力、是否允许执行JavaScript脚本等。
- JSBridge:上述两种进程的通讯机制,允许数据双向流动。Webview进程通过JSBridge通道访问原生拓展API。
方案设计
Hybrid应用鸿蒙化方案主要集中在双端通信JSBridge实现、拓展接口实现,以及基于同层渲染的原生组件实现。JSBridge是前端与原生进行双向通信的桥梁。通过JSBridge,前端应用能访问到原生侧实现的拓展接口,实现更丰富的业务功能。视图层方面,可以使用系统提供的同层渲染能力,把部分性能要求比较高的前端组件改成原生实现,以达到更好的体验效果。下图蓝色背景的方框图展示了上述三点所处的框架位置:
业务实现中的关键点
Hybrid应用鸿蒙化方案主要围绕双端通信、API鸿蒙化、组件鸿蒙化三方面进行开发。双端通信:JS侧使用原生能力的通道,是鸿蒙化的基石;API鸿蒙化:针对JS侧平台相关的API,提供一套鸿蒙版本的实现;组件鸿蒙化:针对Web组件,以同层渲染的方式提供替代组件,以提升组件的性能与交互体验。
双端通信
JSBridge扮演Webview进程与原生ArkUI主进程沟通的桥梁,是一种双向通信的机制。HarmonyOS系统提供Web组件以及@ohos.web.webview等ArkWeb API来进行Web开发。可以通过WebMessagePort以及javaScriptProxy代理的方式实现JSBridge。
- WebMessagePort是一种比较基础的消息发送以及接收机制,支持的消息类型为string和ArrayBuffer,具体业务消息内容的封装和解析需要从零设计,存在上手难、工作量大的特点。
- JavaScriptProxy代理机制注入ArkUI主进程对象(如命名为native)到Webview中,在Webview的window上生成对应代理对象,业务可以直接调用该代理对象的方法,相关的操作将作用到ArkUI主进程的native对象。比如拉起拨打电话界面示例:
build() {
Column() {
// web组件加载本地index.html页面
Web({ src: $rawfile('index.html'), controller: webviewController})
// 将对象注入到web端
.javaScriptProxy({
object: nativeObj,
name: "native",
methodList: ["makePhoneCall"],
controller: this.webviewController
})
}
}
前端可以使用native.makePhoneCall(…) 的方式进行调用。且方法的参数支持基本类型、字典对象、函数等,对于JSBridge的设计提供了便利。
通过对比,javaScriptProxy注入对象的方式构造JSBridge是一个比较好的技术选型。建议JSBridge的实现基于注入机制进行设计,并考虑分层设计来提高其通用性和灵活性,下图展示一种分层设计思路:
- 通信层:对上层屏蔽具体的通信机制,主要负责JS端和原生端数据的传递,但不解析数据的业务含义,不关注传递的数据内容。数据可以序列化为字符串进行传递或者以object对象进行传递。使用javaScriptProxy代理机制实现的通信层代码示例如下:
build() {
Web({ src: this.webUrl, controller: this.webController })
.javaScriptProxy({
object: {
nativeMethod: (channelType: string, objectJson: string) => {
return ChannelInstance.call(channelType, objectJson) as object
},
}, name: 'JSBridge', methodList: ['nativeMethod'], controller: this.taroWebController.webController,
})
}
- 通道层(Channel):允许注册多种方法层通道。该层的JS侧实现负责把方法层的API信息对象(包含名称、参数、返回值类型等信息)打包成通信层识别的信息数据,交给通信层传递到原生侧。原生侧的实现包含两个主要功能,一个是把信息数据解包出API的信息,并交给原生侧的方法层调用具体的API;另外一个功能就是执行jsCall,原生侧通过WebviewController .runJavaScript方法在执行JS侧的回调函数。
在JS侧,nativeCall方法提供打包转换能力。如下面示例,把信息数据序列化成字符串后传递给通信层:
nativeCall: function (channelType: string, object: any) {
var objectJson = JSON.stringify(object);
var resultJson = window.JSBridge && window.JSBridge.nativeMethod(channelType, objectJson);
return resultJson && JSON.parse(resultJson);
}
在原生侧,通道层call方法反序列化字符串生成信息对象,并调用对应channelType的处理方法处理信息:
call(channelType: string, objectJson: string): any{
const fun = this.channelListeners.get(channelType);
if(!fun) {
return undefined;
}
const result = fun(JSON.parse(objectJson));
return JSON.stringify(result);
}
- 方法层(MethodChannel):可以针对一类API格式封装成一种MethodChannel。同种MethodChannel的API具备一致的参数规范、返回值规范,比如小程序API规范,这样便于把API的调用信息封装成结构化的信息对象,供给通道层进行传递。
JSBridge的设计是否合理关系到应用的性能,开发者也可以考虑是否需要批量缓存请求再统一发送请求来减少请求次数,或者把不变的请求结果进行缓存等等。
API鸿蒙化
H5业务设计中除了使用W3C API外,还可以使用原生侧API拓展来访问设备。如下图所示:
原生高阶API是对系统API的一层封装,实现更符合业务要求的接口。拓展API的规范设计具有较大的灵活性,建议对API的参数,返回值类型格式进行限制,使用基本类型或者简单的字典对象,尽量避免使用复杂的类型的参数或返回值,可以参考比较成熟的小程序框架,其规范格式可以分成三种类型:
- func(paramObj), 其中 paramObj包含基本类型的数据属性以及success/fail/complete回调函数。
- on/offFunc(callback), 注册和移除监听函数。
- getXxManager(): obj, 获取某类功能的全局单例管理器,如文件管理器。管理器的方法也遵守上述两点规范。
设计过程中可以把API都汇聚到一个对象作为属性字段存在,方便在切面视角增加统一的参数、返回值加工处理,拦截处理。示意图如下:
组件鸿蒙化
鸿蒙系统提供同层渲染能力把原生组件直接渲染到WebView层级,从而获得更大的灵活性以及性能上获得更好表现。开发者可通过Web组件同层渲染相关属性来进行控制:enableNativeEmbedMode开关控制;onNativeEmbedLifecycleChange处理同层渲染生命周期:CREATE/UPDATE/DESTROY;onNativeEmbedGestureEvent处理交互事件。同层渲染功能要求前端页面文件中显式使用embed标签,并且embed标签内type必须以“native/”开头。使用Vue等框架可以方便地进一步封装embed标签生成自定义组件,并增加更多属性、事件和方法,通过JSBridge与原生侧进行同步。在原生侧,对应地需要自定义实现一个原生组件或者使用系统内置组件,通过NodeContainer组件进行动态挂载。同层渲染的原理如下:
开发角度:前端页面开发者使用标签来表示使用原生组件;应用开发者使用NodeContainer关联离屏节点树,使用makeNode接口在H5页面上渲染出组件。
离屏节点动态上下树:
1)开发者初始构建一个NodeContainer对象表示一个空的占位符。NodeContainer里面内容为空时,在初始化的时候大小为0,不参与布局。
2)NodeController持有buildnode对象,通过makeNode接口将buildnode对象 返回给NodeContainer,来实现动态上树。
3) NodeController里面rebuild方法,触发NodeContainer重新调用makeNode接口。 makeNode接口若返回空,则实现动态下树。
使用Vue框架结合embed标签示例:
<template>
<div>
<embed
id= "embedId"
type="native/lottie"
:width="300" :height="150" />
</div>
</template>
<script>
export default {
name: "HlLottie",
props: {
sourceJson: {
type: String,
required: false,
},
},
// ...
methods: {
play() { /*..*/ },
},
}
</script>
在原生侧,可以扩展NodeController来统一管理同层渲染节点。其makeNode接口实现示例如下:
makeNode(uiContext: UIContext): FrameNode | null {
if (this.rootNode === null) {
// 动态下树
return null;
}
if (!this.embedType || !this.componentId) {
return null;
}
// 未定义根节点则创建
if (!this.rootNode) {
this.rootNode = new BuilderNode<[Object]>(uiContext, { surfaceId: this.surfaceId, type: this.renderType });
if (nativeComponentBuilderMap.get(this.embedType)) {
const wrapBuilder: WrappedBuilder<[Object]> = new WrappedBuilder(nativeComponentBuilderMap.get(this.embedType) as (...args: Object[]) => void);
this.rootNode.build(wrapBuilder, sameLayerArgsMap.get(this.componentId));
} else {
return null;
}
}
// 动态上树
return this.rootNode.getFrameNode();
}
实现中可以使用map容器把embedType和离屏节点的builder函数进行关联,当makeNode执行时,取出embedType对应的builder函数来创建rootNode节点,最后把rootNode节点关联的FrameNode返回,达到离屏节点动态上树、H5渲染出原生组件的效果。