# 前言

为了提高开发效率,开发人员往往会使用原生 app 里面嵌套前端 H5 页面的快速开发方式,这就要涉及到 H5 和原生的相互调用,互相传递数据,接下来就实践项目中的交互方式做一个简单的记录分享,废话不多说,直接上正文:

由于安卓和 ios 的处理方式不一样,所以我们要分开处理先贴上判断访问终端的代码

//判断访问终端
function browserVersion(){
    var u = navigator.userAgent;
    return {
      trident: u.indexOf('Trident') > -1, //IE内核
      presto: u.indexOf('Presto') > -1, //opera内核
      webKit: u.indexOf('AppleWebKit') > -1, //苹果、谷歌内核
      gecko: u.indexOf('Gecko') > -1 && u.indexOf('KHTML') == -1,//火狐内核
      mobile: !!u.match(/AppleWebKit.*Mobile.*/), //是否为移动终端
      ios: !!u.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/), //ios终端
      android: u.indexOf('Android') > -1 || u.indexOf('Adr') > -1, //android终端
      iPhone: u.indexOf('iPhone') > -1 , //是否为iPhone或者QQHD浏览器
      iPad: u.indexOf('iPad') > -1, //是否iPad
      webApp: u.indexOf('Safari') == -1, //是否web应该程序,没有头部与底部
      weixin: u.indexOf('MicroMessenger') > -1, //是否微信 (2015-01-22新增)
      qq: u.match(/\sQQ/i) == " qq" //是否QQ
    };

# 通信原理之先了解 webview

IOS容器在 IOS 客户端中,我们首先要提到的是一个叫UIWebView的容器,苹果对他的介绍是:

UIWebView 是一个可加载网页的对象,它有浏览记录功能,且对加载的网页内容是可编程的。说白了 UIWebView 有类似浏览器的功能,我们使用可以它来打开页面,并做一些定制化的功能,如可以让 js 调某个方法可以取到手机的 GPS 信息。

但需要注意的是,Safari 浏览器使用的浏览器控件和 UIwebView 组件并不是同一个,两者在性能上有很大的差距。幸运的是,苹果发布 iOS8 的时候,新增了一个 WKWebView 组件容器,如果你的 APP 只考虑支持 iOS8 及以上版本,那么你就可以使用这个新的浏览器控件了。

WKWebView 重构了原有 UIWebView 的 14 个类,3 个协议,性能提升的同时,赋予了开发者更加细致的配置(这些配置仅针对客户端 IOS 开发,对于前端 H5 来说,保持两种容器调用方法的一致性很重要)。

Android 容器在安卓客户端中,webView 容器与手机自带的浏览器内核一致,多为 android-chrome。不存在兼容性和性能问题。

RN 容器在 react-native 开发中,从 rn 0.37 版本开始官方引入了组件,在安卓中调用原生浏览器,在 IOS 中默认调用的是 UIWebView 容器。从 IOS12 开始,苹果正式弃用 UIWebView,统一采用 WKWebView。

RN 从 0.57 起,可指定使用 WKWebView 作为 WebView 的实现

// rn js code
<WebView useWebKit={true} source={{ url: 'https://m.douyu.com' }} />

WebView 组件不要嵌套在或原生点击组件中,会造成 H5 内页面滚动失效

# H5 向 ios 客户端发送消息

在 ios 中,并没有现成的 api 让 js 去调用 native 的方法,但是 UIWebView 与 WKWebView 能够拦截 H5 内发起的所有网络请求。所以我们的思路就是通过在 H5 内发起约定好的特定协议的网络请求,如’jsbridge://bridge2.native?params=’ + encodeURIComponent(obj)然后带上你要传递给 ios 的参数;然后在客户端内拦截到指定协议头的请求之后就阻止该请求并解析 url 上的参数,执行相应逻辑

