JS Bridge 通信原理,一文说清

具体可以参考苹果开发者官网文档:Defining a Custom URL Scheme for Your App

JS 调用 Native


在 iOS 里面又需要区分 UIWebView 和 WKWebView 两种 WebView:

WKWebView 是 iOS8 之后出现的,目的是取代笨重的 UIWebView,它占用内存更少,大概是 UIWebView 的 1/3,支持更好的 HTML5 特性,性能更加强大。

但也有一些缺点,比如不支持缓存,需要自己注入 Cookie,发送 POST 请求的时候带不了参数,拦截 POST 请求的时候无法解析参数等等。

「题外话(小小吐槽一下)」

我们这边有很多场景是需要客户端和银行交互的,为了从银行 H5 回调到我们的客户端,常常是会让银行跳到我们的 H5 中间页,客户端去拦截并解析银行带来的参数。

以前有些页面是 Python 服务端渲染做的,支持 POST 请求打开页面。后来 iOS 客户端升级  WKWebView,导致无法获取银行 POST 带过来的参数。

为了配合客户端发版上线,曾经连夜修改了我们 App 里面所有相关的绑卡和支付 H5 页面,还好没有搞出重大线上事故。

JS 调用 Native 通信大致有三种方法:

  1. 拦截 Scheme

  2. 弹窗拦截

  3. 注入 JS 上下文

这三种方式总体上各有利弊,下面会一一介绍。

拦截 Scheme

仔细思考一下,如果是 JS 和 Java 之间传递数据,我们该怎么做呢?

对于前端开发来说,调 Ajax 请求接口是最常见的需求了。不管对方是 Java 还是 Python,我们都可以通过 http/https 接口来获取数据。实际上这个流程和 JSONP 更加类似。

已知客户端是可以拦截请求的,那么可不可以在这个上面做文章呢?

如果我们请求一个不存在的地址,上面带了一些参数,通过参数告诉客户端我们需要调用的功能呢?

比如我要调用扫码功能:

