WebViewJavascriptBridge 原始碼中 Get 到的“橋樑美學”

Lision發表於2017-12-25

前言

Emmmmm...這篇文章釋出出來可能正逢聖誕節?,Merry Christmas!

WebViewJavascriptBridge 原始碼中 Get 到的“橋樑美學”

Web 頁面中的 JS 與 iOS Native 如何互動是每個 iOS 猿必須掌握的技能。而 JS 和 iOS Native 就好比兩塊沒有交集的大陸,如果想要使它們相互通訊就必須要建立一座“橋樑”。

思考一下,如果專案組讓你去造這座“橋”,如何才能做到既優雅又實用?

本文將結合 WebViewJavascriptBridge 原始碼逐步帶大家找到答案。

WebViewJavascriptBridge 是盛名已久的 JSBridge 庫,早在 2011 年就被作者 Marcus Westin 釋出到 GitHub,直到現在作者還在積極維護中,目前該專案已收穫近 1w star 咯,其原始碼非常值得我們學習。

WebViewJavascriptBridge 的程式碼邏輯清晰,風格良好,加上自身程式碼量比較小使得其原始碼閱讀非常輕鬆(可能需要一些 JS 基礎)。更加難能可貴的是它僅使用了少量程式碼就實現了對於 Mac OS X 的 WebView 以及 iOS 平臺的 UIWebView 和 WKWebView 三種元件的完美支援。

我對 WebViewJavascriptBridge 的評價是小而美,這類小而美的原始碼非常利於我們對其實現思想的學習(本文分析 WebViewJavascriptBridge 原始碼版本為 v6.0.3)。

關於 iOS 與 JS 的原生互動知識,之前我有寫過一篇文章《iOS 與 JS 互動開發知識總結》,文章除了介紹 JavaScriptCore 庫以及 UIWebView 和 WKWebView 與 JS 原生互動的方法之外還捎帶提到了 Hybrid 的發展簡史,文末還提供了一個 JS 通過 Native 呼叫 iOS 裝置攝像頭的 Demo

所以這篇文章不會再把重點放在 iOS 與 JS 的原生互動了,本文旨在介紹 WebViewJavascriptBridge 的設計思路和實現原理,對 iOS 與 JS 原生互動知識感興趣的同學推薦去閱讀上面提到的文章,應該會有點兒幫助(笑)。

索引

  • WebViewJavascriptBridge 簡介
  • WebViewJavascriptBridge && WKWebViewJavascriptBridge 探究
  • WebViewJavascriptBridgeBase - JS 呼叫 Native 實現原理剖析
  • WebViewJavascriptBridge_JS - Native 呼叫 JS 實現解讀
  • WebViewJavascriptBridge 的“橋樑美學”
  • 文章總結

WebViewJavascriptBridge 簡介

WebViewJavascriptBridge 原始碼中 Get 到的“橋樑美學”

WebViewJavascriptBridge 是用於在 WKWebView,UIWebView 和 WebView 中的 Obj-C 和 JavaScript 之間傳送訊息的 iOS / OSX 橋接器。

有許多不錯的專案都有使用 WebViewJavascriptBridge,這裡簡單列一部分(笑):

關於 WebViewJavascriptBridge 的具體使用方法詳見其 GitHub 頁面

在讀完 WebViewJavascriptBridge 的原始碼之後我將其劃分為三個層級:

層級 原始檔
介面層 WebViewJavascriptBridge && WKWebViewJavascriptBridge
實現層 WebViewJavascriptBridgeBase
JS 層 WebViewJavascriptBridge_JS

其中 WebViewJavascriptBridge && WKWebViewJavascriptBridge 作為介面層主要負責提供方便的介面,隱藏實現細節,其實現細節都是通過實現層 WebViewJavascriptBridgeBase 去做的,而 WebViewJavascriptBridge_JS 作為 JS 層其實儲存了一段 JS 程式碼,在需要的時候注入到當前 WebView 元件中,最終實現 Native 與 JS 的互動。

WebViewJavascriptBridge && WKWebViewJavascriptBridge 探究

WebViewJavascriptBridge 原始碼中 Get 到的“橋樑美學”

WebViewJavascriptBridge 和 WKWebViewJavascriptBridge 作為介面層分別對應於 UIWebView 和 WKWebView 元件,我們來簡單看一下這兩個檔案暴露出的資訊:

WebViewJavascriptBridge 暴露資訊:

@interface WebViewJavascriptBridge : WVJB_WEBVIEW_DELEGATE_INTERFACE

+ (instancetype)bridgeForWebView:(id)webView; // 初始化
+ (instancetype)bridge:(id)webView; // 初始化

+ (void)enableLogging; // 開啟日誌
+ (void)setLogMaxLength:(int)length; // 設定日誌最大長度

- (void)registerHandler:(NSString*)handlerName handler:(WVJBHandler)handler; // 註冊 handler (Native)
- (void)removeHandler:(NSString*)handlerName; // 刪除 handler (Native)
- (void)callHandler:(NSString*)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback; // 呼叫 handler (JS)
- (void)setWebViewDelegate:(id)webViewDelegate; // 設定 webViewDelegate
- (void)disableJavscriptAlertBoxSafetyTimeout; // 禁用 JS AlertBox 的安全時長來加速訊息傳遞,不推薦使用

@end
複製程式碼

WKWebViewJavascriptBridge 暴露資訊:

// Emmmmm...這裡應該不需要我註釋了吧
@interface WKWebViewJavascriptBridge : NSObject<WKNavigationDelegate, WebViewJavascriptBridgeBaseDelegate>

+ (instancetype)bridgeForWebView:(WKWebView*)webView;
+ (void)enableLogging;

- (void)registerHandler:(NSString*)handlerName handler:(WVJBHandler)handler;
- (void)removeHandler:(NSString*)handlerName;
- (void)callHandler:(NSString*)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback;
- (void)reset;
- (void)setWebViewDelegate:(id)webViewDelegate;
- (void)disableJavscriptAlertBoxSafetyTimeout;

