Android JSBridge原理与实现

背景

WebView​作为承载动态页面的容器,在安卓中本身只是一个用于加载​web​页面的视图控件,但​web​页面中常需要与​Native​进行交互动作,比如跳转到一个​Native​页面、弹出一条​Toast​提示、检测设备状态等。

在更加复杂的情境中:

  • 小程序
    • 需要根据​web​的需要在​WebView​上覆盖显示一些​Native​控件以提供接近​native​的体验(​input​框、地图等)
    • 提供一些诸如本地储存、定位数据之类的服务供​web​使用(虽然部分走的是​V8​引擎,但仍需要​JSBridge​去进行一些通信)
  • Hybrid应用
    • ​Native​控件与​web​频繁交互
    • ​Native​页面/组件利用​JSBridge​与后端同步数据,简化后端工作量(不需要维护两套通信接口),但过度依赖于​WebView​

以上通信的基础设施就是​JSBridge​,​JSBridge​的实现本身并不复杂,可以看作是对系统接口的二次封装。

从系统接口说起 *Android

Native调用js

相对来说比较简单,​webview​为我们提供了如下两个接口来执行​js​代码:

  • api19之前:

    1
    2
    3
    4
    5
    6
    7
    8
    /**
    * Loads the given URL.
    * <p>
    * Also see compatibility note on {@link #evaluateJavascript}.
    *
    * @param url the URL of the resource to load
    */
    public void loadUrl(String url)
  • api19之后(效率更高):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /**
    * Asynchronously evaluates JavaScript in the context of the currently displayed page.
    * If non-null, |resultCallback| will be invoked with any result returned from that
    * execution. This method must be called on the UI thread and the callback will
    * be made on the UI thread.
    * <p>
    * Compatibility note. Applications targeting {@link android.os.Build.VERSION_CODES#N} or
    * later, JavaScript state from an empty WebView is no longer persisted across navigations like
    * {@link #loadUrl(String)}. For example, global variables and functions defined before calling
    * {@link #loadUrl(String)} will not exist in the loaded page. Applications should use
    * {@link #addJavascriptInterface} instead to persist JavaScript objects across navigations.
    *
    * @param script the JavaScript to execute.
    * @param resultCallback A callback to be invoked when the script execution
    * completes with the result of the execution (if any).
    * May be null if no notification of the result is required.
    */
    public void evaluateJavascript(String script, ValueCallback<String> resultCallback)

我们只需要构建​javascript:​开头形式的代码字符串传入执行就可以了,以上两个方法都是直接返回的。

js调用Native

实现方式比较多样,先上一张图:

shouldOverrideUrlLoading拦截特定schema

​WebView​提供了​shouldOverrideUrlLoading​方法允许我们拦截​web​页面加载的​url​,我们可以利用这个方法采用加载伪协议的方式进行通信:

1
2
3
4
5
6
7
8
9
10
public class CustomWebViewClient extends WebViewClient {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
......
// 截取url并操作
return true;
}
return super.shouldOverrideUrlLoading(view, url);
}
}

伪协议形式根据业务不同复杂度也不同,后面的工作主要就是围绕这个scheme字符串解析/生成。

在​web​端,采用加载不可见​iframe​的方式传递​url​到​Native​:

1
2
3
4
5
6
7
8
9
10
11
function openURL (url) {
var iframe = document.createElement('iframe');
iframe.style.cssText = 'display:none;width:0px;height:0px;';
var container = document.body || document.documentElement;
container.appendChild(iframe);
iframe.onload = fail;
iframe.src = url;
setTimeout(function() {
iframe.parentNode.removeChild(iframe);
}, 0);
}

但是此方法在测试中存在一个比较严重的问题:无法在短时间内回调多次​shouldOverrideUrlLoading​方法,也就是说频繁交互的情况下,会有较大概率多次​url​跳转只回调一次该方法,在​github​上非常著名的一个​JSBridge​实现中,将消息排队压缩为一个消息,然后使用一个​url​去通知​Native​调用​js​的取消息​Handler​,​js​再将整合后的消息一起发送给​Native​。

