看完就懂的 Hybrid 框架設計方案

本篇文章探討 “基於 Webview,如何在 App 內實現帶離線包能力的 H5”。在當下這個主題似乎有些過時,但 H5 技術以其良好的跨端一致性,長期來看會佔據一席之地,希望整理一個較完整的方案,從基本的實現原理到業務具體應用,讓不瞭解的同學對 “離線 H5" 有一個較完整的視角。

01

前言

2009 年,PhoneGap 以 “橋接 Web 與 iPhone SDK 之間縫隙 “的理念橫空出世,讓人驚歎於 JS 居然可以調用客戶端原生能力。

隨着移動互聯網的快速發展,跨端需求迫在眉睫。前期 Hybrid 開發理念的萌芽,基於 Webview 的 Hybrid 開發模式開始盛行,WebViewJavascriptBridge 等 JSBridge 框架也開始流行,同時各大 App 也開始自研 Hybrid 框架。

Webveiw 因爲性能問題一直被社區詬病,2015 年 FB 推出 RN 類原生框架,在社區激起波瀾。原生渲染、離線、可以和客戶端控件混合等特性非常驚豔。相比較 Webview 開發效率有所降低,但在一些較高性能要求的場景有了用武之地。

當所有人都認爲 Webview 是低性能代表時,2017 年微信推出小程序,底層基於 Webview,社區再次激起千層浪,Webview 不慢了,體驗非常好啊!

似乎 Hybrid 框架走到了盡頭,Webview 有很好的跨端一致性,經過優化也有較好的性能,類原生框架有接近原生的性能,能夠滿足大部分業務場景。

2017 年 Google 推出了 Flutter,Flutter 的推出並沒有引起大的反響,對於 Google 的技術產品,國內社區也持謹慎態度。但隨着 Flutter 的發展,類 Flutter 框架同時面向前端和終端跨端,其性能與終端幾乎無差異,填補了類原生框架的不足。Flutter 也成爲了當下最火熱的 Hybrid 技術。

Hybrid 框架的發展史也是移動互聯網的盛衰史,移動互聯網淪爲了 “古典互聯網”,Hybrid 框架這次似乎真的發展到了盡頭。

但無論如何,“生活” 總要繼續。本篇文章探討 “基於 Webview,如何在 App 內實現帶離線包能力的 H5”。在當下這個主題似乎有些過時,但 H5 技術以其良好的跨端一致性,長期來看會佔據一席之地,希望整理一個較完整的方案,從基本的實現原理到業務具體應用,讓不瞭解的同學對 “離線 H5" 有一個較完整的視角。以下 Hybrid 均指基於 Webview 的混合式方案。

一個 Hybrid 框架有一些重要的組成部分,我們先以一張圖來描述其整體架構,然後再詳盡介紹核心模塊應該如何去設計。

從架構圖來看,Hybrid 主要由以下模塊組成:

其中,JSBridge 作爲前端和客戶端通信的基礎,是整個框架運作的核心,JSBridge 的設計至關重要,所以我們先分析如何選擇通信方案。

02

通信方案

所謂通信,即 JS 可以調用 Native 的能力,Native 也可以直接執行一段 JS 代碼,達到 Native 通知 JS 的目的。那麼通信方式有哪些,應該如何選擇?

備註:目前大部分知名 App 均選擇 WKWebview 作爲內核,所以以下方案的選擇也不再考慮 UIWebview,其原因可參考網上的一些文章,這裏不做說明。

   2.1 JS -> Native

在 App 內,JS 做不到的能力就需要藉助 Native 去實現,比如分享,獲取系統信息,關閉 Webveiw 等。JS 調用 Native 主要有以下幾種方案:

方式一:假跳轉 - 同時發送多個請求丟消息、URL 有長度限制,當下最不應該選擇的方案。

所謂 “假跳轉”,本質是約定一種協議,客戶端無差別攔截所有請求,正常 URL 放行,符合約定協議的請求攔截,並做出對應的操作。並且攔截下來的 URL 不會導致 Webview 跳轉錯誤地址,因此是無感知的。

比如:

// 正常網頁跳轉地址
const url = 'https://qq.com/xxx?param=xxx'
// 約定跳轉 url
const fakeUrl = 'scheme://getUserInfo/action?param=xx&callbackid=xx'

一個 URL 由協議 / 域名 / 路徑 / 參數等組成,我們可以參考這個組成規則,約定一個假的 URL:

當然,不限於這個規則,任何一種合理的約定都可以讓 JS 和 Native 正常通信。

網頁中有多種方式可以發起一次請求:

// 1. A 標籤發起一次
<a href="scheme://getUserInfo/action?param=xx&callbackid=xx">用戶信息</a>
// 2. 在JS中創建一個iframe,然後動態插入到 DOM 中
$('body').append('<iframe src="scheme://getUserInfo/action?param=xx&callbackid=xx"></iframe>');
// 3. location.href 跳轉
location.href = 'scheme://getUserInfo/action?param=xx&callbackid=xx'

JS 發起請求後,客戶端如何攔截呢?

