Hybrid开发模式在拥有便利性、动态性等Web开发优势的同时,还能极大程度地利用Native的能力。因此无论是BAT等大厂,还是开源社区都拥有成熟的Hybrid解决方案。而整个Hybrid方案最底层、最核心的技术就是JS和Native的互相通信,本文就主要来扒一扒这其中的原理。

Bridge

JS和Native毕竟是两种完全不同的语言,他们是不能直接互相调用的。我们把JS和Native之间调用转换的组件叫做Bridge。Bridge架起了JS和Native调用的桥梁,其核心功能是:

  • 在己端注册功能,供对端调用,也就是Native侧注册供JS侧调用的函数,JS侧注册供Native调用的函数
  • 从己端发起对对端已注册功能的调用,也就是在JS侧发起对Native已注册功能的调用,Native侧发起对JS已注册功能的调用

所以Bridge其实是由两部分组成的。

  • JSBridge:负责Bridge在JS侧的逻辑
  • NativeBridge:负责Bridge在Native侧的逻辑

示意如下:

Bridge示意图

WebViewJavascriptBridge是github上开源的JS和Native通信的框架。业界Hybrid解决方案或多或少都得到过该框架的启发,有的甚至直接将该框架作为解决方案的底层。我们接下来以WebViewJavascriptBridge为基础,详细解读Hybrid通信机制。

JS -> Native通信(CallNative)

首先来看JS是如何调用Native的功能的。调用之前需要NativeBridge先注册功能,示例代码注册了名为testObjcCallback的功能函数,然后JS端发起了对testObjcCallback的调用。

// Native端,注册功能函数
[_bridge registerHandler:@"testObjcCallback" handler:^(id data, WVJBResponseCallback responseCallback) {
    NSLog(@"testObjcCallback called: %@", data);
    responseCallback(@"Response from testObjcCallback");
}];
// JS端,调用Native功能
bridge.callHandler('testObjcCallback', {'foo': 'bar'}, function(response) {
    log('JS got response', response)
})

Native功能函数是可以自定义,所以理论上来说,JS端可以调用任何的Native方法,想象空间巨大,当然安全隐患也很大。

NativeBridge注册功能

NativeBridge以handlerName为key,将用户自定义的功能存放在名为messageHandlers的字典,这是最为普通不过的注册了,没什么可多说的。

// base bridge定义
@interface WebViewJavascriptBridgeBase : NSObject
......
@property (strong, nonatomic) NSMutableDictionary* messageHandlers;
......
@end

// 注册handler函数
- (void)registerHandler:(NSString *)handlerName handler:(WVJBHandler)handler {
    _base.messageHandlers[handlerName] = [handler copy];
}

JSBridge调用功能

通过上面的Demo,我们知道JS是通过和Native约定好的handlerName来调用相应的Native功能的。那么问题来了:

  1. 如何实现JS到Native的调用?
  2. 如何通过handleName找到对应的Native功能函数的?
  3. 调用Native功能的参数是如何传递的?
  4. 如果有回调的话,Native是怎么回调JS函数的?

如何实现JS到Native的调用?

这个问题是挺核心的一个问题,应该也是整个通信机制里面最有技术含量的。

我们知道UIWebViewDelegate是UIWebView的委托,页面内容加载会触发相应的回调。UIWebViewDelegate里面有这样一个回调函数webView:shouldStartLoadWithRequest:navigationType:,苹果对这个回调函数的说明是:

Sent before a web view begins loading a frame.

注:(对于IOS 8之后的WKWebView可以选用webView:decidePolicyForNavigationAction:decisionHandler:,原理是一样的)

也就是说,该回调会在webview的frame加载之前被调用。

于是乎,

JSBridge创建了一个空的iframe,插入到正常的网页中,当需要调用Native的时候,就给iframe的src赋值,然后就会触发系统的webView:shouldStartLoadWithRequest:navigationType:回调。注意,这里的src不是随便赋值的,而是特殊定义的一个URL,这个很重要。

var CUSTOM_PROTOCOL_SCHEME = 'https';
var QUEUE_HAS_MESSAGE = '__wvjb_queue_message__';

// 定义用于消息传递的iframe
messagingIframe = document.createElement('iframe');
messagingIframe.style.display = 'none';
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
document.documentElement.appendChild(messagingIframe);