不幸的是,这种方式仍有丢消息的情况,有一笔pr修复了该问题,采用了两个​iframe​一个用于通知、一个用于数据传送。但该方式的效率会显著低于以下几种。

onJsPrompt传递数据

​js​调用​window​的​window.alert​,​window.confirm​,​window.prompt​三种方法时,​WebView​中注入的​WebChromeClient​对象的对应方法会被调用,并且可以带有​js​传递过来的参数,我们可以选择其中之一作为我们数据传递的通道,由于​promopt​使用频率较低,所以一般采用它作为调用方法。

1
2
3
4
5
6
7
public class JSBridgeWebChromeClient extends WebChromeClient {
@Override
public boolean onJsPrompt(WebView view, String url, Stringt message, String defaultValue, JsPromptResult result) {
//对message进行处理
return true;
}
}

​js​中只要调用​window.prompt​就可以了:

1
window.prompt(uri, "");

数据传递的格式并没有要求,我们可以采用上述的​schema​或者自己另外制定协议。如果出于与​js​保持一致的考虑,就使用​schema​

console.log传递数据

与上种方法大同小异,只不过利用的是​js​调用​console.log​时WebChromeClientonConsoleMessage回调来处理消息,​js​端只要使用​console.log​就可以了。

addJavascriptInterface注入对象

addJavascriptInterface​是​WebView​的一个方法,顾名思义,这个方法是安卓为我们提供的官方实现​JSBridge​的方式,通过它我们可以向​WebView​加载的页面中注入一个​js​对象,​js​可以通过这个对象调用到相应的​Native​方法:

1
2
3
4
5
6
7
8
9
// class for injecting to js
class Bridge {
@JavascriptInterface
fun send(msg: String) {
doSomething()
}
}
// inject Bridge as _sBridge
webview.addJavascriptinterface(Bridge(), "_sBridge")

我们创建了一个​Bridge()​对象并作为​_sBridge​注入到了​webview​的当前页面中,在​js​端即可以通过以下形式调用:

1
window._sBridge.send(msg);

该方法是阻塞的,会等待​Native​方法的返回,​Native​会在一个后台线程中执行该方法调用。
关于安全性问题:
在安卓4.2之前通过注入的对象进行反射调用可以执行其他类的一些方法,存在严重安全漏洞,具体见:https://blog.csdn.net/weekendboyxw/article/details/48175027
4.2之后加入了上述的​@JavascriptInterface​注解来避免暴露额外的方法,从而解决了这一问题。

性能测试

测试方法:
​> js​端收到​Bridge​注入完成的事件后,连续触发100次传递消息到​Native​的方法调用,传递2w个英文字符作为消息体,在​Native​端作处理时分别采用立即返回和延迟10ms返回模拟方法处理耗时。统计​js​调用开始到结束的平均时间。

方法 方式立即返回耗时 延迟10ms返回耗时
addJavascriptInterface 1.2ms 13.37ms
shouldOverrideUrlLoading - -
onJsPrompt 1.78ms 15.87ms
console.log 0.16ms 0.16ms(完全不阻塞)

​shouldOverrideUrlLoading​方式由于采用队列压缩消息,耗时数据与实际业务中数据收发频率相关,暂不测试,可以认为耗时显著高于其他几种。

如何选择


从编码角度上看,​addJavascriptInterface()​方法实现是最为简洁明了的,同时上表中的速度一栏,在实际测试中发现​addJavascriptInterface()​方法耗时比​onJsPrompt​要少。

​console.log()​在​10ms​延迟测试中由于自身不阻塞的特性,耗时较短,但在实际处理中,会在​addJavascriptInterface()​中另开线程去异步处理消息,延迟时间也非常短。