@end
複製程式碼

Note: disableJavscriptAlertBoxSafetyTimeout 方法是通過禁用 JS 端 AlertBox 的安全時長來加速網橋訊息傳遞的。如果想使用那麼需要和前端約定好,如果禁用之後前端 JS 程式碼仍有呼叫 AlertBox 相關程式碼(alert, confirm, 或 prompt)則程式將被掛起,所以這個方法是不安全的,如無特殊需求筆者不推薦使用。

可以看得出來這兩個檔案暴露出的介面幾乎一致,其中 WebViewJavascriptBridge 中使用了巨集定義 WVJB_WEBVIEW_DELEGATE_INTERFACE 來分別適配 iOS 和 Mac OS X 平臺的 UIWebView 和 WebView 元件需要實現的代理方法。

WebViewJavascriptBridge 中的巨集定義

其實 WebViewJavascriptBridge 中為了適配 iOS 和 Mac OS X 平臺的 UIWebView 和 WebView 元件使用了一系列的巨集定義,其原始碼比較簡單:

#if defined __MAC_OS_X_VERSION_MAX_ALLOWED
    #define WVJB_PLATFORM_OSX
    #define WVJB_WEBVIEW_TYPE WebView
    #define WVJB_WEBVIEW_DELEGATE_TYPE NSObject<WebViewJavascriptBridgeBaseDelegate>
    #define WVJB_WEBVIEW_DELEGATE_INTERFACE NSObject<WebViewJavascriptBridgeBaseDelegate, WebPolicyDelegate>
#elif defined __IPHONE_OS_VERSION_MAX_ALLOWED
    #import <UIKit/UIWebView.h>
    #define WVJB_PLATFORM_IOS
    #define WVJB_WEBVIEW_TYPE UIWebView
    #define WVJB_WEBVIEW_DELEGATE_TYPE NSObject<UIWebViewDelegate>
    #define WVJB_WEBVIEW_DELEGATE_INTERFACE NSObject<UIWebViewDelegate, WebViewJavascriptBridgeBaseDelegate>
#endif
複製程式碼

分別根據所在平臺不同定義了 WVJB_WEBVIEW_TYPEWVJB_WEBVIEW_DELEGATE_TYPE 以及剛才提到的 WVJB_WEBVIEW_DELEGATE_INTERFACE 巨集定義,並且分別定義了 WVJB_PLATFORM_OSXWVJB_PLATFORM_IOS 便於之後的實現原始碼區分當前平臺時使用,下面的 supportsWKWebView 巨集定義也是同樣的道理:

#if (__MAC_OS_X_VERSION_MAX_ALLOWED > __MAC_10_9 || __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_7_1)
#define supportsWKWebView
#endif
複製程式碼

在引入標頭檔案的時候可以通過這個 supportsWKWebView 巨集靈活引入所需的標頭檔案:

// WebViewJavascriptBridge.h
#if defined supportsWKWebView
#import <WebKit/WebKit.h>
#endif

// WebViewJavascriptBridge.m
#if defined(supportsWKWebView)
#import "WKWebViewJavascriptBridge.h"
#endif
複製程式碼

WebViewJavascriptBridge 的實現分析

我們接著看一下 WebViewJavascriptBridge 的實現部分,首先從內部變數資訊看起:

#if __has_feature(objc_arc_weak)
    #define WVJB_WEAK __weak
#else
    #define WVJB_WEAK __unsafe_unretained
#endif

@implementation WebViewJavascriptBridge {
    WVJB_WEAK WVJB_WEBVIEW_TYPE* _webView; // bridge 對應的 WebView 元件
    WVJB_WEAK id _webViewDelegate; // 給 WebView 元件設定的代理(需要的話)
    long _uniqueId; // 唯一標識,Emmmmm...但是我發現沒鳥用,只有 _base 中的 _uniqueId 才有用
    WebViewJavascriptBridgeBase *_base; // 上文說過,底層實現其實都是 WebViewJavascriptBridgeBase 在做
}
複製程式碼

上文提到 WebViewJavascriptBridge 和 WKWebViewJavascriptBridge 的 .h 檔案暴露介面資訊非常相似,那麼我們要不要看看 WKWebViewJavascriptBridge 的內部變數資訊呢?

// 註釋參見 WebViewJavascriptBridge 就好
@implementation WKWebViewJavascriptBridge {
    __weak WKWebView* _webView;
    __weak id<WKNavigationDelegate> _webViewDelegate;
    long _uniqueId;
    WebViewJavascriptBridgeBase *_base;
}
複製程式碼

嘛~ 這倆貨簡直是一個媽生的。其實這是作者故意為之,因為作者想對外提供一套介面,即 WebViewJavascriptBridge,我們只需要使用 WebViewJavascriptBridge 就可以自動根據繫結的 WebView 元件的不同生成與之對應的 JSBridge 例項。

+ (instancetype)bridge:(id)webView {
// 如果支援 WKWebView
#if defined supportsWKWebView
    // 需要先判斷當前入參 webView 是否從屬於 WKWebView
    if ([webView isKindOfClass:[WKWebView class]]) {
        // 返回 WKWebViewJavascriptBridge 例項
        return (WebViewJavascriptBridge*) [WKWebViewJavascriptBridge bridgeForWebView:webView];
    }
#endif
    // 判斷當前入參 webView 是否從屬於 WebView(Mac OS X)或者 UIWebView(iOS)
    if ([webView isKindOfClass:[WVJB_WEBVIEW_TYPE class]]) {
        // 返回 WebViewJavascriptBridge 例項
        WebViewJavascriptBridge* bridge = [[self alloc] init];
        [bridge _platformSpecificSetup:webView];
        return bridge;
    }
    
    // 丟擲 BadWebViewType 異常並返回 nil
    [NSException raise:@"BadWebViewType" format:@"Unknown web view type."];
    return nil;
}
複製程式碼

