JSBridge
在 iOS 和 Android 系统上运行 JavaScript 并不是一件难事儿,但是对于一个真正意义上的跨平台应用来说,还需要做到H5(即 WebView 容器)和原生平台的交互,于是 JSBridge 技术就诞生了。
JS和客户端通讯的基本流程就是JS访问客户端能力,传递参数和回调函数,然后客户端通过回调函数返回内容。
起源
主要原因JavaScript主要载体Web是当前世界上的最易编写、最易维护、最易部署的UI构建方式。工程师可以用很简单的HTML标签和CSS样式快速的构建出一个页面,并且在服务端部署后,用户不需要主动更新,就能看到最新的UI展现
因此,开发维护成本和更新成本较低的Web技术称为混合开发中几乎不二的选择,而作为Web技术逻辑核心的JavaScript也理所应当肩负起与其他技术桥接的责任,并且作为移动端不可缺少的一部分,任何一个移动操作系统中都包含可运行JavaScript的容器,例如Webview和JSCore。所以,运行JavaScript不用像运行其他语言时,要额外添加运行环境。因此,基于上面种种原因,JSBridge应运而生。
实现原理
Web端和Native可以类比于Client/Server模式,Web端调用原生接口时就如同Client向Server端发送一个请求类似,JSB在此充当类似于HTTP协议的角色,实现JSBridge主要是两点:
- 将Native端原生接口封装成JavaScript接口
- 将Web端JavaScript接口封装成原生接口
Native调用JS
首先来说Native端调用Web端,这个比较简单,JavaScript作为解释性语言,最大的一个特性就是可以随时随地地通过解释器执行一段JS代码,所以可以将拼接的JavaScript代码字符串,传入JS解析器执行就可以,JS解析器在这里就是webView。
Android 4.4之前只能用loadUrl来实现,并且无法执行回调:
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.loadUrl("javascript: " + jsCode);
Android 4.4之后提供了evaluateJavascript来执行JS代码,并且可以获取返回值执行回调:
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.evaluateJavascript(jsCode, new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
}
});
iOS的UIWebView使用stringByEvaluatingJavaScriptFromString:
NSString *jsStr = @"执行的JS代码";
[webView stringByEvaluatingJavaScriptFromString:jsStr];
iOS的WKWebView使用evaluateJavaScript:
[webView evaluateJavaScript:@"执行的JS代码" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
}];
JS调用Native
Web调用Native端主要有三种方式:
- 拦截 Scheme
- 弹窗拦截
- 注入 JS 上下文
拦截Webview请求的URL Schema
前端和客户端通信传递参数和callback的这种方式,其本质和前端和后端发送http请求类似,前后端通信依赖于http协议,那么前端和客户端通信自然而然也会有一种协议来约定,这种协议称为schema协议。
URL Schema是类URL的一种请求格式,格式如下:
<protocol>://<host>/<path>?<qeury>#fragment
我们可以自定义JSBridge通信的URL Schema,比如:jsbridge://showToast?text=hello
Native加载WebView之后,Web发送的所有请求都会经过WebView组件,所以Native可以重写WebView里的方法,从来拦截Web发起的请求,我们对请求的格式进行判断:
- 如果符合我们自定义的URL Schema,对URL进行解析,拿到相关操作、操作,进而调用原生Native的方法
- 如果不符合我们自定义的URL Schema,我们直接转发,请求真正的服务
Web发送URL请求的方法有这么几种:
- a标签
- location.href
- 使用iframe.src
- 发送ajax请求
这些方法,a标签需要用户操作,location.href可能会引起页面的跳转丢失调用,发送ajax请求Android没有相应的拦截方法,所以使用iframe.src是经常会使用的方案:
- 安卓提供了shouldOverrideUrlLoading方法拦截
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.startsWith("taobao")) {
// 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法
return true;
}
}
- UIWebView使用shouldStartLoadWithRequest,WKWebView则使用decidePolicyForNavigationAction
- (BOOL)shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(BPWebViewNavigationType)navigationType
{
if (xxx) {
// 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法
return NO;
}
return [super shouldStartLoadWithRequest:request navigationType:navigationType];
}
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(nonnull WKNavigationAction *)navigationAction decisionHandler:(nonnull void (^)(WKNavigationActionPolicy))decisionHandler
{
if(xxx) {
// 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法
BLOCK_EXEC(decisionHandler, WKNavigationActionPolicyCancel);
} else {
BLOCK_EXEC(decisionHandler, WKNavigationActionPolicyAllow);
}
[self.webView.URLLoader webView:webView decidedPolicy:policy forNavigationAction:navigationAction];
}
这种方式从早期就存在,兼容性很好,但是由于是基于URL的方式,长度受到限制而且不太直观,数据格式有限制,而且建立请求有时间耗时。
拦截的应用主要有:
- 通过小程序,利用Scheme协议打开原生app
- H5页面点击锚点,根据锚点具体跳转路径APP端跳转具体的页面
- APP端收到服务器端下发的PUSH通知栏消息,根据消息的点击跳转路径跳转相关页面
- APP根据URL跳转到另外一个APP指定页面
- 通过短信息中的url打开原生APP
目前不建议只使用拦截 URL Scheme 解析参数的形式,主要存在几个问题。
- 连续续调用 location.href 会出现消息丢失,因为 WebView 限制了连续跳转,会过滤掉后续的请求。
- URL 会有长度限制,一旦过长就会出现信息丢失 因此,类似 WebViewJavaScriptBridge 这类库,就结合了注入 API 的形式一起使用,这也是我们这边目前使用的方式,后面会介绍一下。
弹窗拦截
这种方式是利用弹窗会触发 WebView 相应事件来拦截的。
Android端一般是在 setWebChromeClient 里面的 onJsAlert、onJsConfirm、onJsPrompt 方法拦截并解析他们传来的消息。
// 拦截 Prompt
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
if (xxx) {
// 解析 message 的值,调用对应方法
}
return super.onJsPrompt(view, url, message, defaultValue, result);
}
// 拦截 Confirm
@Override
public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {
return super.onJsConfirm(view, url, message, result);
}
// 拦截 Alert
@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
return super.onJsAlert(view, url, message, result);
}
由于拦截上述方法会对性能造成一定影响,因此需要选择使用频率较低的方法,而在Android中,相比其它几个方法,几乎不会使用到
iOS 由于安全机制, WKWebView 对 alert、confirm、prompt 等方法做了拦截,如果通过此方式进行 Native 与 JS 交互,需要实现 WKWebView 的三个 WKUIDelegate 代理方法。
iOS中我们以 WKWebView 为例:
+ (void)webViewRunJavaScriptTextInputPanelWithPrompt:(NSString *)prompt
defaultText:(NSString *)defaultText
completionHandler:(void (^)(NSString * _Nullable))completionHandler
{
/** Triggered by JS:
var person = prompt("Please enter your name", "Harry Potter");
if (person == null || person == "") {
txt = "User cancelled the prompt.";
} else {
txt = "Hello " + person + "! How are you today?";
}
*/
if (xxx) {
BLOCK_EXEC(completionHandler, text);
} else {
BLOCK_EXEC(completionHandler, nil);
}
}
这种方式的缺点就是在 iOS 上面 UIWebView 不支持,但是 WKWebView 又有更好的 scriptMessageHandler,比较尴尬。
向Webview中注入JS API
这个方法会通过webView提供的接口,App将Native的相关接口注入到JS的Context(window)的对象中,一般来说这个对象内的方法名与Native相关方法名是相同的,Web端就可以直接在全局window下使用这个暴露的全局JS对象,进而调用原生端的方法。
这个过程会更加简单直观,不过有兼容性问题,大多数情况下都会使用这种方式
Android(4.2+)提供了addJavascriptInterface注入:
// 注入全局JS对象
webView.addJavascriptInterface(new NativeBridge(this), "NativeBridge");
class NativeBridge {
private Context ctx;
NativeBridge(Context ctx) {
this.ctx = ctx;
}
// 增加JS调用接口
@JavascriptInterface
public void showNativeDialog(String text) {
new AlertDialog.Builder(ctx).setMessage(text).create().show();
}
}
在Web端直接调用这个方法即可:
window.NativeBridge.showNativeDialog('hello');
iOS的UIWebView提供了JavaSciptCore,可以实现执行 JS 以及注入 Native 对象等功能。这种方式不依赖拦截,主要是通过 WebView 向 JS 的上下文注入对象和方法,可以让 JS 直接调用原生。
// 获取 JS 上下文
JSContext *context = [webview valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
// 注入 Block
context[@"callHandler"] = ^(JSValue * data) {
// 处理调用方法和参数
// 调用 Native 功能
// 回调 JS Callback
}
Web端调用
window.callHandler(JSON.stringify({
type: "scan",
data: "",
callback: function(data) {
}
}));
这种方式的牛逼之处在于,JS 调用是同步的,可以立马拿到返回值。
我们也不再需要像拦截方式一样,每次传值都要把对象做 JSON.stringify,可以直接传 JSON 过去,也支持直接传一个函数过去。
iOS的WKWebView 里面通过 addScriptMessageHandler 来注入对象到 JS 上下文,可以在 WebView 销毁的时候调用 removeScriptMessageHandler 来销毁这个对象。
前端调用注入的原生方法之后,可以通过 didReceiveScriptMessage 来接收前端传过来的参数。
WKWebView *wkWebView = [[WKWebView alloc] init];
WKWebViewConfiguration *configuration = wkWebView.configuration;
WKUserContentController *userCC = configuration.userContentController;
// 注入对象
[userCC addScriptMessageHandler:self name:@"nativeObj"];
// 清除对象
[userCC removeScriptMessageHandler:self name:@"nativeObj"];
// 客户端处理前端调用
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
// 获取前端传来的参数
NSDictionary *msgBody = message.body;
// 如果是 nativeObj 就进行相应处理
if (![message.name isEqualToString:@"nativeObj"]) {
//
return;
}
}
使用 addScriptMessageHandler 注入的对象实际上只有一个 postMessage 方法,无法调用更多自定义方法。前端的调用方式如下:
window.webkit.messageHandlers.nativeObj.postMessage(data);
需要注意的是,这种方式要求 iOS8 及以上,而且返回不是同步的。和 UIWebView 一样的是,也支持直接传 JSON 对象,不需要 stringify。
带回调的调用
上面已经说到了Native、Web间双向通信的两种方法,但站在一端而言还是一个单向通信的过程 ,比如站在Web的角度:Web调用Native的方法,Native直接相关操作但无法将结果返回给Web,但实际使用中会经常需要将操作的结果返回,也就是JS回调。
所以在对端操作并返回结果,有输入有输出才是完整的调用,那如何实现呢?
其实基于之前的单向通信就可以实现,我们在一端调用的时候在参数中加一个callbackId标记对应的回调,对端接收到调用请求后,进行实际操作,如果带有callbackId,对端再进行一次调用,将结果、callbackId回传回来,这端根据callbackId匹配相应的回调,将结果传入执行就可以了。
可以看到实际上还是通过两次单项通信实现的。
以Android,在Web端实现带有回调的JSB调用为例:
// Web端代码:
<body>
<div>
<button id="showBtn">获取Native输入,以Web弹窗展现</button>
</div>
</body>
<script>
let id = 1;
// 根据id保存callback
const callbackMap = {};
// 使用JSSDK封装调用与Native通信的事件,避免过多的污染全局环境
window.JSSDK = {
// 获取Native端输入框value,带有回调
getNativeEditTextValue(callback) {
const callbackId = id++;
callbackMap[callbackId] = callback;
// 调用JSB方法,并将callbackId传入
window.NativeBridge.getNativeEditTextValue(callbackId);
},
// 接收Native端传来的callbackId
receiveMessage(callbackId, value) {
if (callbackMap[callbackId]) {
// 根据ID匹配callback,并执行
callbackMap[callbackId](value);
}
}
};
const showBtn = document.querySelector('#showBtn');
// 绑定按钮事件
showBtn.addEventListener('click', e => {
// 通过JSSDK调用,将回调函数传入
window.JSSDK.getNativeEditTextValue(value => window.alert('Natvie输入值:' + value));
});
</script>
// Android端代码
webView.addJavascriptInterface(new NativeBridge(this), "NativeBridge");
class NativeBridge {
private Context ctx;
NativeBridge(Context ctx) {
this.ctx = ctx;
}
// 获取Native端输入值
@JavascriptInterface
public void getNativeEditTextValue(int callbackId) {
MainActivity mainActivity = (MainActivity)ctx;
// 获取Native端输入框的value
String value = mainActivity.editText.getText().toString();
// 需要注入在Web执行的JS代码
String jsCode = String.format("window.JSSDK.receiveMessage(%s, '%s')", callbackId, value);
// 在UI线程中执行
mainActivity.runOnUiThread(new Runnable() {
@Override
public void run() {
mainActivity.webView.evaluateJavascript(jsCode, null);
}
});
}
}
以上代码简单实现了一个demo,在Web端点击按钮,会获取Native端输入框的值,并将值以Web端弹窗展现,这样就实现了Web->Native带有回调的JSB调用,同理Native->Web也是同样的逻辑,不同的只是将callback保存在Native端罢了,在此就不详细论述了。
JSBridge 如何引用
对于 JSBridge 的引用,常用有两种方式,各有利弊。
由 Native 端进行注入
注入方式和 Native 调用 JavaScript 类似,直接执行桥的全部代码。
它的优点在于:桥的版本很容易与 Native 保持一致,Native 端不用对不同版本的 JSBridge 进行兼容;
它的缺点是:注入时机不确定,需要实现注入失败后重试的机制,保证注入的成功率,同时 JavaScript 端在调用接口时,需要优先判断 JSBridge 是否已经注入成功。
由 JavaScript 端引用
与由 Native 端注入正好相反,它的优点在于:JavaScript 端可以确定 JSBridge 的存在,直接调用即可;
缺点是:如果桥的实现方式有更改,JSBridge 需要兼容多版本的 Native Bridge 或者 Native Bridge 兼容多版本的 JSBridge。
开源的JSBridge
可以看到,实现一个完整的JSBridge还是挺麻烦的,还需要考虑低端机型的兼容问题、同步异步调用问题,好在已经有开源的JSBridge供我们直接使用了:
- DSBridge,主要通过注入API的形式,DSBridge for Android、DSBridge for IOS
- JsBridge,主要通过拦截URL Schema,JsBridge