1 前言
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 超出系統最長限制了,消息會被截斷,這種情況是不可接受的。
基于這兩個原因,當下不應該再選擇這種通信方式。
客戶端可以攔截 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(@"");
}
}
這種通信方式沒有明顯的短板,而且還支持同步調用獲取結果,唯一的缺點是不支持直接傳遞對象,需要序列化數據,在高頻/大數據量通信的場景可能有一些性能上的損耗。
//準備要傳給native的數據,包括指令,數據,回調等
const data={
module: 'base',
action:'getUserInfo',
params:'xxxx',
callbackId:'xxxx',
};
//直接使用這個客戶端注入的函數
nativeObject.getUserInfo(data);
由于 WKWebview 不支持,這里不展開討論了。
安卓可以在 loadUrl 之前 WebView 創建之后,即可配置相關注入功能,注入后 JS 可以直接調用掛載在 nativeObject 上的所有方法:
// 通過addJavascriptInterface()將Java對象映射到JS對象
//參數1:Javascript對象名
//參數2:Java對象名
mWebView.addJavascriptInterface(new AndroidtoJs(), "nativeObject");
JS 調用:安卓注入的對象掛載在全局,直接調用接口。
nativeObject.getUserInfo("js調用了android中的getUserInfo方法");
這種通信方式的優勢在于,沒有參數的限制,可直接傳對象,無需序列化。同時也支持同步返回結果。
不同于安卓注入到 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 引擎執行。已有方案如下,根據版本選擇即可:
具體是如何調用的呢?假設 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 要滿足以下要求:
我們通過三個具體的使用場景來思考如何設計 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();
整個流程分為以下幾個調用步驟:
業務方調用支持 Promise 和 callback 兩種調用風格:
BridgeNameSpace.getSystemInfo()
.then(res=> {
console.log(res);
})
.catch(err=> {
console.log(err);
});
BridgeNameSpace.getSystemInfo((res)=> {
console.log(res);
});
這里只涉及 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 來維護。
對于 JS 來說,只能獲取到當前 Webview 上下文,單純通過 JS 是不能感知到其他 Webview 存在的。所以兩個 Webveiw 之間要通信,需要借助 Native 做中轉,其通信模型如下:
(一個 App 內在使用多套框架時,不同框架之間通信也可以基于這個模型)
Webview 之間通信分為三個步驟:
那么如何來設計這個通信模型呢?
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 來配合使用。
總結
實際在設計時,還有一些細節上的考量,可根據實際情況做一些規范化要求:
核心設計思路主要是基于底層通信模型,上層做語義化的封裝,按模塊職責進行劃分,進而達到易用、易管理等目的。
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 離線包協議
我們約定,一個離線包包含如下的關鍵文件:
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 離線包版本
離線包每次發布后,都會生成一條記錄,有一些基本的屬性來標識本條記錄:
3.2.2 離線包更新
對于離線包的使用一般有這樣的一些訴求:
要滿足以上訴求,核心是控制離線包的更新時機。
離線包的下載分為兩部分:離線包配置表管理和離線包下載。整體流程如下:
App 啟動時,會去拉取一個離線包配置表,配置表記錄了當前 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 打開流程還需要一些額外的安全校驗:
安全校驗失敗時可以采取一定的安全策略:比如合法域名的 H5 直接在線訪問,非白名單域名增加安全提醒等。
前面提到,通常 H5 在打包時會設置 publicPath,這些資源是引用的 CDN 地址,我們同樣希望這些資源能使用本地資源。
在 iOS 中可以使用 WKURLSchemeHandler 進行攔截,Native 攔截到地址后,需要解析出文件名(前端 js 、css 等資源通常帶了 md5 值,可以唯一標識),然后根據文件名去本地查找,如果找到了可直接返回。需要注意的是,這個 API 需要 iOS 11+ 以上才支持。
3.3 版本控制
每個離線包都需要知道最小支持 App 版本,JS 調用的 JSBridge 方法,需要對應版本的 App 去實現,所以版本控制非常重要。版本控制分為兩部分:
在設計時,離線包版本通過一個虛擬的版本號(這里表示為 SDK@ver)來對應 App 版本,這樣好處是 SDK 可以映射不同端 App 版本(iOS、Android、鴻蒙 App 版本號不一致),App 版本和 SDK 版本號解耦。
以如下場景為例:
一個 H5 頁面,分別對應三個 App 版本均部署了離線包,其中對應 App@10.1.0 的離線包處于灰度狀態。
當我們用 App@10.1.0 去拉取離線包時,應該返回什么版本呢?
不同版本的 App 去拉取離線包時,從最高支持的 App 版本依次往下匹配離線包,直到找到最新的離線包版本。
04 容器基礎能力
為了更高效地進行業務開發,Webview 容器還需要提供一些基礎能力:
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 來實現,比如微信、美團、百度等,這么做可能有以下考慮:
不使用 Native Header 的好處,其實就是提供最大限度的靈活性,整個頁面都可以由 JS 來實現。同時部分業務場景,在設計上有特殊要求,需要做到“全屏“的效果。
對于平臺化的 App ,基礎組件一旦依賴 Native,響應速度會變得非常慢,需要根據實際情況來做權衡:統一使用 Native Header,或者主要場景使用 Native Header,同時放開配置化能力,業務可以決定是否使用。
使用 Native Header 也可以做到一定程度的配置化,能夠滿足大部分的業務場景。將整個 Header 分為三個區域:
實際實現時,可以根據業務需要,設計靈活的配置參數:
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 頁面也有分享功能,兩端可以復用邏輯。
不同的業務場景,面板呈現的內容不同。在兼顧動態化和易用性有如下的設計思路:
05 開發調試
一個離線包從開發到正式發布,不同階段有不同的訴求:
下面我們用兩部分來講解如何做的。
5.1 本地開發
混合式開發和 H5 開發并沒有太大的區別,唯一區別是調用 JSBridge 時,需要用真機進行調試。
首先在 App 上實現一個調試界面,主要包含以下功能:
本地開發時,讓手機和電腦在同網段,真機掃碼訪問電腦本機服務地址即可(例如:ip:port/index.html#/index)。前端開發框架一般都具備熱更新能力,這種方式和在電腦上開發沒有本質區別。
打開頁面后,因為在 App 環境內,H5 可直接調用 JSBridge ,非常方便。
5.2 在線更新
所謂在線更新,是指 H5 打包成離線包,上傳到管理平臺后,App 通過后臺接口拉取離線包,而不是直接訪問 H5 地址。
對 App 來說,需要有多套環境:開發、測試、預發布、正式,離線包管理平臺需要有對應的環境嗎?其實不然,我們最好解耦兩者的關系。
離線包管理平臺只有兩套環境:
業務代碼中內置各環境對應 API 地址,運行時通過 JSBridge 獲取 App 當前的環境配置,這樣的好處是,離線包管理平臺不用關心 App 有幾套環境,兩套環境僅僅是為了測試、正式包的隔離。
5.3 測試環境多版本問題
默認情況下,App 只會更新當前版本對應最新離線包。同一個 App 版本,當某個離線包涉及多個需求并行開發時,測試沖突怎么辦?這里面臨的問題是,多人部署多個版本的離線包,相互存在覆蓋。
這里提供一種解決方案:在離線包管理后臺,每個版本的離線包都有一個二維碼,App 掃碼后可以下載并使用該版本離線包。
06 穩定性與安全
這部分內容不再詳細闡述,主要介紹一下應該從哪些方面去考慮框架的穩定性與安全。
6.1 資源校驗
6.2 穩定性方面
打開一個頁面,有一些關鍵節點:下載離線包、解壓縮離線包、加載頁面、頁面渲染等。
實際在生產中,我們發現每個節點都可能失敗。所以在整個流程中,有必要對每個節點做好容錯和監控,分析具體原因,進行長期的優化。
6.3 安全容器
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
合應用程序增長的最大原因是,您只需編寫一次混合移動應用程序,即可在每個平臺上運行它,而無需付出任何額外的努力。通過使用移動HTML5 UI框架提供的組件,將應用程序設計為常規Web應用程序,即適合不同屏幕尺寸的自適應Web應用程序。通過諸如Cordova(PhoneGap)提供的原生JavaScript API可以訪問諸如Camera或Accelerometer之類的原生硬件組件。然后,您的應用程序可以編譯到特定于平臺的版本包并發布到應用商店。
在過去的幾年中,ionic確立了自己在混合移動應用程序開發領域的領導者的地位。Ionic團隊通過適應最新趨勢,在競爭對手中一直保持著快速穩定的更新。Ionic是免費使用和開源的,其生態系統已經變得相當龐大,您可以輕松地從社區中找到大量的開發資源,以立即開始使用。
Ionic框架集成了Angular,這讓它的開發可以非常高效。與大多數其他混合應用程序框架一樣,Ionic也利用Cordova將其本地化為iOS,Android,Windows Phone和其他平臺。
Ionic框架是可維護和可伸縮的,使用簡潔易讀的標記,并隨附高度移動優化的CSS(由Sass提供支持),HTML和js組件庫。它還具有豐富的工具和手勢,以確保輕松開發交互式應用程序。
Onsen UI相對較新,但卻給Ionic帶來了激烈的競爭。它是開源的。Onsen UI的大多數UI組件也使用Angular指令和Topcoat框架。
對于jQuery愛好者,它附帶了基于jQuery的組件。可以選擇不使用Angular而是使用jquery來構建混合應用程序。Onsen UI具有大量現成可用的組件,開箱即用的響應能力使您可以構建應用程序的移動,平板和桌面版本,允許您使用HTML5和JavaScript編寫應用程序并通過Phonegap和Cordova推送到本地化。
Onsen UI帶來的易用性,靈活性,語義標記和性能給我個人留下了深刻的印象。
Sencha Touch是企業級產品,用于使用HTML5和JavaScript構建跨平臺的端到端移動Web應用程序。您可以將其稱為商業領域(主要是企業)中移動應用程序開發平臺的老大哥。Sencha有與Sencha Touch緊密配合的多種產品,其中大多數產品價格不菲。
對于個人開發人員和自由職業者而言,Onsen UI將是一個更好的選擇,但對于企業而言,Sencha Touch可以輕松帶路。
ExtJS是流行的JavaScript框架之一,它是Sencha touch平臺的核心,該平臺可幫助創建具有接近本機體驗的高性能應用程序。Sencha Touch可以打包用于所有主要平臺(包括iOS,android,Windows Phone和Blackberry)的具有原生外觀的小部件。Sencha還具有可拖放的HTML5可視化應用程序構建器,其中包含大量隨時可用的模板。還可以構建自定義組件并將其添加到庫中,以在各個應用程序之間重用。
Kendo UI是用于使用HTML5,JavaScript和CSS構建任何類型的Web應用程序或混合移動應用程序的另一個框架。它高度依賴jQuery,并附帶了70多種隨時可以使用的jQuery小部件。Kendo UI也允許與Angular集成,并支持Bootstrap UI框架。
Kendo UI還包含內置的即用型主題包,包括具有材質設計外觀和感覺的主題。還有一個主題生成器可用于創建自定義主題,這是Kendo UI框架的最大優勢之一。
Kendo UI由Telerik維護和支持,并帶有開源和商業許可選項。當然,開放源代碼在功能上會受到一些限制,并且您不會獲得專門的技術支持。
像Sencha一樣,Kendo UI在企業中也很受歡迎。它擁有龐大的客戶群,包括索尼,美國航空航天局,微軟,沃爾沃和東芝等公司。
Framework 7在iOS混合應用和移動應用程序開發中處于領先地位。雖然Framework 7不提供對構建跨平臺混合應用程序的支持。但這絕對是iOS混合應用程序開發的最佳,功能最豐富的框架。
Framework 7的主要優點在于,它使開發人員能夠僅使用CSS,JavaScript和HTML來構建iOS應用程序,這是Web開發人員已經知道的技術。Framework 7非常接近本機iOS應用程序,甚至提供了開箱即用后退等功能。還有許多其他可供使用的UI元素,包括列表視圖,媒體列表,側面板,模式等,這些元素可以按原樣使用,而無需使用JavaScript。
Famo.us是另一個強大的html5應用程序開發框架,目標是在混合應用程序中提供近乎本機的體驗。famo.us和其他混合html5框架之間的主要區別在于,它更多地關注圖形渲染,2d和3d,因此更適合游戲開發。
Trigger.io有自己的平臺,可以彌補html5開發與本機訪問之間的差距。他們聲稱它產生的混合應用程序的性能要比基于Cordova的應用程序好得多。
Trigger.io使用偽造的JavaScript API訪問像加速度計和攝像機等本地函數。
從Github上來看,目前Flutter活躍用戶正在高速增長。Flutter的文檔、資源也越來越豐富,開發過程中遇到的很多問題都可以在Stackoverflow或其github issue中找到答案。
現在Google正在大力推廣Flutter,Flutter的作者中很多人都是來自Chromium團隊,并且github上活躍度很高。另一個角度,從今年上半年Flutter頻繁的版本發布也可以看出Google對Flutter的投入的資源不小,所以在官方技術支持這方面,大可不必擔心。
Flutter的熱重載可幫助開發者快速地進行測試、構建UI、添加功能并更快地修復錯誤。在iOS和Android模擬器或真機上可以實現毫秒級熱重載,并且不會丟失狀態。這真的很棒,相信我,如果你是一名原生開發者,體驗了Flutter開發流后,很可能就不想重新回去做原生了,畢竟很少有人不吐槽原生開發的編譯速度。
React Native產出的并不是“網頁應用”, 或者說“HTML5應用”,又或者“混合應用”。 最終產品是一個真正的移動應用,從使用感受上和用Objective-C或Java編寫的應用相比幾乎是無法區分的。 React Native所使用的基礎UI組件和原生應用完全一致。 你要做的就是把這些基礎組件使用JavaScript和React的方式組合起來。
React Native完美兼容使用Objective-C、Java或是Swift編寫的組件。 如果你需要針對應用的某一部分特別優化,中途換用原生代碼編寫也很容易。 想要應用的一部分用原生,一部分用React Native也完全沒問題 —— Facebook的應用就是這么做的。
uni-app 是一個使用 Vue.js 開發所有前端應用的框架,開發者編寫一套代碼,可發布到iOS、Android、H5、以及各種小程序(微信/支付寶/百度/頭條/QQ/釘釘)等多個平臺。
案例多,插件豐富,App端支持weex原生渲染,可支撐更流暢的用戶體驗。
混生道路千萬條,條條都是不歸路。雖然混生在開發效率和便捷性上有著超乎想象的能力,但是親生的就是親生的,這個是永遠不會改變的。
我這里并不是說混生就不行,而是說各有各的好,有舍才有得,你舍棄了一些東西,必然上天會給你一些東西作為補償,有些東西只有親身經歷過才知道究竟好不好,否則道聽途說和親眼所見未必都是真的。
著現代 CSS 技術的發展,CSS 新特性被越來越多的瀏覽器所支持,本文主要講述使用純 CSS 實現3D導航欄的步驟,并對其中用到的關鍵樣式做一個解析。
實現方案將從一個基礎的樣式寫起,然后逐漸添加響應的偽元素來實現不同的邊,實現3D效果。與此同時,實現的過程中還給導航設置了動畫,方便鼠標 hover 的時候有個更好地用戶體驗。
小懶首先通過 html:5 快速創建 html5 頁面基礎框架,然后通過 schema div[class=container]>ul[class=navlist]>(li>a[href=#])*5 快速創建導航 html 框架。同時給基礎框架增加了基礎樣式,樣式中我們使用了現代 CSS 的一些特性,比如 CSS 元素嵌套(插入鏈接)、CSS 自定義屬性等新的特性。
<style>
:root {
--color: #4855B0;
}
body { margin: 0; padding: 0}
.container {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
.navlist {
list-style: none;
padding: 0;
& li {
& a {
display: block;
padding: 15px 25px;
background-color: var(--color);
color: #fff;
text-decoration: none;
&:hover {
--color: #f00;
left: -20px;
}
}
}
}
}
</style>
<!--div[class=container]>ul[class=navlist]>(li>a[href=#])*5-->
<div class="container">
<ul class="navlist">
<li><a href="#">首頁</a></li>
<li><a href="#">用戶管理</a></li>
<li><a href="#">菜單管理</a></li>
<li><a href="#">日志管理</a></li>
<li><a href="#">權限管理</a></li>
</ul>
</div>
效果如下:
為了實現 3D 效果,需要旋轉對各面做傾斜變化,正面需要Y軸傾斜 -15deg,側面需要Y軸傾斜 45deg,頂面需要X傾斜 45deg。側面和頂面我們使用 CSS 偽元素 ::before 和 ::after 來實現。在CSS 實現中背景顏色我們使用 color-mix 顏色混合函數來自動計算背景顏色。
// 正面
a {
transform: skewY(-15deg);
}
a {
&::before {
position: absolute;
left: -20px;
bottom: 10px;
width: 20px;
height: 100%;
content: "";
background-color: color-mix(in srgb, var(--color), black 20%);
transform: skewY(45deg);
transition: background-color 200ms;
}
&::after {
position: absolute;
left: -10px;
top: -20px;
width: 100%;
height: 20px;
content: "";
background-color: color-mix(in srgb, var(--color), black 20%);
transform: skewX(45deg);
}
}
效果圖如下:
從上面效果圖可以看到,3D效果已經實現,但是頂面和正面的層級還是有點問題,以至于效果看著比較別扭,我們再整體調試一節中將調試細節。請注意:color-mix 函數雖然得到大多數現代瀏覽器的支持,但是在生成環境中請謹慎使用。
1)首先對導航的各項做了層級定義:
& li {
&:nth-child(1) {
& a {
z-index: 5;
}
}
&:nth-child(2) {
& a {
z-index: 4;
}
}
&:nth-child(3) {
& a {
z-index: 3;
}
}
&:nth-child(4) {
& a {
z-index: 2;
}
}
&:nth-child(5) {
& a {
z-index: 1;
}
}
}
&::after {
z-index: -1;
}
& a {
transition: left 200ms, background-color 200ms;
&::before {
transition: background-color 200ms;
}
&::after {
transition: background-color 200ms;
}
}
4)整體實現代碼
<style>
:root {
--color: #4855B0;
}
.container {
display: flex;
justify-content: center;
align-items: center;
padding-top: 150px;
.navlist {
list-style: none;
padding: 0;
transform: skewY(-15deg);
& li {
&:nth-child(1) {
& a {
z-index: 5;
}
}
&:nth-child(2) {
& a {
z-index: 4;
}
}
&:nth-child(3) {
& a {
z-index: 3;
}
}
&:nth-child(4) {
& a {
z-index: 2;
}
}
&:nth-child(5) {
& a {
z-index: 1;
}
}
& a {
position: relative;
left: 0;
display: block;
padding: 15px 25px;
background-color: var(--color);
color: #fff;
text-decoration: none;
transition: left 200ms, background-color 200ms;
&::before {
position: absolute;
left: -20px;
bottom: 10px;
width: 20px;
height: 100%;
content: "";
background-color: color-mix(in srgb, var(--color), black 20%);
transform: skewY(45deg);
transition: background-color 200ms;
}
&::after {
position: absolute;
left: -10px;
top: -20px;
width: 100%;
height: 20px;
content: "";
background-color: color-mix(in srgb, var(--color), black 20%);
transform: skewX(45deg);
transition: background-color 200ms;
z-index: -1;
}
&:hover {
--color: #f00;
left: -20px;
}
}
}
}
}
</style>
<div class="container">
<ul class="navlist">
<li><a href="#">首頁</a></li>
<li><a href="#">用戶管理</a></li>
<li><a href="#">菜單管理</a></li>
<li><a href="#">日志管理</a></li>
<li><a href="#">權限管理</a></li>
</ul>
</div>
現代 CSS 賦予了現代開發者更多的能力,之前需要使用 JavaScript 來解決的業務需求,現在可以通過純 CSS 來實現了,這對開發者是一大利好。有句話能用CSS能實現的,盡量不要用 JavaScript 來實現。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。