JSBridge实现方案

Posted by Youzi on August 31, 2024

JSBridge 实现方案

本文总结一些 JSBridge(以下简称 JSB)实现方案;JSB 主要是针对 H5 和原生通信的场景。

JSB 为了实现什么目的

通常来说 JSB 应用在 H5 内嵌到原生 webview 的场景,实现 H5 和原生 app 通信;JSB 需要同时实现 H5 调用原生的某些方法,以及原生调用 H5;

原生调用 H5

原生向 H5 页面发送消息,基本原理是原生在 webview 容器中执行一段 JS 脚本;JS 将需要被原生调用的方法挂载在window全局对象上;原生可以通过以下方法动态执行 JS 语句;

平台 方法 描述
Android4.4+ webview.evaluateJavascript() 可以拿到 JS 回调结果
Android4.4 以下 webview.loadUrl() 不能拿到 JS 回调结果
iOS UIWebView webview.stringByEvaluatingJavaScriptFromString() 不能拿到 JS 回调结果
iOS WKWebView webview.evaluateJavascript() 可以拿到 JS 回调结果

值得一提的是,可以拿到回调结果的两个方法,第二个参数就是回调方法,可以拿到 JS 的执行结果;

H5 调用原生

H5 调用原生的原,就是 H5 执行时,某些代码可以被 webview 监听到,然后原生根据内容进行处理;所以基本可以分成两种实现方案;拦截方案和注入方案;

拦截方案

H5 在 webview 中发送的请求,都可以被监听;通信双方通过url scheme的方式,约定好协议就行了;不是约定的协议,就正常转发出去;是约定的协议,就可以触发原生的能力了;

平台 方法 描述
iOS11+ WKURLSchemeHandler WKWebview 使用
iOS11 以下 NSURLProtocol 据说是私有方法
Android shouldOverrideUrlLoading 安卓拦截页面 url
Android onLoadResource 安卓拦截 静态资源
Android shouldInterceptRequest 安卓拦截所有请求

Android 拦截方法

  • shouldOverrideUrlLoading返回是否应该覆盖 URL 加载,如果这个方法返回true,webview 就会停止加载当前的 url;这个方法捕获不了 post 请求,也捕获不到资源加载(图片,css,js 等),http 请求也捕获不到;特别提醒,这个方法很有用处,可以监听到 H5iframe标签发出的请求;后面会提到
  • onLoadResource返回是否应该加载资源,这个方法可以捕获到所有资源的加载;但同样不能捕获不到 http 请求;
  • shouldInterceptRequest可以捕获 webview 中所有的请求;这个方法也有问题,捕获不到 post 请求的 body 参数;

所以其实上述的几个方法都有自己的局限性;只用拦截式的方式,在 Android 平台上还需要 H5 做一些改造,比如所有 post 请求的 body 参数都放到请求 url 上,但同样的这样也会有 url 长度限制;适用于 H5 和原生都是开发可控的场景;

应对的思路可以是通过注入式 api 向 webview 注入 JS 脚本,重写 ajax/fetch 方法,将 body 信息在每次请求时都记录一下;

iOS 拦截方法

iOS 拦截的方法,也根据 iOS 版本分好几类;这个文章介绍挺全面的:iOS 拦截 webview 请求的方式,优缺点也很明显;特别是要提一下NSURLProtocol这个方案会导致 webview 所有 post 请求都丢失 body 参数,所以如果要用这个方案,需要改写 post 方法的传参方式;

拦截方案案例

既然原生可以拦截 H5 的请求,H5 发出请求的方式有这几种:<a/>,<iframe src="xxx"/>,fetch/ajax等等,我们分别讲讲;

  • <a/>标签,这个方案其实是不需要考虑的,这玩意会跳转页面,放弃了;

特别讲一下后两个方案;

  • iframe,这个方案是以前大多数 JSB 的方案,适用于原生接口调用间隔不是特别短的场景;这个开源的 JSB就使用的是这个方案,并且还额外实现了 H5 的消息队列,不需要开发者手动维护回调函数队列了;

这个方案双方要协定一个 url-scheme,iframe 创建时的 src 带着这个 scheme 前缀,并且需要 H5 将回调函数注册到全局window对象上,H5 还得在 src 中带上回调函数的方法名,不然原生不知道回调要调用哪个;最好的就是直接使用上面开源的方案;不过 iframe 的创建和销毁也是会占用一定资源的,也有一定的耗时,如果请求间隔时间很短可能存在问题,详见这个文章:iframe 与 webview ,记录一次使用 jsBridge 遇到的 bug 解决过程

  • fetch/ajax,适用于原生接口需要连续调用的场景,而且这个方案回调很简单;

为了解决iframe的问题,可以尝试在接口连续调用的场景,用这个方案;比如 H5 请求需要原生代理时,或者所有请求需要原生加一些参数啥的,可以考虑用这个方案;这个方案优势在于原生可以直接响应 H5 的请求,H5 不需要额外注册回调函数了;缺点也很明显,在 iOS 低版本上会丢失 body,所以需要改写 post 方法的传参方式;

