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

折騰範兒_味精發表於2018-03-07

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

上一篇文章介紹瞭如果自己親自開發一個hybrid容器,應該怎麼去選擇JS通訊方案,並且重點強調了現在市面上流傳最廣的WebViewJavascriptBridge 他存在的致命問題,以及不應該因為所謂的看似寫法統一而在api選型上做功能妥協的思想

前言

這篇文章拖到年後才開始寫是有點罪過罪過,不過在寫之前我想說幾點,這系列文章我想表達的並不是在推廣什麼我自己的新Bridge輪子,也不是針對某個開源 Bridge 框架進行深度的原始碼分析。我們從看開源框架輪子如何設計,如何使用,原始碼如何工作的思維方式中跳出來,換一種模式去從目的出發,從需求出發,思考當你什麼都沒有的時候,你要從零思考構建一個 hybrid 框架的時候,你都要考慮哪些方面?這些方面採用怎樣的設計思想能做到未來在使用中靈活自如。

單純去做一個純hybrid通訊框架,網上有的各類 bridge 輪子,但是通訊只是 hybrid 這個話題下的一個底層基礎,上層的玩法還有很多,跳出通訊框架看看現在各種前端+客戶端的混合開發模式下,他們是否有啥共通點,然後汲取裡面的設計思想,然後靈活的在自己業務中發揮出來,這樣你面臨的就從來不是一個選擇題,我要不要用RN?我要不要用新玩具 Flutter?(這玩意槽點很多也離我這篇文章的主題有點遠,我就提一嘴不打算多說)你面臨的只是一個問答題:新出來個重型大輪子,我能不能看看這個思想借鑑到現有業務裡的?

Hybrid

狹義Hybrid

也是現在大家普遍認知的,Hybrid就是一種給 WebView 增加一些js通訊可以呼叫原生API的方式

廣義Hybrid

我能否認為,只要是前端的開發思路與客戶端原生的開發思路相結合,就認為他是一種 Hybrid?

我能否認為,通過原生的配合,把原本js or 前端開發做不到的事情做到了,用原生的方式增強了原本的前端技術能力,是否就是一種 Hybrid?

我能否認為,無論是 WebView+Bridge 也好,RN類似的原生渲染框架也好,小程式也好,某種意義上講,他們都算 Hybrid?

因為Hybrid本是一個面向業務服務的東西,如果業務的野心足夠大,WebView 容器的想象空間應該是在能力上與RN/小程式看齊的,沒錯,WebView 在 Hybrid 的支援下,不單純是設計幾個 Bridge 呼叫幾個原生 API 的事,我其實在不少聊天群裡深度聊過這個話題,泛前端動態化這個方向上,各種技術輪子都是一脈相承通著的,所以你看RN or 小程式是一個大廠新做的重型輪子?在我看來他們都是一回事,完全可以拆解RN中的每個環節,把RN號稱比 WebView 好的原生渲染/原生元件拆解融入 WebView,我也可以學習小程式保持 Html/CSS/JS 的開發方式(當然我知道小程式是WXML/WXSS),而非RN那樣統一用JSX開發。我甚至還能把RN與小程式都沒有的動態bridge融入到Hybrid容器中去,What's more? 還有更多可以開放的腦洞。

這種拆解不是說可以做到把所有框架優點塞在一個大而全的框架裡就完事的,各種優化方案的選擇背後一定帶來的是一些取捨。誰來決定取捨,業務決定,如果自己能深度把握這裡面的設計思想,就不用在乎什麼新的輪子新的框架,取其設計優點(優點一定帶來取捨,如果選擇這個有點意味著也要選擇他的取捨),融入自己的業務之中。

WebView容器 - 基本功能

前邊把話題聊得有點大,有點虛,那麼收回來,我後面的內容還是圍繞著 WebView 來設計我們想要的 Hybrid 框架,本系列第二篇文章我會傳統點,先只從傳統的對 WebView 容器上的功能需求出發說一些基本的功能的設計方案與思路,但是第三篇就會從一些“黑腦洞”的功能層面,在 WebView 的基礎上擴充套件出一些非常規的能力。本文就基於 WKWebView 說了,UIWebView 如果說真理解設計思想的話,其實沒區別,同理雖然我所有的解釋說明都是 iOS 的,但對於安卓來說設計思想完全適用。

  • 選擇合適的 JS 通訊方案(第一篇)

  • 實現基本的 WebView 容器能力(第二篇 本篇)

  • 嘗試擴充 WebView 容器的額外能力(第三篇 待續)

一個標準的WebView容器要具備哪些基礎的功能需求,來滿足常規的 hybrid-webview 開發呢?

  • 良好的 JS 與原生通訊互動能力

  • 靈活的業務模組擴充套件能力

  • UserAgent 管理

  • Cookies 管理

  • 本地載入 JS 管理

通訊互動設計

<!--more-->

上一篇文章其實介紹了好幾種JS/OC通訊方案,如果涉及相關的程式碼,我只會以WKWebView的 messageHandler(非同步) + Prompt彈框攔截(同步)evaluatingJavaScript 的方式進行一些展示程式碼介紹,但其實用啥方案並無區別,思考理解設計思路,然後在任何的通訊方案下遷移運用

我說的只是一種設計思路,並不是唯一設計思路

一個好的互動通訊設計應該考慮哪幾個方面?這幾個方面的考慮都是出於什麼目的?

  • JS主動呼叫原生:

最基礎功能,WebView 各種想要呼叫原生能力都通過這個設計來通知原生,無論是開啟新頁面新路由,還是彈個 Tips 框,還是執行 IAP 購買,還是開啟攝像頭等等。

  • JS主動呼叫原生後回撥:

還是在基礎功能之上,如果 WebView 是想要獲取一些只有原生才有的資料,比如讀原生資料庫,檢視原生裝置網路/磁碟等硬體狀況,需要在上面的功能下還額外回撥給 WebView

  • 原生主動呼叫JS:

有什麼業務場景需要原生主動呼叫JS呢?舉個例子H5開發的時候特別想知道很多事件與時機,比如在H5介面下使用者home/鎖屏了,使用者回到 APP 了,H5都想捕獲這個時機用來開發業務需求,比如App開發的 viewWillDisappear /viewDidAppear 等前端開發也想獲得當前頁面進入螢幕,離開螢幕等事件需求,從而執行對應的業務邏輯。

和主動呼叫後回撥相比,直接主動呼叫JS,在底層執行 API 的時候肯定一樣都是 evaluatingJavaScript 但畢竟代表著2種功能形式與場景,因此在設計思想上也會帶來一定差異。

  • 原生主動呼叫JS後回撥:

在原生的事件發起後如果不僅僅想要通知JS,並且還希望從JS獲取資料,那麼就需要直接封裝好回撥(總好過,先通過原生主動呼叫JS,在通過JS主動呼叫回原生傳遞資料,這樣方便的多)

  • 同步通訊 

在上一篇文章中我提出過同步返回對於JS的意義,同樣的程式碼如果能用 = 直接在JS中同步+順序的方式處理資料,則開發複雜度會遠低於非同步callback式的設計(哪怕非同步callback可以被封裝成promise,但其方便程度,也遠遠沒法和 = 同步返回對比)  

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