安卓: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 的 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;
}

前面也提到了,這是當下最不該採用的方式,主要是它有如下兩個致命問題:

1、同時發起多次跳轉,Webview 會直接丟棄掉第二次跳轉,所以第二條消息會直接被丟棄。

location.href = 'scheme://getUserInfo/action?param=111&callbackid=xx'
location.href = 'scheme://getUserInfo/action?param=222&callbackid=xx'

2、URL 超長:如果 URL 超出系統最長限制了,消息會被截斷,這種情況是不可接受的。

基於這兩個原因,當下不應該再選擇這種通信方式。

方式二:彈窗攔截(alert/confirm/prompt)- 無明顯短板,需要序列化參數,支持同步返回數據。

客戶端可以攔截 JS 這三個方法的調用,JS 側需要選擇一個業務不常用的一個方法,避免和業務發生衝突。

JS 側發起如下的調用:

const data = {
    module: 'base',
    action:'getUserInfo',
    params:'xxxx',
    callbackId:'xxxx',
};
const jsonData = JSON.stringify([data]);
// 發起調用,可以同步獲取調用結果
const ret = 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(@"");
    }
}

這種通信方式沒有明顯的短板,而且還支持同步調用獲取結果,唯一的缺點是不支持直接傳遞對象,需要序列化數據,在高頻 / 大數據量通信的場景可能有一些性能上的損耗。

方式三:JSContext 注入 - 能力強大,遺憾的是隻有 UIWebview 支持。不推薦使用

//準備要傳給native的數據,包括指令,數據,回調等
const data = {
    module: 'base',
    action:'getUserInfo',
    params:'xxxx',
    callbackId:'xxxx',
};
//直接使用這個客戶端注入的函數
nativeObject.getUserInfo(data);

由於 WKWebview 不支持,這裏不展開討論了。

方式四:安卓 addJavascriptInterface - 目前推薦的方案,具備 JSContext 注入的所有優點(限安卓 4.2 以上版本)

安卓可以在 loadUrl 之前 WebView 創建之後,即可配置相關注入功能,注入後 JS 可以直接調用掛載在 nativeObject 上的所有方法:

// 通過addJavascriptInterface()將Java對象映射到JS對象
//參數1:Javascript對象名
//參數2:Java對象名
mWebView.addJavascriptInterface(new AndroidtoJs(), "nativeObject");

JS 調用:安卓注入的對象掛載在全局,直接調用接口。

nativeObject.getUserInfo("js調用了android中的getUserInfo方法");

這種通信方式的優勢在於,沒有參數的限制,可直接傳對象,無需序列化。同時也支持同步返回結果。

方式五:WKWebView MessageHandler 注入 - 官方欽點的通信 API,無需 JSON 化傳數據,不丟消息,但不支持同步返回。

不同於安卓注入到 JS 全局上下文,iOS 只能給注入對象起一個名字(這裏已 nativeObject 爲例),同時調用方法只能是 postMessage,所以在 JS 端只能是如下調用:

//準備要傳給native的數據,包括指令,數據,回調等
const data = {
    module: 'base',
    action:'getUserInfo',
    params:'xxxx',
    callbackId:'xxxx',
};
//傳遞給客戶端,不支持同步獲取結果
window.webkit.messageHandlers.nativeObject.postMessage(data)

客戶端接收處理:

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

從調用方式就可以看出,在 iOS 端不能同步拿到調用接口,天然是異步的。

最佳方式

通過以上分析,JS -> Native 當下選擇如下的通信方式是最合適的:

   2.2 Native -> JS

講完了 JS -> Native,Native 如何調用 JS 呢?其實就是客戶端直接執行 JS 代碼,將 JS 代碼(字符串)交給 JS 引擎執行。已有方案如下,根據版本選擇即可:

  1. iOS: evaluatingJavaScript。

  2. 安卓: 其實 2 個區別不大,使用方法差異也不大:

具體是如何調用的呢?假設 JS 上下文存在如下的全局函數。

function calljs(data){
    console.log(JSON.parse(data)) 
    //1 識別客戶端傳來的數據
    //2 對數據進行分析,從而調用或執行其他邏輯  
}

客戶端想要調用這個函數,需要字符串拼接出 JS 代碼,並帶上要傳遞的數據:

//不展開了,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 代碼都可以用這種方式讓 Webview 執行:

calljs('{data:xxx,data2:xxx}');

安卓 4.4 以下沒有 evaluatingJavaScript,只有 loadUrl,但其執行方式和 evaluatingJavaScript 沒有本質的差異,其調用方式如下:

mWebView.loadUrl("javascript:calljs(\'{data:xxx,data2:xxx}\')");

通過直接執行代碼的方式,就達到了 Native 數據向 JS 傳遞的目的。

   2.3 JSBridge SDK 設計

