iOS 中 native 和 JavaScript 的交互原理以及 WebViewJavascript 源码分析

前言

在 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 方法中的方法名字和回调方法即可。

参考文章或链接

setTimeout 函数介绍

JSON 教程

WebViewJavascript github 地址