接下来我们也能够想到NativeBridge会在webView:shouldStartLoadWithRequest:navigationType:判断URL是不是约定好的特殊的URL,如果是的话,说明此次页面加载并不是正常页面加载,而是一次JS对Native的调用。

#define kOldProtocolScheme @"wvjbscheme"
#define kNewProtocolScheme @"https"
#define kQueueHasMessage   @"__wvjb_queue_message__"

- (BOOL)isQueueMessageURL:(NSURL*)url {
    NSString* host = url.host.lowercaseString;
    return [self isSchemeMatch:url] && [host isEqualToString:kQueueHasMessage];
}

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType {
    ......
    // 判断是不是约定好的用于消息调用的URL
    if ([_base isQueueMessageURL:url]) {
        // 获取所有的调用消息
        NSString *messageQueueString = [self _evaluateJavascript:[_base webViewJavascriptFetchQueyCommand]];
        // 根据调用消息发起相应的Native调用
        [_base flushMessageQueue:messageQueueString];
        ......
        // 不加载iframe的真实内容
        return NO;
    }
    ......
}

简单总结一下,JS并不能直接对Native发起调用,而是通过对iframe的src赋值,触发系统的webView:shouldStartLoadWithRequest:navigationType:回调,让Native能有机会处理。

从上面的原理我们可以看出JS对Native的调用都是异步的。

剩余三个问题

好,啃完硬骨头后面就比较轻松了,剩余的三个问题一块秒掉。

首先我们来看下JSBridge的调用逻辑。

// JS调用Native的API
function callHandler(handlerName, data, responseCallback) {
    ......
    // 构造发往Native的消息`{ handlerName:handlerName, data:data }`
    _doSend({ handlerName:handlerName, data:data }, responseCallback);
}
 
// 往Native发送调用消息
function _doSend(message, responseCallback) {
    if (responseCallback) {
        // 构造回调id,并将回调保存在responseCallbacks中等待被调用
        var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
        responseCallbacks[callbackId] = responseCallback;
        message['callbackId'] = callbackId;
    }
    sendMessageQueue.push(message);
    // 通过特殊的url发起对Native的调用
    messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}

JS在调用Native之前构造消息,消息的格式如下:

  • handlerName: 解决 如何通过handleName找到对应的Native功能函数的?
  • data:解决 调用Native功能的参数是如何传递的?
  • callbackId:解决 如果有回调的话,Native是怎么回调JS函数的?

但Native怎么收到这个消息呢?

function _fetchQueue() {
    var messageQueueString = JSON.stringify(sendMessageQueue);
    sendMessageQueue = [];
    return messageQueueString;
}
NSString *messageQueueString = [self _evaluateJavascript:[_base webViewJavascriptFetchQueyCommand]];

UIWebView是提供了stringByEvaluatingJavaScriptFromString:来执行JS代码的,Native通过这个函数获得了JS构造的消息列表。这个函数在后面Native -> JS通信中也起到的关键作用。

注:iOS 8.0之后的WKWebView使用的是evaluateJavaScript:completionHandler:

拿到消息数据,后面一切就好办了。

剩下还值得关注的是回调的实现。Native根据JS告知的callbackId,构造了传递给JS的WVJBMessage,然后在适当的时机CallJS,实现回调,JSBridge会根据callbackId,调用正确的回调函数,有兴趣的童鞋可以自己再挖一挖代码。

- (void)flushMessageQueue:(NSString *)messageQueueString{
    ......
    id messages = [self _deserializeMessageJSON:messageQueueString];
    for (WVJBMessage* message in messages) {
        ......
        WVJBResponseCallback responseCallback = NULL;
        NSString* callbackId = message[@"callbackId"];
        if (callbackId) {
            // 构造回调函数,作为native功能函数
            responseCallback = ^(id responseData) {
                if (responseData == nil) {
                    responseData = [NSNull null];
                }
                // callbackId能让JS在responseCallbacks中找到相应的回调执行
                WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
                // 调用JS回调
                [self _queueMessage:msg];
            };
        } else {
            responseCallback = ^(id ignoreResponseData) {
                // Do nothing
            };
        }
        
        // 根据名字找到相应的功能函数。
        WVJBHandler handler = self.messageHandlers[message[@"handlerName"]];
        ......
        // 调用功能
        handler(message[@"data"], responseCallback);
    }
}