確定了底層通信 API,我們還需要設計一套 SDK 來處理兩端的通信,SDK 要滿足以下要求:

  1. **平臺無關:**兩端的通信機制是有差異的,但對上層業務來說不需要關心這些差異;SDK 是純 JS 邏輯的封裝,和上層使用的業務框架無關(Vue / React 等均支持)

  2. **易用性:**接入簡單,通過 npm 安裝後即可使用;有一定語義化的封裝,比如查詢設備信息,可以直接調用 sdk.getSystemInfo,而不用先去建立底層的通信;API 同時支持 Promsie / Callback 兩種調用風格等

  3. **可擴展:**SDK 除了要有良好的模塊劃分,還需要可擴展,爲後續功能迭代打下基礎

我們通過三個具體的使用場景來思考如何設計 JSBridge SDK:

場景一:JS 查詢設備信息

這種場景本質是 JS 調用 Native 的一個函數,Native 收到請求後,把數據回傳給 JS。整個過程分爲 JS -> Native、Native -> JS 兩個階段,其調用流程如下:

Native -> JS 時,涉及到 Webview 調用 JS 的全局函數,爲了避免暴露過多全局變量,設計時我們只暴露全局唯一對象,然後再將相關的方法掛載在這個對象上。核心代碼如下:

const invokeMap = new Map();
let invokeId = 0;
class BridgeNameSpace {
  /**
   * 調用Native功能
   * @param eventName - 事件名稱
   * @param params - 通訊數據
   * @param callback - 回調函數
   */
  invoke = (eventName, params, callback) => {
    invokeId += 1;
    invokeMap.set(invokeId, callback);
    if (isAndroid) {
      window.BridgeNameSpace.invokeHandler(eventName, params, invokeId);
    } else {
      window.webkit.messageHandlers.invokeHandler.postMessage({
        event: eventName,
        params,
        callbackId: invokeId,
      });
    }
  };
  /**
   * 調用Native功能
   * @param eventName - 事件名稱
   * @param params - 通訊數據
   * @param callback - 回調函數
   */
  invokeSync(eventName, params, callback) {
    invokeId += 1;
    invokeMap.set(invokeId, callback);
    if (isAndroid) {
      window.BridgeNameSpace.invokeHandler(eventName, params, invokeId);
    } else {      // 將消息體直接JSON字符串化,調用 Prompt(),並且可以直接拿到返回值        
      const result = prompt(JSON.stringify(params));
      return result;
    }
  }
  /**
   * Native將invoke結果返回給js的回調句柄
   * @param id - callbackId
   * @param params - 通訊數據
   */
  invokeCallbackHandler = (id, params) => {
    const fn = invokeMap.get(id);
    if (typeof fn === 'function') {
      fn(params);
    }
    invokeMap.delete(id);
  };
  getSystemInfo(callback) {
    const promsie = new Promise((resolve, reject) => {
      this.invoke('getSystemInfo', {}, (res) => {
        if (res.status === 'success') {
          resolve(res);
        } else {
          reject(res);
        }
      });
    });
    if (callback) {
      return promsie.then(callback).catch(callback);
    }
    return promsie;
  }
}
window.BridgeNameSpace = new BridgeNameSpace();

整個流程分爲以下幾個調用步驟:

  1. JS 調用 invoke,生成一個唯一的 callbackId,將 callbackId 和 callback 註冊到全局變量 invokeMap 中。

  2. iOS 端,JS 將參數通過 MessageHandler 傳遞給 Native;安卓通過 Interface 注入的方式,JS 可以直接調用 Native 的方法。

  3. Native 執行業務邏輯,並調用回調函數 BridgeNameSpace.invokeCallbackHandler。

  4. 通過調用時生成的唯一的 callbackId, 從 invokeMap 中找到最初發起調用的 JS callback,執行並回傳數據。

業務方調用支持 Promise 和 callback 兩種調用風格:

BridgeNameSpace.getSystemInfo()
    .then(res => {
        console.log(res);
    })
    .catch(err => {
        console.log(err);
    });
BridgeNameSpace.getSystemInfo((res) => {
    console.log(res);
});

場景二:當 Webview 可見時,JS 捕獲這個時機來做相應的業務邏輯

這裏只涉及 Native 單向通知到 JS,是標準的發佈訂閱模式。Native 和 JS 側約定好事件名,JS 側提前註冊事件,當事件發生時,Native 主動調用 JS。核心實現如下:

const publishMap = {};
class BridgeNameSpace {
  /**
   * 訂閱 Native 事件
   * @param eventName - 事件名
   * @param callback - 回調函數
   */
   subscribe = (eventName, callback) => {
    if (!publishMap[eventName]) {
      publishMap[eventName] = [];
    }
    const oldEvents = publishMap[eventName];
    publishMap[eventName] = oldEvents.concat(callback);
  };
  /**
   * Native將publish結果返回給js的回調句柄
   * @param eventName - 事件名
   * @param params - 調用參數
   */
  subscribeCallbackHandler = (eventName, params) => {
    const cbs = publishMap[eventName] || [];
    if (cbs.length) {
      cbs.forEach((cb) => cb(params));
    }
  };
  /**
   * ⻚⾯可⻅通知
   */
  onPageVisible(callback) {
    this.subscribe(
      'onPageVisible',
      callback,
    );
  }
}