JS與OC,在執行環境上畢竟是差異很大的2個環境,相互之間進行通訊,一定是按著一定的通訊協議來進行的,在協議的處理過程中一定伴隨著通訊編碼,在一個 WebView 容器體系下,通訊編碼也需要注意很多細節,從而保證資料傳輸過程中的健壯性。

上面介紹的四塊後面會有針對性的伴隨著程式碼示例進行詳解

接下來也會介紹一些有一定的設計價值,但不一定要必備,可選的兩個功能,我就不深入詳解了

  • 鑑權設計(可選):

App 內開啟 WebView 的時候,這麼多原生能力都提供給了網頁端,但網頁端是否都是可信並且安全的呢,雖然鑑權設計視業務需求而定,可以做的相對簡單,也可以做的複雜一些,是可選的。

簡單的做那麼就是廣告類的頁面一律用常規 WebView 開啟,只有自己業務所在域名的網頁,才會用帶有 Hybrid 能力的WebView進行開啟,並且所有 Bridge 通訊在執行前先檢查判斷所在域名是否合法,只有自己業務域名下的網頁,才允許bridge通訊,其他的一律拒絕。

想要做複雜點?其實微信服務號就是一個典型的例子,所有微信服務號JSSDK提供的能力,其實都是微信 WebView 的 Hybrid 能力,但你如果想呼叫你必須配置 AppId 與 AppSecret ,然後在 JSSDK 內部與微信原生內部進行許可權認證功能的開發,這我就不深入展開了,一般業務也用不到,只是這個環節我擴充套件提一下,如果想做複雜也是有必要並且有業務場景的。

  • 批量傳送(可選):

JS與OC通訊,必然面臨著上下文通訊開銷,開銷試通訊頻繁次數而定,大部分的傳統 Hybrid WebView 設計,不太需要考慮這一點,因此無需專門設計批量傳送,但如果特殊的業務需求導致必須頻繁通訊,那麼批量傳送通過加大資料吞吐量減少通訊次數,從而減小上下文通訊開銷,這一個環節也是要考慮,但是可選的。

就像我說的一般來說用不上,常規 Hybrid WebView 開發裡那通訊呼叫頻率,一點壓力都沒有,但什麼時候會用上呢?

如果還記得我上篇文章說過的 JS與OC 假跳轉方式的通訊會吃訊息?那麼如果你真的在處理 UIWebView 的時候堅持想使用假跳轉,那麼吃訊息這個事情怎麼解決呢?沒錯,佇列批量傳送,所有JS的通訊在發起呼叫的時候都暫存進入一個陣列佇列,全域性起 timer 心跳,以不會吃訊息的頻次大約300ms一次,檢測訊息佇列是否含有內容,然後通過合併訊息,一次通訊傳送給客戶端。

RN也是有批量傳送功能的,可以關注一下 RN 的 RCTBatchBridge 這個類,RN為什麼需要批量訊息佇列呢?RN並不是假跳轉的方式通訊,而是最穩健功能強大的 JSContext,根本不會發生丟訊息的問題,但RN依然選擇了批量通訊傳送,是因為RN不同於WebView,RN 的渲染層依賴 JS 告知原生進行元件貼圖,那麼多介面元素,每次Dom變化,都可能帶來的複雜高頻次渲染訊息的發出,因此 RN 整體設計了批量傳送功能,思路也是一致,在 JS 這邊每次執行通訊,都暫存在佇列,然後以心跳方式 flush 整個佇列。 WebView的渲染都在 WebKit 核心裡,其實瀏覽器核心也面臨所謂的 Dom 環境與 JS 環境的通訊,但這個已經在核心裡深度優化了,我們就先不管啦,WebView的渲染涉及的高頻詞通訊,完全不在我們的 Hybrid 框架設計的考慮範圍內(前面我說過了一個腦洞,讓WebView也像RN一樣可以渲染原生貼圖,那麼批量傳送就應該在考慮範圍內了)

詳細設計思路 — JS Call OC 無回撥

宣告:我會沿著思路一步步給出示例程式碼,會隨著思路推翻或者修正前面給出過的示例程式碼

JS 傳送訊息

首先在 JS 側我們把每一個呼叫原生的訊息物件設計一下都需要涵蓋什麼內容?先只考慮 JS Call OC 無回撥

var msgBody = {};
msgBody.handler = 'common';
msgBody.action = 'nativeLog';
msgBody.params = params; //任意json物件,用於傳參.複製程式碼

非得設計這幾個欄位目的是啥? handler 和 action 其實主要是給每一條通訊訊息確定唯一的名字,params用於資料傳參。

有的人說想要唯一識別每條訊息,用一個 name 欄位,或者乾脆用個訊息號數字就好了,反正發到客戶端,客戶端還得一一識別然後無論是註冊式分發執行或是switch式分發執行。沒錯,所以我說了,並不是唯一設計思路,都可以靈活根據自己的業務與想法任意調整。

我為什麼用2個呢?主要的原因在於想對大量的通訊訊息有一個整理,對於相近相似可以歸類的訊息,先用 handler 來命名訊息所在的模組,在用 action 來命名訊息的具體名字,好處是未來在進行模組化擴充套件,無論是在 JS 側,還是在 OC 側,都可以根據模組名,把大量的訊息處理程式碼,分割到不同模組的程式碼類之中去,還是本著模組擴充套件與管理的想法,來把訊息體用 handler 和 action 2個欄位來進行描述

sendMessage: function (data) {
    if (this.isIOS) {
        try {
            window.webkit.messageHandlers.WKJSBridge.postMessage(data);
        }
        catch (error) {
            console.log('error native message');
        }
    }
​
    if (this.isAndroid) {
        try {
            prompt(JSON.stringify([data]));
        }
        catch (error) {
            console.log('error native message');
        }
    }
​
},複製程式碼

定義完訊息體我們就需要進行通訊了,這個函式其實就是抽象出一層訊息傳送層,將我們剛剛建立的訊息體,當做 data 傳入 sendMessage 函式,這就是我在第一篇文章中提到的傳送層來隔離平臺差異以及通訊 API 差異,不要為了追求所謂的前端程式碼統一來選擇一個有天生缺陷的假跳轉通訊,就算選擇完全不一樣的通訊方式,設計這樣一箇中間層,一樣可以做到前端 JS 程式碼的統一

從擴充套件性的角度來講,如果未來蘋果可能出了更新更好的 API ,安卓也有通訊 API 的調整,直接處理這個中間層就好了,別管是分平臺適配,分安卓/iOS系統版本號適配,甚至同時相容UIWebView/WKWebView,計算機領域裡沒有什麼問題不是加一箇中間層解決不了的(笑~)

如果以後打算擴充套件 批量訊息,通訊佇列,那麼其實也是一樣的思路,設計一個 sendBatchMessage的Api

通訊編碼

因為本文采用的是 WKWebView 的 messageHandler 方法,在 JS Call OC 的時候會自動處理編碼序列化與解碼反序列化,所以你可以看到 isIOS 分支沒有任何額外處理,就直接 Call 了,但我們們既然講思路,那也得看看需要手動處理通訊編碼的場景。(UIWebView 的 JSContext 通訊同理,可以傳遞物件,系統自動處理)

android 的 prompt 通訊,prompt() 函式本來就是瀏覽器彈出一個輸入框,輸出一串字串,所以只接受純字元輸入,那麼編碼方式就簡單了,直接把 data 的 json 物件,用 JS 的 json序列化變成 json 字串輸出,等客戶端收到攔截後把 json 字串,反序列化成字典物件,(WKWebView如果也採用彈框攔截,同理)

