從零收拾一個hybrid框架(一)-- 從選擇JS通訊方案開始

折騰範兒_味精發表於2018-01-08

相信很多人都在專案裡熟練使用各種Hybrid技術,無論是使用了知名得 WebViewJavascriptBridge 框架來做自己的Hybrid Web容器,又或是自己從頭著手寫了一個滿足自己業務需求的bridge,從而構建起自己的Hybrid Web容器,也有的乾脆直接使用了cordova 這一大型Hybrid容器框架,cordova + ionic 來進行Hybrid的開發

拆解學習框架原始碼是一個好事,但是在拆解優秀框架原始碼的背後,如何將多個優秀原始碼的精華打碎重塑,結合自己的產品業務需求重新組合成為適合自己的,並且紮實掌握可以靈活修改自如控制的程式碼,這也算是另一個層面的提升。

  • 選擇合適的JS通訊方案(第一篇)
  • 實現基本的WebView容器能力(第二篇 待續)
  • 嘗試擴充WebView容器的額外能力(第三篇 待續)

這系列文章我想表達的並不是在推廣什麼我自己的新Bridge輪子,也不是針對某個開源Bridge框架進行深度的原始碼分析。我們從看開源框架輪子如何設計,如何使用,原始碼如何工作的思維方式中跳出來

換一種模式去從目的出發,從需求出發,思考當你什麼都沒有的時候,你要從零思考構建一個hybrid框架的時候,你都要考慮哪些方面?這些方面採用怎樣的設計思想能做到未來在使用中靈活自如,不至於面臨侷限

這一篇先重點聊聊 JS與Native通訊的通訊方案

幾種JS Native相互通訊方式的介紹

大家可能看了很多大框架原始碼,無論是cordova還是WebViewJavascriptBridge他們核心的通訊方式就都是 假跳轉請求攔截

但其實JS與Native通訊並不止一種方式,還有很多種通訊方式,尤為重要的是,不同的通訊方式有著不同的特點,有的甚至雖然受限於安卓/蘋果平臺差異不通用,但獨有的優點卻是 假跳轉請求攔截 無法比擬的

JS 呼叫 Native 的幾種通訊方案

  • 假跳轉的請求攔截
  • 彈窗攔截
    • alert()
    • prompt()
    • confirm()
  • JS上下文注入
    • 蘋果JavaScriptCore注入
    • 安卓addJavascriptInterface注入
    • 蘋果scriptMessageHandler注入

Native 呼叫 JS 的幾種通訊方案

JS是一個指令碼語言,在設計之初就被設計的任何時候都可以執行一段字串js程式碼,換句話說,任何一個js引擎都是可以在任意時機直接執行任意的JS程式碼,我們可以把任何Native想要傳遞的訊息/資料直接寫進JS程式碼裡,這樣就能傳遞給JS了

  • evaluatingJavaScript 直接注入執行JS程式碼

大家在PC上用電腦,用Chrome的時候都知道,可以直接用'javascript:xxxx'來簡單的執行一些JS程式碼,彈個框,這個方法只有安卓可以用,因為iOS必須先將url字串生成Request再交給webview去load,這種'javascript:xxxx'生成request會失敗

  • loadUrl 瀏覽器用'javascript:'+JS程式碼做跳轉地址

WKWebView官方提供了一個Api,可以讓WebView在載入頁面的時候,自動執行注入一些預先準備好的JS

  • WKUserScript WKWebView的addUserScript方法,在載入時機注入

JS 呼叫 Native 的幾種通訊方案

假跳轉的請求攔截

何謂 假跳轉的請求攔截 就是由網頁發出一條新的跳轉請求,跳轉的目的地是一個非法的壓根就不存在的地址比如

//常規的Http地址
https://wenku.baidu.com/xxxx?xx=xx
//假的請求通訊地址
wakaka://wahahalalala/action?param=paramobj
複製程式碼

看我下面寫的那條假跳轉地址,這麼一條什麼都不是的扯淡地址,直接放到瀏覽器裡,直接扔到webview裡,肯定是妥妥的什麼都打不開的,而如果在經過我們改造過的hybrid webview裡,進行攔截不進行跳轉

url地址分為這麼幾個部分

  • 協議:也就是http/https/file等,上面用了wakaka
  • 域名:上面的 wenku.baidu.com 和 wahahalalala
  • 路徑:上面的 xxxx?或action?
  • 引數:上面的 xx=xx或param=paramobj

如果我們構建一條假url

  • 用協議與域名當做通訊識別
  • 用路徑當做指令識別
  • 用引數當做資料傳遞

客戶端會無差別攔截所有請求,真正的url地址應該照常放過,只有協議域名匹配的url地址才應該被客戶端攔截,攔截下來的url不會導致webview繼續跳轉錯誤地址,因此無感知,相反攔截下來的url我們可以讀取其中路徑當做指令,讀取其中引數當做資料,從而根據約定呼叫對應的native原生程式碼

以上其實是一種 協議約定 只要JS側按著這個約定協議生成假url,native按著約定協議攔截/讀取假url,整個流程就能跑通。

完全可以不用按著我寫的這種方式約定協議,可以任意另行約定協議比如,協議當做通訊識別,域名當做模組識別,路徑當做指令識別,引數當做資料傳遞等等,協議協議,任何一種合理的約定都可以,都可以正常的讓JS與Native進行通訊

