jeremygo

jeremygo

我是把下一颗珍珠串在绳子上的人

JSBridge 原理

介绍#

如今移动端盛行的年代,技术选型基本都是混合开发(Hybrid),即结合原生 Native 与 Web H5 两者的优点同时使用这两类技术:

  • 原生技术指 iOS(Objective C、Swift)、Android(Java):开发效率较低,发布依赖用户更新,但性能更高且功能覆盖率更高
  • Web 技术指 JavaScript:开发效率更高,发布更新灵活,但性能较低且功能特性也受限

这种模式下,H5 经常需要使用 Native 的功能,比如打开摄像头、查看本地相册、图片上传、页面分享等等,Native 也需要向 Web 端推送更新状态等。
而 JavaScript 是运行在单独的 JS Context(Webview 容器、JSCore 等),与原生运行环境有隔离,需要有一种机制实现 Native 和 Web 端的双向通信,这种机制就是 JSBridge:

JavaScript 引擎或 Webview 容器作为媒介,通过协定协议进行通信,实现 Native 和 Web 双向通信的一种机制。

通过 JSBridge,Web 端可以调用 Native 端的 Java 接口,Native 端也可以调用 Web 端的 JS 接口,实现双向调用。

通信原理#

JS 调用 Native#

JavaScript 调用 Native 主要有三种方式:注入 API拦截 URL SCHEME重写 prompt 等 JS 全局方法

注入 API#

主要原理是:通过 Webview 提供的接口,向 JS 的运行环境(window)中注入对象或方法,让 JS 调用时直接执行相应的 Native 代码逻辑

iOS UIWebView 实现(主要是 JavaScriptCore

JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];

context[@"postBridgeMessage"] = ^(NSArray<NSArray *> *calls) {
    // Native 逻辑
};

前端调用

window.postBridgeMessage(message)

iOS WKWebView 实现(主要是 WKScriptMessageHandler

@interface WKWebVIewVC ()<WKScriptMessageHandler>

@implementation WKWebVIewVC

- (void)viewDidLoad {
    [super viewDidLoad];

    WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init];
    configuration.userContentController = [[WKUserContentController alloc] init];
    WKUserContentController *userCC = configuration.userContentController;
    // 注入对象,前端调用其方法时,Native 可以捕获到
    [userCC addScriptMessageHandler:self name:@"nativeBridge"];

    WKWebView wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.name isEqualToString:@"nativeBridge"]) {
        NSLog(@"前端传递的数据 %@: ",message.body);
        // Native 逻辑
    }
}

前端调用

window.webkit.messageHandlers.nativeBridge.postMessage(message);

Android 实现

/**
 在 4.2 之前,Android 注入 JavaScript 对象的接口是 addJavascriptInterface,
 但是这个接口有漏洞,可以被不法分子利用,危害用户的安全,
 因此在 4.2 中引入新的接口 @JavascriptInterface 来替代这个接口,解决安全问题。
 所以 Android 注入对对象的方式是 有兼容性问题的
*/  
publicclassJavaScriptInterfaceDemoActivityextendsActivity{
private WebView Wv;

    @Override
    publicvoidonCreate(Bundle savedInstanceState){
        super.onCreate(savedInstanceState);

        Wv = (WebView)findViewById(R.id.webView);     
        final JavaScriptInterface myJavaScriptInterface = new JavaScriptInterface(this);    	 

        Wv.getSettings().setJavaScriptEnabled(true);
        Wv.addJavascriptInterface(myJavaScriptInterface, "nativeBridge");
    }

    publicclassJavaScriptInterface{
         Context mContext;

         JavaScriptInterface(Context c) {
             mContext = c;
         }

         publicvoidpostMessage(String webMessage){	    	
             // Native 逻辑
         }
     }
}

前端调用

window.nativeBridge.postMessage(message);
拦截 URL Scheme#