業務方訂閱示例:

BridgeNameSpace.onPageInvisible(() => {});

不同於 JS 主動調用 Native 函數,訂閱不能直接拿到結果,所有沒有 Promise 調用風格,只能是 callback 形式。實際在設計 API 時,可以從命名上做一些區分,比如訂閱類型的函數都以 onXX 開頭。同時,映射表也由單獨 publishMap 來維護。

場景三:打開了兩個 Webview 頁面 A B,B 頁面向 A 頁面傳遞一些數據

對於 JS 來說,只能獲取到當前 Webview 上下文,單純通過 JS 是不能感知到其他 Webview 存在的。所以兩個 Webveiw 之間要通信,需要藉助 Native 做中轉,其通信模型如下:
(一個 App 內在使用多套框架時,不同框架之間通信也可以基於這個模型)

Webview 之間通信分爲三個步驟:

  1. Webview A 訂閱事件,不同於場景二的訂閱模式,訂閱結果需要維護在 Native,所以這裏需要有一次 JS -> Native 調用。

  2. Webview B 發起通知,先通知到 Native,這裏也有一次 JS -> Native 調用。

  3. Native 收到通知後,發起一次廣播,之前所有註冊過的 Webview 都會收到通知,這裏有一次 Native -> JS 調用。

那麼如何來設計這個通信模型呢?

  1. JS -> Native 訂閱其實就是一次基本的 JS -> Native 函數調用,這裏需要約定一個特定的事件名。

  2. JS -> Native 通知同理,也需要約定一個特定的事件名。

  3. Native -> JS 廣播,是類似於 invokeCallbackHandler、subscribeCallbackHandler 的回調調用,我們也用一個 notifyMap 來維護這個映射關係。

const notifyMap = new Map();
class BridgeNameSpace {
  /**
   * 混合式框架向Native發送通知 notify
   * @param eventName - 事件名,命名空間爲當前包
   * @param params - 參數對象,由通知業務自己定義
   * @param callback - 回調函數,回調是否通知成功
   */
  notify = (eventName, params, callback) => {
    this.invoke('notify', { event: eventName, params }, callback);
  };
  /**
   * webview 事件處理函數,可與notify配合使用
   * 事件訂閱方法,可對本應用及跨應用事件進行訂閱
   * @param {String} eventName
   * @param {Function} callback
   */
  subscribeNotify = (eventName, callback) => {
    this.invoke('subscribeNotification', { event: eventName }, (res) => {
      if (res.status === 'success') {
        notifyMap.set(eventName, callback);
      } else {
        callback(res);
      }
    });
  };
  /**
   * Native將notify結果返回給js的回調句柄
   * @param eventName - 事件名
   * @param params - 調用參數
   */
  notifyCallbackHandler = (eventName, params) => {
    const fn = notifyMap.get(eventName);
    if ('function' === typeof fn) {
      fn(params);
    } else {
      notifyMap.delete(eventName);
    }
  };
}

業務代碼調用示例:

// Webview A 訂閱
BridgeNameSpace.subscribeNotify(
  'QSOverlayPlayerBackClick', 
  (res) => {
    console.log(res);
  }
);
// Webview B 通知
BridgeNameSpace.notify(
  'QSOverlayPlayerBackClick',
  { test: 'a' },
  (res) => {
    if (res.status === 'success') {      console.log('通知成功');
     }
  }
);

第三種場景算是較複雜的場景,實際業務中也較常用,需要兩個或多個 Webview 來配合使用。

總結

實際在設計時,還有一些細節上的考量,可根據實際情況做一些規範化要求:

  1. 不同環境的兼容適配(比如瀏覽器、微信、不同的 App 訪問等)。

  2. 按模塊職責進行劃分,比如基礎、路由、網絡、UI 等。

  3. 規範函數命名:Native 回調均命名爲 xxCallbackHandler、不支持 promise 風格調用的函數均已 onXX 開頭。

核心設計思路主要是基於底層通信模型,上層做語義化的封裝,按模塊職責進行劃分,進而達到易用、易管理等目的。

03

離線包方案

對於 H5 來說,大量時間消耗在網絡請求,資源下載階段,如果 Native 在加載 H5 時,直接從本地讀取資源,再配合緩存數據,就可以大大提升 H5 的首屏速度。

對於前端來說,我們希望直接把 HTML/JS/CSS/Image 等資源直接部署到 CDN,任何地方直接通過 https://domain.com/path/index.html 訪問,在 App 內訪問具備離線能力,普通瀏覽器則是在線訪問。

該如何實現呢?這裏的關鍵在於如何關聯訪問地址和本地的離線包資源。前端項目構建後,除了將資源部署到 CDN,還需要將構建產物打包成 zip 包,上傳到離線包管理平臺,通過唯一 pid 來標識,在 App 內訪問時帶上 pid=xxx,Webview 優先從本地離線資源目錄查找相關資源,找到了直接返回,找不到則在線訪問。整體流程如下:

這裏面還有非常多的細節:zip 內文件是什麼樣的格式、管理平臺如何管理離線包、App 如何更新 / 加載離線包。下面我們介紹一種可能的方案。

   3.1 離線包構建

這裏以前端 SPA 項目爲例,Vue/React 應用構建產物一般是如下格式:

build
├── index.html
└── static
    ├── css
    │   ├── main.f855e6bc.css
    ├── js
    │   ├── 787.d4aba7ab.chunk.js
    │   ├── main.8381e2a9.js
    └── img
        └── arrow.80454996.svg

通常部署時,我們會把 js/css/img 等資源部署到 CDN,通過設置 publicPath,index.html 中引用的地址會是 cdn 地址。

不同框架構建產物格式會有些許的差別,這種差異對 App 來說是不可接受的,我們需要約定一種統一的離線包格式,只要符合這個約定的 zip 包,都可以是離線包。這個約定我們稱之爲離線包協議

   3.1.1 離線包協議

我們約定,一個離線包包含如下的關鍵文件:

  1. page-frame.html,頁面的入口文件。

  2. config.json 頁面配置文件,包含 Webview 容器的一些配置項,下面會單獨介紹。

  3. 其他 js/css/img 等資源路徑不作要求,因爲構建時會自動處理好文件引用路徑(即使有設置 publicPath,路徑中也只是多了 publicPath 一層路徑)。

zip
└── page-frame.html
├── config.json
├── css
│   ├── main.f855e6bc.css
├── js
│   ├── 787.d4aba7ab.chunk.js
│   ├── main.8381e2a9.js
└── img
    └── arrow.80454996.svg

像 Vue-cli、Webpack 等構建工具一般來說都提供了構建 hook,可以在構建完成時,將構建產物修改爲符合離線包協議的產物,再進行打包。

   3.1.2 包配置數據

上面提到,每個離線包有一個 config.json 文件,裏面有一些 Webview 容器相關的配置項,那具體有什麼配置呢?

Webview 本身有一些基本的屬性,比如主題色,是否透明,是否使用 Native 導航頭(爲了統一 App 風格,大部分頁面使用 Native 導航頭;有時設計爲了追求全屏效果,又需要隱藏 Native 導航頭),有時同一個包的不同頁面有不同的風格,需要單獨配置。所以整個配置分爲 global 和 pages 兩部分,pages 的配置優先級高於 global。

{
  "global": {
    "showNavigationBar": false,
    "themes": {
      "black": {
        "backgroundColor": "#0a0c0e"
      },
      "white": {
        "backgroundColor": "#FFFFFF"
      }
    }
  },
  "pages": {
    "index": {
      "showNavigationBar": false
    },
    "detail": {
      "showNavigationBar": true,
      "themes": {}    }
  }
}

實際配置項可根據業務場景進行設計。

   3.1.3 單工程單包 / 多包

現代前端 SPA 應用通常都很複雜,將所有構建產物打包成一個離線包不具備通用性,有時需要將部分產物打進離線包,有時需要將一個項目工程構建出多個離線包。這裏提供一種打包思路:

項目增加一個構建配置文件,配置文件描述了每個頁面的離線包配置信息,還有很重要的一點,需要控制離線包的大小,每個頁面對應的離線包不能包含其他頁面的代碼,需要有 “tree shake” 掉非當前頁面代碼的能力。

實際構建時需要根據一定的規則,比如根據頁面路由來決定當前頁面包含哪些代碼。這種方案會侵入到打包流程,可能需要通過 loader 和規則來做一些刪除代碼的工作,相對來說會複雜一些。但本身來說一個項目工程構建出多個離線包就是一個相對複雜的事,需要根據實際情況來設計打包流程。

[{
    name: 'https://domain-one.com/path/page-frame.html',
    test: function(options) {
      const {
        path
      } = options;
      return /NewsTZBD/i.test(path);
    },
    config: {
      global: {
        showNavigationBar: false,
        themes: {
          panda: {
            backgroundColor: "#f5f6fa",
          },
          black: {
            backgroundColor: "#12161f",
          },
          blue: {
            backgroundColor: "#f5f6fa",
          }
        },
      },
      pages: {
        index: {
          showNavigationBar: false,
        },
      },
    },
  }, {
    name: 'https://domain-two.com/path/page-frame.html',
    config: {},
}]

   3.2 離線包管理

講完了離線包的構建,離線包後續如何管理 / 更新 / 使用是關鍵的一環,下面分三個部分來介紹。

   3.2.1 離線包版本

離線包每次發佈後,都會生成一條記錄,有一些基本的屬性來標識本條記錄:

  1. pid:和頁面訪問地址一一對應。

  2. verify_code:pid 和訪問地址的加密校驗碼,訪問帶 pid 的 url 時,需要做一些安全校驗。

  3. pkg_md5:離線包 md5 值,用於校驗離線包本身是否被篡改。

  4. gray_rule:灰度規則。

  5. pkg_url:離線包 cdn 地址。

  6. sdk: 依賴的 App 最低版本,和 app 版本有一一對應的關係。

  7. status:發佈狀態(未發佈、灰度發佈、全量)。

  8. comment:本次發佈描述。

  9. author: 發佈人。

   3.2.2 離線包更新