假跳轉的請求攔截-JS發起呼叫

JS其實有很多種方式發起假請求,跟發起一個新請求沒啥兩樣,只要按著 協議約定 生成假請求地址,正常的發起跳轉即可,任何一種方式都可以讓客戶端攔截住

  • A標籤跳轉
//在HTML中寫上A標籤直接填寫假請求地址
<a href="wakaka://wahahalalala/action?param=paramobj">A標籤A標籤A標籤A標籤</a>
複製程式碼
  • 原地跳轉
//在JS中用location.href跳轉
location.href = 'wakaka://wahahalalala/action?param=paramobj'
複製程式碼
  • iframe跳轉
//在JS中建立一個iframe,然後插入dom之中進行跳轉
$('body').append('<iframe src="' + 'wakaka://wahahalalala/action?param=paramobj' + '" style="display:none"></iframe>');
複製程式碼

假跳轉的請求攔截-客戶端攔截

  • 安卓的攔截方式 shouldOverrideUrlLoading
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
    //1 根據url,判斷是否是所需要的攔截的呼叫 判斷協議/域名
    if (是){
      //2 取出路徑,確認要發起的native呼叫的指令是什麼
      //3 取出引數,拿到JS傳過來的資料
      //4 根據指令呼叫對應的native方法,傳遞資料
      return true;
    }
    return super.shouldOverrideUrlLoading(view, url);
}

複製程式碼
  • iOS的UIWebView的攔截方式 webView:shouldStartLoadWithRequest:navigationType:
- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
    //1 根據url,判斷是否是所需要的攔截的呼叫 判斷協議/域名
    if (是){
      //2 取出路徑,確認要發起的native呼叫的指令是什麼
      //3 取出引數,拿到JS傳過來的資料
      //4 根據指令呼叫對應的native方法,傳遞資料
      return NO;
      //確認攔截,拒絕WebView繼續發起請求
    }    
    return YES;
}
複製程式碼
  • iOS的WKWebView的攔截方式 webView:decidePolicyForNavigationAction:decisionHandler:
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    //1 根據url,判斷是否是所需要的攔截的呼叫 判斷協議/域名
    if (是){
      //2 取出路徑,確認要發起的native呼叫的指令是什麼
      //3 取出引數,拿到JS傳過來的資料
      //4 根據指令呼叫對應的native方法,傳遞資料

      //確認攔截,拒絕WebView繼續發起請求
        decisionHandler(WKNavigationActionPolicyCancel);
    }else{
        decisionHandler(WKNavigationActionPolicyAllow);
    }
    return YES;
}
複製程式碼

彈窗攔截

前端可以發起很多種彈窗包含

  • alert() 彈出個提示框,只能點確認無回撥
  • confirm() 彈出個確認框(確認,取消),可以回撥
  • prompt() 彈出個輸入框,讓使用者輸入東西,可以回撥

每種彈框都可以由JS發出一串字串,用於展示在彈框之上,而此字串恰巧就是可以用來傳遞資料,我們把所有要傳遞通訊的資訊,都封裝進入一個js物件,然後生成字典,最後序列化成json轉成字串

通過任意一種彈框將字串傳遞出來,交給客戶端就可以進行攔截,從而實現通訊

彈窗攔截 - JS發起呼叫

其實alert/confirm/prompt三種彈框使用上沒任何區別和差異,這裡只取其中一種舉例,可以選一個不常用的當做管道進行JS通訊,這裡用prompt舉例

var data = {
    action:'xxxx',
    params:'xxxx',
    callback:'xxxx',
};
var jsonData = JSON.stringify([data]);
//發起彈框
prompt(jsonData);
複製程式碼

彈窗攔截 - 客戶端攔截

  • 安卓的攔截 onJsPrompt(其他的兩個彈框也有)
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
    //1 根據傳來的字串反解出資料,判斷是否是所需要的攔截而非常規H5彈框
    if (是){
      //2 取出指令引數,確認要發起的native呼叫的指令是什麼
      //3 取出資料引數,拿到JS傳過來的資料
      //4 根據指令呼叫對應的native方法,傳遞資料
      return true;
    }
    return super.onJsPrompt(view, url, message, defaultValue, result);
}
複製程式碼
  • iOS的WKWebView webView:runJavaScriptTextInputPanelWithPrompt:balbala
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler{
    //1 根據傳來的字串反解出資料,判斷是否是所需要的攔截而非常規H5彈框
    if (是){
        //2 取出指令引數,確認要發起的native呼叫的指令是什麼
        //3 取出資料引數,拿到JS傳過來的資料
        //4 根據指令呼叫對應的native方法,傳遞資料
        //直接返回JS空字串
        completionHandler(@"");
    }else{
        //直接返回JS空字串
        completionHandler(@"");
    }
}
複製程式碼
  • iOS的UIWebView

UIWebView不支援截獲任何一種彈框,因此這條路走不通

經過好心人提醒,UIWebView也存在一種利用Undocumented API(只是未公開API,但是否處於被禁止的私有API不一定)的方式來攔截彈框。

原理是可以自行建立一個categroy,在裡面實現一個未出現在任何UIWebView標頭檔案裡的delegate,就能攔截彈框了(這個Undocumented的delegate長得和WKWebView的攔截delegate一個樣子)