综上,使用​addJavascriptInterface​作为​js​向​Native​传递数据的通道是较为合理的选择。如果实在对耗时要求高,可以考虑采用​console.log()​的方式。

JSBridge上层实现

有了上述的双端通信的基础通道,我们就可以基于此去构建一套易用的方法封装。

消息格式

为了一定程度上兼容​iOS​端的​JSBridge​,我们双端都采用注册​Handler​,以​Handler​名作为方法索引,再使用​json​作为参数数据的序列化/反序列化格式。
下一步解决的问题是如何回调调用方的​callback​,我们期望异步调用在完成时被调用方通过回调​callback​方法来返回数据。这里采用注册​Handler​的思路,在调用方进行调用时,为​callback​方法生成一个​callbackId​作为​Key​来保存这个​callback​方法,被调用方完成处理之后,在返回的消息中一并返回​callbackId​(这时它变为了​responseId​),调用方拿到​callbackId​找到对应方法进行回调。

依此,我们制定的消息格式如下:

1
2
3
4
5
6
7
{
"handlerName": "NameOfHandler",
"data": "json data", // 传送给接收方的数据
"callbackId": "", // 接收方回调调用方的方法id
"responseId": "", // 调用方被回调时收到的方法id,即为发送时的callbackId参数
"responseData": "json data" // 接收方返回的数据
}

通信过程可以由下图表示:

为了兼容​schema​格式,在消息体的基础上添加​schema​头部,组成最终的消息协议:

1
CUSTOM_PROTOCOL_SCHEME + '://data/message/' + messageQueueString

messageQueueString​为​json​数组,一个​json​元素为一条消息。

双端通信封装

​Native​的​WebView​加载页面到80%以上时,会不断尝试将本地的一个bridge.js文件注入到​WebView​中,不断尝试是为了解决在弱网状况下一次注入可能失败的问题,js代码保证初始化不会重复进行,后续这个文件的代码可以放在前端加载。bridge.js负责初始化​LkWebViewJavascriptBridge​类,封装了一些通信的方法和数据对象。

bridge初始化

bridge.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
var LkWebViewJavascriptBridge = window.LkWebViewJavascriptBridge = {
init: init,
send: send,
registerHandler: registerHandler,
callHandler: callHandler,
callSync: callSync,
_handleMessageFromNative: _handleMessageFromNative,
debug: true
};

_log("local js injected");
// notify java
callHandler("s.bridge.ready", JSON.stringify("ready msg from js"));
var doc = document;
var readyEvent = doc.createEvent('Events');
readyEvent.initEvent('LkWebViewJavascriptBridgeReady');
readyEvent.bridge = LkWebViewJavascriptBridge;
doc.dispatchEvent(readyEvent);

1-9行创建了​window.LkWebViewJavascriptBridge​对象,用于访问文件中定义的几个方法(见下文),14行调用s.bridge.ready​这个​Native​预设的​Handler​,通知​js​端的​Bridge​已完成初始化。随后15-19行触发一个自定义事件,用于通知​web​其他组件​JSBridge​已初始化完成,可以开始通信了。

JS调用Native Handler流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
LkWebViewJavascriptBridge.callHandler("java_handler", "\"js data\"", function (resJson) {
console.log("data callback from java: ")
console.log(resJson)
})

// js call java handler
function callHandler(handlerName, data, responseCallback) {
_doSend({
handlerName: handlerName,
data: data
}, responseCallback);
}

// sendMessage add message, 触发native处理 sendMessage
function _doSend(message, responseCallback) {
// debugger;
_log(">>>>>>>>>>> _doSend: " + message.handlerName + " " + time());

if (responseCallback) {
var callbackId = 'cb_' + uniqueId++ + '_' + new Date().getTime();
responseCallbacks[callbackId] = responseCallback;
message.callbackId = callbackId;
}

sendMessageQueue.push(message);
var array = [];
array.push(message);
var messageQueueString = JSON.stringify(array);
window._sBridge.send(CUSTOM_PROTOCOL_SCHEME + '://data/message/' + messageQueueString);

_log("_doSend end <<<<<<<<<<<<<<<: " + message.handlerName + " " + time());
}

