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 | public class CustomWebViewClient extends WebViewClient { |
伪协议形式根据业务不同复杂度也不同,后面的工作主要就是围绕这个scheme
字符串解析/生成。
在web
端,采用加载不可见iframe
的方式传递url
到Native
:
1 | function openURL (url) { |
但是此方法在测试中存在一个比较严重的问题:无法在短时间内回调多次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 | public class JSBridgeWebChromeClient extends WebChromeClient { |
js
中只要调用window.prompt
就可以了:
1 | window.prompt(uri, ""); |
数据传递的格式并没有要求,我们可以采用上述的schema
或者自己另外制定协议。如果出于与js
保持一致的考虑,就使用schema
。
console.log传递数据
与上种方法大同小异,只不过利用的是js
调用console.log
时WebChromeClient
的onConsoleMessage
回调来处理消息,js
端只要使用console.log
就可以了。
addJavascriptInterface注入对象
addJavascriptInterface
是WebView
的一个方法,顾名思义,这个方法是安卓为我们提供的官方实现JSBridge
的方式,通过它我们可以向WebView
加载的页面中注入一个js
对象,js
可以通过这个对象调用到相应的Native
方法:
1 | // class for injecting to js |
我们创建了一个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 | { |
通信过程可以由下图表示:
为了兼容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 | ... |
1-9行创建了window.LkWebViewJavascriptBridge
对象,用于访问文件中定义的几个方法(见下文),14行调用s.bridge.ready
这个Native
预设的Handler
,通知js
端的Bridge
已完成初始化。随后15-19行触发一个自定义事件,用于通知web
其他组件JSBridge
已初始化完成,可以开始通信了。
JS调用Native Handler流程
1 | LkWebViewJavascriptBridge.callHandler("java_handler", "\"js data\"", function (resJson) { |
代码逻辑结合上面的消息格式看并不复杂。
注意到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 | /** |
代码逻辑如下:
- 检查消息的合法性(协议等)
- 提取消息体并将消息体反序列化为一个
Message
对象的列表 - 判断
responseId
是否为空,如果为空,说明为JS
对Handler
的调用,否则为对一条Native
消息的回调,我们这里是对s.bridge.ready
的调用 - 生成
callback
函数供handler
调用:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17private 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
以完成回调。
- 获取
handler
,这个过程会把注册在一个map
中的handler
根据handlerName
作为key
取出 - 对
handler
类型做判断,目前有两种,一种会运行在主线程,一种会运行在后台线程池 - 在对应的线程中调用
handler.handle()
传入data
和生成的callbackFunction
作为参数,这样就完成了找到对应handler
并执行其逻辑的过程,handler
执行的时候像这样:1
2
3
4override 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
8fun 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
2const val JS_HANDLE_MESSAGE_FROM_JAVA =
"javascript:LkWebViewJavascriptBridge._handleMessageFromNative(\"%s\");" - 实际上调用了之前注入的
_handleMessageFromNative
方法,然后调用doSendJsCommand
执行指令:现在,消息传递到了1
2
3
4
5
6
7private 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
从responseCallbacks map
中取出对应的callback
并执行。 - 到这里,一次完整的异步通信就完成了。
Native调用JS Handler过程
这个流程与上一步完全对等,代码逻辑也是一样的,故不再分析。