iOS--UIWebView 遮蔽 alert警告框

JS上下文注入

說道JS上下文注入,做iOS的都會了解到iOS7新增的一整個JavaScriptCore這個framework,這個framework被廣泛使用在了JSPatch,RN等上面,但這個東西一般用法都是完全脫離於WebView,只有一個JS上下文,這個JS上下文裡,沒有window物件,沒有dom,嚴格意義上講這個和我們所關注的依賴WebView的Hybrid框架是有很大差異的,就不在這篇文章裡多說了

  • 蘋果UIWebview JavaScriptCore注入
  • 安卓addJavascriptInterface注入
  • 蘋果WKWebView scriptMessageHandler注入

雖然某種意義上講上面三種方式,他們都可以被稱作JS注入,他們都有一個共同的特點就是,不通過任何攔截的辦法,而是直接將一個native物件(or函式)注入到JS裡面,可以由web的js程式碼直接呼叫,直接操作

但這三種注入方式都操作差異還是很大,並且各自的侷限性各不相同,我們下面一一說明

蘋果UIWebview JavaScriptCore注入

UIWebView可以通過KVC的方法,直接拿到整個WebView當前所擁有的JS上下文

documentView.webView.mainFrame.javaScriptContext

拿到了JSContext,一切的使用方式就和直接操作JavaScriptCore沒啥區別了,我們可以把任何遵循JSExport協議的物件直接注入JS,讓JS能夠直接控制和操作

所以在介紹如何JS與Native操作的時候換個順序,先介紹客戶端如何把bridge函式注入到JS,在介紹JS如何使用

蘋果UIWebview JavaScriptCore注入 - 客戶端注入

//拿到當前WebView的JS上下文
JSContext *context = [webview valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
//給這個上下文注入callNativeFunction函式當做JS物件
context[@"callNativeFunction"] = ^( JSValue * data )
{
    //1 解讀JS傳過來的JSValue  data資料
    //2 取出指令引數,確認要發起的native呼叫的指令是什麼
    //3 取出資料引數,拿到JS傳過來的資料
    //4 根據指令呼叫對應的native方法,傳遞資料
    //5 此時還可以將客戶端的資料同步返回!
}
複製程式碼

通過上面的方法可以拿到當前WebView的JS上下文JSContext,然後就要準備往這個JSContext裡面注入準備好的block,而這個準備好的block,負責解讀JS傳過來的資料,從而分發呼叫各種native函式指令

TIPS: 這種注入不止可以把block注入,在JS裡成為一個JS函式,還可以把字元/數字/字典等資料直接注入到JS全域性物件之中,可以讓JS訪問到Native才能獲取的全域性物件,甚至還可以注入任何NSObject物件,只要這個NSObject物件遵循JSExportOC的協議,相當於JS可以直接呼叫訪問OC的記憶體物件

蘋果UIWebview JavaScriptCore注入 - JS呼叫

//準備要傳給native的資料,包括指令,資料,回撥等
var data = {
    action:'xxxx',
    params:'xxxx',
    callback:'xxxx',
};
//直接使用這個客戶端注入的函式
callNativeFunction(data);
複製程式碼

在沒經過客戶端注入的時候,直接使用呼叫callNativeFunction()會報 callNativeFunction is not defined這個錯誤,說明此時JS上下全文全域性,是沒有這個函式的,呼叫無效

當執行完客戶端注入的時候,此時JS上下文全域性global下面,就擁有了這個callNativeFunction的函式物件,就可以正常呼叫,從而傳遞資料到Native

安卓addJavascriptInterface注入

安卓的WebView有一個介面addJavascriptInterface,可以在loadUrl之前提前準備一個物件,通過這個介面注入給JS上下文,從而讓JS能夠操作,這個操作方式很類似蘋果UIWebview JavaScriptCore注入,整個機制也差別不離,但有個很重大的區別,後面在詳述優缺點對比的時候,會重點描述

安卓addJavascriptInterface注入 - 客戶端注入

使用安卓官方的API介面即可,並且可以在loadUrl之前WebView建立之後,即可配置相關注入功能,這個和UIWebView-JSContext的使用差異非常之大,後面會說

// 通過addJavascriptInterface()將Java物件對映到JS物件
//引數1:Javascript物件名
//引數2:Java物件名
mWebView.addJavascriptInterface(new AndroidtoJs(), "nativeObject");
複製程式碼

其中AndroidtoJs這個是一個自定義的安卓物件,他們裡面有個函式callFunction,AndroidtoJs這個物件的其他函式方法JS都可以呼叫

安卓addJavascriptInterface注入 - JS呼叫

剛才注入的js物件叫nativeObject,所以JS中可以在全域性任意使用

nativeObject.callFunction("js呼叫了android中的hello方法");
複製程式碼

我不是很熟悉android,以上很多安卓程式碼都取自 Android:你要的WebView與 JS 互動方式 都在這裡了,後面也會納入參考文獻之中

蘋果WKWebView scriptMessageHandler注入

蘋果在開放WKWebView這個效能全方位碾壓UIWebView的web元件後,也大幅更改了JS與Native互動的方式,提供了專有的互動APIscriptMessageHandler

因為這是蘋果的API,使用方式搜一下一搜一大堆,我並不詳細解釋了,直接展示一下程式碼

蘋果WKWebView scriptMessageHandler注入 - 客戶端注入

//配置物件注入
[self.webView.configuration.userContentController addScriptMessageHandler:self name:@"nativeObject"];
//移除物件注入
[self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"nativeObject"];
複製程式碼

需要說明一下,addScriptMessageHandler就像安卓的addJavascriptInterface一樣,可以在WKWebView loadUrl之前即可進行相關配置

但不一樣的是,如果當前WebView沒用了,需要銷燬,需要先移除這個物件注入,否則會造成記憶體洩漏,WebView和所在VC迴圈引用,無法銷燬。

蘋果WKWebView scriptMessageHandler注入 - JS呼叫

剛才注入的js物件叫nativeObject,但不像前邊兩個注入一樣,直接注入到JS上下文全域性Global物件裡,addScriptMessageHandler方法注入的物件被放到了,全域性物件下一個Webkit物件下面,想要拿到這個物件需要這樣拿

window.webkit.messageHandlers.nativeObject
複製程式碼

並且和之前的兩種注入也不同,前兩種注入都可以讓js任意操作所注入自定義物件的所有方法,而addScriptMessageHandler注入其實只給注入物件起了一個名字nativeObject,但這個物件的能力是不能任意指定的,只有一個函式postMessage,因此JS的呼叫方式也只能是

//準備要傳給native的資料,包括指令,資料,回撥等
var data = {
    action:'xxxx',
    params:'xxxx',
    callback:'xxxx',
};
//傳遞給客戶端
window.webkit.messageHandlers.nativeObject.postMessage(data);
複製程式碼

蘋果WKWebView scriptMessageHandler注入 - 客戶端接收呼叫

前兩種注入方式,都是在注入的時候,就指定了對應的接收JS呼叫的Native函式,但是這次不是,在蘋果的API設計裡,當JS開始呼叫後,會呼叫到指定的iOS的delegate裡

-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    //1 解讀JS傳過來的JSValue  data資料
    NSDictionary *msgBody = message.body;
    //2 取出指令引數,確認要發起的native呼叫的指令是什麼
    //3 取出資料引數,拿到JS傳過來的資料
    //4 根據指令呼叫對應的native方法,傳遞資料
}
複製程式碼

