Skip to content
微信公众号

JSBridge

在 iOS 和 Android 系统上运行 JavaScript 并不是一件难事儿,但是对于一个真正意义上的跨平台应用来说,还需要做到H5(即 WebView 容器)和原生平台的交互,于是 JSBridge 技术就诞生了。

JS和客户端通讯的基本流程就是JS访问客户端能力,传递参数和回调函数,然后客户端通过回调函数返回内容。

起源

主要原因JavaScript主要载体Web是当前世界上的最易编写、最易维护、最易部署的UI构建方式。工程师可以用很简单的HTML标签和CSS样式快速的构建出一个页面,并且在服务端部署后,用户不需要主动更新,就能看到最新的UI展现

因此,开发维护成本和更新成本较低的Web技术称为混合开发中几乎不二的选择,而作为Web技术逻辑核心的JavaScript也理所应当肩负起与其他技术桥接的责任,并且作为移动端不可缺少的一部分,任何一个移动操作系统中都包含可运行JavaScript的容器,例如Webview和JSCore。所以,运行JavaScript不用像运行其他语言时,要额外添加运行环境。因此,基于上面种种原因,JSBridge应运而生。

实现原理

Web端和Native可以类比于Client/Server模式,Web端调用原生接口时就如同Client向Server端发送一个请求类似,JSB在此充当类似于HTTP协议的角色,实现JSBridge主要是两点:

  1. 将Native端原生接口封装成JavaScript接口
  2. 将Web端JavaScript接口封装成原生接口

Native调用JS

首先来说Native端调用Web端,这个比较简单,JavaScript作为解释性语言,最大的一个特性就是可以随时随地地通过解释器执行一段JS代码,所以可以将拼接的JavaScript代码字符串,传入JS解析器执行就可以,JS解析器在这里就是webView。

Android 4.4之前只能用loadUrl来实现,并且无法执行回调:

java
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.loadUrl("javascript: " + jsCode);

Android 4.4之后提供了evaluateJavascript来执行JS代码,并且可以获取返回值执行回调:

java
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.evaluateJavascript(jsCode, new ValueCallback<String>() {
  @Override
  public void onReceiveValue(String value) {

  }
});

iOS的UIWebView使用stringByEvaluatingJavaScriptFromString:

txt
NSString *jsStr = @"执行的JS代码";
[webView stringByEvaluatingJavaScriptFromString:jsStr];

iOS的WKWebView使用evaluateJavaScript:

txt
[webView evaluateJavaScript:@"执行的JS代码" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
  
}];

JS调用Native

Web调用Native端主要有三种方式:

  1. 拦截 Scheme
  2. 弹窗拦截
  3. 注入 JS 上下文

拦截Webview请求的URL Schema

前端和客户端通信传递参数和callback的这种方式,其本质和前端和后端发送http请求类似,前后端通信依赖于http协议,那么前端和客户端通信自然而然也会有一种协议来约定,这种协议称为schema协议。

URL Schema是类URL的一种请求格式,格式如下:

js
<protocol>://<host>/<path>?<qeury>#fragment

我们可以自定义JSBridge通信的URL Schema,比如:jsbridge://showToast?text=hello

Native加载WebView之后,Web发送的所有请求都会经过WebView组件,所以Native可以重写WebView里的方法,从来拦截Web发起的请求,我们对请求的格式进行判断:

  • 如果符合我们自定义的URL Schema,对URL进行解析,拿到相关操作、操作,进而调用原生Native的方法
  • 如果不符合我们自定义的URL Schema,我们直接转发,请求真正的服务

Web发送URL请求的方法有这么几种:

  • a标签
  • location.href
  • 使用iframe.src
  • 发送ajax请求

这些方法,a标签需要用户操作,location.href可能会引起页面的跳转丢失调用,发送ajax请求Android没有相应的拦截方法,所以使用iframe.src是经常会使用的方案:

  • 安卓提供了shouldOverrideUrlLoading方法拦截
