理解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
相關文章
相關標籤/搜索