Native 呼叫 JS 的幾種通訊方案

說完了JS呼叫Native,我們再聊聊Native發起呼叫JS

evaluatingJavaScript 執行JS程式碼

上面也簡單說了一下,JS是一個指令碼語言,可以在無需編譯的情況下,直接輸入字串JS程式碼,直接執行執行看結果,這也是為什麼在Chrome裡,在網頁執行的時候開啟控制檯,可以輸入各種JS指令的看結果的。

也就是說當Native想要呼叫JS的時候,可以由Native把需要資料與呼叫的JS函式,通過字串拼接成JS程式碼,交給WebView進行執行

說明一下,Android/iOS-UIWebView/iOS-WKWebView,都支援這種方法,這是目前最廣泛運用的方法,甚至可以說,Chrome的DevTools控制檯也是用的同樣的方式。

假如JS網頁裡已經有了這麼一個函式

function calljs(data){
    console.log(JSON.parse(data)) 
    //1 識別客戶端傳來的資料
    //2 對資料進行分析,從而呼叫或執行其他邏輯  
}
複製程式碼

那麼客戶端此時要呼叫他需要在客戶端用OC拼接字串,拼出一個js程式碼,傳遞的資料用json

//不展開了,data是一個字典,把字典序列化
NSString *paramsString = [self _serializeMessageData:data];
NSString* javascriptCommand = [NSString stringWithFormat:@"calljs('%@');", paramsString];
//要求必須在主執行緒執行JS
if ([[NSThread currentThread] isMainThread]) {
    [self.webView evaluateJavaScript:javascriptCommand completionHandler:nil];
} else {
    __strong typeof(self)strongSelf = self;
    dispatch_sync(dispatch_get_main_queue(), ^{
        [strongSelf.webView evaluateJavaScript:javascriptCommand completionHandler:nil];
    });
}
複製程式碼

其實我們拼接出來的js只是一行js程式碼,當然無論多長多複雜的js程式碼都可以用這個方式讓webview執行

calljs('{data:xxx,data2:xxx}');
複製程式碼

TIPS:安卓4.4以上才可以使用evaluatingJavaScript這個API

loadUrl 執行JS程式碼

安卓在4.4以前是不能用evaluatingJavaScript這個方法的,因此之前安卓都用的是webview直接loadUrl,但是傳入的url並不是一個連結,而是以"javascript:"開頭的js程式碼,從而達到讓webview執行js程式碼的作用

其實這個過程和evaluatingJavaScript沒啥差異

還按著剛才舉例,假如JS網頁裡已經有了這麼一個函式

function calljs(data){
    console.log(JSON.parse(data)) 
    //1 識別客戶端傳來的資料
    //2 對資料進行分析,從而呼叫或執行其他邏輯  
}
複製程式碼

我不太熟悉安卓,就不寫安卓的字典資料json序列化的邏輯了

mWebView.loadUrl("javascript:callJS(\'{data:xxx,data2:xxx}\')");
複製程式碼

最終實際上相當於執行了一條js程式碼

calljs('{data:xxx,data2:xxx}');
複製程式碼

WKUserScript 執行JS程式碼

