理解h5与native(ios)通讯细节

在跨平台客户端开发中,H5是使用最为普遍的方式,它既能够运行在iOS中,也能够运行在Android中,还能够运行在web浏览器中,能够说是"write once, run anywhere"。可是,H5最为人诟病的就是用户体验不如native流畅,特别是对于低端机型和较差的网络环境,在页面加载时一般有较长一段时间的白屏等待时间。H5开发者想尽办法缩短首屏时间,用户可交互时间,为此使用了一系列的优化手段,好比ssr,code split,compress,lazy load,preload等等,其实主要是围绕尽可能少这一核心原则。为了平衡跨终端能力和用户体验,如今流行的又有RN和Flutter解决方案等。咦,感受跑题了,仍是回到标题说的,具体来看看在IOS中,H5是怎么与native通讯的。文字略长,可是我相信你看完了,会有所收获。javascript

说到通讯,无非就是两种方式,native调用h5,h5调用native。H5在iOS中的宿主是UIWebView或者WKWebView,在IOS8中,Apple引入了WKWebView,将UIWebView标记为Deprecated。如今来讲,大部分app应该都是使用的WKWebView,除非那些须要兼容IOS8如下系统的才会兼容使用UIWebView,本文也主要是说说使用WKWebView的场景。在实现H5与native之间的通讯,比较流行的库就是WebViewJavascriptBridge,为了真正弄明白原理,我也是通读了它的源码,而后根据它的实现思路,本身用swift也实现了一遍。下面就结合一个小例子,谈谈它的实现原理。html

假若有一个需求,是H5在app内会有一个截屏按钮,点击这个按钮能对当前webView截图,而后显示在咱们的H5中一个img元素里。java

如图能够看到,有一个截屏按钮,以及一个紫色区域,这个区域内有一个img,用来显示咱们截屏以后的图片。react

这个一般须要H5与native配合才能完成,截屏的功能确定是native那边完成,可是触发时机确定是H5这边来控制。native须要提供一个bridge接口,好比takeSnapshot,而后在H5中就须要调用takeSnapshot接口并得到相应数据,git

// h5部分代码
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      src: null,
    };
    this.takeSnapshot = this.takeSnapshot.bind(this);
  }
  takeSnapshot() {
    if (window.mpBridge) {
      window.mpBridge.ready((bridge) => {
        bridge.callHandler('takeSnapshot', ({ status, data }) => {
          if (status) {
            this.setState(() => {
              return {
                src: data.path,
              };
            });
          }
        });
      });
    }
  }
  render() {
    return (
      <div>
        <div className="operate">
          <button onClick={this.takeSnapshot}>截屏</button>
        </div>
        <div className="result">
          <img src={this.state.src} />
        </div>
      </div>
    );
  }
}

export default App;
复制代码

这段代码比较简单,就不解释。能够看到在调用takeSnapshot的回调中,h5拿到了path,而后将path赋值给了img标签。github

Bridge的初始化

在完成上面这个例子时,H5和native两边都须要先完成bridge的初始化。H5这边一般会在htmlhead中加载一段sdk代码,用来触发生成H5端bridge对象,每一个公司都会本身提供一个对外的sdk脚本,好比微信提供的sdk等。一般放在head 中,是由于它须要最早执行完成,这样你代码中才可使用。这个sdk脚本,其实就是提供了一个ready函数,bridge对象完成以后,会调用里面的回调函数,并提供bridge对象做为参数。web

/* bridge sdk mpBridge.ready(bridge => { bridge.callHandler('cmd', params, (error, data) => { }) }) */

(function(w, d) {
  // 已经加载了就直接返回,防止加载2遍
  if (w.mpBridge) {
    return;
  }

  // 是否bridge初始化完成
  let initialized = false;
  let queue = [];

  function ready(handler) {
    if (initialized) {
      // 若是bridge初始化完成,则直接派发,执行
      dispatch(handler);
    } else {
      // 不然,先缓存在队列里,等待bridge完成后派发,执行
      queue.push(handler);
    }
  }

  function dispatch(handler) {
    // 派发,执行时,会提供bridge对象看成第一个参数
    handler(w.ClientBridge);
  }

  function _initialize() {
    // bridge初始化完成了,就开始派发,执行先前缓存在队列里的
    for (var handler of queue) {
      dispatch(handler);
    }
    queue = [];
    initialized = true;
  }

  // 通知native,注入bridge对象到当前的window对象上
  setTimeout(function() {
    var iframe = d.createElement('iframe');
    iframe.hidden = true;
    // 这个src会被native那边拦截,而后根据host == 'bridgeinject',来判断是否注入bridge对象
    iframe.src = 'https://bridgeinject';
    d.body.appendChild(iframe);
    setTimeout(function() {
      iframe.remove();
    });
  });

  // interface api
  const mpBridge = {
    ready: ready,
    version: '1.0',
    _initialize: _initialize,
  };

  window.mpBridge = mpBridge;
})(window, document);
复制代码