我們可以看到上面的程式碼,實現並不複雜。如果支援 WKWebView 的話(#if defined supportsWKWebView)則去判斷當前繫結的 WebView 元件是否從屬於 WKWebView,這樣可以返回 WKWebViewJavascriptBridge 例項,否則返回 WebViewJavascriptBridge 例項,最後如果入參 webView 的型別不滿足判斷條件則丟擲 BadWebViewType 異常。

還有一個關於 _webViewDelegate 的小細節,本來不打算講的,但是還是提一下吧(囧)。其實在 WebViewJavascriptBridge 以及 WKWebViewJavascriptBridge 的初始化實現過程中,會把當前 WebView 元件的代理繫結為自己:

// WebViewJavascriptBridge
- (void) _platformSpecificSetup:(WVJB_WEBVIEW_TYPE*)webView {
    _webView = webView;
    _webView.delegate = self;
    _base = [[WebViewJavascriptBridgeBase alloc] init];
    _base.delegate = self;
}

// WKWebViewJavascriptBridge
- (void) _setupInstance:(WKWebView*)webView {
    _webView = webView;
    _webView.navigationDelegate = self;
    _base = [[WebViewJavascriptBridgeBase alloc] init];
    _base.delegate = self;
}
複製程式碼

Note: 替換元件的代理將其代理繫結為 bridge 自己是因為 WebViewJavascriptBridge 的實現原理上是利用我之前的文章《iOS 與 JS 互動開發知識總結》中講過的假 Request 方法實現的,所以需要監聽 WebView 元件的代理方法獲取載入之前的 Request.URL 並做處理。這也是為什麼 WebViewJavascriptBridge 提供了一個介面 setWebViewDelegate: 儲存了一個邏輯上的 _webViewDelegate,這個 _webViewDelegate 也需要遵循 WebView 元件的代理協議,這樣在 WebViewJavascriptBridge 內部不同的代理方法中做完 bridge 要做的事情只有就會再去呼叫 _webViewDelegate 對應的代理方法,其實可以理解為 WebViewJavascriptBridge 對當前 WebView 元件的代理做了 hook。

對於 WebViewJavascriptBridge 中暴露的初始化以外的所有介面,其內部實現都是通過 WebViewJavascriptBridgeBase 來實現的。這樣做的好處就是即使 WebViewJavascriptBridge 因為繫結了 WKWebView 返回了 WKWebViewJavascriptBridge 例項,只要介面一致,對 JSBridge 傳送相同的訊息,就會有相同的實現(都是由 WebViewJavascriptBridgeBase 類實現的)

WebViewJavascriptBridgeBase - JS 呼叫 Native 實現原理剖析

WebViewJavascriptBridge 原始碼中 Get 到的“橋樑美學”

作為 WebViewJavascriptBridge 的實現層,WebViewJavascriptBridgeBase 的命名也可以體現出其是作為整座“橋樑”橋墩一般的存在,我們還是按照老規矩先看一下 WebViewJavascriptBridgeBase.h 暴露的資訊,好對其有一個整體的印象:

typedef void (^WVJBResponseCallback)(id responseData); // 回撥 block
typedef void (^WVJBHandler)(id data, WVJBResponseCallback responseCallback); // 註冊的 Handler block
typedef NSDictionary WVJBMessage; // 訊息型別 - 字典

@protocol WebViewJavascriptBridgeBaseDelegate <NSObject>
- (NSString*) _evaluateJavascript:(NSString*)javascriptCommand;
@end

@interface WebViewJavascriptBridgeBase : NSObject

@property (weak, nonatomic) id <WebViewJavascriptBridgeBaseDelegate> delegate; // 代理,指向介面層類,用以給對應介面繫結的 WebView 元件傳送執行 JS 訊息
@property (strong, nonatomic) NSMutableArray* startupMessageQueue; // 啟動訊息佇列,可以理解為存放 WVJBMessage
@property (strong, nonatomic) NSMutableDictionary* responseCallbacks; // 回撥 blocks 字典,存放 WVJBResponseCallback 型別的 block
@property (strong, nonatomic) NSMutableDictionary* messageHandlers; // 已註冊的 handlers 字典,存放 WVJBHandler 型別的 block
@property (strong, nonatomic) WVJBHandler messageHandler; // 沒鳥用

+ (void)enableLogging; // 開啟日誌
+ (void)setLogMaxLength:(int)length; // 設定日誌最大長度
- (void)reset; // 對應 WKJSBridge 的 reset 介面
- (void)sendData:(id)data responseCallback:(WVJBResponseCallback)responseCallback handlerName:(NSString*)handlerName; // 傳送訊息,入參依次是引數,回撥 block,對應 JS 端註冊的 HandlerName
- (void)flushMessageQueue:(NSString *)messageQueueString; // 重新整理訊息佇列,核心程式碼
- (void)injectJavascriptFile; // 注入 JS
- (BOOL)isWebViewJavascriptBridgeURL:(NSURL*)url; // 判定是否為 WebViewJavascriptBridgeURL
- (BOOL)isQueueMessageURL:(NSURL*)urll; // 判定是否為佇列訊息 URL
- (BOOL)isBridgeLoadedURL:(NSURL*)urll; // 判定是否為 bridge 載入 URL
- (void)logUnkownMessage:(NSURL*)url; // 列印收到未知訊息資訊
- (NSString *)webViewJavascriptCheckCommand; // JS bridge 檢測命令
- (NSString *)webViewJavascriptFetchQueyCommand; // JS bridge 獲取查詢命令
- (void)disableJavscriptAlertBoxSafetyTimeout; // 禁用 JS AlertBox 安全時長以獲取傳送訊息速度提升,不建議使用,理由見上文

@end
複製程式碼

嘛~ 從 .h 檔案中我們可以看到整個 WebViewJavascriptBridgeBase 所暴露出來的資訊,屬性層面上需要對以下 4 個屬性加深印象,之後分析實現的過程中會帶入這些屬性:

  • id <WebViewJavascriptBridgeBaseDelegate> delegate 代理,可以通過代理讓當前 bridge 繫結的 WebView 元件執行 JS 程式碼
  • NSMutableArray* startupMessageQueue; 啟動訊息佇列,存放 Obj-C 傳送給 JS 的訊息(可以理解為存放 WVJBMessage 型別)
  • NSMutableDictionary* responseCallbacks; 回撥 blocks 字典,存放 WVJBResponseCallback 型別的 block
  • NSMutableDictionary* messageHandlers; Obj-C 端已註冊的 handlers 字典,存放 WVJBHandler 型別的 block

Emmmmm...介面層面看一下注釋就好了,後面分析實現的時候會捎帶講解一些介面,剩下一些跟實現無關的介面內容感興趣的同學推薦自己扒原始碼哈。

我們在對 WebViewJavascriptBridgeBase 整體有了一個初始印象之後就可以自己寫一個頁面,簡單的嵌入一些 JS 跑一遍流程,在中間下斷點扒原始碼,這樣我們對於 Native 與 JS 的互動流程就可以一清二楚了。

下面模擬一遍 JS 通過 WebViewJavascriptBridge 呼叫 Native 功能的流程分析 WebViewJavascriptBridgeBase 的相關實現(考慮現在的時間點決定以 WKWebView 為例講解,即針對 WKWebViewJavascriptBridge 原始碼講解):

1.監聽假 Request 並注入 WebViewJavascriptBridge_JS 內的 JS 程式碼

上文說到 WebViewJavascriptBridge 的實現其實本質上是利用了我之前的文章《iOS 與 JS 互動開發知識總結》中講過的假 Request 方法實現的,那麼我們就從監聽假 Request 開始講起吧。

// WKNavigationDelegate 協議方法,用於監聽 Request 並決定是否允許導航
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    // webView 校驗
    if (webView != _webView) { return; }
    NSURL *url = navigationAction.request.URL;
    __strong typeof(_webViewDelegate) strongDelegate = _webViewDelegate;

    // 核心程式碼
    if ([_base isWebViewJavascriptBridgeURL:url]) { // 判定 WebViewJavascriptBridgeURL
        if ([_base isBridgeLoadedURL:url]) { // 判定 BridgeLoadedURL
            // 注入 JS 程式碼
            [_base injectJavascriptFile];
        } else if ([_base isQueueMessageURL:url]) { // 判定 QueueMessageURL
            // 重新整理訊息佇列
            [self WKFlushMessageQueue];
        } else {
            // 記錄未知 bridge msg 日誌
            [_base logUnkownMessage:url];
        }
        decisionHandler(WKNavigationActionPolicyCancel);
        return;
    }
    
    // 呼叫 _webViewDelegate 對應的代理方法
    if (strongDelegate && [strongDelegate respondsToSelector:@selector(webView:decidePolicyForNavigationAction:decisionHandler:)]) {
        [_webViewDelegate webView:webView decidePolicyForNavigationAction:navigationAction decisionHandler:decisionHandler];
    } else {
        decisionHandler(WKNavigationActionPolicyAllow);
    }
}
複製程式碼