對於iOS的WKWebView,除了evaluatingJavaScript,還有WKUserScript這個方式可以執行JS程式碼,他們之間是有區別的

  • evaluatingJavaScript 是在客戶端執行這條程式碼的時候立刻去執行當條JS程式碼

  • WKUserScript 是預先準備好JS程式碼,當WKWebView載入Dom的時候,執行當條JS程式碼

很明顯這個雖然是一種通訊方式,但並不能隨時隨地進行通訊,並不適合選則作為設計bridge的核心方案。但這裡也簡單介紹一下

//在loadurl之前使用
//time是一個時機引數,可選dom開始載入/dom載入完畢,2個時機進行執行JS
//構建userscript
WKUserScript *script = [[WKUserScript alloc]initWithSource:source injectionTime:time forMainFrameOnly:mainOnly];
WKUserContentController *userController = webView.userContentController;
//配置userscript
[userController addUserScript:script]
複製程式碼

幾種通訊方式的優缺點對比

說完了JS主動呼叫Native,也說完了Native主動呼叫JS,有很多很多的方案我們來聊聊這麼些個方案都有哪些侷限性,是否值得我們選擇

假請求的通訊攔截的問題 -- 當下最不該選擇的通訊方式

假通訊攔截請求這種方式看起來是使用最廣泛的,知名的WebViewJavascriptBridgecordova

為什麼這些知名框架選用假請求通訊攔截其實有很多原因,但我想說的是,基於眼下設計自己的Hybrid框架,最不應該選擇的通訊方式就是假請求通訊攔截

先說說他為數不多的優點:

  • 版本相容性好:iOS6及以前只有這唯一的一種方式

cordova的前身是phonegap,隨手搜一下大概能知道這個框架有多老,也可以看下WebViewJavascriptBridge,最早第一次提交是在5年前,在沒有iOS7的時候,有切只有這唯一的一種通訊方式,因此他們都選用了他,但看一眼現在已經iOS11了,再看看iOS6及以下的佔有度,呵呵,一到iOS7就有更好的全方位碾壓的bridge方式了

  • webview支援度好:簡單地說框架的開發者容易偷懶

這是所有JS call Native的通訊方式裡,唯一同時支援安卓webview/蘋果UIWebView/蘋果WKWebView的一種通訊方式,這也就是為什麼WebViewJavascriptBridge在即便蘋果已經推出了更好的WKWebView並且準備了專屬的通訊APImessageHandler的時候,還依然選擇繼續沿用假請求通訊攔截的原因,程式碼不用重寫了,適合寫那種相容iOS7以下的UIWebView,在iOS8以上換WKWebView的程式碼,但看一眼現在的版本佔有度?沒有任何意義

多說兩句:

即便是老專案還在使用UIWebView,要計劃升級到WKWebView的時候,既然是升級就應該全面升級到新的WK式通訊,做什麼妥協和折中方案?

而且最重要的一點,想要做到同時支援多個WebView相容支援並不需要選擇妥協方案,在開發框架的時候完全可以在框架側解決。想要遮蔽這種webview通訊差異,通過在Hybrid框架層設計,抽象統一的呼叫入口出口,把通訊差異在內部消化,這樣依然能做到統一對外業務程式碼流程和清晰的程式碼邏輯,想要做到程式碼統一不應該以功能上犧牲和妥協的方面去考慮。

要知道cordova都專門為WKWebView開發了獨有的cordova-plugin-wkwebview外掛來專門適配WKWebView的更優的官方通訊API,而不是像WebViewJavascriptBridge進行妥協,UI與WK都採取同一種有功能性問題的通訊方案

再說說他最嚴重的缺點:

  • 丟訊息! 一個通訊方案,結果他最大的問題是丟失通訊訊息!
location.href = 'wakaka://wahahalalala/callNativeNslog?param=1111'

location.href = 'wakaka://wahahalalala/callNativeNslog?param=2222'
複製程式碼

上面是一段JS呼叫Native的程式碼,可以靠字面意思猜一下,JS此時的訴求是在同一個執行邏輯內,快速的連續傳送出2個通訊請求,用客戶端本身IDE的log,按順序列印111,222,那麼實際結果是222的通訊訊息根本收不到,直接會被系統拋棄丟掉。

原因:因為假跳轉的請求歸根結底是一種模擬跳轉,跳轉這件事情上webview會有限制,當JS連續傳送多條跳轉的時候,webview會直接過濾掉後發的跳轉請求,因此第二個訊息根本收不到,想要收到怎麼辦?JS裡將第二條訊息延時一下

//發第一條訊息
location.href = 'wakaka://wahahalalala/callNativeNslog?param=1111'

//延時傳送第二條訊息
setTimeout(500,function(){
    location.href = 'wakaka://wahahalalala/callNativeNslog?param=2222'
})
複製程式碼

這根本治標不治本好麼,這種框架設計下決定了JS在任何通訊邏輯都得考慮是否這個時間有其他的JS通訊程式碼剛互動過,導致訊息丟失?是否頁面載入完畢的時候不能同時傳送頁面載入完畢其他具體業務需要的Native訊息,是否任何一個AJax網路請求回來後立刻發起的Native訊息,都要謹慎考慮與此同時是否有別的SetTimeout也在發Native訊息導致衝突?這TM根本是一個天坑,這麼設計絕對是客戶端開發舒坦省事寫bridge框架程式碼,坑死天天做活動上線的前端同學的。

