前言
在 iOS 开发中经常要用到 UIWebView ( iOS8 中可以用 WKWebView,本文章以 UIWebView 为例 ) 来展示一些东西,其中就难免要和网页进行交互。服务端提供 H5 供多个平台使用,我们就不用在 native 中开发了,是不是很棒。Hybrid App 的优势很明显,这就要求我们必须具备 native 和网页交互的技能。学点儿 JavaScript 的知识能帮我们更好的理解交互的原理。本文主要来介绍 UIWebView 和 JavaScript 的交互原理以及 WebViewJavascriptBridge (github 地址) 源码分析。
1 UIWebView 和 JavaScript 的交互原理
理解这个原理之前必须先明确以下两点:
JavaScript 能直接调用 native 方法吗?不可以。
native 能直接调用 JavaScript 代码吗?可以,可以通过以下方式来调用:
[webView stringByEvaluatingJavaScriptFromString:javascriptCommand];
JavaScript 不能直接调用 native 的方法,但是可以间接的通过一些方法来实现。可以利用 UIWebView 的 webView: shouldStartLoadWithRequest: navigationType:
代理方法来做。 WKWebView 中可以通过 webView: decidePolicyForNavigationAction: decisionHandler:
代理方法来做(本文以 UIWebView 为例,WKWebView 与 JavaScript 交互的原理同 UIWebView 一样,后面不会赘述。以下用 webView 来代指 UIWebView 和 WKWebView )。webView 发起的网络请求都会走上面的代理方法,那么就可以在代理里拦截,如果返回的是我们自己定义的 URL ,就不在加载网页,而是来处理一些我们想让它做的事情,从而实现 native 和 JavaScript 的交互。
2 WebViewJavascriptBridge 源码分析
WebViewJavascriptBridge 是封装好的 native 和 JavaScript 交互的组件。下面主要是对它源码的一些分析,以及一些简单的 JavaScript 知识(对只会 Objective-C 程序猿理解 WebViewJavascriptBridge 很有帮助哦)。上面分析的原理是利用 webView 的代理来拦截 URL 从而实现交互。那么 webView 的代理方法 webView: shouldStartLoadWithRequest: navigationType
什么时候会被调用呢?给出的回答是这样的: Sent before a web view begins loading a frame
。如果代理方法只调用一次的话,没办法对其中的 URL 拦截判断(这里指的是我们自定义的 URL),所以就必须想办法在 H5 里做处理来触发 webView 的代理事件。有很多办法能解决这个问题,比如以下两种:
1. 创建 iframe 标签
WebViewJavascriptBridge 中就是用的这种方法。
2. 设置 window 的 location
window.location = "/www/phpStudy/JS/helloJS.html";
本文以尽可能按照代码的执行顺序来分析 WebViewJavascriptBridge 源代码。那现在正式开始,native 创建好 webView 后,来 load ExampleApp.html。先来看看 ExampleApp.html,其中主要的是 script 标签里面的代码,代码如下:
<script>
//alert("在 javascript 中!");
window.onerror = function(err) {
log('window.onerror: ' + err)
}
function setupWebViewJavascriptBridge(callback) {
if (window.WebViewJavascriptBridge) {
//alert("0");
return callback(WebViewJavascriptBridge);
}
if (window.WVJBCallbacks) {
//alert("1");
return window.WVJBCallbacks.push(callback);
}
window.WVJBCallbacks = [callback];
var WVJBIframe = document.createElement('iframe');
WVJBIframe.style.display = 'none';
WVJBIframe.src = 'wvjbscheme://__BRIDGE_LOADED__';
document.documentElement.appendChild(WVJBIframe);
setTimeout(function(){
document.documentElement.removeChild(WVJBIframe)
}, 0)
}
setupWebViewJavascriptBridge(function(bridge) {
var uniqueId = 1
function log(message, data) {
var log = document.getElementById('log')
var el = document.createElement('div')
el.className = 'logLine'
el.innerHTML = uniqueId++ + '. ' + message + ':<br/>' + JSON.stringify(data)
if (log.children.length) { log.insertBefore(el, log.children[0]) }
else { log.appendChild(el) }
}
//alert("在exampleApp setupWebViewJavascriptBridge 中");
bridge.registerHandler('testJavascriptHandler', function(data, responseCallback) {
log('ObjC called testJavascriptHandler with', data)
var responseData = { 'oc调用js后,js给oc的回调':'hello' }
log('JS responding with', responseData)
responseCallback(responseData)
})
document.body.appendChild(document.createElement('br'))
var callbackButton = document.getElementById('buttons').appendChild(document.createElement('button'))
callbackButton.innerHTML = '测试 JS 调用 OC 函数'
callbackButton.onclick = function(e) {
e.preventDefault()
log('JS calling handler "testObjcCallback"')
bridge.callHandler('testObjcCallback', {'foo': 'bar'}, function(response) {
log('JS 得到的回应数据:', response)
})
}
})
</script>
对 Objective-C 程序猿来说猛的一眼看不懂这是什么,其实就是一个简单的 JavaScript 函数调用,只不过是把另一个函数当做参数传给了 setupWebViewJavascriptBridge 函数。简化后如下:
function setupWebViewJavascriptBridge(callback) {}
setupWebViewJavascriptBridge(function(bridge) {})
上面的 setupWebViewJavascriptBridge
函数中对 window.WebViewJavascriptBridge
和 window.WVJBCallbacks 做判断,第一次请求 H5 这两个属性都为空,根本就不回执行 if 里面的语句,可以像上面代码中注释的那样,用 alert 来证实。callback 被加到了 WVJBCallbacks 数组里,这里先记住,后面会用,这里提个醒留个印象。接着函数中还创建了一个隐藏的 iframe 标签,并设置它的 src 属性为 wvjbscheme://__BRIDGE_LOADED__
。这样我们才能在 webView 的代理方法中对 URL 做判断,并做进一步的处理。 来看一下 wevView 的代理方法:
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
if (webView != _webView) {
return YES;
}
NSURL *url = [request URL];
__strong WVJB_WEBVIEW_DELEGATE_TYPE* strongDelegate = _webViewDelegate;
if ([_base isCorrectProcotocolScheme:url]) {
if ([_base isBridgeLoadedURL:url]) {
//拦截到 wvjbscheme://__BRIDGE_LOADED__
[_base injectJavascriptFile];
} else if ([_base isQueueMessageURL:url]) {
//拦截到 wvjbscheme://__WVJB_QUEUE_MESSAGE__
NSString *messageQueueString = [self _evaluateJavascript:[_base webViewJavascriptFetchQueyCommand]];
[_base flushMessageQueue:messageQueueString];
} else {
[_base logUnkownMessage:url];
}
return NO;
} else if (strongDelegate && [strongDelegate respondsToSelector:@selector(webView:shouldStartLoadWithRequest:navigationType:)]) {
return [strongDelegate webView:webView shouldStartLoadWithRequest:request navigationType:navigationType];
} else {
return YES;
}
}
在这里面拦截到是我们定义的 wvjbscheme
后就调用相应的逻辑处理,否则的话就不处理。在这里拦截到 wvjbscheme://__BRIDGE_LOADED__
,执行了 WebViewJavascriptBridge_js 里的 JavaScript 代码。来看看 WebViewJavascriptBridge_js 里的代码都干了点儿什么,先挑出现在能用上的,具体的用的时候再说。看看下面的代码片段:
messagingIframe = document.createElement('iframe');
messagingIframe.style.display = 'none';
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
document.documentElement.appendChild(messagingIframe);
setTimeout(_callWVJBCallbacks, 0);
function _callWVJBCallbacks() {
var callbacks = window.WVJBCallbacks;
delete window.WVJBCallbacks;
for (var i=0; i<callbacks.length; i++) {
callbacks[i](WebViewJavascriptBridge);
}
}
一眼就能看到其中又创建了个 iframe 标签,把 src 设置成了 wvjbscheme://__WVJB_QUEUE_MESSAGE__
,这样的话就能在 webView 的代理方法中拦截了。来说一下 setTimeout 的作用:setTimeout() 方法用于在指定的毫秒数后调用函数或计算表达式(setTimeout 函数介绍)。接着调用了 _callWVJBCallbacks 函数,在这个函数里面取出了上面让暂时记住的 window.WVJBCallbacks (在 ExampleApp.html 里把 callback 放入进 WVJBCallbacks 里),取出里面所有的方法(其实现在就一个 callback 方法)并且执行。让我们来跳到 ExampleApp.html 里面,看看 callback (就是setupWebViewJavascriptBridge 调用处传进来的匿名函数)里都做了什么。log 方法用来打印信息。接着调用了 bridge 的 registerHandler 方法(这个主要是供 native 端来调用的)。创建了一个 callbackButton 并绑定它的 onclick 事件。先来看看 registerHandler 方法都干了什么,跳到 WebViewJavascriptBridge_JS.m 里找到的 registerHandler 如下:
function registerHandler(handlerName, handler) {
messageHandlers[handlerName] = handler;
}
这里是把 registerHandler 里传过来的参数放到了 messageHandlers 数组里,以后 native 调用 JavaScript 方法的时候就会来这里取。
//Objective-C 程序猿的加油站:JavaScript 拥有动态类型
我们申明 messageHandlers 时是这样申明的 var messageHandlers = {}; 可是在用的时候就把它当数组用了,对 Objective-C 程序猿来说是很奇怪的。其实JavaScript 拥有动态类型。这意味着相同的变量可用作不同的类型。
native 调用 JavaScript 方法
native 调用 JavaScript 方法是这样的:
[_bridge callHandler:@"testJavascriptHandler" data:data responseCallback:^(id response) {
NSLog(@"testJavascriptHandler responded: %@", response);
}]
通过上面的函数,一路跟下去,发现是把调用的 JavaScript 的方法名,参数和回调拼装好后在本地执行了 _handleMessageFromObjC
方法,其中 _handleMessageFromObjC
的参数是上面拼装好的字典。来看看 _handleMessageFromObjC
里做了什么。
function _handleMessageFromObjC(messageJSON) {
_dispatchMessageFromObjC(messageJSON);
}
function _dispatchMessageFromObjC(messageJSON) {
//messageJSON 里面包含 data callbackId handlerName
setTimeout(function _timeoutDispatchMessageFromObjC() {
//使用 JSON.parse 将 JSON 字符串转换为对象
var message = JSON.parse(messageJSON);
var messageHandler;
var responseCallback;
if (message.responseId) {
responseCallback = responseCallbacks[message.responseId];
if (!responseCallback) {
return;
}
responseCallback(message.responseData);
delete responseCallbacks[message.responseId];
} else {
if (message.callbackId) {
var callbackResponseId = message.callbackId;
responseCallback = function(responseData) {
_doSend({ responseId:callbackResponseId, responseData:responseData });
};
}
var handler = messageHandlers[message.handlerName];
try {
handler(message.data, responseCallback);
} catch(exception) {
console.log("WebViewJavascriptBridge: WARNING: javascript handler threw.", message, exception);
}
if (!handler) {
console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
}
}
});
}
在 _handleMessageFromObjC
里面又调用了 _dispatchMessageFromObjC
。在这里面又发现了 setTimeout,和上面提到的一样,它会执行 _timeoutDispatchMessageFromObjC
方法。该函数里首先对 message.responseId 做了判断,我们知道它肯定是空的,因为传过来的参数只有 data ,callbackId 和 handlerName。在 else 里面取出来 callbackId 赋值给了 callbackResponseId。从 messageHandlers 中取出 handlerName 给了 handler,这里的 handler 就是 JavaScript 注册到这里的方法。执行 handler 并把回调给 native。执行回调的过程中 使用了 _doSend 函数,我们来看一下:
function _doSend(message, responseCallback) {
//mesage 包含responseId,responseData
//responseCallback为空
if (responseCallback) {
var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
responseCallbacks[callbackId] = responseCallback;
message['callbackId'] = callbackId;
}
sendMessageQueue.push(message);
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}
此时的 responseCallback 为空,不进 if 里面。然后把 message 放到了 sendMessageQueue 数组里,并把 messagingIframe.src 的设置成了 wvjbscheme://__WVJB_QUEUE_MESSAGE__
,就又会执行 webView 的代理方法,在代理方法里判断 URL 为 wvjbscheme://__WVJB_QUEUE_MESSAGE__
的话就会去 通过 _fetchQueue 来取 sendMessageQueue 的数据,并且回调 native 方法。至此,native 调用 JavaScript 注册的方法结束。
//Objective-C 程序猿的加油站:stringify()
在上面的 _fetchQueue 中用到了 stringify(),下面说说 stringify() 是干什么用的
stringify()用于从一个对象解析出字符串,例如:
var a = {a:1,b:2};
执行JSON.stringify(a)后的结果为:
"{"a":1,"b":2}"
JavaScript 调用 native 方法
native 调用的 JavaScript 的方法是在 JavaScript 里提前注册好的。同理,JavaScript 想调用 native 的方法,native 必须也要先注册。
//在 ExampleUIWebViewController.m 中
[_bridge registerHandler:@"testObjcCallback" handler:^(id data, WVJBResponseCallback responseCallback) {
NSLog(@"testObjcCallback called: %@", data);
responseCallback(@{ @"name":@"OC回调给js的参数" });
}];
//在 WebViewJavascriptBridge.m 中
- (void)registerHandler:(NSString *)handlerName handler:(WVJBHandler)handler {
_base.messageHandlers[handlerName] = [handler copy];
}
在代码层面来看,native 的注册其实就是把 native 中需要被调用的名字和回调放到了 messageHandlers 的字典中。那我们来看看调用 native 方法的具体流程。
在点击 webView 上的按钮(在 ExampleApp.html 里面创建的 callbackButton)后,调用了 callHandler 方法,传递的参数为要调用的 native 的方法名,参数和回调。来看看 callHandler 里有什么。
function callHandler(handlerName, data, responseCallback) {
if (arguments.length == 2 && typeof data == 'function') {
responseCallback = data;
data = null;
}
_doSend({ handlerName:handlerName, data:data }, responseCallback);
}
这里做了容错判断(在下面的 Objective-C 程序猿加油站中有说明)后,和 native 调用 JavaScript 一样,都是调用了 _doSend 方法。在里面为 responseCallback 生成了 唯一的 callbackId 并放到 message 里,接下来的流程和 native 调用 JavaScript 大同小异,这里不再赘述。
//Objective-C 程序猿的加油站:arguments
在 JavaScript 中 arguments 对象是比较特别的一个对象,实际上是当前函数的一个内置属性,它的长度是由实参个数而不是形参个数决定的。那么就很容易理解了,上面的是一个冗错处理,也就是说在调用 callHandler 的时候可以不传调用 native 方法中的参数,只传递调用 nativie 方法中的方法名字和回调方法即可。