这是我写的sdk,用于完成上面那个截屏的例子。最为主要功能是生成一个隐藏的iframe,来通知native注入bridge对象到window上,注入的bridge对象就是ClientBridge。它自己本身也会生成一个对象mpBridge,用来提供给开发人员。固然,这个 sdk的功能比较简单,其余公司的可能比较复杂,可是它绝对包含了最为重要的功能。这个时候h5中ClientBridge的初始化才算完成了一半,ClientBridge尚未被真正建立,真正被建立的过程是在native中完成的。json

在native端,在viewController中建立了webview并实现了navigationDelegate,而且也建立了NativeBridge。在navigationDelegate中,咱们能够拦截h5中iframe发送的请求,理解这点很是重要,h5与native之间的通讯就是经过这个拦截操完成的,后面会看到具体拦截细节,咱们先看native端NativeBridge初始化的过程。swift

/// native 代码
/// 建立webview
webView = WKWebView(frame: CGRect.zero, configuration: configuration)
webView.navigationDelegate = self
/// 初始化native端bridge
if let bridgeScriptPath = Bundle.main.path(forResource: "bridge", ofType: "js") {
    self.bridge = Bridge(webView: webView, scriptURL: URL(fileURLWithPath: bridgeScriptPath))
}
复制代码

在native端,也会生成一个bridge对象,经过这个对象,native能够注册接口函数给h5调用,native也能够调用h5中注册的函数。经过sdk中生成的iframe,触发注入h5端ClientBridge,此时,native端才开始把ClientBridge注入到h5中去,api

/// native 代码
func injectClientBridge(completionHandler handler: EvaluateJavasriptHandler?) {
    if let data = try? Data(contentsOf: scriptURL),
    let code = String(data: data, encoding: .utf8) {
        /// 核心点就是,native能够直接执行JavaScript
        evaluateJavascript(code, completionHandler: handler)
    } else {
        handler?(nil, BridgeError.injectBridgeError)
    }
}
复制代码

在native端,能够直接以字符串形式执行JavaScript脚本。一般,会先准备好ClientBridge的脚本,而后在native直接执行,就能够将它注入到H5中去了。我准备的ClientBridge脚本以下,

/* ClientBridge.callHandler('cmd', params, (error, data) => { }) */