如果想繼續使用假跳轉請求,又不想換方案怎麼辦?前端同學在JS框架層包一層佇列,所有JS程式碼呼叫訊息都先進入佇列並不立刻傳送,然後前端會週期性比如500毫秒,清空flush一次佇列,保證在很快的時間內絕對不會連續發2次假請求通訊,這種通訊佇列的設計不光運用解決丟訊息的問題,就連RN根本沒丟訊息問題的JSCore式的通訊,也採用了這種方式,歸根結底他能減少通訊開銷,但是!但是!給假通訊請求做佇列你將面臨第二個根本沒法解決的問題

  • URL長度限制

假跳轉請求歸根結底他還是一個跳轉,拋給客戶端被攔截的時候都已經被封裝成一個request了,那麼如果url超長了呢?那麼這個request裡的url的內容還是你想要傳遞的原內容麼?不會丟內容麼?尤其是當你採用了佇列控制,一次性傳送的是多條訊息組成的陣列資料的時候。

假跳轉是現在這個時候最不該使用的通訊方式!!!

假跳轉是現在這個時候最不該使用的通訊方式!!!

假跳轉是現在這個時候最不該使用的通訊方式!!!

重要的事情說三遍

彈窗攔截

這個方式其實沒啥不好的,而且confirm還可以用更簡單的方式處理callback回撥,因為confirm天然是需要返回JS內容的,但callback其實也可以用其他的方式實現,也許更好,因此這裡按住不表,第二篇文章會整體聊聊,基於這麼多種通訊手段,如何設計一個自己的Hybrid框架

  • UIWebView不支援,但沒事UIWebView有更好的JS上下文注入的方式,JSContext不僅支援直接傳遞物件無需json序列化,還支援傳遞function函式給客戶端呢(藉助隱藏的API也可以支援)
  • 安卓一切正常,不會出現丟訊息的情況
  • WKWebView一切正常,也不會出現丟訊息的情況,但其實WKWebView蘋果給了更好的API,何不用那個,至少用這個是可以直接傳遞物件無需進行json序列化的

唯一需要注意的一點,如果你的業務開發中經常希望在前端程式碼裡使用系統alert()/confirm()/prompt()那麼,你還是挑一個不常用的進行hook,以免干擾常規業務

修訂補充優點!

彈窗攔截也可以支援同步返回!

prompt( ) 攔截在客戶端需要執行confirm(data)從而用同步的方式給客戶端返回資料到JS

//同步JS呼叫Native  JS這邊可以直接寫 =  !!!
var nativeNetStatus = nativeObject.getNetStatus();
//非同步JS呼叫Native JS只能這麼寫
nativeObject.getNetSatus(callback(net){
    console.log(net)
})
複製程式碼

JS上下文注入

JS上下文注入其實一共3種情況,這3種情況每個情況都不同,我會一一進行優缺點說明

UIWebView的JSContext注入

說實話這是我覺得最完美的一種互動方式了,蘋果在iOS7開放了JavaScriptCore這個框架,支撐起了RN,Weex這麼牛逼的擺脫了WebView的深度混合框架,他的能力是最完美的。

牛逼的優點:

  • 支援JS同步返回!

要知道我們看到的所有JS通訊框架設計的都是非同步返回,包括RN(這有設計原因,但不代表JSC不支援同步返回),都是設計了一套callback機制,一條通訊訊息到達Native後,如果需要返回資料,需要呼叫這個callback介面由Native反向通知JS,他們在JS側寫程式碼可是差異非常非常非常之大的!

//同步JS呼叫Native  JS這邊可以直接寫=  !!!
var nativeNetStatus = nativeObject.getNetStatus();

//非同步JS呼叫Native JS只能這麼寫
nativeObject.getNetSatus(callback(net){
    console.log(net)
})
複製程式碼
  • 支援直接傳遞物件,無需通過字串序列化

一個JS物件在JS程式碼中如果想通過假跳轉/彈窗攔截等方式,那麼必須把JS物件搞成json,然後才能傳遞給端,端拿到後還要反解成字典物件,然後才能識別,但是JS上下文注入不需要(其實他本質上是框架層幫你做了這件事情,就是JSValue這個iOS類的能力)

  • 支援傳遞JS函式,客戶端能夠直接快速呼叫callback

在JS裡如果是一個function,可以直接當做引數傳送給客戶端,在客戶端得到一個JSValue,可以通過JSValue的callWithParmas的方式直接當做函式去呼叫

  • 支援直接注入任意客戶端類,客戶端物件,JS可以直接向呼叫客戶端

JavaScriptCore有一種使用方法,是可以讓任意iOS物件,遵循<JSExport>協議,就可以直接把一整個Native物件直接注入,讓JS可以直接操作這個物件,讀取這個物件的屬性,呼叫這個物件的方法

有點尷尬的缺點:

  • only UIWebView

這一點簡直是最大的遺憾,只有UIWebView可以用KVC取到JSContext,取到了JSContext才能發揮JavaScriptCore的牛逼能力,但是如果為了更好的效能升級到了WKWebView,那就得忍痛,我依稀記得曾幾何時我在哪看到過通過私有API,讓WKWebView也能獲取JSContext,但我找不到了,希望知道的同學能給我點指引。但我有一個看法 為了WKWebView的效能提升,捨棄JSContext的優點,值得!

  • JSContext獲取時機