Note: 之前說過 WebViewJavascriptBridge 會 hook 繫結的 WebView 的代理方法,這一點 WKWebViewJavascriptBridge 也一樣,在加入自己的程式碼之後會判斷是否有 _webViewDelegate 響應這個代理方法,如果有則呼叫。

我們還是把注意力放到註釋中核心程式碼的位置,裡面會先判斷當前 url 是否為 bridge url:

// 相關巨集定義
#define kOldProtocolScheme @"wvjbscheme"
#define kNewProtocolScheme @"https"
#define kQueueHasMessage   @"__wvjb_queue_message__"
#define kBridgeLoaded      @"__bridge_loaded__"
複製程式碼

WebViewJavascriptBridge GitHub 頁面 的使用方法中第 4 步明確指出要複製貼上 setupWebViewJavascriptBridge 方法到前段 JS 中,我們先來看一下這段 JS 方法原始碼:

function setupWebViewJavascriptBridge(callback) {
	if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
	if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
	window.WVJBCallbacks = [callback];
	// 建立一個 iframe
	var WVJBIframe = document.createElement('iframe');
	// 設定 iframe 為不顯示
	WVJBIframe.style.display = 'none';
	// 將 iframe 的 src 置為 'https://__bridge_loaded__'
	WVJBIframe.src = 'https://__bridge_loaded__';
	// 將 iframe 加入到 document.documentElement
	document.documentElement.appendChild(WVJBIframe);
	setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
}
複製程式碼

上面的程式碼建立了一個不顯示的 iframe 並將其 src 置為 https://__bridge_loaded__,與上文中 kBridgeLoaded 巨集定義一致,即用於 isBridgeLoadedURL: 方法中判定當前 url 是否為 BridgeLoadedURL。

Note: 假 Request 的發起有兩種方式,-1:location.href -2:iframe。通過 location.href 有個問題,就是如果 JS 多次呼叫原生的方法也就是 location.href 的值多次變化,Native 端只能接受到最後一次請求,前面的請求會被忽略掉,所以這裡 WebViewJavascriptBridge 選擇使用 iframe,後面不再解釋。

因為加入了 src 為 https://__bridge_loaded__ 的 iframe 元素,我們上面截獲 url 的代理方法就會拿到一個 https://__bridge_loaded__ 的 url,由於 https 滿足判定 WebViewJavascriptBridgeURL,將會進入核心程式碼區域接著會被判定為 BridgeLoadedURL 執行注入 JS 程式碼的方法,即 [_base injectJavascriptFile];