(function(w, d) {
  // 已经注入了ClientBridge
  if (w.ClientBridge) {
    return;
  }

  // uid自增,用来标记callBackID的
  var uid = 0;
  // h5中消息队列,用来发送到native中去的
  var messageQueue = [];
  // h5回调函数映射表,经过callBackID关联 
  var callbacksMap = {};

  // 通讯的scheme,能够是其余字符串
  var scheme = 'https';
  // 通讯的host,用来标记请求是h5通讯发出的
  var messageHost = 'bridgemessage';
  var messageUrl = scheme + '://' + messageHost;
  // 会建立一个iframe,h5发送消息给native,经过iframe触发 
  var iframe = (function() {
    var i = d.createElement('iframe');
    i.hidden = true;
    d.body.appendChild(i);
    return i;
  })();

  function _noop() {}

  // 处理来自native端的消息,
  function _handlerMessageFromNative(dataString) {
    console.log('receive message from native: ' + dataString);
    let data = JSON.parse(dataString);
    if (data.responseId) {
      // 若是有responseId , 则说明消息是h5调用了native的接口,根据responseId能够找到存储的回调函数,而后执行回调,将数据传递给H5
      var callback = callbacksMap[data.responseId];
      if (typeof callback === 'function') {
        callback(data.responseData);
      }
      callbacksMap[data.responseId] = null;
    } else {
      // 不然,就是native直接调用h5的接口,
      var callback;
      if (data.callbackId) {
        // 若是有callbackId,则要回发结果
        callback = function(res) {
          _doSend({ responseId: data.callbackId, responseData: res });
        };
      } else {
        // 不然,不处理
        callback = _noop;
      }
      // 经过handlerName,找到h5注册好的接口函数
      var handler = callbacksMap[data.handlerName];
      if (typeof handler === 'function') {
        handler(data.data, callback);
      } else {
        console.warn('receive unknown message from native:' + dataString);
      }
    }
  }

  // native 经过调用_fetchQueue函数来获取H5中消息队列里的消息
  function _fetchQueue() {
    var message = JSON.stringify(messageQueue);
    messageQueue = [];
    console.log('send message to native : ' + message);
    return message;
  }

  // 发送消息 
  function _doSend(message) {
    // 将消息加到消息队列里, 
    messageQueue.push(message);
    // 而后经过iframe触发 
    iframe.src = messageUrl;
  }

  // ClientBridge对外H5的函数,h5能够经过callHandler来调用native中的接口
  function callHandler(name, data, callback) {
    uid = uid + 1;
    if (typeof data === 'function') {
      callback = data;
      data = null;
    }
    if (typeof callback !== 'function') {
      callback = _noop;
    }
    // 先生成一个惟一的callbackId, 
    var callbackId = 'callback_' + uid + new Date().valueOf();
    // 将回调函数保存在哈希表中,后面经过responseId能够取出 
    callbacksMap[callbackId] = callback;
    // 发送 
    _doSend({ handlerName: name, data: data, callbackId: callbackId });
  }

  // ClientBridge对外h5的函数,h5能够经过registerHandler来注册接口,供native来调用
  function registerHandler(name, callback) {
    // 直接将注册的接口保存在哈希表中 
    callbacksMap[name] = callback;
  }

  // 在window上生成ClientBridge对象
  w.ClientBridge = {
    callHandler: callHandler,
    registerHandler: registerHandler,
    _fetchQueue: _fetchQueue,
    _handlerMessageFromNative: _handlerMessageFromNative,
  };

  // 调用sdk中的初始化方法 
  if (w.mpBridge) {
    w.mpBridge._initialize();
  }
})(window, document);

复制代码

核心原理也是经过在h5中生成一个iframe,经过iframe来充当h5与native之间的信使。ClientBridge.callHandlerClientBridge.registerHandler是暴露给h5端使用的,ClientBridge._fetchQueueClientBridge._handlerMessageFromNative是提供给native端使用的。只有当native执行了这一段脚本,h5中bridge才算真正初始化完成。

拦截请求

在native端,经过实现WkWebView的WKNavigationDelegate,能够拦截h5中加载frame的请求,而后经过请求的scheme和host来判断是不是咱们约定好的,例如上面注入bridge的sdk中,咱们约定的scheme是https,host是bridgeinject。

/// native 部分代码 
/// 此函数就是拦截h5中iframe发送的请求
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard webView == self.webView,
            let bridge = self.bridge,
            let url = navigationAction.request.url
        else {
            decisionHandler(.allow)
            return
        }
        if bridge.isBridgeInjectURL(url) {
            /// 若是注入bridge的请求,则开始注入bridge到h5中
            bridge.injectClientBridge(completionHandler: nil)
            /// 并取消掉本次请求,由于并非真正的须要请求,
            decisionHandler(.cancel)
        } else if bridge.isBridgeMessageURL(url) {
            /// 若是是h5与native之间的消息请求,则处理h5那边的消息,
            bridge.flushMessageQueue()
            /// 一样的,须要取消掉本次请求,
            decisionHandler(.cancel)
        } else {
            /// 不然,其余状况,都正常请求
            decisionHandler(.allow)
        }
    }
复制代码

上面的native中代码能够看到,经过实现了WKNavigationDelegate中decidePolicyForNavigationAction的方法,咱们能够拦截iframe以及mainFrame的请求,而后作以下处理:

  • 若是请求是注入bridge到h5的请求,则开始处理注入bridge对象到h5中,并取消本次请求。这个请求就是上面sdk中建立的iframe触发的。它的请求url是https://bridgeinject
  • 若是请求是h5与native之间通讯的请求,则开始处理h5中传递的消息,并取消本次请求。这个请求会在后面看到。它的请求url是https://bridgemessage
  • 不然,就是正常的mainFrame或者iframe请求,正常处理请求

