在跨平台客户端开发中,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
在完成上面这个例子时,H5和native两边都须要先完成bridge的初始化。H5这边一般会在html
的 head
中加载一段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.callHandler
和ClientBridge.registerHandler
是暴露给h5端使用的,ClientBridge._fetchQueue
和ClientBridge._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的请求,而后作以下处理:
先来看看第一种通讯方式,就是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端的接口,好比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
其中第一种方式简单,可是图片直接生成的base64格式,数据太大,对于传递和调试极为不方便。第二种方式,麻烦一点,生成的src又必须是一个约定好的scheme格式,native又经过拦截请求,而后从cache目录里拿到图片,做为response返回。此次的拦截与iframe的拦截方式又不一样,是经过WKWebViewConfiguration.setURLSchemeHandler
来实现的,具体就不详细讨论了,感兴趣能够查看这里。
经过一个例子,详细的讨论了h5与native之间的通讯方式,核心原理以下