axios.get(‘http://xxxx?func=scan&callback_id=yyyy’)

客户端可以拦截这个请求,去解析参数上面的 func 来判断当前需要调起哪个功能。客户端调起扫码功能之后,会获取 WebView 上面的 callbacks 对象,根据 callback_id 回调它。

所以基于上面的例子,我们可以把域名和路径当做通信标识,参数里面的 func 当做指令,callback_id 当做回调函数,其他参数当做数据传递。对于不满足条件的 http 请求不应该拦截。

当然了,现在主流的方式是前面我们看到的自定义 Scheme 协议,以这个为通信标识,域名和路径当做指令。

这种方式的好处就是 iOS6 以前只支持这种方式,兼容性比较好。

JS 端

我们有很多种方法可以发起请求,目前使用最广泛的是 iframe 跳转:

  1. 使用 a 标签跳转

点击我打开淘宝

  1. 重定向

location.href = “taobao://”

  1. iframe 跳转

const iframe = document.createElement(“iframe”);

iframe.src = “taobao://”

iframe.style.display = “none”

document.body.appendChild(iframe)

Android 端

在 Android 侧可以用 shouldOverrideUrlLoading 来拦截 url 请求。

@Override

public boolean shouldOverrideUrlLoading(WebView view, String url) {

if (url.startsWith(“taobao”)) {

// 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法

return true;

}

}

iOS 端

在 iOS 侧需要区分 UIWebView 和 WKWebView 两种方式。在 UIWebView 中:

- (BOOL)shouldStartLoadWithRequest:(NSURLRequest *)request

navigationType:(BPWebViewNavigationType)navigationType

{

if (xxx) {

// 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法

return NO;

}

return [super shouldStartLoadWithRequest:request navigationType:navigationType];

}

在 WKWebView 中:

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(nonnull WKNavigationAction *)navigationAction decisionHandler:(nonnull void (^)(WKNavigationActionPolicy))decisionHandler

{

if(xxx) {

// 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法

BLOCK_EXEC(decisionHandler, WKNavigationActionPolicyCancel);

} else {

BLOCK_EXEC(decisionHandler, WKNavigationActionPolicyAllow);

}

[self.webView.URLLoader webView:webView decidedPolicy:policy forNavigationAction:navigationAction];

}

目前不建议只使用拦截 URL Scheme 解析参数的形式,主要存在几个问题。

  1. 连续续调用 location.href 会出现消息丢失,因为 WebView 限制了连续跳转,会过滤掉后续的请求。

  2. URL 会有长度限制,一旦过长就会出现信息丢失 因此,类似 WebViewJavaScriptBridge 这类库,就结合了注入 API 的形式一起使用,这也是我们这边目前使用的方式,后面会介绍一下。

弹窗拦截

Android 实现

这种方式是利用弹窗会触发 WebView 相应事件来拦截的。

一般是在 setWebChromeClient 里面的 onJsAlertonJsConfirmonJsPrompt 方法拦截并解析他们传来的消息。

// 拦截 Prompt

@Override

public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {

if (xxx) {

// 解析 message 的值,调用对应方法

}

return super.onJsPrompt(view, url, message, defaultValue, result);

}

// 拦截 Confirm

@Override

public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {

return super.onJsConfirm(view, url, message, result);

}

// 拦截 Alert

@Override

public boolean onJsAlert(WebView view, String url, String message, JsResult result) {

return super.onJsAlert(view, url, message, result);

}

iOS 实现

我们以 WKWebView 为例:

+ (void)webViewRunJavaScriptTextInputPanelWithPrompt:(NSString *)prompt

defaultText:(NSString *)defaultText

completionHandler:(void (^)(NSString * _Nullable))completionHandler

{

/** Triggered by JS:

var person = prompt(“Please enter your name”, “Harry Potter”);

if (person == null || person == “”) {

txt = “User cancelled the prompt.”;

} else {

txt = "Hello " + person + “! How are you today?”;

}

*/

if (xxx) {

BLOCK_EXEC(completionHandler, text);

} else {

BLOCK_EXEC(completionHandler, nil);

}

}

这种方式的缺点就是在 iOS 上面 UIWebView 不支持,虽然 WKWebView 支持,但它又有更好的 scriptMessageHandler,比较尴尬。

注入上下文

前面我们有讲过在 iOS 中内置了 JavaScriptCore 这个框架,可以实现执行 JS 以及注入 Native 对象等功能。

这种方式不依赖拦截,主要是通过 WebView 向 JS 的上下文注入对象和方法,可以让 JS 直接调用原生。

PS:iOS 中的 Block 是 OC 对于闭包的实现,它本质上是个对象,定义 JS 里面的函数。

iOS UIWebView

iOS 侧代码:

// 获取 JS 上下文

JSContext *context = [webview valueForKeyPath:@“documentView.webView.mainFrame.javaScriptContext”];

// 注入 Block

context[@“callHandler”] = ^(JSValue * data) {

// 处理调用方法和参数

// 调用 Native 功能

// 回调 JS Callback

}

JS 代码:

window.callHandler(JSON.stringify({

type: “scan”,

data: “”,

callback: function(data) {

}

}));

这种方式的牛逼之处在于,JS 调用是同步的,可以立马拿到返回值。

我们也不再需要像拦截方式一样,每次传值都要把对象做 JSON.stringify,可以直接传 JSON 过去,也支持直接传一个函数过去。

iOS WKWebView

WKWebView 里面通过 addScriptMessageHandler 来注入对象到 JS 上下文,可以在 WebView 销毁的时候调用 removeScriptMessageHandler 来销毁这个对象。

前端调用注入的原生方法之后,可以通过 didReceiveScriptMessage 来接收前端传过来的参数。

WKWebView *wkWebView = [[WKWebView alloc] init];

WKWebViewConfiguration *configuration = wkWebView.configuration;

WKUserContentController *userCC = configuration.userContentController;

// 注入对象

[userCC addScriptMessageHandler:self name:@“nativeObj”];

// 清除对象

[userCC removeScriptMessageHandler:self name:@“nativeObj”];

// 客户端处理前端调用

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message

{

// 获取前端传来的参数

NSDictionary *msgBody = message.body;

// 如果是 nativeObj 就进行相应处理

if (![message.name isEqualToString:@“nativeObj”]) {

//

return;

}

}

使用 addScriptMessageHandler 注入的对象实际上只有一个 postMessage 方法,无法调用更多自定义方法。前端的调用方式如下:

window.webkit.messageHandlers.nativeObj.postMessage(data);

需要注意的是,这种方式要求 iOS8 及以上,而且返回不是同步的。和 UIWebView 一样的是,也支持直接传 JSON 对象,不需要 stringify。

Android addJavascriptInterface

安卓4.2之前注入 JS 一般是使用 addJavascriptInterface ,和前面的 addScriptMessageHandler 有一些类似,但又没有它的限制。

public void addJavascriptInterface() {

mWebView.addJavascriptInterface(new DatePickerJSBridge(), “DatePickerBridge”);

}

private class PickerJSBridge {

public void _pick(…) {

}

}

在 JS 里面调用:

window.DatePickerBridge._pick(…)

但这种方案有一定风险,可以参考这篇文章:WebView中接口隐患与手机挂马利用

在 Android4.2 之后提供了 @JavascriptInterface 注解,暴露给 JS 的方法必须要带上这个。

所以前面的 _pick 方法需要带上这个注解。

private class PickerJSBridge {

@JavascriptInterface

public void _pick(…) {

}

}

Native 调用 JS


Native 调用 JS 一般就是直接 JS 代码字符串,有些类似我们调用 JS 中的 eval 去执行一串代码。一般有 loadUrlevaluateJavascript 等几种方法,这里逐一介绍。

但是不管哪种方式,客户端都只能拿到挂载到 window 对象上面的属性和方法。

Android

在 Android 里面需要区分版本,在安卓4.4之前的版本支持 loadUrl,使用方式类似我们在 a 标签的 href 里面写 JS 脚本一样,都是javascript:xxx 的形式。

这种方式无法直接获取返回值。

webView.loadUrl(“javascript:foo()”)

在安卓4.4以上的版本一般使用 evaluateJavascript 这个 API 来调用。这里需要判断一下版本。

if (Build.VERSION.SDK_INT > 19) //see what wrapper we have

{

webView.evaluateJavascript(“javascript:foo()”, null);

} else {

webView.loadUrl(“javascript:foo()”);

}

UIWebView

在 iOS 的 UIWebView 里面使用 stringByEvaluatingJavaScriptFromString 来调用 JS 代码。这种方式是同步的,会阻塞线程。

results = [self.webView stringByEvaluatingJavaScriptFromString:“foo()”];

WKWebView

WKWebView 可以使用 evaluateJavaScript 方法来调用 JS 代码。

[self.webView evaluateJavaScript:@“document.body.offsetHeight;” completionHandler:^(id _Nullable response, NSError * _Nullable error) {

// 获取返回值 response

}];

JS Bridge 设计


前面讲完了 JS 和 Native 互调的所有方法,这里来介绍一下我们这边 JS Bridge 的设计吧。

我们这边的 JS Bridge 通信是基于 WebViewJavascriptBridge 这个库来实现的。

主要是结合 Scheme 协议+上下文注入来做。考虑到 Android 和 iOS 不一样的通信方式,这里进行了封装,保证提供给外部的 API 一致。

具体功能的调用我们封装成了 npm 包,下面的是几个基础 API:

  1. callHandler(name, params, callback):这个是调用 Native 功能的方法,传模块名、参数、回调函数给 Native。

  2. hasHandler(name):这个是检查客户端是否支持某个功能的调用。

  3. registerHandler(name):这个是提前注册一个函数,等待 Native 回调,比如 pageDidBack 这种场景。

那么这几个 API 又是如何实现的呢?这里 Android 和 iOS 封装不一致,应当分开来说。

Android Bridge

前面我们有说过安卓可以通过 @JavascriptInterface 注解来将对象和方法暴露给 JS。

所以这里的几个方法都是通过注解暴露给 JS 来调用的,在 JS 层面做了一些兼容处理。

hasHandler

首先最简单的是这个 hasHandler,就是在客户端里面维护一张表(其实我们是写死的),里面有支持的 Bridge 模块信息,只需要用 switch...case 判断一下就行了。

@JavascriptInterface

public boolean hasHandler(String cmd) {

switch (cmd) {

case xxx:

case yyy:

case zzz:

return true;

}

return false;

}

callHandler

然后我们来看 callHandler 这个方法,它是提供 JS 调用 Native 功能的方法。在调用这个方法之前,我们一般需要先判断一下 Native 是否支持这个功能。

function callHandler(name, params, callback) {

if (!window.WebViewJavascriptBridge.hasHandler(name)) {

}

}

如果 Native 没有支持这个 Bridge,我们就需要对回调进行兼容性处理。这个兼容性处理包括两个方面,一个是功能方面,一个是 callback 的默认回参。

比如我们调用 Native 的弹窗功能,如果客户端没支持这个 Bridge,或者我们是在浏览器里面打开的这个页面,此时应该降级到使用 Web 的 alert 弹窗。

对于 callback,我们可以默认给传个 0,表示当前不支持这个功能。

假设这个 alert 的 bridge 接收两个参数,分别是 titlecontent,那么此时就应该使用浏览器自带的 alert 展示出来。

function fallback(params, callback) {

let content = ${params.title}\n{params.content}

window.alert(content);

callback && callback(0)

}

这个 fallback 函数我们希望能够更加通用,每个调用方法都应该有自己的 fallback 函数,所以前面的 callHandler 应该设计成这样:

function callHandler(name, params, fallback) {

return function(…rest, callback) {

const paramsList = {};

for (let i = 0; i < params.length; i++) {

paramsList[params] = rest[i];

}

if (!callback) {

callback = function(result) {};

}

if (fallback && !window.WebViewJavascriptBridge.hasHandler(name))) {

fallback(paramsList, callback);

} else {

window.WebViewJavascriptBridge.callHandler(name, params, callback);

}

}

}