UIWebView假跳轉方式通訊,因為假跳轉本意是跳轉到一個非法url,自然資料傳遞必須依靠url的引數規則去定製協議去拼接,那麼就得拼接成類似 xxx:xxx/xxx?handler=xx&action=xx&params="json string" 的url,然後在客戶端攔截到url後,按著同樣的規則反解

通訊編碼問題,會根據通訊方案的選取/通訊協議的設計,有著截然不同實現與方案,以及各自面臨的坑和問題。帶著一個準則,無論最終採用了什麼樣的資料協議設計,JS 怎麼編碼的,OC 怎麼反向解碼還原,就一定沒問題,一定能解決。(就拿假跳轉來說,當你跳進 encodeURIComponent 與 UrlEncode 的坑裡的時候,一旦資料結構複雜起來,那就有的玩了,稍有不慎,可能編解碼中間就出現歧義了就得踩坑了,不過這種事情不是啥大問題,最終一定能解決就是了)

OC接收訊息

需要提前介紹一下我會在OC端設計幾種物件,後面還會在靈活擴充套件的環節詳細解釋

訊息體物件:包含單次訊息的所有資訊

bridge物件:我的 Hybrid WebView 設計理念是組合,而不是繼承,因此我設計的不是一個 XXWebView / XXWebViewController 基類,使用者不需要在業務程式碼中使用 WebView 必須從我這裡繼承。我設計的是一個 NSObject 的 bridge 物件,使用者只需要跟自己業務中用的任意一種 WKWebView 的業務自己的類進行繫結,就可以擁有 Hybrid 的能力

業務web物件:業務方的 webview 物件 or webviewVC物件,業務方可以自由寫自己的程式碼,規劃自己的基類,不受任何限制,綁上bridge後,可以呼叫 bridge api

首先我們在OC也定義一個訊息體物件

@interface msgObject : NSObject
    
@property (nonatomic, copy, readonly) NSString * handler;
@property (nonatomic, copy, readonly) NSString * action;
@property (nonatomic, copy, readonly) NSDictionary * parameters;
​
- (instancetype)initWithDictionary:(NSDictionary *)dict;
​
@end
​複製程式碼

然後我們這裡設計一套 block 註冊式的訊息體處理函式管理(放棄if else / switch 式的訊息分發吧,哈哈),因為訊息體是 handler / action 2層定義,所以 handlerMap 是個二維字典

// 這段程式碼屬於 bridge 物件
// self.handlerMap 是 bridge物件的內部字典屬性,儲存著所有外部註冊的各種通訊的處理block程式碼
​
-(void)registerHandler:(NSString *)handlerName Action:(NSString *)actionName handler:(HandlerBlock)handler{
    if (handlerName && actionName && handler) {
        NSMutableDictionary *handlerDic = [self.handlerMap objectForKey:handlerName];
        if (!handlerDic) {
            handlerDic = [[NSMutableDictionary alloc]init];
        }
        [self.handlerMap setObject:handlerDic forKey:handlerName];
        [handlerDic setObject:handler forKey:actionName];
    }
}
​
-(void)removeHandler:(NSString *)handlerName{
    if (handlerName) {
        [self.handlerMap removeObjectForKey:handlerName];
    }
}複製程式碼

有了這樣的序號產生器制,我們註冊一個 OC 接受 JS 訊息體的處理程式碼,業務在任意 webview 類 or vc 類中,可以呼叫我提供的註冊 api 來實現業務的 訊息體處理程式碼

// 這段程式碼屬於 業務web 物件
// 業務在任意 webview類 or vc 類中,可以呼叫我提供的註冊 api 來實現業務的 訊息體處理程式碼
[self registerHandler:@"common" Action:@"nativeLog" handler:^(msgObject *msg) {
    NSLog(@"webview log : \n%@",msg)
}];複製程式碼

這樣 OC 這邊的程式碼都已經準備就緒,只等 JS 通訊到達的時候,進行訊息體識別和分發,這是 messageHandler的系統 Api,基本思路就是,從 bridge 物件中的 handlerMap 字典中按著一級 handler 二級 action 二維字典取值去出注冊好的執行程式碼block

-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    NSDictionary *msgBody = message.body;
    if (msgBody) {
        msgObject *msg = [[msgObject alloc]initWithDictionary:msgBody];
        NSDictionary *handlerDic = [self.handlerMap objectForKey:msg.handler];
        HandlerBlock handler = [handlerDic objectForKey:msg.action];
        handler(msg);
    }
}複製程式碼

通訊解碼

因為 WKWebView 的 messageHandler Api 自動處理的編碼解碼,此處其實直接通過 NSDictionary *msgBody = message.body; 一句話就直接拿到了最終訊息體物件,但既然是講程式設計思想,如果是安卓,如果是 iOS 但使用的不是 messageHandler 這種通訊方式,自然就要在這一行程式碼的位置,進行手動解碼,完全等同於編碼的逆向操作,不做贅述了。

自此,JS CALL OC 無回撥,基本流程走通

詳細設計思路 — JS Call OC 回撥

JS傳送訊息 宣告含有回撥

含有回撥的 JS Call OC 有了回撥後整個環節會更加完整,因此回撥的設計得從 JS Call OC 的最初階段就進行調整

var msgBody = {};
msgBody.handler = 'common';
msgBody.action = 'nativeLog';
msgBody.params = params; //任意json物件,用於傳參.
//msgBody.callbackId = '';
//msgBody.callbackFunction = '';複製程式碼

對JS訊息體進行改造,增加用於處理回撥相關的資料欄位 callbackId 與 callbackFunction

  • callbackId:對每一次訊息需要發起回撥都會生成一個唯一ID,用來當回撥發生時,找到最初的發起呼叫的 JS Callback

  • callbackFunction:客戶端主動 Call JS 的唯一函式入口,客戶端會用這個字串來拼接回撥注入的 JS 頭,一般設計下,每個訊息這個值都應該不變,不過也可以靈活處理(本來這個值可以不需要傳遞,寫死在客戶端,只要前端客戶端約定好,但如果這個值不寫死,而由前端可控操作,那麼靈活性會更大,不必擔心前端大規模修改 Call JS 唯一入口的時候,還得等客戶端發版)

sendMessage: function (data,callback) {
    if (callback && typeof (callback) === 'function') {
        var callbackid = this.getNextCallbackID();
        this.msgCallbackMap[callbackid] = callback;
        params.callbackId = callbackid;
        params.callbackFunction = 'window.callbackDispatcher';
    }
    
    if (this.isIOS) {
        try {
            window.webkit.messageHandlers.WKJSBridge.postMessage(data);
        }
        catch (error) {
            console.log('error native message');
        }
    }
​
    if (this.isAndroid) {
        try {
            prompt(JSON.stringify([data]));
        }
        catch (error) {
            console.log('error native message');
        }
    }
},
    
sendMessage(msgBody,function(result){
    console.log('回撥觸發');
});複製程式碼

可以看到我們著手修改 sendMessage 函式,如果在呼叫的時候多寫了一個callback函式,那麼就會認為該次通訊需要回撥,因此對 callbackId 與 callbackFunction 進行賦值,callbackId 是一個保證每次通訊都唯一的一個id值 getNextCallbackID ,大概思路可以是用時間戳+一定程度的隨機小數來進行生成,思路不深入展開了。 callbackFunction 這裡我們先寫 window.callbackDispatcher 後面會提到這個入口是怎麼操作的。