H5调用native接口

先来看看第一种通讯方式,就是h5调用native中的接口,好比例子中,h5调用native提供的takeSnapshot接口实现截屏功能。

首先,native端必须先注册好takeSnapshot接口,这样h5才能使用。native端注册takeSnapshot接口代码以下,

/// native端,经过NativeBridge注册takeSnapshot接口
bridge?.registerHandler("takeSnapshot") {
    _, callback in
    /// 调用webView的takeSnapshot函数实现截屏
    self.webView.takeSnapshot(with: nil) {
        image, error in
        let fileName = "snapshot"
        guard let image = image, error == nil else {
            callback(Bridge.HandlerResult(status: .fail(-1)))
            return
        }
        // 将获得的UIimage保存到cache file目录下
        guard let _ = LocalStore.storeCacheImage(image, fileName: fileName) else {
            callback(Bridge.HandlerResult(status: .fail(-2)))
            return
        }
        // 生成src,提供给h5
        guard let src = ImageBridge.generateSRC(fileName: fileName) else {
            callback(Bridge.HandlerResult(status: .fail(-3)))
            return
        }
        // 生成返回数据,包含src
        var result = Bridge.HandlerResult(status: .success)
        result.data = ["path": src]
        callback(result)
    }
}
复制代码

至于native端的NativeBridge实现细节,其实与ClientBridge思路同样的,大体也是有一个字典保存注册的函数,而后根据h5调用handlerName来查找出这个函数,而后执行,具体细节就不说了,感兴趣能够看看这里。能够看到,h5与native两边必须提供相同的handlerName。一般呢,这个handlerName是native开发人员定义好的,而后H5开发人员按照文档使用。native定义好了接口,那么h5这边就须要调用了,

// h5端,调用native定义的接口
if (window.mpBridge) {
    window.mpBridge.ready((bridge) => {
        bridge.callHandler('takeSnapshot', ({ status, data }) => {
            if (status) {
                this.setState(() => {
                    return {
                        src: data.path,
                    };
                });
            }
        });
    });
}
复制代码

h5在调用bridge.callHandler时,生成惟一的callbackId,并将回调保存在哈希表中,而后经过iframe触发通知native。

function callHandler(name, data, callback) {
    uid = uid + 1;
    if (typeof data === 'function') {
        callback = data;
        data = null;
    }
    if (typeof callback !== 'function') {
        callback = _noop;
    }
    // 生成一个惟一的callbackId
    var callbackId = 'callback_' + uid + new Date().valueOf();
    // 将回调函数保存在哈希表中
    callbacksMap[callbackId] = callback;
    // 触发iframe发送消息
    _doSend({ handlerName: name, data: data, callbackId: callbackId });
}
复制代码

native经过拦截iframe的请求,判断是否h5中通讯请求,若是是就开始处理,处理过程以下,

//native 核心代码以下
func flushMessageQueue() {
    // 执行ClientBridge._fetchQueue,获取h5中消息队列中数据
    evaluateJavascript("ClientBridge._fetchQueue()") {
        result, error in
         // 转成json
        let jsonData = try JSONSerialization.jsonObject(with: result, options: [])
        let messages = jsonData as! [BridgeData]
        for message in messages {
            if let callbackId =  message["callbackId"] as? String {
                /// 生成RequestMesage,调用native接口
                self.resumeWebCallHandlerMessage(RequestMessage(handlerName: message["handlerName"] as? String, data: message["data"] as? BridgeData, callbackId: callbackId))

            } 
        }
    }
}
复制代码

获取了h5中消息以后,判断消息中是否包含了callbackId,若是包含了,则说明是h5发送的一个RequestMessage。经过handlerName取出native注册好的接口函数,而后执行,并返回结果。

func resumeWebCallHandlerMessage(_ message: RequestMessage) {
    // 经过handlerName拿到native注册的接口,
    guard let name = message.handlerName, let handler = self.responseHandlersMap[name] else {
        debugPrint("unkown handler name")
        return
    }
    // 而后执行接口,并返回数据
    handler(message.data) {
        result in
        // callbackId对应变成了responseId,返回的数据在responseData中
        let responseMessage = ResponseMessage(responseData: result.getData(), responseId: message.callbackId)
        self.sendToWeb(responseMessage)
    }
}
复制代码

