看完就懂的 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:它是前端和客戶端通信的基礎,是整套框架的核心之一。
-
Webview 容器:作爲 H5 容器,需要提供一些基礎的能力。
-
離線資源管理:客戶端對本地離線資源的拉取 / 更新、攔截等策略。
-
開發調試:開發調試是業務開發的重要組成部分。
-
離線包管理後臺:離線包版本管理系統。
-
後臺服務:根據客戶端版本,返回對應版本的離線包。
-
離線包協議:前端和客戶端約定的離線包協議,前端需要構建出約定的離線包格式。
-
框架穩定性與安全:白屏檢測,異常處理,異常上報等。
其中,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 當下選擇如下的通信方式是最合適的:
-
iOS:推薦使用 MessageHandler + prompt 攔截兩個方案並存,同時實現異步和同步調用。
-
Android:addJavaScriptInterface 能力強大,使用很方便,當下沒有任何缺點。
2.2 Native -> JS
講完了 JS -> Native,Native 如何調用 JS 呢?其實就是客戶端直接執行 JS 代碼,將 JS 代碼(字符串)交給 JS 引擎執行。已有方案如下,根據版本選擇即可:
-
iOS: evaluatingJavaScript。
-
安卓: 其實 2 個區別不大,使用方法差異也不大:
-
4.4 以上 evaluatingJavaScript。
-
4.4 以下 loadUrl。
具體是如何調用的呢?假設 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 要滿足以下要求:
-
平臺無關: 兩端的通信機制是有差異的,但對上層業務來說不需要關心這些差異;SDK 是純 JS 邏輯的封裝,和上層使用的業務框架無關(Vue / React 等均支持)
-
易用性: 接入簡單,通過 npm 安裝後即可使用;有一定語義化的封裝,比如查詢設備信息,可以直接調用 sdk.getSystemInfo,而不用先去建立底層的通信;API 同時支持 Promsie / Callback 兩種調用風格等
-
可擴展: 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();
整個流程分爲以下幾個調用步驟:
-
JS 調用 invoke,生成一個唯一的 callbackId,將 callbackId 和 callback 註冊到全局變量 invokeMap 中。
-
iOS 端,JS 將參數通過 MessageHandler 傳遞給 Native;安卓通過 Interface 注入的方式,JS 可以直接調用 Native 的方法。
-
Native 執行業務邏輯,並調用回調函數 BridgeNameSpace.invokeCallbackHandler。
-
通過調用時生成的唯一的 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 之間通信分爲三個步驟:
-
Webview A 訂閱事件,不同於場景二的訂閱模式,訂閱結果需要維護在 Native,所以這裏需要有一次 JS -> Native 調用。
-
Webview B 發起通知,先通知到 Native,這裏也有一次 JS -> Native 調用。
-
Native 收到通知後,發起一次廣播,之前所有註冊過的 Webview 都會收到通知,這裏有一次 Native -> JS 調用。
那麼如何來設計這個通信模型呢?
-
JS -> Native 訂閱其實就是一次基本的 JS -> Native 函數調用,這裏需要約定一個特定的事件名。
-
JS -> Native 通知同理,也需要約定一個特定的事件名。
-
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 來配合使用。
總結
實際在設計時,還有一些細節上的考量,可根據實際情況做一些規範化要求:
-
不同環境的兼容適配(比如瀏覽器、微信、不同的 App 訪問等)。
-
按模塊職責進行劃分,比如基礎、路由、網絡、UI 等。
-
規範函數命名: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 離線包協議
我們約定,一個離線包包含如下的關鍵文件:
-
page-frame.html,頁面的入口文件。
-
config.json 頁面配置文件,包含 Webview 容器的一些配置項,下面會單獨介紹。
-
其他 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 離線包版本
離線包每次發佈後,都會生成一條記錄,有一些基本的屬性來標識本條記錄:
-
pid:和頁面訪問地址一一對應。
-
verify_code:pid 和訪問地址的加密校驗碼,訪問帶 pid 的 url 時,需要做一些安全校驗。
-
pkg_md5:離線包 md5 值,用於校驗離線包本身是否被篡改。
-
gray_rule:灰度規則。
-
pkg_url:離線包 cdn 地址。
-
sdk: 依賴的 App 最低版本,和 app 版本有一一對應的關係。
-
status:發佈狀態(未發佈、灰度發佈、全量)。
-
comment:本次發佈描述。
-
author: 發佈人。
3.2.2 離線包更新
對於離線包的使用一般有這樣的一些訴求:
-
最新離線包: 離線包更新儘可能快
-
資源離線化: 儘可能使用本地資源
-
高命中率: 重要的模塊,通過預下載,可以大大提高離線包命中率
要滿足以上訴求,核心是控制離線包的更新時機。
離線包的下載分爲兩部分:離線包配置表管理和離線包下載。整體流程如下:
App 啓動時,會去拉取一個離線包配置表,配置表記錄了當前 App 版本對應的所有最新離線包,主要包含以下信息:
-
離線包優先級。
-
離線包 CDN 地址。
-
離線包校驗參數。
爲了保證及時拉取到最新的離線包版本,配置表有一些更新時機:
-
App 啓動時。
-
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 打開流程還需要一些額外的安全校驗:
-
域名校驗,不支持非白名單內的域名。
-
離線包 md5 校驗,防止包被篡改。
-
verify_code 校驗當前訪問地址和 pid 是否匹配。
安全校驗失敗時可以採取一定的安全策略:比如合法域名的 H5 直接在線訪問,非白名單域名增加安全提醒等。
前面提到,通常 H5 在打包時會設置 publicPath,這些資源是引用的 CDN 地址,我們同樣希望這些資源能使用本地資源。
在 iOS 中可以使用 WKURLSchemeHandler 進行攔截,Native 攔截到地址後,需要解析出文件名(前端 js 、css 等資源通常帶了 md5 值,可以唯一標識),然後根據文件名去本地查找,如果找到了可直接返回。需要注意的是,這個 API 需要 iOS 11+ 以上才支持。
3.3 版本控制
每個離線包都需要知道最小支持 App 版本,JS 調用的 JSBridge 方法,需要對應版本的 App 去實現,所以版本控制非常重要。版本控制分爲兩部分:
-
離線包構建時需要明確支持的最高 App 版本,版本信息可以放到項目工程配置文件裏。
-
App 在拉取配置文件 / 拉取單個離線包時,後臺根據當前 App 版本及灰度規則返回正確的離線包。
在設計時,離線包版本通過一個虛擬的版本號(這裏表示爲 SDK@ver)來對應 App 版本,這樣好處是 SDK 可以映射不同端 App 版本(iOS、Android、鴻蒙 App 版本號不一致),App 版本和 SDK 版本號解耦。
以如下場景爲例:
一個 H5 頁面,分別對應三個 App 版本均部署了離線包,其中對應 App@10.1.0 的離線包處於灰度狀態。
當我們用 App@10.1.0 去拉取離線包時,應該返回什麼版本呢?
-
首先 sdk2.3.0 對應的離線包不能返回,因爲它們要求最小支持 App 版本是 10.2.0,一旦返回了可能導致有些 API 調用失敗,App@10.1.0 上沒有對應的實現。
-
如果命中了灰度,則返回 sdk@2.2.9 下的離線包版本 1。
-
如果未命中灰度,則返回 sdk@2.2.8 下的離線包版本 1,JSBridge SDK 通常是向下兼容的,低版本離線包調用的 JSBridge API 高版本的 App 都支持。
不同版本的 App 去拉取離線包時,從最高支持的 App 版本依次往下匹配離線包,直到找到最新的離線包版本。
04 容器基礎能力
爲了更高效地進行業務開發,Webview 容器還需要提供一些基礎能力:
-
Native UI 組件:Toast、Loading。
-
內嵌 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 來實現,比如微信、美團、百度等,這麼做可能有以下考慮:
-
統一 App 風格,做到一致的交互體驗。
-
JS 異常導致白屏時,防止 App 陷入假死狀態,Native Header 可以控制頁面後退。
不使用 Native Header 的好處,其實就是提供最大限度的靈活性,整個頁面都可以由 JS 來實現。同時部分業務場景,在設計上有特殊要求,需要做到 “全屏 “的效果。
對於平臺化的 App ,基礎組件一旦依賴 Native,響應速度會變得非常慢,需要根據實際情況來做權衡:統一使用 Native Header,或者主要場景使用 Native Header,同時放開配置化能力,業務可以決定是否使用。
使用 Native Header 也可以做到一定程度的配置化,能夠滿足大部分的業務場景。將整個 Header 分爲三個區域:
-
左邊區域比較簡單,只有一個返回按鈕,關閉當前 Webview。
-
標題部分,可以設置標題和子標題,注意需要控制和 document.title 的關係。
-
功能區:可以設置分享、字體控件等入口。
實際實現時,可以根據業務需要,設計靈活的配置參數:
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 頁面也有分享功能,兩端可以複用邏輯。
不同的業務場景,面板呈現的內容不同。在兼顧動態化和易用性有如下的設計思路:
-
常用的功能點,比如分享到微信、QQ,我們考慮封裝到 Native 模塊內部,直接通過 API 調用即可,方便業務快速接入使用。
-
不常用的功能模塊(比如複製鏈接、設置皮膚等),通過傳入參數控制,做到靈活配置化。
05 開發調試
一個離線包從開發到正式發佈,不同階段有不同的訴求:
-
開發階段: 開發階段能夠熱更新,實時查看改動效果,突出快。
-
發佈前: 測試環境、預發佈環境充分驗證,需要環境切換能力。
-
正式發佈: 驗證最終效果是否符合預期,需要環境切換能力。
下面我們用兩部分來講解如何做的。
5.1 本地開發
混合式開發和 H5 開發並沒有太大的區別,唯一區別是調用 JSBridge 時,需要用真機進行調試。
首先在 App 上實現一個調試界面,主要包含以下功能:
-
掃碼:可以掃任意的 http(s) 協議地址,可以是 CDN 地址,也可以是同網段的 ip 地址。
-
輸入框:支持手動輸入 URL。
-
打開按鈕:打開輸入框裏面的地址。
-
導航開關:打開的頁面是否展示 Native Header。
本地開發時,讓手機和電腦在同網段,真機掃碼訪問電腦本機服務地址即可(例如:ip:port/index.html#/index)。前端開發框架一般都具備熱更新能力,這種方式和在電腦上開發沒有本質區別。
打開頁面後,因爲在 App 環境內,H5 可直接調用 JSBridge ,非常方便。
5.2 在線更新
所謂在線更新,是指 H5 打包成離線包,上傳到管理平臺後,App 通過後臺接口拉取離線包,而不是直接訪問 H5 地址。
對 App 來說,需要有多套環境:開發、測試、預發佈、正式,離線包管理平臺需要有對應的環境嗎?其實不然,我們最好解耦兩者的關係。
離線包管理平臺只有兩套環境:
-
測試環境:對應 App 開發、測試、預發佈等非正式環境。
-
正式環境:對應正式環境。
業務代碼中內置各環境對應 API 地址,運行時通過 JSBridge 獲取 App 當前的環境配置,這樣的好處是,離線包管理平臺不用關心 App 有幾套環境,兩套環境僅僅是爲了測試、正式包的隔離。
5.3 測試環境多版本問題
默認情況下,App 只會更新當前版本對應最新離線包。同一個 App 版本,當某個離線包涉及多個需求並行開發時,測試衝突怎麼辦?這裏面臨的問題是,多人部署多個版本的離線包,相互存在覆蓋。
這裏提供一種解決方案:在離線包管理後臺,每個版本的離線包都有一個二維碼,App 掃碼後可以下載並使用該版本離線包。
06 穩定性與安全
這部分內容不再詳細闡述,主要介紹一下應該從哪些方面去考慮框架的穩定性與安全。
6.1 資源校驗
-
資源安全性檢測:檢查離線包是否有被篡改,可以是包維度的檢查,也可以是針對具體的資源文件。
-
域名白名單:App 內加載的所有 H5 檢查域名是否是白名單之內。非白名單內的用戶限制調用 JSBridge,並做好相應的安全提示。
6.2 穩定性方面
打開一個頁面,有一些關鍵節點:下載離線包、解壓縮離線包、加載頁面、頁面渲染等。
實際在生產中,我們發現每個節點都可能失敗。所以在整個流程中,有必要對每個節點做好容錯和監控,分析具體原因,進行長期的優化。
6.3 安全容器
-
在一些特殊的業務場景,比如證券交易,容器需要限制不滿足合規要求的操作。
-
像微信小程序一樣,限制使用瀏覽器 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