【转载】android的jsbridge原理

533 阅读11分钟

背景

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

在更加复杂的情境中:

  • 小程序

    • 需要根据​web​的需要在​WebView​上覆盖显示一些​Native​控件以提供接近​native​的体验(​input​框、地图等)
    • 提供一些诸如本地储存、定位数据之类的服务供​web​使用(虽然部分走的是​V8​引擎,但仍需要​JSBridge​去进行一些通信)
  • Hybrid应用

    • ​Native​控件与​web​频繁交互
    • ​Native​页面/组件利用​JSBridge​与后端同步数据,简化后端工作量(不需要维护两套通信接口),但过度依赖于​WebView​

image.png

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

从系统接口说起 *Android

Native调用js

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

api19之前:

/**
 * 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之后(效率更高):

/**
 * 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

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

image.png

shouldOverrideUrlLoading拦截特定schema

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

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​:

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​使用频率较低,所以一般采用它作为调用方法。

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​就可以了:

window.prompt(uri, "");

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

console.log传递数据

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

addJavascriptInterface注入对象

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

// 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​端即可以通过以下形式调用:

window._sBridge.send(msg);

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

性能测试

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

方法方式立即返回耗时延迟10ms返回耗时
addJavascriptInterface1.2ms13.37ms
shouldOverrideUrlLoading--
onJsPrompt1.78ms15.87ms
console.log0.16ms0.16ms(完全不阻塞)

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

如何选择

image.png

从编码角度上看,​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​找到对应方法进行回调。

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

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

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

image.png

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

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

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

双端通信封装

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

bridge初始化

bridge.js

...
    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流程

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​注入的方法,目前只注入了这一个方法用于数据传输。

这条数据是这样的:

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

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

/**
 * 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​调用:
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​执行的时候像这样:
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​:
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​的方法调用指令

const val JS_HANDLE_MESSAGE_FROM_JAVA =
    "javascript:LkWebViewJavascriptBridge._handleMessageFromNative(\"%s\");"

实际上调用了之前注入的​_handleMessageFromNative​方法,然后调用​doSendJsCommand​执行指令

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()​方法:

 // 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​方法是一样的(对等的过程),现在我们收到的消息是这样的:

{"responseData":"{ \"data\":\"callback data from java\"}","responseId":"cb_1_1534851889294"}"

  • 所以​js​会根据​responseId​从​​responseCallback​s ​map​中取出对应的​callback​并执行。
  • 到这里,一次完整的异步通信就完成了

Native调用JS Handler过程

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

原文地址