一、现有混合方案
Hybrid App,俗称混合应用,即混合了 Native技术 与 Web技术 进行开发的移动应用。现在比较流行的混合方案主要有三种,主要是在UI渲染机制上的不同:
- 基于
WebView UI
的基础方案,市面上大部分主流 App 都有采用,例如微信JS-SDK,通过 JSBridge 完成 H5 与 Native 的双向通讯,从而赋予H5一定程度的原生能力。 - 基于
Native UI
的方案,例如 React-Native、Weex。在赋予 H5 原生API能力的基础上,进一步通过 JSBridge 将js解析成的虚拟节点树(Virtual DOM)传递到 Native 并使用原生渲染。 - 另外还有近期比较流行的
小程序方案
,也是通过更加定制化的 JSBridge,并使用双 WebView 双线程的模式隔离了JS逻辑与UI渲染,形成了特殊的开发模式,加强了 H5 与 Native 混合程度,提高了页面性能及开发体验。
以上的3种方案,其实同样都是基于 JSBridge 完成的通讯层,第2、3种方案,其实可以看做是在方案1的基础上,继续通过不同的新技术进一步提高了应用的混合程度。因此,JSBridge 也是整个混合应用最关键的部分。
二、Hybrid技术原理
Hybrid App的本质,其实是在原生的 App 中,使用 WebView 作为容器直接承载 Web页面。因此,最核心的点就是 Native端 与 H5端 之间的双向通讯层,其实这里也可以理解为我们需要一套 跨语言通讯方案,来完成 Native(Java/Objective-c/...) 与 JavaScript 的通讯。实现的关键,便是作为容器的 WebView,一切的原理都是基于 WebView 的机制。
三、Native 通知 H5 (Native 调用 JS)
由于 webview 作为 H5 的宿主,Native 可以通过 webview 的 API直接执行 Js 代码。
3.1 Android 调 H5
loadUrl
// 即当前webview对象
mWebView = new WebView(this);
mWebView.loadUrl("javascript:方法名('参数需要转为字符串')");
// ui线程中运行
runOnUiThread(new Runnable() {
@Override
public void run() {
mWebView.loadUrl("javascript: 方法名('参数需要转为字符串')");
Toast.makeText(Activity名.this, "调用方法...", Toast.LENGTH_SHORT).show();
}
});
该方法没有系统版本的限制,但是无法获取函数的返回值。需要使用 prompt 方法进行兼容,让 H5端 通过 prompt 进行数据的发送,客户端进行拦截并获取数据。
注意: mWebView.loadUrl("javascript: 方法名('参数需要转为字符串')");
函数需在UI线程运行,因为mWebView为UI控件,会阻塞UI线程。
evaluateJavascript
// 异步执行JS代码,并获取返回值
mWebView.evaluateJavascript("javascript: 方法名('参数需要转为字符串')", new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
// 这里的value即为对应JS方法的返回值
}
});
该方法的弊端是在 >= Android 4.4
版本才能使用。但是可以获得 Js 函数执行的返回值。
3.2 iOS 调 H5
stringByEvaluatingJavaScriptFromString
可以取得JS函数执行的返回值,
但是方法必须是绑定在顶层页面的window上对象,如:window.top.foo 。
// Swift
webview.stringByEvaluatingJavaScriptFromString("方法名(参数)")
// OC
[webView stringByEvaluatingJavaScriptFromString:@"方法名(参数);"];
四、H5 通知 Native(JS 调用 Native)
基于 WebView 的机制和开放的API,实现 JS 调用 Native 有3种常见的方案:
- WebView URL Scheme 跳转拦截
- WebView 中的prompt/console/alert拦截:通常使用prompt,因为这个方法在前端中使用频率低,比较不会出现冲突
- WebView API注入:原理其实就是Native获取JavaScript环境上下文,并直接在上面挂载对象或者方法,使 js 可以直接调用,Android 与 IOS 分别拥有对应的挂载方式
4.1 URL Scheme
第1、2种机制的原理是类似的,都是通过对WebView信息冒泡传递的拦截,从而达到通讯的,接下来我们主要从 “概述-定制协议-拦截协议-参数传递-回调机制” 5个方面详细阐述下第1种方案 -- URL Scheme拦截方案。
4.1.1 概述
URL Scheme 是一种类似于url的链接, 是为了方便 App 之间互相调用设计的。可以用系统的 OpenURI 打开一个类似于 url 的链接(可拼入参数),
然后系统会进行判断,如果是系统的 url scheme,则打开系统应用,
否则找看是否有 App 注册这种 scheme,打开对应App。而本文中混合开发交互的 URL Scheme 则是仿照上述的形式的一种方式。
URL Scheme 适用于所有的系统设备(低版本 Android 和低版本 iOS 都适用)。这个是最广为流传的交互方式,因为在 hybrid 刚出来时,很多低版本都需要兼容,因此几乎都采用这种方式。
URL Scheme 的原理就是在 WebView 中发出的网络请求,客户端都能进行监听和捕获。
H5 通过 iframe 或者 location 触发一个 url scheme
-> Native端捕获到url
-> Native分析其参数属于哪一个功能并执行
-> Native调用H5提供的回调函数将执行结果回调给H5
但是 URL Scheme 毕竟是通过url拦截实现的,在大量数据传输以及效率上都有一些影响。
4.1.2 协议的定制
我们需要制定一套URL Scheme规则,通常我们的请求会带有对应的协议开头,例如常见的 https://xxx.com
或者 file://xxx.jpg
,代表着不同的含义。我们这里可以将协议类型的请求定制为:
xx_protocal://xx_host/xx_path?params=xx
这里有几个需要注意点的是:
(1) xx_protocal://
只是一种规则,可以根据业务进行制定,使其具有含义,例如我们定义 xx_company://
为公司所有App系通用,为通用工具协议:
xx_company://xx_host/getNetwork
而定义 xx_app://
为每个App单独的业务协议。
xx_app://xx_host/clipboard?params=xx
不同的协议头代表着不同的含义,这样便能清楚知道每个协议的适用范围。
(2) 这里不要使用 location.href 发送,通过 location.href 有个问题,就是如果我们连续多次修改 window.location.href 的值,在 Native层 只能接收到最后一次请求,前面的请求都会被忽略掉。,而并发协议其实是非常常见的功能。我们会使用创建 iframe 发送请求的方式。
(3) 通常考虑到安全性,需要在客户端中设置域名白名单或者限制,避免公司内部业务协议被第三方直接调用。
4.1.3 协议的拦截
客户端可以通过 API 对 WebView 发出的请求进行拦截:
- IOS:shouldStartLoadWithRequest
- Android:shouldOverrideUrlLoading
当解析到请求 URL 头为制定的协议时,便不发起对应的资源请求,而是解析参数,并进行相关功能或者方法的调用,完成协议功能的映射。
4.1.4 协议回调
由于协议的本质其实是发送请求,这属于一个异步的过程,因此我们便需要处理对应的回调机制。
定义 window.xx
全局方法回调
// 例子:获取当前网络情况。
/** js code **/
function getNetwork() {
return new Promise(resolve => {
// 定义回调函数
let cb = 'getNetwork';
// 定义scheme格式和对应的参数
let url = 'xx_protocal://xx_host/getNetwork¶ms={}&cb=${cb}';
window[cb] = result => resolve(result);
// 触发 scheme
window.location.href = url;
})
}
getNetwork().then(result => { /* 具体业务代码 */ })
/** Native code **/
network_result = getNetworkFromSystem();
cb = getFromSchemeParams();
// Android
mWebView.loadUrl("javascript: cb(network_result)");
// iOS
[webView stringByEvaluatingJavaScriptFromString:@"cb(network_result);"];
利用 JS 的事件订阅/派发
这里我们会用到 window.addEventListener
和 window.dispatchEvent
这两个方法。
- 发送协议时,通过
window.addEventListener
注册自定义事件,将回调绑定到对应的事件上, 客户端完成对应的功能后,调用window.dispatchEvent
,直接携带 data 触发该协议的自定义事件
// 例子:获取当前网络情况。
/** js code **/
function getNetwork() {
return new Promise(resolve => {
// 自定义事件名
let eventType = 'getNetwork';
// 绑定自定义事件
window.addEventListener(eventType, e => {
resolve(e)
})
// 定义scheme格式和对应的参数
let url = 'xx_protocal://xx_host/getNetwork¶ms={}&eventType=${eventType}';
// 触发 scheme
window.location.href = url;
})
}
getNetwork().then(result => { /* 具体业务代码 */ })
/** Native code **/
network_result = getNetworkFromSystem();
eventType = getFromSchemeParams();
let event = new Event(eventType)
event.data = network_result
// Android
mWebView.loadUrl("javascript: dispatchEvent(event)");
// iOS
[webView stringByEvaluatingJavaScriptFromString:@"dispatchEvent(event);"];
这里有一点需要注意的是,应该避免事件的多次重复绑定,因此当唯一标识重置时,需要 removeEventListener对应的事件。
2.1.5 参数传递方式
由于 WebView 对 URL 会有长度的限制,因此常规的通过 search参数 进行传递的方式便具有一个问题,当需要传递的参数过长时,可能会导致被截断,例如:传递base64或者传递大量数据时。
因此我们需要制定新的参数传递规则,我们使用的是函数调用的方式。这里的原理主要是基于Native 可以直接调用 JS 方法并直接获取函数的返回值。
我们只需要对每条协议标记一个唯一标识,并把参数存入参数池中,到时客户端再通过该唯一标识从参数池中获取对应的参数即可。
4.2 WebView API 注入
在 iOS 中使用 JavaScriptCore
, 其不支持 iOS7 以下。在 Android 中使用 addJavascriptInterface
, 其在4.2以前有风险漏洞。随着手机更新换代,这些低版本系统的手机占比逐渐变小, 所以造成的影响不大。
Android - addJavascriptInterface
首先,原生webview需要先注册可供前端调用的JS函数
WebSettings webSettings = mWebView.getSettings();
// 设置webview允许JS脚本运行
webSettings.setJavaScriptEnabled(true);
// 设置桥接对象
mWebView.addJavascriptInterface(getJSBridge(), "JSBridge");
// Android4.2版本及以上,方法要加上注解@JavascriptInterface,否则会找不到方法。
private Object getJSBridge(){
Object insertObj = new Object(){
@JavascriptInterface
public String foo(){
return "foo";
}
@JavascriptInterface
public String foo2(final String param){
return "foo2:" + param;
}
};
return insertObj;
}
然后H5中即可调用原生中注册的函数
// 调用方法1
window.JSBridge.foo(); // 返回:'foo'
// 调用方法2
window.JSBridge.foo2('test'); // 返回:'foo2:test'
iOS - JavaScriptCore
以OC为例,首先,需要引入JavaScriptCore库。
#import <JavaScriptCore/JavaScriptCore.h>
然后原生需要注册API
//webview加载完毕后设置一些js接口
-(void)webViewDidFinishLoad:(UIWebView *)webView{
[self hideProgress];
[self setJSInterface];
}
-(void)setJSInterface{
JSContext *context =[_wv valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
// 注册名为foo的api方法
context[@"foo"] = ^() {
//获取参数
NSArray *args = [JSContext currentArguments];
NSString *title = [NSString stringWithFormat:@"%@",[args objectAtIndex:0]];
//返回一个值 'foo:'+title
return [NSString stringWithFormat:@"foo:%@", title];
};
}
之后 JS 就可以调用了
// 调用方法,用top是确保调用到最*,因为iframe要用top才能拿到*
window.top.foo('test'); // 返回:'foo:test'
注意:引入官方提供的JavaScriptCore库 (需iOS7及以上),可以将api绑定到JSContext上
,JS 默认通过 window.top.*
(iframe中时需加top)可调用。
iOS7之前,js无法直接调用Native,只能通过 url scheme 方式间接调用
五、探讨 JSBridge 接入方式
接下来,我们来理下代码上需要的资源。实现这套方案,可以分为两个部分:
- JS部分: 在JS环境中注入 bridge 的实现代码,包含了协议的拼装/发送/参数池/回调池等一些基础功能。
- Native部分: 在客户端中 bridge 的功能映射代码,实现了URL拦截与解析/环境信息的注入/通用功能映射等功能。
推荐的做法是,将这两部分一起封装成一个 Native SDK,由客户端统一引入。客户端在初始化一个 WebView 页面时,如果页面地址在白名单中,会直接在 HTML 的头部注入对应的 bridge.js。这样的做法有以下的好处:
- 双方的代码统一维护,避免出现版本分裂的情况。有更新时,只要由客户端更新SDK即可,不会出现版本兼容的问题;
- App的接入十分方便,只需要按文档接入最新版本的SDK,即可直接运行整套Hybrid方案,便于在多个App中快速的落地;
- H5端无需关注,这样有利于将 bridge 开放给第三方页面使用。
这里有一点需要注意的是,H5的调用,一定是需要确保执行在bridge.js 成功注入后。由于客户端的注入行为属于一个附加的异步行为,从H5方很难去捕捉准确的完成时机,因此这里需要通过客户端监听页面完成后,基于事件回调机制通知 H5端,页面中即可通过 监听桥接事件进行初始化。
window.addEventListener('bridgeReady', e => {})
本文主要解析了 Hybrid 的基础原理,只有在了解其最本质的实现原理后,才能对这套方案进行实现以及进一步的优化。后续,我们将基于上面的理论,继续探讨真正可用的代码实现方案。