代码逻辑结合上面的消息格式看并不复杂。

注意到20、21行为​callback​生成了​callbackId​并存入了​responseCallbacks​ ​map​中,以便后面回调的处理。

​window._sBridge.send​即为​Native​通过​addJavascriptInterface​注入的方法,目前只注入了这一个方法用于数据传输。

这条数据是这样的:

1
s://data/message/[{"handlerName":"java_handler","data":"\"js data\"","callbackId":"cb_1_1534851889294"}]

Native​收到​send​调用后,进行如下的事件分发处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* handle message from js by call _sBridge.send(msg)
*/
@SuppressLint("CheckResult")
@JavascriptInterface
fun send(msg: String) {
Logger.v(TAG, "\n<-----raw msg from js---->\n$msg")
Flowable.just(msg).subscribeOn(sSchedulers.io())
.filter {
// filter blank or wrong data
if (it.isBlank() || !it.startsWith(BridgeUtil.LARK_RETURN_DATA)) {
Logger.e(TAG, "<-----illegal msg from js---->")
return@filter false
}
return@filter true
}.concatMap {
// separate data from msg
val data = BridgeUtil.getDataFromReturnUrl(it)
?: throw IllegalStateException("can't parse message from js")
// deserialize Message
val list: List<Message> = Message.toArrayList(data)

return@concatMap if (list.isEmpty()) {
Flowable.just(Message())
} else Flowable.fromIterable(list)
}.flatMap {
if (it.responseId.isNullOrBlank()) {
// call java handler
val callbackFunction = generateJavaCallbackFunction(it.callbackId)
val handler = getBridgeHandler(it.handlerName)
val action = Action {
handler?.handle(it.data, callbackFunction)
}
when (handler?.getType()) {
UI -> {
// run on mainThread
return@flatMap Flowable.just(action)
.subscribeOn(sSchedulers.mainThread())
}
BACKGROUND -> {
// run on background
return@flatMap Flowable.just(action)
.subscribeOn(sSchedulers.io())
}
else -> {
return@flatMap Flowable.empty<Action>()
}
}
} else {
// response from js
val javaCallback = javaCallbacks[it.responseId]
if (javaCallback == null) {
Logger.i(TAG, "callback not found for responseId: ${it.responseId}")
return@flatMap Flowable.empty<Action>()
} else {
return@flatMap Flowable.just(Action {
javaCallback.onCallback(it.responseData)
}).subscribeOn(sSchedulers.io())
// response callback would run on background by default
}
}
}.subscribe({ it.run() }, {
Logger.e(TAG, "handle msg from js error: ", it)
})
}