Native > JS 通信(CallJS)

同样的,Native调用JS的功能,也需要JS先注册好功能函数。不多说,上示例代码。

// JS端,注册功能函数
bridge.registerHandler('testJavascriptHandler', function(data, responseCallback) {
    log('ObjC called testJavascriptHandler with', data)
    var responseData = { 'Javascript Says':'Right back atcha!' }
    log('JS responding with', responseData)
    responseCallback(responseData)
})
// Native端,调用功能函数
[_bridge callHandler:@"testJavascriptHandler" data:data responseCallback:^(id response) {
    NSLog(@"testJavascriptHandler responded: %@", response);
}];

首先向JSBridge中注册testJavascriptHandlerJS功能函数,然后Native端发起对testJavascriptHandler的调用。

JS功能函数是可以自定义的,所以理论上来说,Native端可以调用任何的JS方法,其实Native端是可以执行任意的JS的。

JSBridge注册功能

var messageHandlers = {};

function registerHandler(handlerName, handler) {
    messageHandlers[handlerName] = handler;
}

这简直就跟NativeBridge注册功能是一毛一样啊。

NativeBridge调用功能

Okay,相同的,再来四个问题。

  1. 如何实现Native到JS的调用?
  2. 如何通过handleName找到对应的JS功能函数的?
  3. 调用JS功能的参数是如何传递的?
  4. 如果有回调的话,JS是怎么回调Native函数的?

如何实现Native到JS的调用?

Native调用JS要比JS调用Native方便的多得多,苹果的已经给我们提供了现成的解决方案,之前我们也提及过,就是:

UIWebView的stringByEvaluatingJavaScriptFromString:

注:WKWebView对应的是evaluateJavaScript:completionHandler:

所以,Native调用JS的核心逻辑如下:

- (NSString*) _evaluateJavascript:(NSString*)javascriptCommand {
    return [_webView stringByEvaluatingJavaScriptFromString:javascriptCommand];
}

剩余三个问题

分析CallNative的时候,解决这三个问题靠的是消息传递,这么好用的招数,肯定也被移植到callJS上来了。

是不是又是一毛一样呢?

  • handlerName: 解决 如何通过handleName找到对应的JS功能函数的?
  • data:解决 调用JS功能的参数是如何传递的?
  • callbackId: 解决 如果有回调的话,JS是怎么回调Native函数的?
// Native调用JS的API
- (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 {
    // 构造传递给JS的消息体
    NSMutableDictionary* message = [NSMutableDictionary dictionary];
    message[@"data"] = data;
    message[@"handlerName"] = handlerName;
    if (responseCallback) {
        // 构造callbackId,并将回调的实现放在`responseCallbacks`中,等待被调用
        NSString* callbackId = [NSString stringWithFormat:@"objc_cb_%ld", ++_uniqueId];
        self.responseCallbacks[callbackId] = [responseCallback copy];
        message[@"callbackId"] = callbackId;
    }
    [self _queueMessage:message];
}

当JSBridge收到Native的消息之后,就根据消息做类似的处理。

function _dispatchMessageFromObjC(messageJSON) {
    if (message.callbackId) {
        var callbackResponseId = message.callbackId;
        responseCallback = function(responseData) {
            // 调用Native回调
            _doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
        };
    }
    // 根据handler名字找到相应的处理函数
    var handler = messageHandlers[message.handlerName];
    ......
    // 执行处理函数
    handler(message.data, responseCallback);
    ......
}

总分总的结尾

从上述分析我们可以看到,通信的本质无非就是消息的传递,Hybrid的通信也是离不开这个本质的。无论是callNative,还是callJS的实现,都是通过将构造好的消息传递给对端,对端根据消息做出相应的动作。

其实难就难在两点:

  • 如何实现消息的传递
    • callNative曲线救国用了iframe
    • callJS则是得益于苹果的API
  • 如何通过消息来传递多种多样的信息
    • 普通参数可以直接传
    • 功能函数需要通过名字来传递
    • 回调函数则需要通过回调id来传递