- (void)injectJavascriptFile {
    // 獲取到 WebViewJavascriptBridge_JS 的程式碼
    NSString *js = WebViewJavascriptBridge_js();
    // 將獲取到的 js 通過代理方法注入到當前繫結的 WebView 元件
    [self _evaluateJavascript:js];
    // 如果當前已有訊息佇列則遍歷並分發訊息,之後清空訊息佇列
    if (self.startupMessageQueue) {
        NSArray* queue = self.startupMessageQueue;
        self.startupMessageQueue = nil;
        for (id queuedMessage in queue) {
            [self _dispatchMessage:queuedMessage];
        }
    }
}
複製程式碼

至此,第一步互動已完成。關於 WebViewJavascriptBridge_JS 內部的 JS 程式碼我們放到後面的章節解讀,現在可以簡單理解為 WebViewJavascriptBridge 在 JS 端的具體實現程式碼。

2.JS 端呼叫 callHandler 方法之後 Native 端究竟是如何響應的?

WebViewJavascriptBridge GitHub 頁面 中指出 JS 端的操作方式:

setupWebViewJavascriptBridge(function(bridge) {
	
	/* Initialize your app here */

	bridge.registerHandler('JS Echo', function(data, responseCallback) {
		console.log("JS Echo called with:", data)
		responseCallback(data)
	})
	bridge.callHandler('ObjC Echo', {'key':'value'}, function responseCallback(responseData) {
		console.log("JS received response:", responseData)
	})
})
複製程式碼

我們知道 JS 端呼叫 setupWebViewJavascriptBridge 方法會走我們剛才分析過的第一步,即監聽假 Request 並注入 WebViewJavascriptBridge_JS 內的 JS 程式碼。那麼當 JS 端呼叫 bridge.callHandler 時,Native 端究竟是如何做出響應的呢?這裡我們需要先稍微解讀一下之前注入的 WebViewJavascriptBridge_JS 中的 JS 程式碼:

// 呼叫 iOS handler,引數校驗之後呼叫 _doSend 函式
function callHandler(handlerName, data, responseCallback) {
	if (arguments.length == 2 && typeof data == 'function') {
		responseCallback = data;
		data = null;
	}
	_doSend({ handlerName:handlerName, data:data }, responseCallback);
}

// 如有回撥,則設定 message['callbackId'] 與 responseCallbacks[callbackId]
// 將 msg 加入 sendMessageQueue 陣列,設定 messagingIframe.src
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;
}
	
// scheme 使用 https 之後通過 host 做匹配
var CUSTOM_PROTOCOL_SCHEME = 'https';
var QUEUE_HAS_MESSAGE = '__wvjb_queue_message__';
複製程式碼

可以看到 JS 端的程式碼中有 callHandler 函式的實現,其內部將入參 handlerName 以及 data 以字典形式作為引數呼叫 _doSend 方法,我們看一下 _doSend 方法的實現:

  • _doSend 方法內部會先判斷入參中是否有回撥
  • 如果有回撥則根據規則生成 callbackId 並且將回撥 block 儲存到 responseCallbacks 字典(囧~ JS 不叫字典的,我是為了 iOS 讀者看著方便),之後給訊息也加入一個鍵值對儲存剛才生成的 callbackId
  • 之後給 sendMessageQueue 佇列加入 message
  • messagingIframe.src 設定為 https://__wvjb_queue_message__

好,點到為止,對於 WebViewJavascriptBridge_JS 內的 JS 端其他原始碼我們放著後面看。注意這裡加入了一個 src 為 https://__wvjb_queue_message__messagingIframe,它也是一個不可見的 iframe。這樣 Native 端會收到一個 url 為 https://__wvjb_queue_message__ 的 request,回到第 1 步中獲取到假的 request 之後會進行各項判定,這次會滿足 [_base isQueueMessageURL:url] 的判定呼叫 Native 的 WKFlushMessageQueue 方法。

- (void)WKFlushMessageQueue {
    // 執行 WebViewJavascriptBridge._fetchQueue(); 方法
    [_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];
    }];
}

- (NSString *)webViewJavascriptFetchQueyCommand {
    return @"WebViewJavascriptBridge._fetchQueue();";
}
複製程式碼

可見 Native 端會在重新整理佇列中呼叫 JS 端的 WebViewJavascriptBridge._fetchQueue(); 方法,我們來看一下 JS 端此方法的具體實現:

// 獲取佇列,在 iOS 端重新整理訊息佇列時會呼叫此函式
function _fetchQueue() {
   // 將 sendMessageQueue 轉為 JSON 格式
	var messageQueueString = JSON.stringify(sendMessageQueue);
	// 重置 sendMessageQueue
	sendMessageQueue = [];
	// 返回 JSON 格式的 
	return messageQueueString;
}
複製程式碼

這個方法會把當前 JS 端 sendMessageQueue 訊息佇列以 JSON 的形式返回,而 Native 端會呼叫 [_base flushMessageQueue:result]; 將拿到的 JSON 形式訊息佇列作為引數呼叫 flushMessageQueue: 方法,這個方法是整個框架 Native 端的精華所在,就是稍微有點長(笑)。