這裡有一步最最重要的操作就是,this.msgCallbackMap[callbackid] = callback; 會把 JS 業務的回撥函式,儲存在一個全域性可處理的回撥字典之中,而 Key 就是這個唯一ID callbackId,這樣當 OC 發起回撥的時候,你才能找到對應的 JS Function

OC接受訊息 識別處理回撥

OC這邊的訊息體也得針對性進行修改,加入了 callbackID , callbackFunction, 加入了OC類的函式回撥Api

typedef void (^JSResponseCallback)(NSDictionary* responseData);
​
@interface msgObject : NSObject
​
- (instancetype)initWithDictionary:(NSDictionary *)dict;
​
@property (nonatomic, copy, readonly) NSString * handler;
@property (nonatomic, copy, readonly) NSString * action;
@property (nonatomic, copy, readonly) NSDictionary * parameters;
@property (nonatomic, copy, readonly) NSString * callbackID;
@property (nonatomic, copy, readonly) NSString  *callbackFunction;
​
-(void)setCallback:(JSResponseCallback)callback; //block 作為屬性,儲存在msgObject的.m檔案裡
​
-(void)callback:(NSDictionary *)result;//在msgObject的.m檔案裡 呼叫儲存在訊息體裡的block
​
@end複製程式碼

所以我們繼續修改 OC 這邊收到 JS 訊息的函式體,當判斷訊息體含有回撥資訊的時候,就會生成用於回撥的 OC Block,當OC業務處理完畢,準備回撥回傳資料的時候使用

-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    NSDictionary *msgBody = message.body;
    if (msgBody) {
        msgObject *msg = [[msgObject alloc]initWithDictionary:msgBody];
        NSDictionary *handlerDic = [self.handlerMap objectForKey:msg.handler];
        HandlerBlock handler = [handlerDic objectForKey:msg.action];
        //處理回撥
        if (msg.callbackID && msg.callbackID.length > 0) {
            //生成OC的回撥block,輸入引數是,任意字典物件的執行結果
            JSResponseCallback callback = ^(id responseData){
                //執行OC 主動 Call JS 的編碼與通訊
                [weakSelf injectMessageFuction:callbackFunction withActionId:callbackId withParams:responseData];
            };
            [msg setCallback:callback];
        }
        if (handler){
            handler(msg);
        }
    }
}複製程式碼

那業務在註冊 OC 訊息處理函式的時候,就可以使用這個block 進行回撥

// 這段程式碼屬於 業務web 物件
// 業務在任意 webview類 or vc 類中,可以呼叫我提供的註冊 api 來實現業務的 訊息體處理程式碼
[self registerHandler:@"common" Action:@"nativeLog" handler:^(msgObject *msg) {
    NSLog(@"webview log : \n%@",msg)
    NSDictionary *result = @{@"result":"result"};
    //回撥一個key value均為 result 字串的字典當做資料
    [msg callback:result];
}];複製程式碼

通訊編碼

之前說到 WKWebView 的 Api 自動處理的 JS CALL OC 的編碼解碼,但是 OC CALL JS 的編碼解碼並沒有自動處理,所以我們得親自做,這就是上面提到的 injectMessageFuction:withActionId:withParams 函式,介紹一下三個輸入引數

  • Fuction:就是前邊JS 傳過來的 window.callbackDispatcher

  • ActionId:就是前邊JS 傳過來的 每個訊息體的唯一ID

  • Params:就是客戶端要回撥的資料體,可以為空

我們會按著下面這種方式去拼接 JS 然後用 evaluateJavaScript: 來注入呼叫JS

[NSString stringWithFormat:@"%@('%@', '%@');", msg,actionId,paramString]

可以看到我們這麼拼接出來的 JS 程式碼字串其實是

window.callbackDispatcher('12345callbackid','{\'result\':\'result\'}');複製程式碼

但是編碼過程還是需要注意的,我們如何把字典 params 轉化為 paramString,確實直接用系統API NSJSONSerialization 轉一下就看起來沒問題了,但這裡其實存在一定的隱患。

OC 主動 Call JS 的原理其實是,在客戶端拼接出一段 JS 程式碼,但如果 params 這個資料中存在一定特殊字元比如 \r \n \f 等等,這些特殊字元會破壞 JS 的程式碼結構,打破原本的 JS 語法,這塊要非常小心,尤其是你要傳遞大型巢狀字典資料的時候,簡單的測試資料這個問題是無法暴露出來的,如果 JS 程式碼結構被破壞,那麼所有通訊 JS 的方法就失效了,所以編碼這塊大致程式碼思路可以是這樣