代码逻辑如下:

  1. 检查消息的合法性(协议等)
  2. 提取消息体并将消息体反序列化为一个​Message​对象的列表
  3. 判断​responseId​是否为空,如果为空,说明为​JS​对​Handler​的调用,否则为对一条​Native​消息的回调,我们这里是对s.bridge.ready的调用
  4. 生成​callback​函数供​handler​调用:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    private fun generateJavaCallbackFunction(callbackId: String?): ICallbackFunction {
    return if (callbackId.isNullOrBlank()) {
    object : ICallbackFunction {
    override fun onCallback(data: String?) {
    // do nothing
    }
    }
    } else {
    object : ICallbackFunction {
    override fun onCallback(data: String?) {
    // send data back to js
    val msg = Message(responseData = data, responseId = callbackId)
    sendToJs(msg)
    }
    }
    }
    }
  • 可以看到,如果消息中有​callbackId​的话,就会将​handler​传入的消息作为​responseData​,​callbackId​作为​responseId​构建消息发送到​js​以完成回调。
  1. 获取​handler​,这个过程会把注册在一个​map​中的​handler​根据​handlerName​作为​key​取出
  2. 对​handler​类型做判断,目前有两种,一种会运行在主线程,一种会运行在后台线程池
  3. 在对应的线程中调用​handler.handle()​传入​data​和生成的​callbackFunction​作为参数,这样就完成了找到对应​handler​并执行其逻辑的过程,​handler​执行的时候像这样:
    1
    2
    3
    4
    override fun handle(data: String?, callback: ICallbackFunction) {
    Toast.makeText(context, data, Toast.LENGTH_LONG).show()
    callback.onCallback("{ \"data\":\"callback data from java\"}")
    }
  • 直接调用​callback​的​onCallback​回传数据就可以了。
  • ​onCallback​通过​sendToJs()​方法传递数据到​js​:

    1
    2
    3
    4
    5
    6
    7
    8
    fun sendToJs(msg: Message) {
    var messageJson = msg.toJson()
    // escape special characters for json string
    messageJson = messageJson?.replace("(\\\\)([^utrn])".toRegex(), "\\\\\\\\$1$2")
    messageJson = messageJson?.replace("(?<=[^\\\\])(\")".toRegex(), "\\\\\"")
    val javascriptCommand = String.format(BridgeUtil.JS_HANDLE_MESSAGE_FROM_JAVA, messageJson)
    doSendJsCommand(javascriptCommand)
    }
  • ​Message​进行序列化,同时处理转义字符的问题,然后第6行将消息格式化为一条对​js​的方法调用指令:

    1
    2
    const val JS_HANDLE_MESSAGE_FROM_JAVA =
    "javascript:LkWebViewJavascriptBridge._handleMessageFromNative(\"%s\");"
  • 实际上调用了之前注入的​_handleMessageFromNative​方法,然后调用​doSendJsCommand​执行指令:

    1
    2
    3
    4
    5
    6
    7
    private fun doSendJsCommand(javascriptCommand: String) {
    if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
    evaluateJavascript(javascriptCommand, null) // return value not used
    } else {
    loadUrl(javascriptCommand)
    }
    }

现在,消息传递到了​js​的​_handleMessageFromNative()​方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
 // java 调用入口
function _handleMessageFromNative(messageJSON) {
if (receiveMessageQueue && receiveMessageQueue.length > 0) {
receiveMessageQueue.push(messageJSON);
} else {
_dispatchMessageFromNative(messageJSON);
}
}

// 提供给native使用
function _dispatchMessageFromNative(messageJSON) {
_log("<-----raw msg from java---->\n" + messageJSON);
(function () {
var message = JSON.parse(messageJSON);
var responseCallback;
// java call finished, now need to call js callback function
if (message.responseId) {
// 对某条已发送消息的回复
responseCallback = responseCallbacks[message.responseId];
if (!responseCallback) {
return;
}
var resJson = JSON.parse(message.responseData);
responseCallback(resJson);
} else {
// 调用js handler
if (message.callbackId) {
// java callback
var callbackResponseId = message.callbackId;
responseCallback = function responseCallback(responseData) {
_doSend({
responseId: callbackResponseId,
responseData: responseData
});
};
}

var handler = LkWebViewJavascriptBridge._messageHandler;
// 查找指定handler
if (message.handlerName) {
handler = messageHandlers[message.handlerName];
}
handler(message.data, responseCallback);
}
})();
}

  • ​_dispatchMessageFromNative​的代码逻辑其实和刚刚分析的​send​方法是一样的(对等的过程),现在我们收到的消息是这样的:

    1
    {"responseData":"{ \"data\":\"callback data from java\"}","responseId":"cb_1_1534851889294"}"
  • 所以​js​会根据​responseId​从​​responseCallback​s ​map​中取出对应的​callback​并执行。

  • 到这里,一次完整的异步通信就完成了。

    Native调用JS Handler过程

    这个流程与上一步完全对等,代码逻辑也是一样的,故不再分析。