java
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
    if (url.startsWith("taobao")) {
        // 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法
        return true;
    }
}
  • UIWebView使用shouldStartLoadWithRequest,WKWebView则使用decidePolicyForNavigationAction
txt
- (BOOL)shouldStartLoadWithRequest:(NSURLRequest *)request
                    navigationType:(BPWebViewNavigationType)navigationType
{

    if (xxx) {
        // 拿到调用路径后解析调用的指令和参数,根据这些去调用 Native 方法
        return NO;
    }

    return [super shouldStartLoadWithRequest:request navigationType:navigationType];
}
txt
- (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协议打开原生app
  • H5页面点击锚点,根据锚点具体跳转路径APP端跳转具体的页面
  • APP端收到服务器端下发的PUSH通知栏消息,根据消息的点击跳转路径跳转相关页面
  • APP根据URL跳转到另外一个APP指定页面
  • 通过短信息中的url打开原生APP

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

  1. 连续续调用 location.href 会出现消息丢失,因为 WebView 限制了连续跳转,会过滤掉后续的请求。
  2. URL 会有长度限制,一旦过长就会出现信息丢失 因此,类似 WebViewJavaScriptBridge 这类库,就结合了注入 API 的形式一起使用,这也是我们这边目前使用的方式,后面会介绍一下。

弹窗拦截

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

Android端一般是在 setWebChromeClient 里面的 onJsAlert、onJsConfirm、onJsPrompt 方法拦截并解析他们传来的消息。

java
// 拦截 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);
   }

由于拦截上述方法会对性能造成一定影响,因此需要选择使用频率较低的方法,而在Android中,相比其它几个方法,几乎不会使用到

iOS 由于安全机制, WKWebView 对 alert、confirm、prompt 等方法做了拦截,如果通过此方式进行 Native 与 JS 交互,需要实现 WKWebView 的三个 WKUIDelegate 代理方法。

iOS中我们以 WKWebView 为例:

txt
+ (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,比较尴尬。

向Webview中注入JS API

这个方法会通过webView提供的接口,App将Native的相关接口注入到JS的Context(window)的对象中,一般来说这个对象内的方法名与Native相关方法名是相同的,Web端就可以直接在全局window下使用这个暴露的全局JS对象,进而调用原生端的方法。

这个过程会更加简单直观,不过有兼容性问题,大多数情况下都会使用这种方式

Android(4.2+)提供了addJavascriptInterface注入:

java
// 注入全局JS对象
webView.addJavascriptInterface(new NativeBridge(this), "NativeBridge");

class NativeBridge {
  private Context ctx;
  NativeBridge(Context ctx) {
    this.ctx = ctx;
  }

  // 增加JS调用接口
  @JavascriptInterface
  public void showNativeDialog(String text) {
    new AlertDialog.Builder(ctx).setMessage(text).create().show();
  }
}

在Web端直接调用这个方法即可:

js
window.NativeBridge.showNativeDialog('hello');

iOS的UIWebView提供了JavaSciptCore,可以实现执行 JS 以及注入 Native 对象等功能。这种方式不依赖拦截,主要是通过 WebView 向 JS 的上下文注入对象和方法,可以让 JS 直接调用原生。

txt
// 获取 JS 上下文
JSContext *context = [webview valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
// 注入 Block
context[@"callHandler"] = ^(JSValue * data) {
    // 处理调用方法和参数
    // 调用 Native 功能
    // 回调 JS Callback
}

Web端调用

js
window.callHandler(JSON.stringify({
    type: "scan",
    data: "",
    callback: function(data) {
    }
}));

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

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

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

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

txt
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 方法,无法调用更多自定义方法。前端的调用方式如下:

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

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

带回调的调用

上面已经说到了Native、Web间双向通信的两种方法,但站在一端而言还是一个单向通信的过程 ,比如站在Web的角度:Web调用Native的方法,Native直接相关操作但无法将结果返回给Web,但实际使用中会经常需要将操作的结果返回,也就是JS回调。

所以在对端操作并返回结果,有输入有输出才是完整的调用,那如何实现呢?

其实基于之前的单向通信就可以实现,我们在一端调用的时候在参数中加一个callbackId标记对应的回调,对端接收到调用请求后,进行实际操作,如果带有callbackId,对端再进行一次调用,将结果、callbackId回传回来,这端根据callbackId匹配相应的回调,将结果传入执行就可以了。

可以看到实际上还是通过两次单项通信实现的。

以Android,在Web端实现带有回调的JSB调用为例:

js
// Web端代码:
<body>
  <div>
    <button id="showBtn">获取Native输入,以Web弹窗展现</button>
  </div>
</body>
<script>
  let id = 1;
  // 根据id保存callback
  const callbackMap = {};
  // 使用JSSDK封装调用与Native通信的事件,避免过多的污染全局环境
  window.JSSDK = {
    // 获取Native端输入框value,带有回调
    getNativeEditTextValue(callback) {
      const callbackId = id++;
      callbackMap[callbackId] = callback;
      // 调用JSB方法,并将callbackId传入
      window.NativeBridge.getNativeEditTextValue(callbackId);
    },
    // 接收Native端传来的callbackId
    receiveMessage(callbackId, value) {
      if (callbackMap[callbackId]) {
        // 根据ID匹配callback,并执行
        callbackMap[callbackId](value);
      }
    }
  };

	const showBtn = document.querySelector('#showBtn');
  // 绑定按钮事件
  showBtn.addEventListener('click', e => {
    // 通过JSSDK调用,将回调函数传入
    window.JSSDK.getNativeEditTextValue(value => window.alert('Natvie输入值:' + value));
  });
</script>
java
// Android端代码
webView.addJavascriptInterface(new NativeBridge(this), "NativeBridge");

class NativeBridge {
  private Context ctx;
  NativeBridge(Context ctx) {
    this.ctx = ctx;
  }

  // 获取Native端输入值
  @JavascriptInterface
  public void getNativeEditTextValue(int callbackId) {
    MainActivity mainActivity = (MainActivity)ctx;
    // 获取Native端输入框的value
    String value = mainActivity.editText.getText().toString();
    // 需要注入在Web执行的JS代码
    String jsCode = String.format("window.JSSDK.receiveMessage(%s, '%s')", callbackId, value);
    // 在UI线程中执行
    mainActivity.runOnUiThread(new Runnable() {
      @Override
      public void run() {
        mainActivity.webView.evaluateJavascript(jsCode, null);
      }
    });
  }
}

以上代码简单实现了一个demo,在Web端点击按钮,会获取Native端输入框的值,并将值以Web端弹窗展现,这样就实现了Web->Native带有回调的JSB调用,同理Native->Web也是同样的逻辑,不同的只是将callback保存在Native端罢了,在此就不详细论述了。

JSBridge 如何引用

对于 JSBridge 的引用,常用有两种方式,各有利弊。

由 Native 端进行注入

注入方式和 Native 调用 JavaScript 类似,直接执行桥的全部代码。

它的优点在于:桥的版本很容易与 Native 保持一致,Native 端不用对不同版本的 JSBridge 进行兼容;

它的缺点是:注入时机不确定,需要实现注入失败后重试的机制,保证注入的成功率,同时 JavaScript 端在调用接口时,需要优先判断 JSBridge 是否已经注入成功。

由 JavaScript 端引用

与由 Native 端注入正好相反,它的优点在于:JavaScript 端可以确定 JSBridge 的存在,直接调用即可;

缺点是:如果桥的实现方式有更改,JSBridge 需要兼容多版本的 Native Bridge 或者 Native Bridge 兼容多版本的 JSBridge。

开源的JSBridge

可以看到,实现一个完整的JSBridge还是挺麻烦的,还需要考虑低端机型的兼容问题、同步异步调用问题,好在已经有开源的JSBridge供我们直接使用了:

相关文章

本站总访问量次,本站总访客数人次
Released under the MIT License.