// 字典JSON化
- (NSString *)_serializeMessageData:(id)message{
    if (message) {
        return [[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:message options:NSJSONWritingPrettyPrinted error:nil] encoding:NSUTF8StringEncoding];
    }
    return nil;
}
// JSON Javascript編碼處理
- (NSString *)_transcodingJavascriptMessage:(NSString *)message
{
    //NSLog(@"dispatchMessage = %@",message);
    message = [message stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
    message = [message stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
    message = [message stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
    message = [message stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
    message = [message stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
    message = [message stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
    message = [message stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
    message = [message stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];
    
    return message;
}
// 通訊回撥
-(void)injectMessageFuction:(NSString *)msg withActionId:(NSString *)actionId withParams:(NSDictionary *)params{
    if (!params) {
        params = @{};
    }
    NSString *paramsString = [self _serializeMessageData:params];
    NSString *paramsJSString = [self _transcodingJavascriptMessage:paramsString];
    NSString* javascriptCommand = [NSString stringWithFormat:@"%@('%@', '%@');", msg,actionId,paramsJSString];
    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 接收 OC 回撥

上面提到了,客戶端會把 callbackId callbackFuction ResultString 拼接成如下 JS 程式碼,注入回 WebView

window.callbackDispatcher('12345callbackid','{\'result\':\'result\'}');複製程式碼

那麼前端要做的就是準備好對應的函式,在window的物件上,掛上 callbackDispatcher 這個函式,這就是為啥我一開始說 callbackFunction 寫死 window.callbackDispatcher 的原因,客戶端用這個字串,拼出了 JS 程式碼,這個 JS 程式碼執行的時候,就剛好window下有這麼一個函式接著

window.callbackDispatcher: function (callbackId, resultjson) {
    var handler = this.msgCallbackMap[callbackId];
    if (handler && typeof (handler) === 'function') {
        // JSON.parse(resultjson)
        console.log(resultjson);
        var resultObj = resultjson ? JSON.parse(resultjson) : {};
        handler(resultObj);
    }
},複製程式碼

當OC 已經成功回撥到 JS 了,那麼就用 callbackId 在剛才儲存的回撥字典裡找到要回撥的方法,然後把傳過來的 resultjson 用 JS 的 JSON.parse 反序列化成字典,然後用找到的回撥方法把資料傳遞進去

詳細設計思路 — OC 主動 Call JS

徹底介紹完了 JS Call OC + 回撥了,其實大致的思路已經說個七七八八了,再介紹OC 主動 Call JS 會簡單許多,甚至真的自己沿著類似的設計思路思考捉摸一下,也能自行設計一個比較合理的方案了

JS 監聽來自 OC 的主動訊息

既然是容器框架程式碼層與業務解耦,提供監聽的 Api 是一種比較好的方式,業務方會把監聽事件用一個字串來約定,比如鎖屏事件約定為 applicationEnterBackground ,呼叫 API 的時候把事件字串與事件處理函式傳入,在一個全域性可以管理的 eventCallMap 字典中進行儲存,等待事件監聽到達的時候,發起呼叫

//監聽的API
window.onListenEvent: function (eventId, handler) {
    var handlerArr = this.eventCallMap[eventId];
    if (handlerArr === undefined) {
        handlerArr = [];
        this.eventCallMap[eventId] = handlerArr;
    }
    if (handler !== undefined) {
        handlerArr.push(handler);
    }
},複製程式碼

那麼當某個H5頁面打算使用這個監聽API的時候就這麼使用就好了

//業務呼叫該API
window.onListenEvent('applicationEnterBackground', function () {
   console.log('home press')
});複製程式碼

剛才提到 JS Call OC 回撥的時候有一個 callbackDispatcher 函式來承接,那麼這種OC Call JS也得用類似的方式進行承接,於是我們準備一個 eventDispatcher ,思路是類似的,我不多介紹了

//接收OC事件的API
window.eventDispatcher: function (eventId, resultjson) {
    var handlerArr = this.eventCallMap[eventId];
    for (var key in handlerArr) {
        if (handlerArr.hasOwnProperty(key)) {
            var handler = handlerArr[key];
            if (handler && typeof (handler) === 'function') {
                var resultObj = resultjson ? JSON.parse(resultjson) : {};
                handler(resultObj);
            }
        }
    }
},複製程式碼

OC 主動呼叫 JS

既然是OC主動發起,那麼我們就拿鎖屏這個事件來舉例

-(void)addLifeCycleListenerCommon{
    // app從後臺進入前臺都會呼叫這個方法
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationEnterForeground) name:UIApplicationWillEnterForegroundNotification object:nil];
    // 新增檢測app進入後臺的觀察者
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationEnterBackground) name: UIApplicationDidEnterBackgroundNotification object:nil];
​
}
​
-(void)removeLifeCycleListenerCommon{
    [[NSNotificationCenter defaultCenter]removeObserver:self];
}
​
-(void)applicationEnterForeground{
    //最關鍵的主動通訊JS的函式
    [self sendEventName:@"applicationEnterForeground" withParams:nil];
}
​
-(void)applicationEnterBackground{
    //最關鍵的主動通訊JS的函式
    [self sendEventName:@"applicationEnterBackground" withParams:nil];
}複製程式碼

剛剛 JS Call OC 回撥的時候使用的是injectMessageFuction:withActionId:withParams:函式,那麼這裡介紹的 sendEventName:withParams:則是類似的用於拼接JS進行回撥的函式

-(void)sendEventName:(NSString *)event withParams:(NSDictionary *)params{
    NSString *jsFunction = 'window.eventDispatcher'; 
    //還是走`injectMessageFuction:withActionId:withParams:` 這個函式,統一進行通訊編碼處理
    [self injectMessageFuction:jsFunction withActionId:event withParams:params];
}複製程式碼

TIPS 思路擴充套件:

此處window.eventDispatcher 是客戶端寫死的,但我們之前提到過 callbackDispatcher就設計成 JS 通過主動通訊傳給客戶端而不是寫死,這樣便於擴充套件。那麼eventDispatcher 能不能也這樣呢?其實很簡單,我們設計一個 JS Call OC 的訊息,傳過來讓客戶端儲存住就好了嘛。

詳細設計思路 — OC 主動 Call JS 回撥

剛才設計一個sendEventName:withParams:的方法,而這個方法內部呼叫的是injectMessageFuction: withActionId:event withParams:,來把OC主動發起的訊息與資料引數傳給JS,那麼我們在此基礎上進行擴充套件,擴充套件成支援回撥回傳資料。 WKWebView的evaluateJavaScript:completionHandler:這個函式天然有一個引數是completionHandler,因此天然支援OC主動呼叫evaluateJavaScript,並且回傳資料結果,就是通過completionHandler,所以我們只需要把我們設計出來的sendEventXxxxxinjectMessageXxxx兩個方法多設計一個block回撥輸入引數 

-(void)injectMessageFuction:(NSString *)msg withActionId:(NSString *)actionId withParams:(NSDictionary *)params withCallback:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))handler{
    if (!params) {
        params = @{};
    }
    NSString *paramsString = [self _serializeMessageData:params];
    NSString *paramsJSString = [self _transcodingJavascriptMessage:paramsString];
    NSString* javascriptCommand = [NSString stringWithFormat:@"%@('%@', '%@');", msg,actionId,paramsJSString];
    if ([[NSThread currentThread] isMainThread]) {
        [self.webView evaluateJavaScript:javascriptCommand completionHandler:handler];
    } else {
        __strong typeof(self)strongSelf = self;
        dispatch_sync(dispatch_get_main_queue(), ^{
            [strongSelf.webView evaluateJavaScript:javascriptCommand completionHandler:handler];
        });
    }
}


-(void)sendEventName:(NSString *)event withParams:(NSDictionary *)params withCallback:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))handler{
    NSString *jsFunction = @'window.eventDispatcher'; 
    [self injectMessageFuction:jsFunction withActionId:event withParams:params withCallback:handler];
} 複製程式碼


那麼JS要做的事情就簡單了,之前JS這邊程式碼eventDispatcher在查詢到handler之後直接呼叫就好了,那麼現在呢?多寫一個return

eventDispatcher: function (eventId, resultjson) {
    var handlerArr = this.eventCallMap[eventId];
    var me = this;
    for (var key in handlerArr) {
        if (handlerArr.hasOwnProperty(key)) {
            var handler = handlerArr[key];
            if (handler && typeof (handler) === 'function') {
                var resultObj = resultjson ? JSON.parse(resultjson) : {};
                var returnData = handler(resultObj);
                //多寫一個return
                return returnData; 
            }
        }
    }
},複製程式碼


詳細設計思路 — 同步返回 JS Call OC

由於JS Call OC 同步返回我這裡採用了不同於非同步messageHandler的通訊方式,因此同步返回要單獨進行設計。

訊息體和編碼協議完全保持不變,重新設計一下傳送介面,通過prompt()

JS 傳送同步訊息給 OC

sendSyncMessage: function (data) {
    if (this.isIOS) {
        try {
            //將訊息體直接JSON字串化,呼叫Prompt()            
            var resultjson = prompt(JSON.stringify(params));
            //直接用 = 接收 Prompt()的返回資料,JSON反解
            var resultObj = resultjson ? JSON.parse(resultjson) : {};
            return resultObj;
        }
        catch (error) {
            console.log('error native message');
        }
    }
},複製程式碼

OC 攔截Prompt() 接收訊息

WKWebView有個攔截Prompt()的UIDelegate,我們在這裡進行彈窗攔截,經過同步運算後將資料通過completionHandler同步返回給JS

- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler{
    if (webView != self.webView) {
        completionHandler(@"");
        return;
    }
    
    NSData *jsonData = [prompt dataUsingEncoding:NSUTF8StringEncoding];
    NSError *err;
    NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:jsonData
                                                    options:NSJSONReadingMutableContainers
                                                          error:&err];
    //可以看出來,訊息體結構不變,所有邏輯保持和非同步一致
    msgObject *msg = [[msgObject alloc]initWithDictionary:msgBody];
    NSDictionary *handlerDic = [self.handlerMap objectForKey:msg.handler];
    HandlerBlock handler = [handlerDic objectForKey:msg.action];
    handler(msg);
    //修改 msg的callback方法,當發現是同步訊息的時候,callback在block執行完畢後將資料儲存到msg的syncReturn中
    NSString *resultjson = [self _serializeMessageData:msg.syncReturn];
    completionHandler(resultjson);
    
}
複製程式碼

這樣就完成了同步返回資料 JS 中,可以很開心的寫同步程式碼了

var params = syncGetPastboard();
var pastboard = params.content;
console.alert(pastboard);複製程式碼

業務模組擴充套件

上面把基本的 JS Call OC / 回撥 / OC Call JS 的基本通訊流程設計思路串了一遍,但我們提一下程式碼模組設計思路,因為通訊是底層通用邏輯,但在這之上,業務會發展出各種專為業務服務的訊息體,這些訊息是堆積在一個程式碼裡越積越多,毫無管理,還是設計成模組式劃分,橫向靈活擴充套件可插拔式的程式碼結構?這裡只提一些我的個人的想法,程式碼的整潔之道有很多,每個人都有自己的體會,並不是說怎樣就是最好的,我這裡也僅僅是很粗略的提一下。

JS 程式碼模組設計

  • jsbridge-core.js

所有底層通訊的相關程式碼能力,都會放到core這個js程式碼裡,也就是上面我們介紹的各種核心通訊框架程式碼

  • jsbridge-common.js

假如有一些通用的bridge訊息需求,比如日誌/獲取裝置資訊/螢幕鎖屏監聽/螢幕,各種Common相關的需求程式碼,都放到這裡

  • jsbridge-haha.js

假如有一些業務獨有的需求,比如加入購物車,比如購買兌換積分等等,可以統一歸類到 haha 模組,所有跟 haha 模組相關的程式碼,都放在這裡

var Core = function () {
    this.ua = navigator.userAgent;
    this.isAndroid = (/(Android);?[\s\/]+([\d.]+)?/.test(this.ua));
    this.isIOS = !!this.ua.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/);
    this.msgCallbackMap = {};
    this.eventCallMap = {};
    this.sendMessage = function(xxxx){xxxx};
    this.onListenEvent = function(xxxx){xxxx};
    this.eventDispatcher = function(xxxx){xxxx};
    this.callbackDispatcher = function(xxxx){xxxx};
};
window.bridge.Core = Core;複製程式碼

那麼我們就把 JS 的通訊底層 Core 模組設定好了,剛才提到的各種程式碼都在裡面,這裡這麼寫有點low,只為展示思路,有了思路,前端怎麼整理程式碼優化程式碼都可以。另外注意此時我們已經是 window.bridge.core.callbackDispatcher了,所以傳遞 callbackFunction 的時候要注意

var Common = function () {
    this.webviewAppearEvent = 'webviewAppear';
    this.webviewDisappearEvent = 'webviewDisappear';
    this.applicationEnterBackgroundEvent = 'applicationEnterBackground';
    this.applicationEnterForegroundEvent = 'applicationEnterForeground';
};
​
// dataDic為Object物件
Common.nativeLog = function (dataDic) {
    var params = {};
    params.dataDic = dataDic;
    this.sendCommonMessage('nativeLog', params);
},
​
// traceData為字串
Common.crashTrace = function (traceData) {
    var params = {};
    params.data = traceData;
    this.sendCommonMessage('crashTrace', params);
},
// 複製剪下板
Common.copyContent = function (content) {
    var params = {};
    params.str = content;
    this.sendCommonMessage('copyContent', params);
},
//獲取裝置一些通用資訊
Common.getCommonParams = function (callback) {
    this.sendCommonMessage('commonParams', {}, callback);
},
// common模組的基礎類,選用同樣的 handler name => Common
Common.sendCommonMessage: function (action, params, callback) {
    var msgBody = {};
    msgBody.handler = 'Common';
    msgBody.action = action;
    msgBody.params = params;
    window.bridge.Core.sendMessage(msgBody, callback);
}
window.bridge.Common = Common;複製程式碼

那麼業務頁面中使用就這樣咯

//具體的某個h5頁面
//頁面有複製進入剪下板的需求
window.bridge.Common.copyContent('哈哈哈哈,我複製進剪下板啦')
//頁面有讀取客戶端資訊的需求
window.bridge.Common.getCommonParams(function (params) {
    console.log(params);
});
//頁面有監聽鎖屏的需求
window.bridge.Core.onListenEvent(window.bridge.Common.applicationEnterBackgroundEvent, function () {
    console.log('home press')
});複製程式碼

所有程式碼都有點low,只是介紹思路,我們業務程式碼也不會這樣簡單粗暴的寫,只是為了簡單說明意圖,並且我也不深入擴充套件 haha 模組了就是舉例。以後如果有新的一類業務需求,可以擴充套件新的模組,如果某個模組有新訊息需求,可以單獨新增訊息

OC 程式碼模組設計

前文:

我的 Hybrid WebView 設計理念是組合,而不是繼承,因此我設計的不是一個 XXWebView / XXWebViewController 基類,使用者不需要在業務程式碼中使用 WebView 必須從我這裡繼承。我設計的是一個 NSObject 的 bridge 物件,使用者只需要跟自己業務中用的任意一種 WKWebView 的業務自己的類進行繫結,就可以擁有 Hybrid 的能力

我也會準備一個類做核心通訊類,比如就叫 XXBridge ,整合自 NSObject,使用者在ViewController裡可以建立各自業務自己封裝的任意WebView物件,然後執行繫結操作,把 XXBridge 物件與 WebView 繫結起來,類似這樣

//在任意業務VC的viewDidLoad裡
//建立WKWebView
WKWebViewConfiguration *config = [WKWebViewConfiguration new];
config.preferences = [WKPreferences new];
config.preferences.minimumFontSize = 10;
config.preferences.javaScriptEnabled = YES;
config.preferences.javaScriptCanOpenWindowsAutomatically = YES;
WKWebView *webView = [[WKWebView alloc]initWithFrame:CGRectZero configuration:config];
self.webView = webView;
//建立並繫結Bridge
self.jsBridge = [[WKJSBridge alloc]init];
self.jsBridge.delegate = self;
[self.jsBridge bindBridgeWithWebView:webView];複製程式碼

所謂的繫結過程大概思路其實只是,把WebView的一些 navigationDelegate/UIDelegate、configuration.userContentController 設定指向 XXBridge 內的處理函式,再把所有 WKWebView 的navigationDelegate UIDelegate通過 XXBridge 的delegate 轉發給原VC,其實就是一層簡單的代理攔截,我就不詳解了,剩下的都是上面的提到過的具體通訊程式碼了。

當然你完全可以使用繼承,強調過很多次,只說設計思路,並且並不是唯一思路,也不代表是最優思路。

  • XXBridge Class

  • XXBridge+Common Category

  • XXBridge+haha Category