UIWebView的JSContext是通過iOS的kvc方法拿到,而非UIWebView的直接介面API,因此UIWebView-JSContext注入使用上要非常注意注入時機

  • UIWebView-JSContext 在loadUrl之前注入無效
  • UIWebView-JSContext 在FinishLoad之後注入有效但有延遲

因為WebView每次載入一個新地址都會啟用一個新的JSContext,在loadUrl之前注入,會因為舊的JSContext已被捨棄導致注入無效,若在WebView觸發FinishLoad事件的時候注入,又會導致在FinishLoad之前執行的JS程式碼,是無法呼叫native通訊的

曾經寫過一篇文章UIWebView程式碼注入時機與姿勢,可以參考看看,有私有API解決辦法,不在這裡多言

如果你還在使用UIWebView,真的應該徹底丟棄什麼假跳轉,直接使用這個方案(iOS7.0現在已經不是門檻了吧),並且深度開發JavaScriptCore這麼多牛逼優勢所帶來的一些黑科技(我感覺會在第三篇文章裡提這個)

如果你還在使用UIWebView,就用JSContext吧!不要猶豫!

如果你還在使用UIWebView,就用JSContext吧!不要猶豫!

如果你還在使用UIWebView,就用JSContext吧!不要猶豫!

安卓的addJavascriptInterface注入

我不太瞭解安卓,因此這粗略寫一寫,此處如果有錯誤非常希望大家幫我指出

安卓的addJavascriptInterface注入,其實原理機制幾乎和UIWebView的JSContext注入一樣,所以UIWebView的JSContext注入的有點他其實都有

  • 可以同步返回
  • 無需json化透傳資料
  • 可以傳遞函式(不確定)
  • 可以注入Native物件

但是安卓的addJavascriptInterface沒有注入時機這個缺點(類比-UIWebView的JSContext獲取時機),原因是UIWebView缺失一個時機由核心通知外圍,當前JSContext剛剛建立完畢,還未開始執行相關JS,導致在iOS下無法在這個最應該進行注入的時機進行注入,除非通過私有API,但安卓沒事,安卓系統提供了個API來讓外圍獲得這個最佳時機 onResourceloaded,詳細說明見 UIWebView程式碼注入時機與姿勢

WKWebView的scriptMessageHandler注入

蘋果iOS8之後官方抓們推出的新一代webview,號稱全面優化,效能大幅度提升,是和safari一樣的web核心引擎,帶著光環出生,而scriptMessageHandler正是這個新WKWebView欽點的互動API

優點:

  • 無需json化傳遞資料

是的,webkit.messageHandlers.xxx.postMessage()是支援直接傳遞json資料,無需前端客戶端字串處理的

  • 不會丟訊息

我們團隊的以前老程式碼在丟訊息上吃了無數的大虧,導致我對這個事情耿耿於懷,怨念極深!真是坑了好幾代前端開發,叫苦不堪

缺點:

  • 版本要求iOS8

我們捨棄了,不是問題

  • 不支援JSContext那樣的同步返回

喪失了很多黑科技黑玩法的想象力!但我覺得還是有可能有辦法哪怕用私有API的方式想辦法找回來的,希望知道的朋友提供更多資訊

如果你已經上了WKWebView,就用它,不需要考慮

如果你已經上了WKWebView,就用它,不需要考慮

如果你已經上了WKWebView,就用它,不需要考慮

evaluatingJavaScript 直接執行JS程式碼

說完了JS主動呼叫Native,我們再說說Native主動呼叫JS,evaluatingJavaScript是一個非常非常通用普遍的方式了,原因也在介紹裡解釋過,js的指令碼引擎天然支援,直接扔字串進去,當做js程式碼開始執行

也沒啥優缺點可以說的,除了有個特性需要在介紹WKUserScript的時候在多解釋一下

安卓/UIWebView/WKWebView都支援

loadUrl 跳轉javascript地址執行JS程式碼

具體的使用方式不詳細介紹了,直說一個優點

  • 版本支援

在安卓4.4以前是沒有evaluatingJavaScript API的,因此通過他來執行JS程式碼,但本質上和evaluatingJavaScript區別不大

WKUserScript 執行JS程式碼

這裡要特別說明一下WKUserScript並不適合當做Hybrid Bridge的通訊手段,原因是這種Native主動呼叫JS,只能在WebView載入時期發起,並不能在任意時刻發起通訊

WKUserScript不能採用在Hybrid設計裡當做通訊手段

WKUserScript不能採用在Hybrid設計裡當做通訊手段

WKUserScript不能採用在Hybrid設計裡當做通訊手段

但WKUserScript卻有一點值得說一下,上文也提到的UIWebView的注入時機,如果你想在恰當時機讓JS上下文執行一段JS程式碼,在UIWebView你是找不到一個合適的載入時機的,除非你動用私有API,但WKWebView解決了這個問題,在構造WKUserScript的時候可以選擇dom load start的時候執行JS,也可以選擇在dom load end的時候執行JS。但這個有點其實與設計Hybrid框架的核心通訊方案,關係不大,但預載入JS預載入CSS也是一個Hybrid框架的擴充套件功能,後面第二篇會介紹的。

