JSBridge原理与实现分析
前言:本文讲述JSBridge相关原理以及实现方面的知识,主内容整理自网络。
Hybrid App简介
hybrid app是一种将native app和web app结合起来的一项app开发策略。
众所周知,native app开发周期长,审核严格,同时更新较为繁琐,但用户体验非常好;而web app开发周期比较短,版本更新迭代都非常快速,但是用户体验较差。它们各自拥有不同的适用场景,那么能否根据具体的业务场景,在app开发的过程中将二者结合起来,发挥各自的长处呢?有!那就是hybrid混合app。
一般hybrid app的体验效果介于上述两类app之间,其原理是将android和iOS app中都有的WebView(Android为WebView,iOS为UIWebView)嵌入到app页面中并像浏览器一样加载网页。如果将业务页面写成网页形式,通过WebView加载到app中,就可以将web app和native app混合起来,成为hybrid app。
但是仅仅嵌入WebView并加载特定的页面并不能完全满足所有的业务场景,有些业务场景需要使用原生能力,比如获取定位、调用原生弹窗等。有时app也需要通知业务页面作出指定的行为,比如重定向、修改隔离配置等。这时候就需要js和native之间可以通信(相互调用),即业务页面的js脚本可以调用原生代码去执行相关操作,原生代码可以调用js代码去操作业务逻辑。
下面将分析js与native几种通信策略的实现原理以及兼容等问题。
Js与Native通信
JavaScript 调用 Native
JavaScript 调用 Native 的方式,主要有两种:注入 API 和 拦截 URL SCHEME。
注入API
注入 API 方式的主要原理是,通过 WebView 提供的接口,向 JavaScript 的 Context(window)中注入对象或者方法,让 JavaScript 调用时,直接执行相应的 Native 代码逻辑,达到 JavaScript 调用 Native 的目的。
iOS UIWebView实现:
JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
context[@"postBridgeMessage"] = ^(NSArray<NSArray *> *calls) {
// Native 逻辑
};
前端调用方式:
window.postBridgeMessage(message);
iOS WKWebView实现:
@interface WKWebVIewVC ()<WKScriptMessageHandler>
@implementation WKWebVIewVC
- (void)viewDidLoad {
[super viewDidLoad];
WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init];
configuration.userContentController = [[WKUserContentController alloc] init];
WKUserContentController *userCC = configuration.userContentController;
// 注入对象,前端调用其方法时,Native 可以捕获到
[userCC addScriptMessageHandler:self name:@"nativeBridge"];
WKWebView wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];
// TODO 显示 WebView
}
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"nativeBridge"]) {
NSLog(@"前端传递的数据 %@: ",message.body);
// Native 逻辑
}
}
前端调用方式:
window.webkit.messageHandlers.nativeBridge.postMessage(message);
Android addJavascriptInterface 实现:
public class JavaScriptInterfaceDemoActivity extends Activity {
private WebView Wv;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Wv = (WebView)findViewById(R.id.webView);
final JavaScriptInterface myJavaScriptInterface = new JavaScriptInterface(this);
Wv.getSettings().setJavaScriptEnabled(true);
Wv.addJavascriptInterface(myJavaScriptInterface, "nativeBridge");
// TODO 显示 WebView
}
public class JavaScriptInterface {
Context mContext;
JavaScriptInterface(Context c) {
mContext = c;
}
@JavascriptInterface
public void postMessage(String webMessage){
// Native 逻辑
}
}
}
前端调用方式:
window.nativeBridge.postMessage(message);
Android 拦截Prompt 实现:
该实现并不属于注入API类型,它的原理是通过js调用prompt(或alert\confirm)方法触发WebView相关事件,并将调用的接口和参数信息存入事件对象,android端通过重写事件响应函数,获取到相关的接口和参数信息,并执行调用操作。
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
return super.onJsPrompt(view, url, message, defaultValue, result);
}
前端调用方式:
window.prompt(message);
注意:在 4.2 之前,Android 注入 JavaScript 对象的接口是 addJavascriptInterface,但是这个接口有漏洞,可以被不法分子利用,危害用户的安全,因此在 4.2 中引入新的接口 @JavascriptInterface(上面代码中使用的)来替代这个接口,解决安全问题。所以 Android 注入js对象的方式是有兼容性问题的,Android 4.2以前多采用的是拦截 Prompt 实现的。
拦截 URL SCHEME
URL SCHEME:URL SCHEME是一种类似于url的链接,是为了方便app直接互相调用设计的,形式和普通的 url 近似,主要区别是 protocol 和 host 一般是自定义的,例如: qunarhy://hy/url?url=http://ymfe.tech,protocol 是 qunarhy,host 则是 hy。
拦截 URL SCHEME 的主要流程是:Web 端通过某种方式(例如 iframe.src)发送 URL Scheme 请求,之后 Native 拦截到请求并根据 URL SCHEME(包括所带的参数)进行相关操作。
前端发送 URL Scheme:
//创建隐藏iframe过程
var messagingIframe = document.createElement('iframe');
messagingIframe.style.display = 'none';
document.documentElement.appendChild(messagingIframe);
//触发scheme
messagingIframe.src = uri;
Android shouldOverrideUrlLoading 拦截 Scheme:
public boolean shouldOverrideUrlLoading(WebView view, String url){
//如果返回false,则WebView处理链接url,如果返回true,代表WebView根据程序来执行url
return true;
}
iOS中,UIWebView有个特性:在UIWebView内发起的所有网络请求,都可以通过delegate函数在Native层得到通知。这样,我们可以在webview中捕获url scheme的触发(原理是利用 shouldStartLoadWithRequest)。
iOS shouldStartLoadWithRequest 拦截 Scheme:
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
NSURL *url = [request URL];
NSString *requestString = [[request URL] absoluteString];
//获取利润url scheme后自行进行处理
}
在实际使用过程中,这种方式有一定的 缺陷:
-
使用 iframe.src 发送 URL SCHEME 会有 url 长度的隐患。
-
创建请求,需要一定的耗时,比注入 API 的方式调用同样的功能,耗时会较长。
但是之前为什么很多方案使用这种方式呢?因为它 支持 iOS6。而现在的大环境下,iOS6 占比很小,基本上可以忽略,所以并不推荐为了 iOS6 使用这种并不优雅 的方式。
注意:
-
有些方案为了规避 url 长度隐患的缺陷,在 iOS 上采用了使用 Ajax 发送同域请求的方式,并将参数放到 head 或 body 里。这样,虽然规避了 url 长度的隐患,但是 WKWebView 并不支持这样的方式。
-
为什么选择 iframe.src 不选择 locaiton.href ?因为如果通过 location.href 连续调用 Native,很容易丢失一些调用。
Native 调用 JavaScript
相比于 JavaScript 调用 Native, Native 调用 JavaScript 较为简单,毕竟不管是 iOS 的 UIWebView 还是 WKWebView,还是 Android 的 WebView 组件,都以子组件的形式存在于 View/Activity 中,直接调用相应的 API 即可。
Native 调用 JavaScript,其实就是执行拼接 JavaScript 字符串,从外部调用 JavaScript 中的方法,因此 JavaScript 的方法必须在全局的 window 上。(闭包里的方法,JavaScript 自己都调用不了,更不用想让 Native 去调用了)
iOS UIWebView 实现:
result = [uiWebview stringByEvaluatingJavaScriptFromString:javaScriptString];
iOS WKWebView 实现:
[wkWebView evaluateJavaScript:javaScriptString completionHandler:completionHandler];
Android Kitkat(4.4)之前loadUrl 实现:
webView.loadUrl("javascript:" + javaScriptString);
Android Kitkat(4.4)之后 evaluateJavascript 实现:
webView.evaluateJavascript(javaScriptString, new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
}
});
注意:使用 loadUrl 的方式,并不能获取 JavaScript 执行后的结果。
JSBridge原理
JavaScript 是运行在一个单独的 JS Context 中(例如,WebView 的 Webkit 引擎、JSCore)。由于这些Context 与原生运行环境的天然隔离,我们可以将这种情况与 RPC(Remote Procedure Call,远程过程调用)通信进行类比,将 Native 与 JavaScript 的每次互相调用看做一次 RPC 调用。如此一来我们可以按照通常的 RPC 方式来进行设计和实现。
在 JSBridge 的设计中,可以把前端看做 RPC 的客户端,把 Native 端看做 RPC 的服务器端,从而 JSBridge 要实现的主要逻辑就出现了:通信调用(Native 与 JS 通信) 和 句柄解析调用。(如果你是个前端,而且并不熟悉 RPC 的话,你也可以把这个流程类比成 JSONP 的流程)
Native 和 Javascript 通信的原理是 JSBridge 实现的核心,实现方式可以各种各样,但是万变不离其宗。这里,笔者推荐的实现方式如下:
-
JavaScript 调用 Native 推荐使用 注入 API 的方式(iOS6 忽略,Android 4.2以下使用 WebViewClient 的 onJsPrompt 方式)。
-
Native 调用 JavaScript 则直接执行拼接好的 JavaScript 代码即可。
-
也可以将URL SCHEME的Restful形式的接口参数格式同注入API方式相结合,做到接口调用方式上的兼容(只改动底层实现)
JSBridge设计与实现
使用url scheme形式来设计和实现 JSBridge,现有的可参考框架和文章都非常多,若想详细了解建议阅读《JSBridge深度剖析》,此处不作赘述。
设计与实现
上文中已介绍了native与js交互的方式,但是都是较为底层的实现,实际使用时需要在 javascript 端和 native 端封装出对应的模块,来统一管理它们之间的相互调用。
javascript端的模块需要实现两个功能:
-
调用 Native(给 Native 发消息)
-
被 Native 调用(接收 Native 消息)
根据这两个功能点,可以初步设计出JSBridge js模块的初步结构:
window.JSBridge = {
// 调用 Native
invoke: function(msg) {
// 判断环境,获取不同的 nativeBridge
nativeBridge.postMessage(msg);
},
receiveMessage: function(msg) {
// 处理 msg
}
};
在上面的文章中,提到过 RPC 中有一个非常重要的环节是 句柄解析调用 ,这点在 JSBridge 中体现为 句柄与功能对应关系。同时,我们将句柄抽象为桥名(BridgeName),最终演化为 一个 BridgeName 对应一个 Native 功能或者一类 Native 消息。 基于此点,JSBridge 的实现可以优化为如下:
window.JSBridge = {
// 调用 Native
invoke: function(bridgeName, data) {
// 判断环境,获取不同的 nativeBridge
nativeBridge.postMessage({
bridgeName: bridgeName,
data: data || {}
});
},
receiveMessage: function(msg) {
var bridgeName = msg.bridgeName,
data = msg.data || {};
// 具体逻辑
}
};
JSBridge 大概的雏形出现了。现在终于可以着手解决这个问题了:消息都是单向的,那么调用 Native 功能时 Callback 怎么实现的?
对于 JSBridge 的 Callback ,其实就是 RPC 框架的回调机制。当然也可以用更简单的 JSONP 机制解释: 当发送 JSONP 请求时,url 参数里会有 callback 参数,其值是当前页面唯一 的,而同时以此参数值为 key 将回调函数存到 window 上,随后服务器返回 script 中,也会以此参数值作为句柄,调用相应的回调函数。
由此可见,callback 参数这个唯一标识是这个回调逻辑的关键。这样,我们可以参照这个逻辑来实现 JSBridge:用一个自增的唯一 id,来标识并存储回调函数,并把此 id 以参数形式传递给 Native,而 Native 也以此 id 作为回溯的标识,来实现 Callback 回调逻辑。
(function () {
let id = 0,
callbacks = {};
window.JSBridge = {
// 调用 Native
invoke: function(bridgeName, callback, data) {
// 判断环境,获取不同的 nativeBridge
let thisId = id++; // 获取唯一 id
callbacks[thisId] = callback; // 存储 Callback
nativeBridge.postMessage({
bridgeName: bridgeName,
data: data || {},
callbackId: thisId // 传到 Native 端
});
},
receiveMessage: function(msg) {
let bridgeName = msg.bridgeName,
data = msg.data || {},
callbackId = msg.callbackId; // Native 将 callbackId 原封不动传回
// 具体逻辑
// bridgeName 和 callbackId 不会同时存在
if (callbackId) {
if (callbacks[callbackId]) { // 找到相应句柄
callbacks[callbackId](msg.data); // 执行调用
}
} else if (bridgeName) {
}
}
};
})();
最后用同样的方式加上 Native 调用的回调逻辑,同时对代码进行一些优化,就大概实现了一个功能比较完整的 JSBridge。其代码如下:
(function () {
var id = 0,
callbacks = {},
registerFuncs = {};
window.JSBridge = {
// 调用 Native
invoke: function(bridgeName, callback, data) {
// 判断环境,获取不同的 nativeBridge
var thisId = id ++; // 获取唯一 id
callbacks[thisId] = callback; // 存储 Callback
nativeBridge.postMessage({
bridgeName: bridgeName,
data: data || {},
callbackId: thisId // 传到 Native 端
});
},
receiveMessage: function(msg) {
var bridgeName = msg.bridgeName,
data = msg.data || {},
callbackId = msg.callbackId, // Native 将 callbackId 原封不动传回
responstId = msg.responstId;
// 具体逻辑
// bridgeName 和 callbackId 不会同时存在
if (callbackId) {
if (callbacks[callbackId]) { // 找到相应句柄
callbacks[callbackId](msg.data); // 执行调用
}
} else if (bridgeName) {
if (registerFuncs[bridgeName]) { // 通过 bridgeName 找到句柄
var ret = {},
flag = false;
registerFuncs[bridgeName].forEach(function(callback) => {
callback(data, function(r) {
flag = true;
ret = Object.assign(ret, r);
});
});
if (flag) {
nativeBridge.postMessage({ // 回调 Native
responstId: responstId,
ret: ret
});
}
}
}
},
register: function(bridgeName, callback) {
if (!registerFuncs[bridgeName]) {
registerFuncs[bridgeName] = [];
}
registerFuncs[bridgeName].push(callback); // 存储回调
}
};
})();
当然,这段代码片段只是一个示例,主要用于剖析 JSBridge 的原理和流程,里面存在诸多省略和不完善的代码逻辑,读者们可以自行完善。
这一节主要讲的是,JavaScript 端的 JSBridge 的实现,对于 Native 端涉及的并不多。在 Native 端配合实现 JSBridge 的 JavaScript 调用 Native 逻辑也很简单,主要的代码逻辑是:接收到 JavaScript 消息 => 解析参数,拿到 bridgeName、data 和 callbackId => 根据 bridgeName 找到功能方法,以 data 为参数执行 => 执行返回值和 callbackId 一起回传前端。 Native 调用 JavaScript 也同样简单,直接自动生成一个唯一的 ResponseId,并存储句柄,然后和 data 一起发送给前端即可。
JSBridge 引用
对于 JSBridge 的引用,常用有两种方式,各有利弊。
由 Native 端进行注入:
注入方式和 Native 调用 JavaScript 类似,直接执行桥的全部代码。
-
优点:桥的版本很容易与 Native 保持一致,Native 端不用对不同版本的 JSBridge 进行兼容;
-
缺点:注入时机不确定,需要实现注入失败后重试的机制,保证注入的成功率,同时 JavaScript 端在调用接口时,需要优先判断 JSBridge 是否已经注入成功。
由 JavaScript 端引用:
直接与 JavaScript 一起执行。
-
优点:JavaScript 端可以确定 JSBridge 的存在,直接调用即可;
-
缺点:如果桥的实现方式有更改,JSBridge 需要兼容多版本的 Native Bridge 或者 Native Bridge 兼容多版本的 JSBridge。
灵犀LXJSBridge.js实现
实现原理基于上文,具体实现时,LXJSBridge.js在接口层面设计了两类js调用native方式:一类是方法的直接调用,分为同步调用和异步调用;一类是注册监听事件的方式,但是这两类调用方式其本质是一样,js调用andriod native利用的是prompt方法,js调用ios native利用的postMessage方法。具体协议参见下图,需要web端和native侧约定好native能力的service以及action名称。

针对异步调用的结果返回以及native事件通知,LXJSBridge.js在接口层面也是设计了两类native调用js接口:异步调用返回结果使用callbackFromNative方法;事件触发使用fireFromNative方法。

为了方便web端使用,我们仿照微信jssdk,在LXJSBridge之上又封装了一层LXJSSDK的接口层,web只需要使用LXJSSDK里面的接口,就可以访问到native中的本地能力。具体参见代码分析。