提到模組化可插拔式擴充套件,在OC裡面最快想到的當然是Category,既然 JS 程式碼都劃分為 Core/Common/haha,那麼OC也這麼做唄(不用category當然也行,只要把程式碼按著模組簡潔合理的分割開來易於擴充套件和管理就好)

@implementation XXBridge (Common)
​
-(void)registCommonHandler{
    [self addLifeCycleListenerCommon];
    __weak typeof(self) weakSelf = self;
    
    [self registerHandler:@"Common" Action:@"commonParams" handler:^(WKJSBridgeMessage *msg) {
        NSDictionary *result = [weakSelf getCommonParams];
        [msg callback:result];
    }];
    
    [self registerHandler:@"Common" Action:@"copyContent" handler:^(WKJSBridgeMessage *msg) {
        NSDictionary *params = msg.parameters;
        NSString *content = [params objectForKey:@"str"];
        UIPasteboard *pasteboard = [UIPasteboard generalPasteboard];
        pasteboard.string = content;
    }];
    
    [self registerHandler:@"Common" Action:@"nativeLog" handler:^(WKJSBridgeMessage *msg) {
        [weakSelf nativeLog:msg.parameters];
    }];
    [self registerHandler:@"Common" Action:@"crashTrace" handler:^(WKJSBridgeMessage *msg) {
        [weakSelf crashTrace:msg.parameters];
    }];
}
​
-(void)applicationEnterForeground{
    [self sendEventName:WKJSBridgeAppEnterForegroundEvent withParams:nil];
}
​
-(void)applicationEnterBackground{
    [self sendEventName:WKJSBridgeAppEnterBackgroundEvent withParams:nil];
}
​
-(void)addLifeCycleListenerCommon{
    // app從後臺進入前臺都會呼叫這個方法
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationEnterForeground) name:UIApplicationWillEnterForegroundNotification object:nil];
    // 新增檢測app進入後臺的觀察者
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationEnterBackground) name: UIApplicationDidEnterBackgroundNotification object:nil];
}
​
-(void)removeLifeCycleListenerCommon{
    [[NSNotificationCenter defaultCenter]removeObserver:self];
}
​
@end複製程式碼

UserAgent管理

WebView容器其實有一個很重要的需求,就是修改WebView UA,作為區別巨有容器能力的WebView識別方式,一般情況下拿到的UA會長這樣

Mozilla/5.0 (Linux; Android 6.0.1; XT1650-05 Build/MCC24.246-37; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/51.0.2704.81 Mobile Safari/537.36複製程式碼

但我們在APP內無論是建立有 Hybrid 能力的 WebView,還是常規 WebView ,他們的UA都是系統預設UA,無法做到從 UA 上區別,客戶端使用的 WebView 容器了,因此其實還有一個很實用的需求就是,針對 Hybrid WebView 擴充 UA

Mozilla/5.0 (Linux; Android 6.0.1; XT1650-05 Build/MCC24.246-37; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/51.0.2704.81 Mobile Safari/537.36 NBBRIDGE_螢幕寬_螢幕高_作業系統版本號_App版本號_裝置型別複製程式碼

如果我擴充這樣的UA有什麼好處呢?前端可以很方便的通過對UA進行正則處理,快速便捷的取到

  • 螢幕尺寸

  • 作業系統尺寸

  • APP版本號

  • 裝置型別

有人會問,這些資料也可以通過設計一個Bridge訊息,直接從客戶端拿,為啥非得走UA呢?因為對於網頁來說,分網頁的 Client Side 和網頁的 Server Side ,Client就說明這段 JS 已經執行在客戶端的瀏覽器裡,但 Server Side 會發生在 WebView 向 URL 發起請求,打到伺服器端,此時可能是 PHP/Node/JAVA/GO等各種語言寫的伺服器,但他們的共同特點是在 Server Side 是沒有bridge,是不可能建立通訊的,此時UA就有意義了。

全域性UA

iOS 8及 8 以下只能進行全域性 UA 修改,可以通過 NSUserDefaults 的方式修改,一次修改每個WebView都有效(無論是常規 WebView 還是被你改造過的 Hybrid WebView)

NSDictionary *dictionary = [NSDictionary dictionaryWithObjectsAndKeys:UAStringXXX, @"UserAgent", nil];
[[NSUserDefaults standardUserDefaults] registerDefaults:dictionary];複製程式碼

獨立UA

iOS 9 有了獨立UA,可以針對每一個 WKWebView 物件例項,設定專屬的UA

if (@available(iOS 9.0, *)) {
    self.webView.customUserAgent = self.fullUserAgent;
}複製程式碼

Cookie管理

前邊介紹UA的時候說過,在 Server Side 的時候是無法建立 Hybrid 通訊的,因此傳遞資料的方式就只有 UA/Cookie/URL Query

  • UA適合傳遞跟裝置相關的,描述裝置固定不變的資訊

  • Cookie適合傳遞任何你想要的資料,但Cookie有失效與域名限制

  • URL Query 適合傳遞任何你想要的資料,不過最好這個資料沒什麼安全敏感,因為GET請求是明文的(POST請求也可以,類比一下不多說了)

提到 WKWebView 就不得不把Cookie管理單獨說一下。因為,WKWebView在Cookie上有太多的坑了,所以非常有必要把 Cookie 進行專門的手動程式碼管理。

傳統的NSHTTPCookieStorage

通過 NSHTTPCookieStorage 設定的 Cookie ,這樣設定的Cookie 無論是 UIWebView 頁面請求還是 NSURLSession 網路請求,都會帶上 Cookie,所以十分方便

WKWebView 的 Cookie 大坑

【騰訊Bugly乾貨分享】WKWebView 那些坑

這篇文章裡介紹了很多WKWebView的坑,其中會詳細說好多Cookie的問題,簡單說就是

  • WKWebView 發起的請求並不會帶上 NSHTTPCookieStorage 裡面的 Cookie

而比如使用者登陸狀態token等,最基礎的設計就是把 token 寫到 cookie 裡,如果 WebView 獲取不到 Cookie 的登陸狀態應該怎麼辦

WKWebView ServerSide Cookie 設定

簡單的說就是把 WKWebView 發起的 NSURLRequest 攔截,MutableCopy 一個,然後手動在RequestHeader裡從NSHTTPCookieStorage讀取Cookie進行新增

-(void)syncRequestCookie:(NSMutableURLRequest *)request
{
    if (!request.URL) {
        return;
    }
    
    NSArray *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:request.URL];
    NSMutableArray *filterCookie = [[NSMutableArray alloc]init];
 
    if (filterCookie.count > 0) {
        NSDictionary *reqheader = [NSHTTPCookie requestHeaderFieldsWithCookies:filterCookie];
        NSString *cookieStr = [reqheader objectForKey:@"Cookie"];
        [request setValue:cookieStr forHTTPHeaderField:@"Cookie"];
    }
    return;
}複製程式碼

TIPS:

當伺服器發生重定向的時候,此時第一次在 RequestHeader 中寫入的 Cookie 會丟失,還需要重新對重定向的 NSURLRequest 進行 RequestHeader 的 Cookie 處理 ,簡單的說就是在 webView:decidePolicyForNavigationAction:decisionHandler: 的時候,判斷此時 Request 是否有你要的 Cookie 沒有就Cancel掉,修改Request 重新發起

WKWebView ClientSide Cookie 設定

上面這麼寫完了,當頁面載入的時候,後端無論是啥語言,都能從請求裡看到 Cookie 了,但是後端渲染返回頁面後,在 Client Side 瀏覽器裡執行的時候,JS 在執行的時候用 document.cookie API 是讀取不到的。所以還得針對 Client Side Cookie 進行處理

-(void)syncClientCookieScripts:(NSMutableURLRequest *)request{
    if (!request.URL) {
        return;
    }
    NSArray *availableCookie = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:request.URL];
    NSMutableArray *filterCookie = [[NSMutableArray alloc]init];
   
    for (NSHTTPCookie * cookie in availableCookie) {
        if (self.syncCookieMode) {
            //httponly需求不得寫入js cookie
            if (!cookie.HTTPOnly) {
                [filterCookie addObject:cookie];
            }
        }
    }
    
    // 拼接 JS 程式碼 對 Client Side 注入Cookie
    NSDictionary *reqheader = [NSHTTPCookie requestHeaderFieldsWithCookies:filterCookie];
    NSString *cookieStr = [reqheader objectForKey:@"Cookie"];
    if (filterCookie.count > 0) {
        for (NSHTTPCookie *cookie in filterCookie) {
            NSTimeInterval expiretime = [cookie.expiresDate timeIntervalSince1970];
            NSString *js = [NSString stringWithFormat:@"document.cookie ='%@=%@;expires=%f';",cookie.name,cookie.value,expiretime];
            WKUserScript *jsscript = [[WKUserScript alloc]initWithSource:js injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO];
            [self.userContentController addUserScript:jsscript];
        }
    }
    return;
}複製程式碼