在 H5 中发起这种特定协议的请求方式分两种:

  1. 通过 localtion.href; 通过 location.href 有个问题,就是如果我们连续多次修改 window.location.href 的值,在 Native 层只能接收到最后一次请求,前面的请求都会被忽略掉。

  2. 通过 iframe 方式; 使用 iframe 方式,以唤起 Native;以唤起分享组件为例

// H5 js code 将它封装一下
function createIframe(url){
  var url = 'jsbridge://getShare?title=分享标题&desc=分享描述&link=http%3A%2F%2Fwww.douyu.com&cbName=jsCallClientBack';
  var iframe = document.createElement('iframe');
  iframe.style.width = '1px';
  iframe.style.height = '1px';
  iframe.style.display = 'none';
  iframe.src = https://segmentfault.com/a/url;
  document.body.appendChild(iframe);
  setTimeout(function() {
    iframe.remove();
  }, 100);
}

然后客户端通过拦截这个请求,并且解析出相应的方法和参数:这里以 ios 为例:

// IOS swift code
func webView(webView: UIWebView, shouldStartLoadWithRequest request: NSURLRequest, navigationType: UIWebViewNavigationType) -> Bool {
  let url = request.URL
  let scheme = url?.scheme
  let method = url?.host
  let query = url?.query
  if url != nil && scheme == "jsbridge" {
    switch method! {
      case "getShare":
        self.getShare()
      default:
        print("default")
    }
    return false
  } else {
    return true
  }
}

看不懂就略过,非重点。。。。。

这里我们在请求参数中加上了 cbName=jsCallClientBack,这个 jsCallClientBack 为 JS 调用客户端所定义的回调函数,在业务层 jsBridge 封装中,我们传入一个匿名函数作为回调,底层将这个函数绑定在 window 的 jsbridge 对象下并为其定义一个独一无二的 key,这个 key 就是 jsCallClientBack,客户端在处理完逻辑后,会通过上面已经介绍过的方法来回调 window 下的方法。

ps: 在将回调绑定在 window 下时,特别注意要使用 bind 保持函数内 this 的原有指向不变

# IOS 客户端调用 H5 方法

Native 调用 Javascript 语言,是通过 UIWebView 组件的 stringByEvaluatingJavaScriptFromString 方法来实现的,该方法返回 js 脚本的执行结果。

// IOS swift code
webview.stringByEvaluatingJavaScriptFromString('window.methodName()');

从上面代码可以看出它其实就是执行了一个字符串化的 js 代码,调用了 window 下的一个对象,如果我们要让 native 来调用我们 js 写的方法,那这个方法就要在 window 下能访问到。但从全局考虑,我们只要暴露一个对象如 JSBridge 给 native 调用就好了。

调用客户端原生方法的回调函数也将绑在 window 下供客户端成功反调用,实际上一次调用客户端方法最后产生的结果是双向互相调用。

# H5 调用 Android 客户端方法

在安卓 webView 中有三种调用 native 的方式:

通过 schema 方式,客户端使用 shouldOverrideUrlLoading 方法对 url 请求协议进行解析。这种 js 的调用方式与 ios 的一样,使用 iframe 来调用 native 方法。通过在 webview 页面里直接注入原生 js 代码方式,使用 addJavascriptInterface 方法来实现。

// android JAVA code
  class JSInterface {
    @JavascriptInterface
    public String getShare() {
      //...
      return "share";
    }
}
webView.addJavascriptInterface(new JSInterface(), "AndroidNativeApi");

上面的代码就是在页面的window对象里注入了AndroidNativeApi对象。在 js 里可以直接调用原生方法。

使用prompt,console.log,alert方式,这三个方法对 js 里是属性原生的,在android webview这一层是可以重写这三个方法的。一般我们使用prompt,因为这个在 js 里使用的不多,用来和 native 通讯副作用比较少。

 // android JAVA code
class WebChromeClient extends WebChromeClient {
    @Override
    public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
      // 重写window下的prompt,通过result返回结果
    }
    @Override
    public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
    }
    @Override
    public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
    }
}

