查看原文
其他

如何实现 JsBridge

路遥 路遥远
2024-08-24

前言

对于移动端应用来说,跨平台动态化 一直是两个痛点。在过去的十几年的技术发展历程中,Cordova、Weex、RN、Flutter、Compose Multiplatform 等框架层出不穷。这里且不讨论框架的孰优孰劣,不同的技术团队,不同的历史背景,做出的技术选型都不尽相同。但对于一些并不那么在意性能的非核心页面,简单快速的 H5 一定会成为一些技术团队的跨平台方案选择。

所有跨平台方案都存在和原生通信的问题,H5 也不例外。一个一个网页被放置在 WebView 容器中,和原生环境隔离,无法直接调用 Native 能力,就需要我们制定好通信协议来供双方通信,通常就称之为 JsBridge  。

基础技术原理

借助于 JavaScript 的语言特性和 WebView 提供的系统能力,原生 Native 和 Web 的通信其实很简单。这里我以 Android 的实现为例简单说明。

Native 调用 Web

Native 调用 Web,直接利用 WebView 的系统能力执行 JS 代码即可。

<script>  
    function handleMessage(message) 
        console.log("handleMessage: " + message)  
        return "nativeCallWeb"  
    }  
</script>

H5 中定义了 handleMessage() 方法,返回值是一个字符串。现在要从 Native 端去调用的话,可以通过  WebView.evaluateJavascript(String script, ValueCallback<String> resultCallback)  方法。

webView.evaluateJavascript("javascript:window.handleMessage('Hello')") {  
    Log.e("JsBridge""receive from web: $it")  
}

有一点需要注意,evaluateJavascript() 方法添加于 API 19。如果你不幸的仍然需要适配 Android 4.4 以下的设备,可以通过 WebView.loadUrl() 方法来调用 Web 方法。

webView.loadUrl("javascript:window.handleMessage('Hello')")

loadUrl() 方法是没有回调的,但方法是死的,思路是活的。Native 可以调用 Web,Web 也可以调用 Native 的话,曲线救国,肯定是可以完成回调的。

Web 调用 Native

Android WebView 通过 addJavascriptInterface(Object object, String name) 方法,提供了向 Web 端全局 window 注入对象的能力,通过这个对象可以调用 Native 提供的方法。

addJavascriptInterface(JsBridge(this@MainActivity, webView), "JsBridge")

class JsBridge(private val activity: Activity, private val webView: WebView) {  
  
    @JavascriptInterface  
    fun webCallNative(message: String) {  
        Log.e("JsBridge""webCallNative: ${Thread.currentThread().name}")  
        Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()  
    }  
}

上面的代码注入了名叫 JsBridge 的全局对象,在 Web 端可以直接这样调用:

window.JsBridge.webCallNative("webCallNative")

注意 Native 端注入对象的方法需要添加 @JavascriptInterface (API 17 可用)注解,才可以暴露给 Web 调用。

除了这种注入方案之外,还有一种巧妙的方案。如果你使用过开源的 JsBridge ,并且需要实现自定义的 WebViewClient 的话,必须继承它提供的 BridgeWebViewClient ,就是因为它利用 shouldOverrideUrlLoading 巧妙的完成了整个通信流程 。

public boolean shouldOverrideUrlLoading(WebView view, String url) {  
    try {  
        url = URLDecoder.decode(url, "UTF-8");  
    } catch (UnsupportedEncodingException e) {  
        e.printStackTrace();  
    }  
  
    if (url.startsWith(BridgeUtil.YY_RETURN_DATA)) { // 如果是返回数据  
        webView.handlerReturnData(url);  
        return true;    
    } else if (url.startsWith(BridgeUtil.YY_OVERRIDE_SCHEMA)) {
        webView.flushMessageQueue();  
        return true;    
    } else {  
        return super.shouldOverrideUrlLoading(view, url);  
    }  
}

上面的代码也直接说明了通信原理,在 Web 端加载携带数据的指定格式的 url,在 Native 端通过 shouldOverrideUrlLoading 拦截并解析以获取数据,完成通信。

Web 端通常使用 iFrame.src 来,开源的 JsBridge 也是如此:

bizMessagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://return/_fetchQueue/' + encodeURIComponent(messageQueueString);

这种方案的隐藏风险是参数过长导致加载的 url 长度超过 WebView 的限制。上面两种方案都是没有直接回调的,无法满足实际使用中双向通信的需求。但正如前面说过的,既然两边的单向通信都是通畅的,那么一来一回的两次单向通信就可以完成双向通信。

双向通信

还是用上面动态注入 JsBridge 的例子:

window.JsBridge.webCallNative("webCallNative")

在 Native 端接收到 webCallNative() 调用之后,再通过 evaluateJavascript() 方法调用 Web,以达到回调的效果。

@JavascriptInterface  
fun webCallNative(message: String) {  
    Log.e("JsBridge""webCallNative: ${Thread.currentThread().name}")  
    Toast.makeText(activity, message, Toast.LENGTH_SHORT).show()  
    activity.runOnUiThread {  
        webView.evaluateJavascript("javascript:window.handleMessage('Hello')") {  
            Log.e("JsBridge""receive from web: $it")  
        }  
    }}

那么问题来了,应该调用 Web 的什么方法呢?或者说,调用 Web 方法之后,H5 如何区分是哪个方法调用的回调呢?

其实也很简单,在 Web 端定义好方法回调 callBack,在每次对 Native 的调用时指定一个 callbackId,通过 map 保存 callbackId 和 callBack 的映射关系。这个 callbackId 要传递到 Native 端,Native端通过 evaluateJavascript 进行反向调用时再把 callbackId 带回来,Web 端根据 callbackId 就可以找到 callback,直接调用即可。直接上代码更容易理解一些。Js 端:

<body>  
<p>  
    <input type="button" value="Web 调用 Native"            onclick="webCallNative((value => console.log(value)))"/>  
</p>
  
</body>


<script>  
    var callbackMap = {};  
    var callbackId = 1000;  
  
    function webCallNative(callback) {  
        callbackId ++;
        /
/ 保存 callbackId 和 callback 的映射关系
        callbackMap[callbackId] = callback;  
        const param = {  
            message: "webCallNative",  
            callbackId: callbackId  
        };  
        /
/ 将通信数据和 callbackId 传递给 native
        window.JsBridge.webCallNative(JSON.stringify(param))  
    }   
</
script>

Native 端:

@JavascriptInterface  
fun webCallNative(message: String) {  
    val jsonObject = JSONObject(message)
    // 取出 Web 传递过来的 callbackId  
    val callbackId = jsonObject.optInt("callbackId")  
 
    val callbackJsonObj = JSONObject().apply {  
        put("callbackId", callbackId)  
        put("message""Hello")  
    }  
    val json = callbackJsonObj.toString()  
    activity.runOnUiThread {
     // 把 callbackId 和自己的业务数据发送给 Web 的 handleMessage() 方法  
        webView.evaluateJavascript("javascript:window.handleMessage('$json')") {  
            Log.e("JsBridge""receive from web: $it")  
        }  
    }
}

再来到 Web 端:

<script>  
    var callbackMap = {};  
    var callbackId = 1000;  
  
    function webCallNative(callback) {  
        callbackId ++;
        // 保存 callbackId 和 callback 的映射关系
        callbackMap[callbackId] = callback;  
        const param = {  
            message"webCallNative",  
            callbackId: callbackId  
        };  
        // 将通信数据和 callbackId 传递给 native
        window.JsBridge.webCallNative(JSON.stringify(param))  
    }

 function handleMessage(message) {
  // 解析 Native 传递的数据
     var param = JSON.parse(message.toString());  
     var message = param.message;
     // 获取 callbackId
     var callbackId = param.callbackId;  
     // 获取 callbackId 从 callbackMap 中取出之前存入的 callback
     var callback = callbackMap[callbackId];  
     // 执行 callback
     callback(message);  
     return "nativeCallWeb"  
 }
</script>

同样,Native 对 Web 的带回调的调用也是一样,只不过角色互换,在 Native 端保存 callBackId 和 callBack 的对应关系。至此,带回调的双向通信就完成了。但是要作为基础设施供团队使用,还差一些火候。

JsBridge 还应该具备哪些能力

