前言
Emmmmm...這篇文章釋出出來可能正逢聖誕節?,Merry Christmas!
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 是用於在 WKWebView,UIWebView 和 WebView 中的 Obj-C 和 JavaScript 之間傳送訊息的 iOS / OSX 橋接器。
有許多不錯的專案都有使用 WebViewJavascriptBridge,這裡簡單列一部分(笑):
- Facebook Messenger
- Facebook Paper
- ELSEWHERE
- ... & many more!
關於 WebViewJavascriptBridge 的具體使用方法詳見其 GitHub 頁面。
在讀完 WebViewJavascriptBridge 的原始碼之後我將其劃分為三個層級:
層級 | 原始檔 |
---|---|
介面層 | WebViewJavascriptBridge && WKWebViewJavascriptBridge |
實現層 | WebViewJavascriptBridgeBase |
JS 層 | WebViewJavascriptBridge_JS |
其中 WebViewJavascriptBridge && WKWebViewJavascriptBridge 作為介面層主要負責提供方便的介面,隱藏實現細節,其實現細節都是通過實現層 WebViewJavascriptBridgeBase 去做的,而 WebViewJavascriptBridge_JS 作為 JS 層其實儲存了一段 JS 程式碼,在需要的時候注入到當前 WebView 元件中,最終實現 Native 與 JS 的互動。
WebViewJavascriptBridge && WKWebViewJavascriptBridge 探究
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_TYPE
,WVJB_WEBVIEW_DELEGATE_TYPE
以及剛才提到的 WVJB_WEBVIEW_DELEGATE_INTERFACE
巨集定義,並且分別定義了 WVJB_PLATFORM_OSX
和 WVJB_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 的實現層,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
型別的 blockNSMutableDictionary* 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 實現解讀
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 的“橋樑美學”之前請再回顧一下 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 聯絡我~
補充~ 我建了一個技術交流微信群,想在裡面認識更多的朋友!如果各位同學對文章有什麼疑問或者工作之中遇到一些小問題都可以在群裡找到我或者其他群友交流討論,期待你的加入喲~
Emmmmm..由於微信群人數過百導致不可以掃碼入群,所以請掃描上面的二維碼關注公眾號進群。