- (void)flushMessageQueue:(NSString *)messageQueueString {
    // 校驗 messageQueueString
    if (messageQueueString == nil || messageQueueString.length == 0) {
        NSLog(@"WebViewJavascriptBridge: WARNING: ObjC got nil while fetching the message queue JSON from webview. This can happen if the WebViewJavascriptBridge JS is not currently present in the webview, e.g if the webview just loaded a new page.");
        return;
    }

    // 將 messageQueueString 通過 NSJSONSerialization 解為 messages 並遍歷
    id messages = [self _deserializeMessageJSON:messageQueueString];
    for (WVJBMessage* message in messages) {
        // 型別校驗
        if (![message isKindOfClass:[WVJBMessage class]]) {
            NSLog(@"WebViewJavascriptBridge: WARNING: Invalid %@ received: %@", [message class], message);
            continue;
        }
        [self _log:@"RCVD" json:message];
        
        // 嘗試取 responseId,如取到則表明是回撥,從 _responseCallbacks 取匹配的回撥 block 執行
        NSString* responseId = message[@"responseId"];
        if (responseId) { // 取到 responseId
            WVJBResponseCallback responseCallback = _responseCallbacks[responseId];
            responseCallback(message[@"responseData"]);
            [self.responseCallbacks removeObjectForKey:responseId];
        } else { // 未取到 responseId,則表明是正常的 JS callHandler 呼叫 iOS
            WVJBResponseCallback responseCallback = NULL;
            // 嘗試取 callbackId,示例 cb_1_1512035076293
            // 對應 JS 程式碼 var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
            NSString* callbackId = message[@"callbackId"];
            if (callbackId) { // 取到 callbackId,表示 js 端希望在呼叫 iOS native 程式碼後有回撥
                responseCallback = ^(id responseData) {
                    if (responseData == nil) {
                        responseData = [NSNull null];
                    }
                    
                    // 將 callbackId 作為 msg 的 responseId 並設定 responseData,執行 _queueMessage
                    WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
                    // _queueMessage 函式主要是把 msg 轉為 JSON 格式,內含 responseId = callbackId
                    // JS 端呼叫 WebViewJavascriptBridge._handleMessageFromObjC('msg_JSON'); 其中 'msg_JSON' 就是 JSON 格式的 msg
                    [self _queueMessage:msg];
                };
            } else { // 未取到 callbackId
                responseCallback = ^(id ignoreResponseData) {
                    // Do nothing
                };
            }
            
            // 嘗試以 handlerName 獲取 iOS 端之前註冊過的 handler
            WVJBHandler handler = self.messageHandlers[message[@"handlerName"]];
            if (!handler) { // 沒註冊過,則跳過此 msg
                NSLog(@"WVJBNoHandlerException, No handler for message from JS: %@", message);
                continue;
            }
            // 呼叫對應的 handler,以 message[@"data"] 為入參,以 responseCallback 為回撥
            handler(message[@"data"], responseCallback);
        }
    }
}
複製程式碼

嘛~ flushMessageQueue: 方法作為整個 Native 端的核心,有點長是可以理解的。我們簡單理一下它的實現思路:

  • 入參校驗
  • 將 JSON 形式的入參轉換為 Native 物件,即訊息佇列,這裡面訊息型別是之前定義過的 WVJBMessage,即字典
  • 如果訊息中含有 “responseId” 則表明是之前 Native 呼叫的 JS 方法回撥過來的訊息(因為 JS 端和 Native 端實現邏輯是對等的,所以這個地方不明白的可以參考下面的分析)
  • 如果訊息中不含 “responseId” 則表明是 JS 端通過 callHandler 函式正常呼叫 Native 端過來的訊息
  • 嘗試獲取訊息中的 “callbackId”,如果 JS 本次訊息需要 Native 響應之後回撥才會有這個鍵值,具體參見上文中 JS 端 _doSend 部分原始碼分析。如取到 “callbackId” 則需生成一個回撥 block,回撥 block 內部將 “callbackId” 作為 msg 的 “responseId” 執行 _queueMessage 將訊息傳送給 JS 端(JS 端處理訊息邏輯與 Native 端一致,所以上面使用 “responseId” 判斷當前訊息是否為回撥方法傳遞過來的訊息是很容易理解的)
  • 嘗試以訊息中的 “handlerName” 從 messageHandlers(上文提到過,是儲存 Native 端註冊過的 handler 的字典)取到對應的 handler block,如果取到則執行程式碼塊,否則列印錯誤日誌

Note: 這個訊息處理的方法雖然長,但是邏輯清晰,而且有效的解決了 JS 與 Native 相互呼叫的過程中引數傳遞的問題(包括回撥),此外 JS 端的訊息處理邏輯與 Native 端保持一致,實現了邏輯對稱,非常值得我們學習。

WebViewJavascriptBridge_JS - Native 呼叫 JS 實現解讀

WebViewJavascriptBridge 原始碼中 Get 到的“橋樑美學”

Emmmmm...這一章節主要講 JS 端注入的程式碼,即 WebViewJavascriptBridge_JS 中的 JS 原始碼。由於我沒做過前段,能力不足,水平有限,可能有謬誤希望各位讀者發現的話及時指正,感激不盡。預警,由於 JS 端和上文分析過的 Native 端邏輯對稱且上文已經分析過部分 JS 端的函式,所以下面的 JS 原始碼沒有另做拆分,為避免被大段 JS 程式碼糊臉不感興趣的同學可以直接看程式碼後面的總結。