混乱不堪的 Bridge 方法

在以往使用开源 JsBridge 的过程中,往往都是各个原生技术团队自行与 H5 约定 Bridge 方法,这样就存在以下几个问题:

  • Bridge 方法混乱不堪,方法定义不尽合理,比如同一功能不同团队之间重复约定。
  • 没有完善文档支持造成方法定义不明确,甚至几次交接之后找不到 Bridge 方法的具体含义
  • 针对一些基础通用的 Bridge 方法,比如 key-value 数据的存取,页面的跳转,分享功能等等,无法形成直接可用的基础能力

当然,这也并不全是框架的问题,但我们可以尝试从源头解决问题,通过向 Web 端提供 Js Sdk 的形式,将 Bridge 方法的定义规范化

怎么理解 Js Sdk 呢?用伪代码表示一下:

var api = {
 putKVfunction (e) { ... },
 getKVfunction (e) { ... },
 sharefunction (e) { ... }
}

只有在 Js Sdk 中事先定义好的 Bridge 方法,才可以正常调用。固定成员负责维护 Js Sdk 和方法说明文档,从源头上解决了混乱不堪的 Bridge 方法。Js SDK 本身也要防止越加越乱,最好遵循这几个原则:

  • 业务无关的公用 Bridge 统一定义,所有团队共同使用
  • 团队独有业务的 Bridge 方法可以考虑单独定义一个 Js Sdk,但也要考虑维护成本
  • 业务方有新增 Bridge 方法的需求时,先判断是否有必要,非必要不添加

现在 Bridge 方法通过 Js SDK 的形式对前端暴露,那么 Native 端应该如何处理呢?我们可以 只通过唯一一个底层 Bridge 方法进行桥接

再回到前面 webCallNative() 的例子:

<script>  
    var callbackMap = {};  
    var callbackId = 1000;  
  
    function webCallNative(callback) {  
        callbackId ++;
        // 保存 callbackId 和 callback 的映射关系
        callbackMap[callbackId] = callback;  
        const param = {  
            message"webCallNative",  
            callbackId: callbackId  
        };  
        // 将通信数据和 callbackId 传递给 native
        window.JsBridge.webCallNative(JSON.stringify(param))  
    }   
</script>

我们只需要简单改造一下传递的 Json 参数。

const param = {  
    apiName"webCallNative"// Bridge 方法名称
    callbackId: callbackId,   // 回调 id
    params"xxx"             // 传递的参数
};

这样通过唯一的桥接方法来传递每一次调用的 方法名称,callbackId,参数,可以更方便的对通信流程做统一处理。在此基础上,就可以解决下一节的问题。

裸露的 Bridge 数据

上面的示例代码中,所有的通信数据都是在裸奔,没有任何安全性。得益于上一节提出的方案,所有通信都被收口到同一个 Bridge 方法,这让我们可以针对通信数据做统一的处理,从而进行数据的完整性校验,或者加解密。具体的安全方案,需要衡量不同方案对通信速度的影响。Js 侧的校验逻辑也可以通过 Native 端 WebView 注入的方式。

理想的接入方式

至此,JsBridge 的核心逻辑其实已经完成了。对于 Web 端,无需区分 iOS 还是 Android,仅需引入 js sdk,然后直接调用 sdk 中已经定义好的方法即可。对于 Native 端,需要再给业务侧提供一个足够好用的 WebView sdk,着重以下几点:

  • 让业务侧可以用足够简洁的,可配置的代码快速展示一个网页
  • 针对通用的,非业务相关的 Bridge 方法,提供基础实现
  • 设计一套简洁易用的机制,让业务侧可以快速对接非公用的 Bridge 方法
  • 针对 WebView 本身的功能完善和优化

总结

JsBridge 的原理很简单,基于注入或者拦截 URL ,但要形成一个可用性良好的 SDK,还是有很多工作可以做的。这里推荐几个开源方案。 

拦截 URL 方案:https://github.com/lzyzsd/JsBridge

Android/iOS 通用:https://github.com/wendux/DSBridge-Android

js-sdk 方案:https://github.com/hcanyz/ZJsBridge-ZJs


继续滑动看下一个
路遥远
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存