Client Side Cookie 注入依靠的是建立一個 JS 指令碼,讓 WebView 去執行,介紹通訊的時候我們用的是 evaluateScript 去進行主動注入,好處是隨時隨地呼叫都可以執行,但眼下這個場景用 WKUserScript 更好,並且推薦使用 WKUserScriptInjectionTimeAtDocumentStart 這個時機。

本地指令碼管理

UIWebView程式碼注入時機與姿勢

我的另一篇文章就提到了一個問題,注入時機。

在頁面載入前主動注入,由於頁面載入的時候,JS 會啟用全新的 JSContext 因此之前注入全都無效,在頁面載入完畢的時候注入,JS 會注入的比較晚,導致在 JS程式碼開始執行 -> 頁面完全載入完畢 期間,Client Side 是沒有 JS 注入的效果的

  • 我的Link中提到的CSS注入

  • 我的上文提到的Cookie注入

因此 WKWebView 的 WKUserScript 就提供了對於這個問題的解決辦法,他的使用方式不是主動注入,而是提前準備好要注入的 JS 程式碼,在 WKWebView 載入頁面的系統處理期間,由系統幫你選擇在固定的時機,注入你準備好的 JS 程式碼,目前只有2個時機,DocumentStart/DocumentEnd

  • WKUserScriptInjectionTimeAtDocumentStart

  • WKUserScriptInjectionTimeAtDocumentEnd

因此這種提前預置靜態 JS 注入的需求,也是一個 Hybrid WebView 容器應該考慮到的

WebView容器 - 常規擴充套件

一個 Hybrid WebView 容器,如果設計好了基礎通訊流程,設計好了模組擴充套件模式,其實還可以做一些比較通用的功能元件,這思路其實也和 RN 很類似的,好的架構設計好了,就應該可以橫向自由靈活的自己擴充套件任意業務元件,但 RN 不也內建了很多 FB 提前幫你寫好了的通用元件麼?

  • Common 元件:我們的示例程式碼就是 Common 元件的一些基礎操作

    • 複製剪下板

    • 獲取裝置資訊

    • 打客戶端Log,上報日誌

    • 打客戶端Crash追蹤Log,隨Crash上報

    • ……開啟你的想象力

  • CommonUI 元件:也有一些 Common 並且與 UI 相關的基礎操作

    • showTips 展示客戶端文字浮層

    • showDialog 展示客戶端確認彈框,回撥使用者選擇按鈕

    • pullRefresh 採用客戶端的下拉重新整理,但配合 H5 進行資料載入

    • Router 跳轉任意 App 內路由頁面

    • NaviBarControl 可以讓前端來定製客戶端頂部 NaviBar

      • share Button 增加原生分享按鈕,點選後出發原生分享

      • Other Button 增加任意原生按鈕,點選後跳轉任意 App 內路由頁面

    • ……開啟你的想象力

  • NetWork 元件:判斷瀏覽器除錯環境下走 AJAX 網路請求,判斷是客戶端就通過客戶端發起原生網路請求,請求結果回撥 JS (為什麼做?一般原生會封裝網路請求,有更精細粒度的cache控制 ,和通用無痕日誌埋點)

    • Get 不解釋了

    • Post 不解釋了

    • ……開啟你的想象力

  • Storage 元件:前端的儲存只能使用 LocalStorage 和 Cookie 這二者都有很大的缺陷

    • Key - Value Plist Storage : 可以讓前端把 Key Value 發給客戶端,讓客戶端通過本地Plist 儲存/讀取/刪除

    • File Storage:可以讓前端把大段需要儲存的字串,發給客戶端,讓客戶端在App沙盒內開闢檔案路徑,saveToFile儲存成檔案,並且提供目錄操作能力,建立目錄/刪除目錄/建立檔案/刪除檔案/讀取檔案

    • ……開啟你的想象力

  • Push元件:可以讓前端有能力寫本地Push鬧鈴到App 或者上報遠端Push Token

    • 本地 Push 設定

    • 遠端 Push 獲取 Token

其實如果基礎功能擴充的足夠強大,Hybrid WebView 可以有很強的能力,可以充分開啟你的想象力,Hybrid 的宗旨就是,如果 WebView 原本做不到,或者做起來有很大限制或者效能不佳,那麼就讓原生配合,一起做到

WebView容器 - 腦洞預告

其實這部分內容本來應該是第三篇的內容,但也可以提前簡單說說吊吊胃口╮(╯_╰)╭

  • 深度除錯能力

在客戶端下除錯 WebView 只能 safari 除錯?能不能更方便一些? 能不能在QA黑盒測試不重新打包執行連電腦的情況下進行 JS 除錯?

  • 動態呼叫能力

所有的訊息都必須提前在 OC 客戶端寫好處理模組,前端才能呼叫,能不能不發版就呼叫新原生邏輯?

能不能直接在JS裡面寫OC?

  • 原生渲染能力

RN被很多人拿來說 RN 做出來的是原生 App ,介面層級都是原生的,WebView就做不到麼?

小程式底層就是 WebView ,但小程式有些元件官方文件會寫,此元件為原生,無法控制與其他 Dom 的 Z軸層級,他是怎麼做的?(視訊/地圖/Canvas元件)

我們自己寫的 WebView 容器能不能做到?

  • 非同步執行緒能力

都說 JS 是單執行緒,而且慢,能不能給 JS 增加多執行緒能力?(沒錯 WebKit 在2017年有個WebKit新標準提案就是這個,但我們暫時先不需要瀏覽器核心支援,客戶端Hybrid能不能先簡單支援一下?)

  • 離線秒開能力

小程式會打包,然後下發給微信客戶端,這樣小程式的介面框架載入完全無網路請求,極大程度的加快前端散碎靜態資源的載入速度,秒開能力(注意這和騰訊的 VasSonic 的秒開並不是一類方案)


相關文章