理解WebViewJavascriptBridge框架

WebViewJavascriptBridge是 iOS 开发中混合 H5 页面时经常用到的三方库。使用它可以很方便的在 iOS 和 JS 之间相互调用。该篇文章将探究其所以然,主要有两个目标:

  1. JS 如何调用 iOS 的?
  2. iOS 如何调用 JS 的?

该篇主要分析WKWebViewJavascriptBridge,其他兼容性相关代码暂且不表。

顺藤摸瓜-从初始化开始

按照官方的教程,使用WebViewJavascriptBridge需要 iOS、JS 双端都进行初始化配置。我们就从这里入手,看看能不能找出端倪。

iOS 端

[WKWebViewJavascriptBridge bridgeForWebView:webView];

在引入库后,需要先使用上面的方法生成Bridge对象。再使用Bridge对象注册供 JS 调用的方法。

+ (instancetype)bridgeForWebView:(WKWebView*)webView {
    WKWebViewJavascriptBridge* bridge = [[self alloc] init];
    [bridge _setupInstance:webView];
    [bridge reset];
    return bridge;
}

- (void) _setupInstance:(WKWebView*)webView {
    _webView = webView;
    _webView.navigationDelegate = self; // 1
    _base = [[WebViewJavascriptBridgeBase alloc] init]; // 2
    _base.delegate = self;
}

- (void)reset {
    [_base reset];
}

/// WebViewJavascriptBridgeBase
- (void)reset { // 3
    self.startupMessageQueue = [NSMutableArray array];
    self.responseCallbacks = [NSMutableDictionary dictionary];
    _uniqueId = 0;
}

可以看到,整个初始化非常的简单。生成实例对象,进行必要的配置,就返回了。有几点需要注意的是:

  1. 我们传入的webView的导航代理被设置为Bridge对象。所以我们需要通过-setWebViewDelegate:设置我们自己的代理,以接收相应回调。
  2. WebViewJavascriptBridgeBase对象是幕后的大 boss,Bridge对象的诸多 api 都依赖它。这样设计的原因是该库兼容多种类型的WebView,base 实现了基本逻辑。
  3. 重置时会清空 base 对象的startupMessageQueueresponseCallbacks以及_uniqueId。这 3 个属性是整个库的核心内容,后面会详细说明。

完成初始化后,下一步就是注册对应的方法了。

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

😂 简单的不能再简单了,就是在 base 的messageHandlers中以key-value的方式记录下来。

JS 端

JS 端的初始化也只是执行了下面一段简单的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function setupWebViewJavascriptBridge(callback) {
  if (window.WebViewJavascriptBridge) {
    return callback(window.WebViewJavascriptBridge);
  }
  if (window.WVJBCallbacks) {
    return window.WVJBCallbacks.push(callback);
  }
  window.WVJBCallbacks = [callback];
  var WVJBIframe = document.createElement("iframe");
  WVJBIframe.style.display = "none";
  WVJBIframe.src = "https://__bridge_loaded__";
  document.documentElement.appendChild(WVJBIframe);
  setTimeout(function () {
    document.documentElement.removeChild(WVJBIframe);
  }, 0);
}

从这里我们可以了解到:

  1. window.WebViewJavascriptBridge是 JS 端的主要依赖对象。后续 JS 使用的所有 api 都在该对象中。
  2. window.WVJBCallbackscallbacks 的持有者。多次调用setupWebViewJavascriptBridge方法,只会记录多个callback
  3. 通过在dom上挂载iframe,发起加载请求,告知 iOS 端。

直觉告诉我这个字符串不简单:https://__bridge_loaded_。然而…在库中搜索,却没有发现是怎么使用的。(后来才发现自己脑子秀逗了…😂)

柳暗花明 - webview 的代理

在 iOS 端初始化时,设置了webview的代理。这个操作绝对事出有因。于是翻了下具体实现的代理方法: -w970

乍一看还挺多。仔细捋一捋,其实没啥,90%都是在给_webView.navigationDelegate = self这句擦屁股…

下面红框的代码才是重中之重: -w964

  1. 处理 JS 端的初始化事件,注入 JS api 依赖对象
  2. 处理 JS 端发起的调用,刷新消息队列
  3. 异常处理

深入浅出 - iframe-evaluateJavaScript:completionHandler:是绝对的功臣

先上一张事件序列图,听我慢慢道来: webview-javascript-bridge

被字符串秀到了

在上一部分说 JS 端初始化中,会通过iframe加载https://__bridge_loaded_链接,来通知 iOS 端。但没有在 iOS 端找到使用它地方!其实,是我搜索的有问题,应该搜索__bridge_loaded_

webview的代理中,触发了下面的方法:

1
2
3
4
- (BOOL)isBridgeLoadedURL:(NSURL*)url {
    NSString* host = url.host.lowercaseString;
    return [self isSchemeMatch:url] && [host isEqualToString:kBridgeLoaded];
}

可以看到,这里先校验了scheme,之后校验了host。是分开的!!

在判断是load过程后,就进入了注入阶段。对应的是-injectJavascriptFile方法。这里主要干了两件事:

  1. 注入window.WebViewJavascriptBridge对象,提供 JS 端 api。
  2. 分发 iOS 配置的启动消息或者在 JS 环境没有准备好之前 iOS 端的调用。

JS 调用 iOS