對於離線包的使用一般有這樣的一些訴求:

  1. **最新離線包:**離線包更新儘可能快

  2. **資源離線化:**儘可能使用本地資源

  3. **高命中率:**重要的模塊,通過預下載,可以大大提高離線包命中率

要滿足以上訴求,核心是控制離線包的更新時機。

離線包的下載分爲兩部分:離線包配置表管理和離線包下載。整體流程如下:

App 啓動時,會去拉取一個離線包配置表,配置表記錄了當前 App 版本對應的所有最新離線包,主要包含以下信息:

  1. 離線包優先級。

  2. 離線包 CDN 地址。

  3. 離線包校驗參數。

爲了保證及時拉取到最新的離線包版本,配置表有一些更新時機:

  1. App 啓動時。

  2. N 分鐘內 App 激活更新。

離線包的預下載主要依賴配置表,在合適的時機,如 App 首頁渲染完成後,提前下載高優先級離線包。

除了預下載離線包,非高優離線包首次訪問時,優先在線訪問,同時啓動異步加載。當然根據業務需求,提供下載指定離線包的 API,業務側可以在合適的時機提前下載。

   3.2.3 訪問頁面

在 App 如何打開一個 H5 頁面呢,打開頁面會經歷哪些步驟,和普通瀏覽器打開 H5 有哪些差別?

不同於 SPA 應用,App 內頁面堆棧需要符合 App 規範,我們仍然可以按 SPA hash 路由的方式來渲染頁面,但每個路由對應一個新開的 Webview,在頁面回退時其實是關閉了當前 Webview。

新開 Webview 需要調用 Native 能力,標準的 Native 函數調用,可以如下來調用:

class BridgeNameSpace {
  /**
   * @params{Object} params 傳遞數據 { url, p_showNav}
   * params.url
   */
  navigateTo(params) {
    this.invoke('navigateTo', params, () => {
    // 
    })
  }
}
const url = 'https://domain.com/path/index.html?pid=xxx#/index';
BridgeNameSpace.navigateTo({
  p_url: url,
  p_showNav: true,
});

整個 H5 打開流程還需要一些額外的安全校驗:

  1. 域名校驗,不支持非白名單內的域名。

  2. 離線包 md5 校驗,防止包被篡改。

  3. verify_code 校驗當前訪問地址和 pid 是否匹配。

安全校驗失敗時可以採取一定的安全策略:比如合法域名的 H5 直接在線訪問,非白名單域名增加安全提醒等。

前面提到,通常 H5 在打包時會設置 publicPath,這些資源是引用的 CDN 地址,我們同樣希望這些資源能使用本地資源。

在 iOS 中可以使用 WKURLSchemeHandler 進行攔截,Native 攔截到地址後,需要解析出文件名(前端 js 、css 等資源通常帶了 md5 值,可以唯一標識),然後根據文件名去本地查找,如果找到了可直接返回。需要注意的是,這個 API 需要 iOS 11+ 以上才支持。

   3.3 版本控制

每個離線包都需要知道最小支持 App 版本,JS 調用的 JSBridge 方法,需要對應版本的 App 去實現,所以版本控制非常重要。版本控制分爲兩部分:

  1. 離線包構建時需要明確支持的最高 App 版本,版本信息可以放到項目工程配置文件裏。

  2. App 在拉取配置文件 / 拉取單個離線包時,後臺根據當前 App 版本及灰度規則返回正確的離線包。

在設計時,離線包版本通過一個虛擬的版本號(這裏表示爲 SDK@ver)來對應 App 版本,這樣好處是 SDK 可以映射不同端 App 版本(iOS、Android、鴻蒙 App 版本號不一致),App 版本和 SDK 版本號解耦。

以如下場景爲例:

一個 H5 頁面,分別對應三個 App 版本均部署了離線包,其中對應 App@10.1.0 的離線包處於灰度狀態。

當我們用 App@10.1.0 去拉取離線包時,應該返回什麼版本呢?

  1. 首先 sdk2.3.0 對應的離線包不能返回,因爲它們要求最小支持 App 版本是 10.2.0,一旦返回了可能導致有些 API 調用失敗,App@10.1.0 上沒有對應的實現。

  2. 如果命中了灰度,則返回 sdk@2.2.9 下的離線包版本 1。

  3. 如果未命中灰度,則返回 sdk@2.2.8 下的離線包版本 1,JSBridge SDK 通常是向下兼容的,低版本離線包調用的 JSBridge API 高版本的 App 都支持。

不同版本的 App 去拉取離線包時,從最高支持的 App 版本依次往下匹配離線包,直到找到最新的離線包版本。

04

容器基礎能力

爲了更高效地進行業務開發,Webview 容器還需要提供一些基礎能力:

  1. Native UI 組件:Toast、Loading。

  2. 內嵌 Native 能力:Native Header、分享面板、下拉刷新。

   4.1 Native UI 組件