一般而言安卓客户端选用 1、2 方案中的一种进行通信,从前端层面来讲,推荐客户端都使用schema协议的方式,便于前端 jsBridge 底层代码的维护与迭代。

# Android 客户端调用 H5 方法

在安卓 APP 中,客户端通过webviewloadUrl进行调用:

// android JAVA code
 webView.loadUrl("javascript:window.jsBridge.getShare()");

H5 端将方法绑定在 window 下的对象即可,无需与 IOS 作区分

# H5 调用 RN 客户端

我们知道 RN 的 webView 组件实际上就是对原生容器的二次封装,因此我们不需要直接通过schema协议来通信,只需要使用浏览器postMessage、onMessage来传递消息即可,类似于iframe,而真正的通信过程 RN 已经帮我们做了。

// H5 js code
window.postMessage(data);
// rn js code
<WebView
  ref="webView"
  source={require('../html/index.html')}
  injectedJavaScript={'window.androidConfig = {}'} // 通过这个props可以在webView初始化时注入属性方法
  onMessage={(e) => {
    let { data } = e.nativeEvent;
    //...
  }}
/>;

# RN 客户端调用 H5

postMessage是双向的,所以也可以在 RN 里发消息,H5 里接消息来触发对应的回调

this.refs.webView.postMessage({
  cbName: 'xxx',
  param: {},
});

# 前端 jsBridge 的封装

在了解了 js 与客户端底层的通信原理后,我们可以将 IOS、安卓统一封装成 jsBridge 提供给业务层开发调用。

class JsBridge {
    static lastCallTime
    constructor() {
        if (UA.isReactNative()) {
           document.addEventListener('message', function(e) {
              window.jsClientCallBack[e.data.cbName](e.data.param);
           });
         }
    }
    // 通用callNtive方法
    callClient(functionName, data, callback) {
        // 避免连续调用
        if (JsBridge.lastCallTime && (Date.now() - JsBridge.lastCallTime) < 100) {
            setTimeout(() => {
                this.callClient(functionName, data, callback);
            }, 100);
            return;
        }
        JsBridge.lastCallTime = Date.now();
        data = data || {};
        if (callback) {
            const cbName = randomName();
            Object.assign(data, { cbName });
            window.jsClientCallBack[cbName] = callBack.bind(this);
        }
        if (UA.isIOS()) {
            data.forEach((key, value) => {
                try {
                  data[key] = JSON.stringify(value);
                } catch(e) { }
            });
            var url = 'jsbridge://' + functionName + '?' parseJSON(data);
            var iframe = document.createElement('iframe');
            iframe.style.width = '1px';
            iframe.style.height = '1px';
            iframe.style.display = 'none';
            iframe.src = url;
            document.body.appendChild(iframe);
            setTimeout(() => {
                iframe.remove();
            }, 100);
        } else if (UA.isAndroid()) {    //  这里安卓客户端使用的是上面说的第二种通信方法
            window.AndroidNativeApi &&
            window.AndroidNativeApi[functionName] &&
            window.AndroidNativeApi[functionName](JSON.stringify(data));
        } else if (UA.isReactNative()) {     //rn的<webView>组件可以设置props.userAgent来让H5识别
            window.postMessage(
              JSON.stringify(data);
            );
        } else {
            console.error('未获取platform信息,调取api失败');
        }
    }
    // 业务层自定义方法
    getShare(data, callBack) {
      //..
    }
}

在核心封装的基础上,我们可以还做更多的优化,比如将每个回调函数调用后自我销毁释放内存

# 调试

安卓使用chrome://inspect进行调试,需要翻墙 IOS 使用mac safari的develop选项进行调试 使用 RN 的http://localhost:8081/debugger-ui 只能调试 RN 代码,无法调试 webView 代码,RN 下 webView 调试和对应 native 相同,但是在chrome://inspect下会出现样式问题。除非是纯 RN 编写,直接打包成 APP,否则不建议在 RN 下调用 webView 组。

H5 与移动端原生应用通信 (opens new window)