我们可以基于这个函数封装一些功能方法,比如前面的 alert:

function fallback(params, callback) {

let content = ${params.title}\n{params.content}

window.alert(content);

callback && callback(0)

}

function alert(

title,

content,

cb: any

) {

return callHandler(

‘alert’,

[‘title’, ‘content’],

fallback

)(title, content, cb);

}

alert(this is titlehahaha, function() {

console.log(‘success’)

})

具体效果类似下面这种,这是从 Google 上随便找的一张图(侵删):

那么客户端又如何实现回调 callback 函数的呢?前面说过,客户端想调用 JS 方法,只能调用挂载到 window 对象上面的。

因此,这里使用了一种很巧妙的方法,实际上 callback 函数依然是 JS 执行的。

在调用 Native 之前,我们可以先将 callback 函数和一个 uniqueId 映射起来,然后存在 JS 本地。我们只需要将 callbackId 传给 Native 就行了。

function callHandler(name, data, callback) {

const id = cb_${uniqueId++}_${new Date().getTime()};

callbacks[id] = callback;

window.bridge.send(name, JSON.stringify(data), callbackId)

}

在客户端这里,当 send 方法接收到参数之后,会执行相应功能,然后使用 webView.loadUrl 主动调用前端的一个接收函数。

@JavascriptInterface

