前言

在 iOS 开发中经常要用到 UIWebView ( iOS8 中可以用 WKWebView,本文章以 UIWebView 为例 ) 来展示一些东西,其中就难免要和网页进行交互。服务端提供 H5 供多个平台使用,我们就不用在 native 中开发了,是不是很棒。Hybrid App 的优势很明显,这就要求我们必须具备 native 和网页交互的技能。学点儿 JavaScript 的知识能帮我们更好的理解交互的原理。本文主要来介绍 UIWebView 和 JavaScript 的交互原理以及 WebViewJavascriptBridge (github 地址) 源码分析。

UIWebView 和 JavaScript 的交互原理

理解这个原理之前必须先明确以下两点:

1.JavaScript 能直接调用 native 方法吗?不可以。

2.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 的交互。

WebViewJavascriptBridge 源码分析

WebViewJavascriptBridge 是封装好的 native 和 JavaScript 交互的组件。下面主要是对它源码的一些分析,以及一些简单的 JavaScript 知识(对只会 Objective-C 程序猿理解 WebViewJavascriptBridge 很有帮助哦)。上面分析的原理是利用 webView 的代理来拦截 URL 从而实现交互。说到这里可能很多做前端的小伙伴不理解 webview 的这个代理是什么东东,这里简单说明一下。在用 webview 来加载网页的时候会触发 webview 的代理,来询问是否要加载网页,这个由 native 来决定,发现是我们希望加载的网页可以进行直接加载,但发现不是自己希望加载的网页可以不加载,可以来处理我们想让它的干的事,简单的理解就是 webview 在加载网页的时候会征求我们的意见。那么 webView 的代理方法 webView: shouldStartLoadWithRequest: navigationType 什么时候会被调用呢?给出的回答是这样的: Sent before a web view begins loading a frame

native 和 JavaScript 交互的核心思想如下:当 webview 加载网页的时候通过 webView: shouldStartLoadWithRequest: navigationType 代理方法来拦截实现。比如 webview 加载 ExampleApp.html ,在加载前触发 webview 的代理方法,发现是 ExampleApp.html 的 url 后能让网页顺利加载。如果代理方法只调用一次的话,没办法对其中的 URL 拦截判断(这里指的是我们自定义的 URL),所以就必须想办法在 H5 里做处理来触发 webView 的代理事件。有很多办法能解决这个问题,比如以下两种:

  • 创建 iframe 标签,WebViewJavascriptBridge 中就是用的这种方法。
  • 设置 window 的 location,例如 window.location = “/www/phpStudy/JS/helloJS.html”;

通过在 ExampleApp.html 的 js 方法中创建一个隐藏的 iframe 标签,设置其 src 为一个自定义的 URL,触发 webview 的代理方法,发现是自定义的 URL,就可以让它做我们想让它做的事情了。

总体流程

本文以尽可能按照代码的执行顺序来分析 WebViewJavascriptBridge 源代码。那现在正式开始,native 创建好 webView 后,来 load ExampleApp.html。先来看看 ExampleApp.html,其中主要的是 script 标签里面的代码,代码如下:

<script>
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);
}]
- (void)callHandler:(NSString *)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback {
[_base sendData:data responseCallback:responseCallback handlerName:handlerName];
}
- (void)sendData:(id)data responseCallback:(WVJBResponseCallback)responseCallback handlerName:(NSString*)handlerName {
NSMutableDictionary* message = [NSMutableDictionary dictionary];

if (data) {
message[@"data"] = data;
}

if (responseCallback) {
NSString* callbackId = [NSString stringWithFormat:@"objc_cb_%ld", ++_uniqueId];
self.responseCallbacks[callbackId] = [responseCallback copy];
message[@"callbackId"] = callbackId;
}

if (handlerName) {
message[@"handlerName"] = handlerName;
}
[self _queueMessage:message];
}

sendData: responseCallback: handlerName: 方法需要简单说一下,这里会把传过来的要调用 js 的函数名,参数和回掉id放到一个 message 的字典中,其中的回掉的 id 和 回掉的方法存到了 responseCallbacks 字典中以供调用 js 成功后,js 能回掉到 native 的方法。

- (void)_queueMessage:(WVJBMessage*)message {
if (self.startupMessageQueue) {
[self.startupMessageQueue addObject:message];
} else {
[self _dispatchMessage:message];
}
}
- (void)_dispatchMessage:(WVJBMessage*)message {
NSString *messageJSON = [self _serializeMessage:message pretty:NO];
[self _log:@"SEND" json:messageJSON];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];

NSString* javascriptCommand = [NSString stringWithFormat:@"WebViewJavascriptBridge._handleMessageFromObjC('%@');", messageJSON];
if ([[NSThread currentThread] isMainThread]) {
[self _evaluateJavascript:javascriptCommand];

} else {
dispatch_sync(dispatch_get_main_queue(), ^{
[self _evaluateJavascript:javascriptCommand];
});
}
}

通过上面的函数,一路跟下去,发现是把调用的 JavaScript 的方法名,参数和回调 id 拼装好后在本地执行了 _handleMessageFromObjC 方法,其中 _handleMessageFromObjC 的参数是上面拼装好的字典。来看看 _handleMessageFromObjC 里做了什么。

function _handleMessageFromObjC(messageJSON) {
_dispatchMessageFromObjC(messageJSON);
}

function _dispatchMessageFromObjC(messageJSON) {
if (dispatchMessagesWithTimeoutSafety) {
setTimeout(_doDispatchMessageFromObjC);
} else {
_doDispatchMessageFromObjC();
}

function _doDispatchMessageFromObjC() {
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({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
};
}

var handler = messageHandlers[message.handlerName];
if (!handler) {
console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
} else {
handler(message.data, responseCallback);
}
}
}
}

_handleMessageFromObjC 里面又调用了 _dispatchMessageFromObjC。该函数里首先对 message.responseId 做了判断,我们知道它肯定是空的,因为传过来的参数只有 data ,callbackId 和 handlerName。在 else 里面取出来 callbackId 赋值给了 callbackResponseId。从 messageHandlers 中取出 handlerName 给了 handler,这里的 handler 就是 JavaScript 注册到这里的方法。执行 handler 并把回调给 responseCallback。执行回调的过程中使用了 _doSend 函数,我们来看一下:

function _doSend(message, 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 为空,因为在调用它的时候只传了 { handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData } 一个参数,不进 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 程序猿的 js 加油站:arguments

在 JavaScript 中 arguments 对象是比较特别的一个对象,实际上是当前函数的一个内置属性,它的长度是由实参个数而不是形参个数决定的。那么就很容易理解了,上面的是一个冗错处理,也就是说在调用 callHandler 的时候可以不传调用 native 方法中的参数,只传递调用 nativie 方法中的方法名字和回调方法即可。

参考文章或链接

setTimeout 函数介绍

JSON 教程

WebViewJavascript github 地址