通常來說,前端有自己的 UI 組件庫,希望做到 “一碼多端”。但 App 和 H5 有較大的體驗差異,部分基礎組件,前端和 Native 不容易對齊,如 Toast、Loading,可以通過 JSBridge 直接調用 Native 組件:

class BridgeNameSpace {
  /**
   * 顯示toast
   * @param {String} position 彈出位置,center(中間),top(頂部)
   * @param {String} text 要提示的⽂字
   */
  showToast(position, text, callback) {
    this.invoke('showToast', { position, text }, callback);
  },
  /**
   * loading view控制 loadingBar
   * @param {String} action: show/hide, 控制顯示/隱藏
   */
  loadingBar(action, callback) {
    this.invoke('loadingBar', { action }, callback);
  }
}

   4.2 內嵌 Native 能力

一個典型的頁面通常由這些部分組成:頁頭 + 刷新區域 + 主內容區 + 分享面板等。我們以它來剖析如何規劃前端和 Native 的職責。

   4.2.1 頁頭

主流容器的頁頭均使用 Native Header 來實現,比如微信、美團、百度等,這麼做可能有以下考慮:

  1. 統一 App 風格,做到一致的交互體驗。

  2. JS 異常導致白屏時,防止 App 陷入假死狀態,Native Header 可以控制頁面後退。

不使用 Native Header 的好處,其實就是提供最大限度的靈活性,整個頁面都可以由 JS 來實現。同時部分業務場景,在設計上有特殊要求,需要做到 “全屏 “的效果。

對於平臺化的 App ,基礎組件一旦依賴 Native,響應速度會變得非常慢,需要根據實際情況來做權衡:統一使用 Native Header,或者主要場景使用 Native Header,同時放開配置化能力,業務可以決定是否使用。

使用 Native Header 也可以做到一定程度的配置化,能夠滿足大部分的業務場景。將整個 Header 分爲三個區域:

  1. 左邊區域比較簡單,只有一個返回按鈕,關閉當前 Webview。

  2. 標題部分,可以設置標題和子標題,注意需要控制和 document.title 的關係。

  3. 功能區:可以設置分享、字體控件等入口。

實際實現時,可以根據業務需要,設計靈活的配置參數:

class BridgeNameSpace {
  setHeaderConfig(config, callback) {
    this.invoke('setHeaderConfig', { 
      title: config.title,
      subTitle: config.subTitle,
      right: [{
        actionName: 'font',
      }, {
        actionName: 'share',
        // 可傳入圖標,沒有使用系統默認的
        icon: '',
      }]
     }, callback);
  },
  /**
  * 監聽按鈕點擊事件
  */
  onHeaderButtonClick(callback) {
    this.on('onHeaderButtonClick', callback);
  }
}

   4.2.2 刷新區域

上下拉刷新是一個常見的功能,一般包含:刷新動畫、提示文案兩部分。

這裏最核心的問題是,在 App 內我們希望有統一的交互體驗,儘管前端有自己的刷新控件,但主刷新控件包含一定複雜度的動畫,前端很難和 Native 動畫做到統一,所以最好直接使用 Native 控件。通過約定 API 來達到使用 Native 控件的目的:

class BridgeNameSpace {
/**
   * 啓用下拉刷新(默認關閉),前端仍然可以決定是否使用 Native 刷新控件
   * @param {Boolean} enabled 下拉刷新開啓標識
   * @param callback
   */
  enablePullDownRefresh(enabled, callback) {
    this.invoke('enablePullDownRefresh', { enabled }, callback);
  },
  /**
   * 下拉刷新,通過 API 調用即可觸發,和手動刷新一致
   * @function startPullDownRefresh
   */
  startPullDownRefresh(callback) {
    this.invoke('startPullDownRefresh', {}, callback);
  }
  /**
   * 下拉刷新完成調用,將收起下拉刷新條
   */
  stopPullDownRefresh(callback) {
    this.invoke('stopPullDownRefresh', {}, callback);
  },
  /**
   * 下拉刷新觸發通知
   * @param {Function} callback 回調函數
   */
  onPullDownRefresh(callback) {
    this.on('onPullDownRefresh', callback);
  }
}

   4.2.3 主內容區

主內容區其實沒有什麼爭議,完全用 JS 來實現。涉及到 Native 能力的部分,通過 JSBridge 來調用即可。

   4.2.4 分享面板

分享面板有其特殊性,一般來說呼起分享面板要有全局的遮罩(蓋住前端 + Native 內容),這就必須通過 Native 來實現。一些 Native 頁面也有分享功能,兩端可以複用邏輯。

不同的業務場景,面板呈現的內容不同。在兼顧動態化和易用性有如下的設計思路:

  1. 常用的功能點,比如分享到微信、QQ,我們考慮封裝到 Native 模塊內部,直接通過 API 調用即可,方便業務快速接入使用。

  2. 不常用的功能模塊(比如複製鏈接、設置皮膚等),通過傳入參數控制,做到靈活配置化。

05

開發調試