URL Scheme 是一种类似 url 的链接,为了方便 App 直接相互调用设计,格式如下:
<protocol>://<host>/<path>?<qeury>#fragment
自定义的一个 JSBridge 通信的 URL Scheme:jsbridge://showToast?text=hello

主要原理是:Native 加载 Webview 后,Web 发送的所有请求都会经过 Webview 组件,所以 Native 可以重写对应方法来拦截请求判断符合自定义的 URL Scheme 格式则进行解析处理进而调用原生 Native 方法

Web 发送 URL 请求有以下几种方式

  • a 标签(缺点:需要用户操作)
  • location.href(缺点:可能引起页面跳转丢失调用)
  • ajax 请求(Android 没有相应拦截方法)
  • 使用 iframe src

以上方法中 iframe src 从早期开始就是经常使用的方案,兼容性很好:

  • 安卓提供了shouldOverrideUrlLoading 方法拦截
  • UIWebView 使用 shouldStartLoadWithRequestWKWebView 则使用decidePolicyForNavigationAction

不过也有一定的缺陷:

  • 基于 URL,长度有所限制
  • 创建请求有一定耗时,比起注入 API 的方式调用同样功能耗时更长
重写 prompt 等 JS 全局方法#

Webview 有一个方法 setWebChromeClient 可以设置 WebChromeClient 对象,其中有三个方法 onJsAlertonJsConfirmonJsPrompt,当 JS 调用 window.alertwindow.confirmwindow.prompt 时上面对应的方法也会被触发,利用这个机制也能做一些处理
由于拦截上述方法会对性能造成一定影响,因此需要选择使用频率较低的方法,而在 Android 中,相比其它几个方法,几乎不会使用到 prompt 方法,因此占用 prompt 是最佳方案。

Native 调用 JS#

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

  • iOS UIWebview 使用 stringByEvaluatingJavaScriptFromString
  NSString *jsStr = @"执行的JS代码";
[webView stringByEvaluatingJavaScriptFromString:jsStr];
  • iOS WKWebView 使用 evaluateJavaScript
[webView evaluateJavaScript:@"执行的JS代码" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
  
}];
  • Android 4.4 之前只能使用 loadUrl 实现并且无法执行回调且调用的时候会刷新 WebView
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.loadUrl("javascript: " + jsCode);
  • Android 4.4 之后提供了 evaluateJavascript 来执行 JS 代码可以获取返回值执行回调
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.evaluateJavascript(jsCode, new ValueCallback<String>() {
  @Override
  public void onReceiveValue(String value) {

  }
});

带回调的调用#

上面提到的 Native、Web 双向通信的几种方法站在一端看还是一个单向通信的过程:Web 端调用 Native 方法,Native 直接进行相关操作但无法将结果返回 Web。
那么如何实现对端操作后返回结果呢?

基于之前的单向通信,一端调用时在参数中加一个 callbackId 标记对应的回调,对端收到调用请求后进行实际操作后再进行一次调用将结果、callbackId 回传回来,原端根据 callbackId 匹配相应的回调将结果传入执行即可,实际上两端都走了一次调用。

image

具体例子:在 Web 端点击按钮,获取 Native 端输入框的值,并将值以 Web 端弹窗展示。

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

这样就实现了 Web -> Native 带有回调的调用,同理 Native -> Web 也是一样的逻辑(相对 callbackId 有 bridgeName 的概念)

这里也贴一张完整的调用时序图,可以看到整个过程跟 JSONP 的机制非常相像,需要两端遵循一套调用机制:

image

引用方式#

实际的 JSBridge 引用方式有以下两种,各有利弊:

方式优点缺点
Native 端注入JSBridge 版本容易与 Native 对齐,不用考虑兼容注入时机不确定,需要有失败重试机制
同时 JS 端调用时也要判断是否已注入
JS 端引用时机可控,JS 直接调用方便需要考虑 JSBridge 与 Native 之间多版本的兼容

参考#

JSBridge 的原理
深入浅出 JSBridge:从原理到使用
JSBridge 原理与实践

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。