;(function() {
    // window.WebViewJavascriptBridge 校驗,避免重複
	if (window.WebViewJavascriptBridge) {
		return;
	}

    // 懶載入 window.onerror,用於列印 error 日誌
	if (!window.onerror) {
		window.onerror = function(msg, url, line) {
			console.log("WebViewJavascriptBridge: ERROR:" + msg + "@" + url + ":" + line);
		}
	}
	
	// window.WebViewJavascriptBridge 宣告
	window.WebViewJavascriptBridge = {
		registerHandler: registerHandler,
		callHandler: callHandler,
		disableJavscriptAlertBoxSafetyTimeout: disableJavscriptAlertBoxSafetyTimeout,
		_fetchQueue: _fetchQueue,
		_handleMessageFromObjC: _handleMessageFromObjC
	};

    // 變數宣告
	var messagingIframe; // 訊息 iframe
	var sendMessageQueue = []; // 傳送訊息佇列
	var messageHandlers = {}; // JS 端註冊的訊息處理 handlers 字典(囧,JS 其實叫物件)
	
	// scheme 使用 https 之後通過 host 做匹配
	var CUSTOM_PROTOCOL_SCHEME = 'https';
	var QUEUE_HAS_MESSAGE = '__wvjb_queue_message__';
	
	var responseCallbacks = {}; // JS 端存放回撥的字典
	var uniqueId = 1; // 唯一標示,用於回撥時生成 callbackId
	var dispatchMessagesWithTimeoutSafety = true; // 預設啟用安全時長

    // 通過禁用 AlertBoxSafetyTimeout 來提速網橋訊息傳遞
    function disableJavscriptAlertBoxSafetyTimeout() {
		dispatchMessagesWithTimeoutSafety = false;
	}

    // 同 iOS 邏輯,註冊 handler 其實是往 messageHandlers 字典中插入對應 name 的 block
	function registerHandler(handlerName, handler) {
		messageHandlers[handlerName] = handler;
	}
	
	// 呼叫 iOS handler,引數校驗之後呼叫 _doSend 函式
	function callHandler(handlerName, data, responseCallback) {
	    // 如果引數只有兩個且第二個引數型別為 function,則表示沒有引數傳遞,即 data 為空
		if (arguments.length == 2 && typeof data == 'function') {
			responseCallback = data;
			data = null;
		}
		// 將 handlerName 和 data 作為 msg 物件引數呼叫 _doSend 函式
		_doSend({ handlerName:handlerName, data:data }, responseCallback);
	}
	
	// _doSend 向 Native 端傳送訊息
	function _doSend(message, responseCallback) {
	    // 如有回撥,則設定 message['callbackId'] 與 responseCallbacks[callbackId]
		if (responseCallback) {
			var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
			responseCallbacks[callbackId] = responseCallback;
			message['callbackId'] = callbackId;
		}
		// 將 msg 加入 sendMessageQueue 陣列,設定 messagingIframe.src
		sendMessageQueue.push(message);
		messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
	}

    // 獲取佇列,在 iOS 端重新整理訊息佇列時會呼叫此函式
	function _fetchQueue() {
	    // 內部將傳送訊息佇列 sendMessageQueue 轉為 JSON 格式並返回
		var messageQueueString = JSON.stringify(sendMessageQueue);
		sendMessageQueue = [];
		return messageQueueString;
	}

	// iOS 端 _dispatchMessage 函式會呼叫此函式
	function _handleMessageFromObjC(messageJSON) {
	    // 排程從 Native 端獲取到的訊息
        _dispatchMessageFromObjC(messageJSON);
	}
    
    // 核心程式碼,排程從 Native 端獲取到的訊息,邏輯與 Native 端一致
	function _dispatchMessageFromObjC(messageJSON) {
		// 判斷有沒有禁用 AlertBoxSafetyTimeout,最終會呼叫 _doDispatchMessageFromObjC 函式
		if (dispatchMessagesWithTimeoutSafety) {
			setTimeout(_doDispatchMessageFromObjC);
		} else {
			 _doDispatchMessageFromObjC();
		}
		
		// 解析 msgJSON 得到 msg
		function _doDispatchMessageFromObjC() {
			var message = JSON.parse(messageJSON);
			var messageHandler;
			var responseCallback;

			// 如果有 responseId,則說明是回撥,取對應的 responseCallback 執行,之後釋放
			if (message.responseId) {
				responseCallback = responseCallbacks[message.responseId];
				if (!responseCallback) {
					return;
				}
				responseCallback(message.responseData);
				delete responseCallbacks[message.responseId];
			} else { // 沒有 responseId,則表示正常的 iOS call handler 呼叫 js
				// 如 msg 包含 callbackId,說明 iOS 端需要回撥,初始化對應的 responseCallback
				if (message.callbackId) {
					var callbackResponseId = message.callbackId;
					responseCallback = function(responseData) {
						_doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
					};
				}
				
				// 從 messageHandlers 拿到對應的 handler 執行
				var handler = messageHandlers[message.handlerName];
				if (!handler) {
				    // 如未取到對應的 handler 則列印錯誤日誌
					console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
				} else {
					handler(message.data, responseCallback);
				}
			}
		}
	}

    // messagingIframe 的宣告,型別 iframe,樣式不可見,src 設定
	messagingIframe = document.createElement('iframe');
	messagingIframe.style.display = 'none';
	messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
	// messagingIframe 加入 document.documentElement 中
	document.documentElement.appendChild(messagingIframe);

    // 註冊 disableJavscriptAlertBoxSafetyTimeout handler,Native 可以通過禁用 AlertBox 的安全時長來加速橋接訊息
	registerHandler("_disableJavascriptAlertBoxSafetyTimeout", disableJavscriptAlertBoxSafetyTimeout);
	
	setTimeout(_callWVJBCallbacks, 0);
	function _callWVJBCallbacks() {
		var callbacks = window.WVJBCallbacks;
		delete window.WVJBCallbacks;
		for (var i=0; i<callbacks.length; i++) {
			callbacks[i](WebViewJavascriptBridge);
		}
	}
}
複製程式碼

JS 端和 Native 端邏輯一致,上面的程式碼已經加入了詳細的中文註釋,上文在對於“WebViewJavascriptBridgeBase - JS 呼叫 Native 實現原理剖析”章節的分析過程中為了走通整個呼叫的邏輯已經對部分 JS 端程式碼進行了分析,這裡我們簡單的梳理一下 JS 端核心程式碼 _doDispatchMessageFromObjC 函式的邏輯:

  • 將 messageJSON 使用 JSON 解析出來
  • 嘗試取解析到的訊息中的 responseId,如果有取到則說明是 Native 端響應 JS 端之後通過回撥向 JS 端發出的訊息,用 responseId 取 responseCallbacks 中對應的回撥響應 block,找到後執行該 block 之後刪除
  • 如果沒取到 responseId 則表示這條訊息是 Native 端通過 callHandler:data:responseCallback: 正常呼叫 JS 註冊的 handler 傳送過來的訊息(這裡的正常是針對回撥而言)
  • 如果當前的訊息有 callbackId 則表明 Native 端需要 JS 端響應本次訊息之後回撥反饋,生成一個 responseCallback 作為回撥 block (JS 端是 function) ,其內部使用 _doSend 方法傳遞一個帶有 responseId 的訊息給 Native 端,表明此條訊息是之前的回撥訊息
  • 最後按照解析到的訊息中 handlerName 從 messageHandlers,即 JS 端註冊過的 handlers 中找到與名稱對應的處理函式執行,如果沒找到則列印附帶相關資訊的錯誤日誌

嘛~ 對比一下 Native 端的核心程式碼 flushMessageQueue: 看一下,很容易發現兩端的處理實現是邏輯對稱的。

WebViewJavascriptBridge 的“橋樑美學”

WebViewJavascriptBridge 原始碼中 Get 到的“橋樑美學”

在總結 WebViewJavascriptBridge 的“橋樑美學”之前請再回顧一下 WebViewJavascriptBridge 的工作流:

  • JS 端加入 src 為 https://__bridge_loaded__ 的 iframe
  • Native 端檢測到 Request,檢測如果是 __bridge_loaded__ 則通過當前的 WebView 元件注入 WebViewJavascriptBridge_JS 程式碼
  • 注入程式碼成功之後會加入一個 messagingIframe,其 src 為 https://__wvjb_queue_message__
  • 之後不論是 Native 端還是 JS 端都可以通過 registerHandler 方法註冊一個兩端約定好的 HandlerName 的處理,也都可以通過 callHandler 方法通過約定好的 HandlerName 呼叫另一端的處理(兩端處理訊息的實現邏輯對稱)

嘛~ 所以我們很容易列舉出 WebViewJavascriptBridge 所具有的“美學”:

  • 隱性適配
  • 介面對等
  • 邏輯對稱

我們結合本文展開來說一下上面的“美學”的具體實現。

隱性適配

WebViewJavascriptBridge 主要是作為 Mac OS X 和 iOS 端(Native 端)與 JS 端相互通訊,互相呼叫的橋樑。對於 Mac OS X 和 iOS 兩種平臺包含的三種 WebView 功能元件而言,WebViewJavascriptBridge 做了隱性適配,即僅用一套程式碼即可繫結不同平臺的 WebView 元件實現同樣功能的 JS 通訊功能,這一點非常方便。

介面對等

WebViewJavascriptBridge 對於 JS 端和 Native 端設計了對等的介面,不論是 JS 端還是 Native 端,註冊本端的響應處理都是用 registerHandler 介面,呼叫另一端(給另一端發訊息)都是用 callHandler 介面。

這樣做是非常合理的,因為不論是 JS 端還是 Native 端,作為通訊的雙方就通訊本身而言是處於對等的地位的。這就好比一座大橋連線兩塊陸地,兩地用大橋相互運輸貨物並接收資源,兩塊陸地在大橋的運輸使用過程中邏輯上也是地位對等的。

邏輯對稱

WebViewJavascriptBridge 在 JS 端和 Native 端對傳送過來的訊息有著相同邏輯的處理實現,如果考慮到收發雙方的身份則可以把邏輯相同看做邏輯對稱。

這種實現方式依舊非常合理,被橋連線的兩塊大陸在裝貨上橋和下橋卸貨這兩處邏輯上就應該是對稱的。

嘛~ 說到這裡就不得不祭出一個詞來形容 WebViewJavascriptBridge 了,這個詞就是優雅(笑)。當大家結合 WebViewJavascriptBridge 原始碼閱讀本文之後不難發現其整個架構和設計思想跟現實橋樑設計中很多設計思想不謀而合,比如橋一般會分為左右橋幅,而左右幅橋一般只有一條線路中心線,即一個前進方向,用於橋上單一方向的資源傳輸,左右橋幅在功能上對等。

文章總結

  • 文章系統分析了 WebViewJavascriptBridge 原始碼,希望各位讀者能夠在閱讀本文之後對 WebViewJavascriptBridge 的架構有一個整體認識。
  • 文章對 WebViewJavascriptBridge 在 JS 端和 Native 端的訊息處理實現做了深入剖析,希望可以對各位讀者這部分原始碼的理解提供一些微薄的幫助。
  • 總結了 WebViewJavascriptBridge 作為一個 JSBridge 框架所具有的優勢,即文中所指的“橋樑美學”,期望可以對大家以後自己封裝一個 JSBridge 提供思路,拋磚引玉。

Emmmmm...不過需要注意的是 WebViewJavascriptBridge 僅僅是作為 JSBridge 層用於提供 JS 和 Native 之間相互傳遞訊息的基礎支援的。如果想要封裝自己專案中的 WebView 元件還需要另外實現 HTTP cookie 注入,自定義 User-Agent,白名單或者許可權校驗等功能,更進一步還需要對 WebView 元件進行初始化速度,頁面渲染速度以及頁面快取策略的優化。我之後也許可能大概應該會寫一篇文章分享一下自己封裝 WebView 元件時踩到的一些坑以及經驗,因為自己水平有限...所以也可能不會寫(笑)。

文章寫得比較用心(是我個人的原創文章,轉載請註明 lision.me/),如果發現錯誤會優先在我的 個人部落格 中更新。如果有任何問題歡迎在我的微博 @Lision 聯絡我~


補充~ 我建了一個技術交流微信群,想在裡面認識更多的朋友!如果各位同學對文章有什麼疑問或者工作之中遇到一些小問題都可以在群裡找到我或者其他群友交流討論,期待你的加入喲~

WebViewJavascriptBridge 原始碼中 Get 到的“橋樑美學”

Emmmmm..由於微信群人數過百導致不可以掃碼入群,所以請掃描上面的二維碼關注公眾號進群。

相關文章