一個離線包從開發到正式發佈,不同階段有不同的訴求:

  1. **開發階段:**開發階段能夠熱更新,實時查看改動效果,突出快。

  2. **發佈前:**測試環境、預發佈環境充分驗證,需要環境切換能力。

  3. **正式發佈:**驗證最終效果是否符合預期,需要環境切換能力。

下面我們用兩部分來講解如何做的。

   5.1 本地開發

混合式開發和 H5 開發並沒有太大的區別,唯一區別是調用 JSBridge 時,需要用真機進行調試。

首先在 App 上實現一個調試界面,主要包含以下功能:

  1. 掃碼:可以掃任意的 http(s) 協議地址,可以是 CDN 地址,也可以是同網段的 ip 地址。

  2. 輸入框:支持手動輸入 URL。

  3. 打開按鈕:打開輸入框裏面的地址。

  4. 導航開關:打開的頁面是否展示 Native Header。

本地開發時,讓手機和電腦在同網段,真機掃碼訪問電腦本機服務地址即可(例如:ip:port/index.html#/index)。前端開發框架一般都具備熱更新能力,這種方式和在電腦上開發沒有本質區別。

打開頁面後,因爲在 App 環境內,H5 可直接調用 JSBridge ,非常方便。

   5.2 在線更新

所謂在線更新,是指 H5 打包成離線包,上傳到管理平臺後,App 通過後臺接口拉取離線包,而不是直接訪問 H5 地址。

對 App 來說,需要有多套環境:開發、測試、預發佈、正式,離線包管理平臺需要有對應的環境嗎?其實不然,我們最好解耦兩者的關係。

離線包管理平臺只有兩套環境:

  1. 測試環境:對應 App 開發、測試、預發佈等非正式環境。

  2. 正式環境:對應正式環境。

業務代碼中內置各環境對應 API 地址,運行時通過 JSBridge 獲取 App 當前的環境配置,這樣的好處是,離線包管理平臺不用關心 App 有幾套環境,兩套環境僅僅是爲了測試、正式包的隔離。

   5.3 測試環境多版本問題

默認情況下,App 只會更新當前版本對應最新離線包。同一個 App 版本,當某個離線包涉及多個需求並行開發時,測試衝突怎麼辦?這裏面臨的問題是,多人部署多個版本的離線包,相互存在覆蓋。

這裏提供一種解決方案:在離線包管理後臺,每個版本的離線包都有一個二維碼,App 掃碼後可以下載並使用該版本離線包。

06

穩定性與安全

這部分內容不再詳細闡述,主要介紹一下應該從哪些方面去考慮框架的穩定性與安全。

   6.1 資源校驗

  1. 資源安全性檢測:檢查離線包是否有被篡改,可以是包維度的檢查,也可以是針對具體的資源文件。

  2. 域名白名單:App 內加載的所有 H5 檢查域名是否是白名單之內。非白名單內的用戶限制調用 JSBridge,並做好相應的安全提示。

   6.2 穩定性方面

打開一個頁面,有一些關鍵節點:下載離線包、解壓縮離線包、加載頁面、頁面渲染等。

實際在生產中,我們發現每個節點都可能失敗。所以在整個流程中,有必要對每個節點做好容錯和監控,分析具體原因,進行長期的優化。

   6.3 安全容器

  1. 在一些特殊的業務場景,比如證券交易,容器需要限制不滿足合規要求的操作。

  2. 像微信小程序一樣,限制使用瀏覽器 API。

07

番外篇

Q:除了介紹 Hybrid 開發的原理,當下研究 Webview 還有哪些意義?

A:同樣基於 Webview,微信小程序基於「管控」和「體驗」,設計了雙線程模型 + 離線包的架構,讓 Webview 體驗煥發新生。還有人說 Webview JS 組件和客戶端不能混排,但微信小程序通過同層渲染的方式解決了這個問題。脫離 JSBridge,上層應用還有很多種玩法,瞭解基本原理才能走得更遠。

Q:技術的價值?

A:近兩年一直在思考技術的價值,似乎做了什麼,似乎什麼也沒做。潛意識中,我希望在某個平平無奇的日子裏,想到一個點子,做點不一樣的東西。就像小程序一樣,只是多加了一層 webview,竟撐起萬億市值。

08

總結

讓我想起了六年前的一次面試,面試官問 JS 代碼在 Native 層到底如何執行,執行結果是如何回傳給 JS 的。臣妾做不到啊!現在我終於可以大膽的說,我不僅搞懂了,還知道如何設計一套框架。

本篇文章的完成,離不開前人經驗的總結,甚至有部分代碼是直接參考,由於水平有限,歡迎多多交流指正。

本篇文章的完成,離不開前人經驗的總結,甚至有部分代碼是直接參考,以下是主要參考鏈接:

移動 H5 首屏秒開優化方案探討:https://blog.cnbang.net/tech/3477/

70% 以上業務由 H5 開發,手機 QQ Hybrid 的架構如何優化演進?:https://mp.weixin.qq.com/s/evzDnTsHrAr2b9jcevwBzA

原創作者 | 田雪志

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/2Rthrh6uxCTdiAf_Y4XHbw