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
了的方法了;
主要涉及了WKUserContentController
和 WKScriptMessageHandler
;看下豆包 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