最后,native经过执行ClientBridge._handlerMessageFromNative来将结果返回给h5。

/// 将消息发送给h5端
func sendToWeb(_ message: MessageProtocol) {
    do {
        /// 先序列化json数据
        let data = try JSONSerialization.data(withJSONObject: message.serialization(), options: [])
        let result = String(data: data, encoding: .utf8) ?? ""
        // 最后执行ClientBridge._handlerMessageFromNative
        evaluateJavascript("\(clientBridgeName)._handlerMessageFromNative('\(result)')", completionHandler: { _,_ in
                                                                                                            })
    } catch {
        debugPrint(error)
    }
}
复制代码

native调用h5接口

再来看看第二种通讯方式,就是native调用h5端的接口,好比h5中会注册一个监听导航条上的返回按钮的函数,比较叫作onBackEvent,native经过调用h5中onBackEvent的接口函数,决定是否直接关闭当前webView。

相似的,h5中必须先注册onBackEvent接口,

if (window.mpBridge) {
    window.mpBridge.ready((bridge) => {
        bridge.registerHandler('onBackEvent', (data, done) => {
            // do something, 
            // 返回true 则直接关闭当前webView,false 则不关闭当前webView
            done(true);
        });
    });
}
复制代码

而后,在native中监听导航条那个返回按钮的点击事件中,调用h5的onBackEvent,根据结果来决定是否关闭当前webView。

/// 导航条返回按钮的点击事件 
@objc private func handleBackTap() {
    if let bridge = self.bridge {
        /// 调用h5中注册的onBackEvent函数,
        bridge.callHandler("onBackEvent") {
            data in
            guard let pop = data as? Bool else {
                return
            }
            // 若是为true,则关闭当前webView
            if pop {
                self.navigationController?.popViewController(animated: true)
            }
        }
    } else {
        self.navigationController?.popViewController(animated: true)
    }
}
复制代码

NativeBridge中的callHandler函数实现思路和h5中的同样,也是生成一个惟一的callbackId,而后将回调保存在字典表中,再将消息发送到h5。

/// native 端 callHandler的实现
func callHandler(_ name: String, callback: @escaping RequestCallback) {
    // 生成一个惟一的callbackId
    let uuid = UUID().uuidString
    // 将回调保存在字典表中
    requestHandlersMap[uuid] = callback
    // 生成一个requestmessage,
    let requestMessage = RequestMessage(handlerName: name, data: nil, callbackId: uuid)
    // 而后发送到h5去
    sendToWeb(requestMessage)
}
复制代码

h5这边经过ClientBridge._handlerMessageFromNative 能够接受这个消息,而后根据handlerName查找到h5已经注册的接口函数,最后执行并返回数据给native。

// native call web
var callback;
if (data.callbackId) {
    // 若是有callbackId,则要回发结果
    callback = function(res) {
        _doSend({ responseId: data.callbackId, responseData: res });
    };
} else {
    // 不然,不处理
    callback = _noop;
}
var handler = callbacksMap[data.handlerName];
if (typeof handler === 'function') {
    handler(data.data, callback);
} else {
    console.warn('receive unknown message from native:' + dataString);
}
复制代码

通讯流程图

展现截屏图片

其实,在h5调用native中takeSnapshot接口后,native实现了截屏,得到到UIImage,有两种返回能够返回数据给h5

  1. native直接返回图片的base64数据,h5端直接展现
  2. native现将图片存在cache 目录里,生成一个src,返回给h5,h5请求这个src的图片

其中第一种方式简单,可是图片直接生成的base64格式,数据太大,对于传递和调试极为不方便。第二种方式,麻烦一点,生成的src又必须是一个约定好的scheme格式,native又经过拦截请求,而后从cache目录里拿到图片,做为response返回。此次的拦截与iframe的拦截方式又不一样,是经过WKWebViewConfiguration.setURLSchemeHandler来实现的,具体就不详细讨论了,感兴趣能够查看这里

小结

经过一个例子,详细的讨论了h5与native之间的通讯方式,核心原理以下

  • native能够直接执行JavaScript字符串形式执行js脚本,与h5通讯
  • native能够拦截iframe的请求,执行h5的通讯请求
  • h5经过iframe来发送数据给native
相关文章
相关标签/搜索