public void send(final String cmd, String data, final String callbackId) {

// 获取数据,根据 cmd 来调用对应功能

// 调用结束后,回调前端 callback

String js = String.format(“javascript: window.bridge.onReceive(‘%1KaTeX parse error: Expected group as argument to '\'' at end of input: s\', \'%2s’);”, callbackId, result.toDataString());

webView.loadUrl(js);

}

所以 JS 需要事前定义好这个 onReceive 方法,它接收一个 callbackId 和一个 result。

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

最后

由于篇幅限制,pdf文档的详解资料太全面,细节内容实在太多啦,所以只把部分知识点截图出来粗略的介绍,每个小节点里面都有更细化的内容!有需要的程序猿(媛)可以帮忙点赞+点击【学习资料】即可免费领取!

String.format(“javascript: window.bridge.onReceive(‘%1KaTeX parse error: Expected group as argument to '\'' at end of input: s\', \'%2s’);”, callbackId, result.toDataString());

webView.loadUrl(js);

}

所以 JS 需要事前定义好这个 onReceive 方法,它接收一个 callbackId 和一个 result。

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数前端工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Web前端开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。

[外链图片转存中…(img-FmhJhWCC-1712163343479)]

[外链图片转存中…(img-K2ckWYrE-1712163343480)]

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上前端开发知识点,真正体系化!

[外链图片转存中…(img-p9gcGryW-1712163343480)]

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:前端)

最后

由于篇幅限制,pdf文档的详解资料太全面,细节内容实在太多啦,所以只把部分知识点截图出来粗略的介绍,每个小节点里面都有更细化的内容!有需要的程序猿(媛)可以帮忙点赞+点击【学习资料】即可免费领取!

[外链图片转存中…(img-9NcpTm0s-1712163343480)]

[外链图片转存中…(img-wTVSmCTm-1712163343480)]

  • 24
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值