对于 JS 端的调用:

1
2
3
4
5
6
7
bridge.callHandler(
  "pickImage",
  { key: "value" },
  function responseCallback(responseData) {
    console.log("JS received response:", responseData);
  }
);

其对应的调用栈为: -w1668

可以很清楚的看到:

  1. callHandler只是将入参拼接为message,传入_doSend。另外callHandler也支持这种调用:callHandler('methodName', () => {})
  2. _doSend是真正处理发送message的逻辑。在有回调的情况下,生成对应的回调 id,然后使用对应 id 将回调函数存储在responseCallbacks中;同时,这个 id 也会使用callbackId作为键名插入message,便于 iOS 端处理该次 JS 调用后,回调到 JS。这个message,最后会被放入sendMessageQueue中,在发起新的iframe加载(url: https://__wvjb_queue_message__)后会被 iOS 处理。

到这里,JS 调用 iOS 这一过程中,JS 端的处理算是完成了。

回到 iOS 端,WKFlushMessageQueue是重要的一步:

- (void)WKFlushMessageQueue {
    [_webView evaluateJavaScript:[_base webViewJavascriptFetchQueyCommand] completionHandler:^(NSString* result, NSError* error) {
        if (error != nil) {
            NSLog(@"WebViewJavascriptBridge: WARNING: Error when trying to fetch data from WKWebView: %@", error);
        }
        [_base flushMessageQueue:result];
    }];
}

这里先是执行了一段 JS,成功后处理返回的结果。

这段 JSWebViewJavascriptBridge._fetchQueue();就是获取上一步sendMessageQueue中的内容:

1
2
3
4
5
function _fetchQueue() {
  var messageQueueString = JSON.stringify(sendMessageQueue);
  sendMessageQueue = [];
  return messageQueueString;
}

很清楚,返回 JSON 化的字符串。那么下一步 iOS 端必然会存在解析 JSON 的过程: -w1440

可以看到:

  1. 在必要的入参判断后,第一步就是解析 JSON。
  2. 若存在callbackId,则生成响应 JS 调用的 Block,这个 Block 就是向 JS 发送消息,内容为@{ @"responseId":callbackId, @"responseData":responseData }
  3. 使用handlerNamemessageHandlers中取出该次调用对应的处理者,然后将该次调用的数据和生成的 Block 一起传入处理者。
  4. iOS 调用 JS 时,存在回调时,JS 也会发送一个响应回调,就像这里的第二步。

iOS 调用 JS

同样,对于 iOS 的调用:

1
2
3
[_bridge callHandler:@"reload" data:@{ @"by":@"test" } responseCallback:^(id responseData) {
    NSLog(@"call reload response: %@", responseData);
}];

可以跟踪到以下调用栈: -w544

我们逐个看下每个方法:

  1. -callHandler:data:responseCallback:什么也没做,只是调用了下一步的方法。
  2. -sendData:responseCallback:handlerName: -w826这里会生成message,主要包含callbackIdhandlerName两个字段。和 JS 端拼装的消息如出一辙。紧接着就将消息传入下一个方法。需要注意的是,这里也会通过callbackIdresponseCallbacks中记录回调 Block

  3. -_queueMessage:会区分初始化是否完成。未完成时,会将该次调用存储在startupMessageQueue中,后续初始化完成后再分发;若已经初始化完成,会直接进入下一步。
  4. -_dispatchMessage: -w902在这里会将上一步生成的message转换成 JSON,然后替换特殊字符,最后拼接到WebViewJavascriptBridge._handleMessageFromObjC('%@')中,使用webview-evaluateJavaScript:completionHandler:执行。
  5. 最后我们在看一下 JS 端的处理流程:
    1. _handleMessageFromObjC直接调用了_dispatchMessageFromObjC
    2. _dispatchMessageFromObjC会根据是否开启dispatchMessagesWithTimeoutSafety(默认 true)来确定是否通过setTimeout调用_doDispatchMessageFromObjC。这里使用这个开关,是因为 JS 调用alert, confirm, and prompt会导致 app 挂起。具体没有测试出来,还请知晓的同学告知下。
    3. _doDispatchMessageFromObjC是这里的重头戏,和 iOS 端的处理逻辑类似。-w578还是先 JSON 解析。之后根据callbackId生成回调函数,加上传过来的数据调用根据handlerName找到的处理函数。

至此,我们就完全梳理了两端相互调用的逻辑。有问题欢迎大家提问。

值得思考的点

为什么 iOS 端处理 JS 的调用时,需要使用批处理。而不是每次 JS 调用 iOS 都分别处理一次?

JS 调用 iOS 是通过iframe.src="https://__wvjb_queue_message__"来触发 iOS 的处理流程的。频繁的设置iframe.src浏览器并不会及时触发更新。若每次调用 iOS,单独处理,可能会造成调用丢失问题。

为什么 JS 端的支持对象window.WebViewJavascriptBridge需要通过 JS 触发,iOS 注入的方式进行?

这里应该是考虑到 JS 通过网络加载带了的延迟问题。

为什么 JS 调用 iOS 时的数据都被被 JSON 化了?

WKWebView中 JS 和 iOS 两端的数据类型会自动转换,使用 JSON 做中转应该不是必须的。这里可能是历史遗留问题。在从UIWebView更新到WKWebView时,这部分的逻辑保留了旧版的处理方式。