也可以这两种方案同时用,具体用哪种就是看调用频率,比如需要原生打开相机/相册,就用iframe方案,因为这种场景不会短时间多次调用;像是获取 gps,就用fetch/ajax方案,比较简单,也可能存在多次调用;

注入方案

注入式的原理是通过 webview 注入 JS 脚本,在window对象上注入对象或者方法,JS 调用这些方法时,就可以动态调用到原生的方法了;

Android 注入方案

Android 平台一般通过addJavascriptInterface方法进行注入;这个方法在 Android4.2 版本之前是有漏洞的,因为 JS 可以通过这个方法来调用 java 的方法,由于 java 语言特性,可以通过反射机制获得比指定方法名更多的方法;所以在 4.2 之后,需要添加注解:@JavascriptInterface,这个注解标记了哪些方法可以被 JS 正常调用;

原生注入方式:webView.addJavascriptInterface(new JsBridge(this), "NativeBridge");;JS 调用:window.NativeBridge.showToast('Hello from WebView!');;很有意思(第一次看到我都惊了)

iOS 注入方案

iOS 平台的 uiwebivew 已经被弃用了,所以这里就只讲WKWebView了的方法了;

主要涉及了WKUserContentControllerWKScriptMessageHandler;看下豆包 AI 写的一个例子

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
import UIKit
import WebKit

class ViewController: UIViewController, WKScriptMessageHandler {

    @IBOutlet weak var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()

        // 允许跨域访问,以便进行本地测试
        let webConfiguration = WKWebViewConfiguration()
        webConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
        let userController = WKUserContentController()

        //注入 JavaScript 代码
        let javaScript = WKUserScript(source: "function callNativeMethod(message) { window.webkit.messageHandlers.Native.postMessage(message); }", injectionTime: WKUserScriptInjectionTime.atDocumentStart, forMainFrameOnly: true)

        userController.addUserScript(javaScript)
        webConfiguration.userContentController = userController
        webView.configuration = webConfiguration
        webView.navigationDelegate = self

        // 添加消息处理器,用于接收来自 JavaScript 的消息
        userController.add(self, name: "Native")
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        // 处理来自 JavaScript 的消息
        if message.name == "Native" {
            if let messageBody = message.body as? String {
                print("Received message from JavaScript: \(messageBody)")
                // 调用原生方法
                callNativeMethodInSwift(messageBody)
            }
        }
    }

    func callNativeMethodInSwift(_ message: String) {
       // H5 调用方法,可以在这里跳到对应的控制器,或者调用别的方法
    }
}
1
2
// 调用 JavaScript 方法
window.webkit.messageHandlers.Native.postMessage('Hello from JavaScript!');

总结

总的来说注入式实在是太简单了;H5 不用管原生实现了啥,只要管调用就行了,感觉拓展性很好;拦截式的方案,需要 H5 做一些改造,比如所有 post 请求的 body 参数都放到请求 url 上,但同样的这样也会有 url 长度限制,优点的话就是兼容性比较好,而且如果使用了fetch/ajax的方案,那可以直接进行响应,都不用调用window上的方法;看取舍呗

双方通信

讲完了 H5 调用原生,和原生调用 H5,那如何将一次通信过程串联起来呢?为啥有这个疑问呢,举个例子:H5 发起了请求,需要调用原生获取 GPS 的接口,原生处理消息后获取了 GPS 信息,如何回调给 H5 呢?如果用了拦截式的fetch/ajax方案,那可以直接响应,H5 天然就能接收到结果了;如果使用了其他方案,iframe或者注入式,都需要原生调用window对象上的方法才能给到 H5 回调结果;所以,基于此,就衍生出了串联的方案,也就是上面提到的这个开源的 JSB,实现类似消息队列的方案;

简单讲下实现原理;

原生和 H5 都需要实现一个消息队列,H5 发送消息时,需要生成responseId,将responseId放在消息的某个指定字段里,并且将responseId和回调方法推入消息队列中;原生接收到消息时,也要将消息推入消息队列中,执行完成后,从队列中取出responseId,然后调用window上的指定方法,也要把responseId带上,这样 H5 才能从队列中找到指定的回调函数进行回调;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 回调函数
const getLocationCallback = res => {
  console.log(res);
};
// H5 发送消息
window.NativeBridge.send('getLocation', { responseId: '123456' });
// 推入消息队列
window.NativeBridge.callbackArr.push({
  responseId: '123456',
  callback: getLocationCallback
});
// 执行原生的回调
window.NativeBridge.callback = (responseId, res) => {
  const callback = window.NativeBridge.callbackArr.find(item => item.responseId === responseId);
  if (callback) {
    callback.callback(res);
    window.NativeBridge.callbackArr = window.NativeBridge.callbackArr.filter(item => item.responseId !== responseId);
  }
};
1
2
// 原生接收消息
webview.evaluateJavascript("window.NativeBridge.callback(\"123456\", \"{lat:123,lng:123}\")")

结语

本文梳理了下目前比较主流的JSB方案,推荐几个成熟的JSB方案;顺带一提我们目前项目中用到了其中几个的结合,也算是缝合怪了。

https://github.com/uknownothingsnow/JsBridge

https://github.com/marcuswestin/WebViewJavascriptBridge