橫向對比

如果我們要自主設計一個Hybrid框架,通訊方案到底該如何取捨?

JS主動呼叫Native的方案

通訊方案 版本支援 丟訊息 支援同步返回 傳遞物件 注入原生物件 資料長度限制
假跳轉 全版本全平臺 會丟失 不支援 不支援 不支援 有限制
彈窗攔截 UIWebView不支援 不丟失 支援 不支援 不支援 無限制
JSContext注入 只有UIWebView支援 不丟失 支援 支援 支援 無限制
安卓interface注入 安卓全版本 不丟失 支援 支援 支援 無限制
MessageHandler注入 只有WKWebView支援 不丟失 不支援 不支援 不支援 無限制

Native主動呼叫JS的方案

  • iOS: evaluatingJavaScript
  • 安卓: 其實2個區別不大,使用方法差異也不大
    • 4.4以上 evaluatingJavaScript
  • 4.4以下 loadUrl

這樣對比優缺點,再根據自己專案需要支援的版本號,可以比較方便的選擇合適的通訊方案,進一步親自設計一個Hybrid框架

一點個人看法

即便是老專案還在使用UIWebView,要計劃升級到WKWebView的時候,既然是升級就應該全面升級到新的WK式通訊,做什麼妥協和折中方案?

而且最重要的一點,想要做到同時支援多個WebView相容支援並不需要選擇妥協方案,在開發框架的時候完全可以在框架側解決。想要遮蔽這種webview通訊差異,通過在Hybrid框架層設計,抽象統一的呼叫入口出口,把通訊差異在內部消化,這樣依然能做到統一對外業務程式碼流程和清晰的程式碼邏輯,想要做到程式碼統一不應該以功能上犧牲和妥協的方面去考慮。

前面其實提到過這個看法不過說的還不徹底,可能有些人會覺得假跳轉這個方案最大的好處是全平臺全版本的適配與統一,甚至還可以統一安卓平臺,可以保證程式碼一致性,但我認為這絕對不能建立在有嚴重功能短板導致開發中帶來很嚴重問題的基礎之上的,為了程式碼一致性,而妥協了框架的功能與能力

可能因為不同的平臺/不同的版本/不同的WebView的使用與相容,導致了我們需要在開發Hybrid框架的時候需要適配,但這一切都是可以通過設計良好的框架對外輸入輸出,把所有區別適配內部消化,從而做到在框架外層的業務程式碼依然保持程式碼一致性,保持乾淨整潔的。這裡所說的框架絕不僅僅包括客戶端這一側,JS側也同理,誰說區分安卓和IOS平臺來進行不同的通訊方式程式碼就不整潔了,那是你框架層設計的不夠優秀,合理框架層程式碼應該可以做到當新的系統元件出現,新的更優秀的通訊方案出現的時候,能夠立刻的支援和擴充,獲得最新的能力和效能,但又在業務上層做到無感知,保持框架外圍使用的一致性,這才是良好的設計。

所以我之前微博曾經說過一小段話:

就為了相容從而選擇放棄更合理的WKWebview 官方注入interface方式,為了湊和UIWebView依然採用無論是iframe還是location.href的糊弄方式,這種我實在不覺得是美學,只是一種偷懶而已,抱著UIWebview時代的包袱不想丟還讓WKWebview去遷就

沒錯,說的就是WebViewJavascriptBridge

如果是你,你會怎麼設計Hybrid框架

聊了這麼多這個好好,如果換做我們專案,我會選擇啥?

  • iOS:MessageHandler注入/Prompt彈框攔截(JSToNative) + evaluatingJavaScript (NativeToJS)

經過修正,非同步返回採用MessageHandler 同步返回採用Prompt彈框攔截(JSToNative)

其實同步/非同步在iOS上都可以採用 Prompt彈框攔截(JSToNative) 但畢竟MessageHandler是系統欽定API,並且擁有直接傳遞JSON物件,無需手動序列化這一優勢,所以我們依然選擇2個方案都用,一個用來非同步,一個用來同步

但其實,你也可以同步/非同步都使用 Prompt彈框攔截

  • 安卓: 攔截彈窗(JSToNative)+loadUrl(NativeToJS)

我們安卓還需要支援更低的版本╮(╯_╰)╭

安卓就直接把 Prompt彈框攔截 當做同步/非同步都選擇的通訊方式

以上在設計Hybrid框架API的時候,都考慮了2種sendMessage模式的,一種非同步,一種同步

經過各種優缺點對比,我們確認了最核心的JS與Native通訊方案,下一步就是親自設計一個Hybrid框架了,這篇也太長了,挖個坑後面在寫吧

本篇參考文獻

由於我不是很懂安卓,本篇很多安卓的資訊來自我和同事之間的探討以及這篇文章

Android:你要的WebView與 JS 互動方式 都在這裡了

另外聊到UIWebView的JSContext ,扯了好多JS上下文時機的事情,詳細介紹在我自己的另一篇文章裡

UIWebView程式碼注入時機與姿勢

系列相關文章

從零收拾一個hybrid框架(一)-- 從選擇JS通訊方案開始

從零收拾一個hybrid框架(二)-- WebView容器基礎功能設計思路

從零收拾一個Hybrid框架(三)-- WebView 容器的一些腦洞方案思路探討 (挖坑ing)

相關文章