一個 Hybrid SDK 設計與實現(xiàn)
隨著移動浪潮的興起,各種 App 層出不窮,極速發(fā)展的業(yè)務拓展提升了團隊對開發(fā)效率的要求,這個時候純粹使用 Native 開發(fā)技術成本難免會更高一點。而 H5 的低成本、高效率、跨平臺等特性馬上被利用起來了,形成一種新的開發(fā)模式:Hybrid App
作為一種混合開發(fā)的模式,Hybrid App 底層依賴于 Native 提供的容器(Webview),上層使用各種前端技術完成業(yè)務開發(fā)(現(xiàn)在三足鼎立的 Vue、React、Angular),底層透明化、上層多樣化。這種場景非常有利于前端介入,非常適合業(yè)務的快速迭代。于是 Hybrid 火了。
大道理誰都懂,但是按照我知道的情況,還是有非常多的人和公司在 Hybrid 這一塊并沒有做的很好,所以我將我的經驗做一個總結,希望可以幫助廣大開發(fā)者的技術選型有所幫助
Hybrid 的一個現(xiàn)狀
可能早期都是 PC 端的網頁開發(fā),隨著移動互聯(lián)網的發(fā)展,iOS、Android 智能手機的普及,非常多的業(yè)務和場景都從 PC 端轉移到移動端。開始有前端開發(fā)者為移動端開發(fā)網頁。這樣子早期資源打包到 Native App 中會造成應用包體積的增大。越來越多的業(yè)務開始用 H5 嘗試,這樣子難免會需要一個需要訪問 Native 功能的地方,這樣子可能早期就是懂點前端技術的 Native 開發(fā)者自己封裝或者暴露 Native 能力給 JS 端,等業(yè)務較多的時候者樣子很明顯不現(xiàn)實,就需要專門的 Hybrid 團隊做這個事情;量大了,就需要規(guī)矩,就需要規(guī)范。
總結:
Hybrid 開發(fā)效率高、跨平臺、低成本
Hybrid 從業(yè)務上講,沒有版本問題,有 Bug 可以及時修復
Hybrid 在大量應用的時候就需要一定的規(guī)范,那么本文將討論一個 Hybrid 的設計知識。
- Hybrid 、Native、前端各自的工作是什么
- Hybrid 交互接口如何設計
- Hybrid 的 Header 如何設計
- Hybrid 的如何設計目錄結構以及增量機制如何實現(xiàn)
- 資源緩存策略,白屏問題...
Native 與前端分工
在做 Hybird 架構設計之前我們需要分清 Native 與前端的界限。首先 Native 提供的是宿主環(huán)境,要合理利用 Native 提供的能力,要實現(xiàn)通用的 Hybrid 架構,站在大前端的視覺,我覺得需要考慮以下核心設計問題。
交互設計
Hybrid 架構設計的第一要考慮的問題就是如何設計前端與 Native 的交互,如果這塊設計不好會對后續(xù)的開發(fā)、前端框架的維護造成深遠影響。并且這種影響是不可逆、積重難返。所以前期需要前端與 Native 好好配合、提供通用的接口。比如
Native UI 組件、Header 組件、消息類組件
通訊錄、系統(tǒng)、設備信息讀取接口
H5 與 Native 的互相跳轉。比如 H5 如何跳轉到一個 Native 頁面,H5 如何新開 Webview 并做動畫跳轉到另一個 H5 頁面
賬號信息設計
賬號系統(tǒng)是重要且無法避免的,Native 需要設計良好安全的身份驗證機制,保證這塊對業(yè)務開發(fā)者足夠透明,打通賬戶體系
Hybrid 開發(fā)調試
功能設計、編碼完并不是真正結束,Native 與前端需要商量出一套可開發(fā)調試的模型,不然很多業(yè)務開發(fā)的工作難以繼續(xù)。
iOS調試技巧 https://www.jianshu.com/p/f430caa81fa8
Android 調試技巧:
App 中開啟 Webview 調試(WebView.setWebContentsDebuggingEnabled(true); )
chrome 瀏覽器輸入 chrome://inspect/#devices 訪問可以調試的 webview 列表
需要翻墻的環(huán)境

Hybrid 交互設計
Hybrid 交互無非是 Native 調用 H5 頁面JS 方法,或者 H5 頁面通過 JS 調 Native 提供的接口。2者通信的橋梁是 Webview。
業(yè)界主流的通信方法:1.橋接對象(時機問題,不太主張這種方式);2.自定義 Url scheme

App 自身定義了 url scheme,將自定義的 url 注冊到調度中心,例如
weixin:// 可以打開微信。
關于 Url scheme 如果不太清楚可以看看 這篇文章
JS to Native
Native 在每個版本都會提供一些 Api,前端會有一個對應的框架團隊對其封裝,釋放業(yè)務接口。舉例
SDGHybrid.http.get() // 向業(yè)務服務器拿數(shù)據(jù)
SDGHybrid.http.post() // 向業(yè)務服務器提交數(shù)據(jù)
SDGHybrid.http.sign() // 計算簽名
SDGHybrid.http.getUA() // 獲取UserAgent
SDGHybridReady(function(arg){
SDGHybrid.http.post({
url: arg.baseurl + '/feedback',
params:{
title: '點菜很慢',
content: '服務差'
},
success: (data) => {
renderUI(data);
},
fail: (err) => {
console.log(err);
}
})
})
前端框架定義了一個全局變量 SDGHybrid 作為 Native 與前端交互的橋梁,前端可以通過這個對象獲得訪問 Native 的能力
Api 交互
調用 Native Api 接口的方式和使用傳統(tǒng)的 Ajax 調用服務器,或者 Native 的網絡請求提供的接口相似

所以我們需要封裝的就是模擬創(chuàng)建一個類似 Ajax 模型的 Native 請求。

格式約定
交互的第一步是設計數(shù)據(jù)格式。這里分為請求數(shù)據(jù)格式與響應數(shù)據(jù)格式,參考 Ajax 模型:
$.ajax({
type: "GET",
url: "test.json",
data: {username:$("#username").val(), content:$("#content").val()},
dataType: "json",
success: function(data){
renderUI(data);
}
});
$.ajax(options) => XMLHTTPRequest
type(默認值:GET),HTTP請求方法(GET|POST|DELETE|...)
url(默認值:當前url),請求的url地址
data(默認值:'') 請求中的數(shù)據(jù)如果是字符串則不變,如果為Object,則需要轉換為String,含有中文則會encodeURI
所以 Hybrid 中的請求模型為:
requestHybrid({
// H5 請求由 Native 完成
tagname: 'NativeRequest',
// 請求參數(shù)
param: requestObject,
// 結果的回調
callback: function (data) {
renderUI(data);
}
});
這個方法會形成一個 URL,比如:
SDGHybrid://NativeRequest?t=1545840397616&callback=Hybrid_1545840397616¶m=%7B%22url%22%3A%22https%3A%2F%2Fwww.datacubr.com%2FApi%2FSearchInfo%2FgetLawsInfo%22%2C%22params%22%3A%7B%22key%22%3A%22%22%2C%22page%22%3A1%2C%22encryption%22%3A1%7D%2C%22Hybrid_Request_Method%22%3A0%7D
Native 的 webview 環(huán)境可以監(jiān)控內部任何的資源請求,判斷如果是 SDGHybrid 則分發(fā)事件,處理結束可能會攜帶參數(shù),參數(shù)需要先 urldecode 然后將結果數(shù)據(jù)通過 Webview 獲取 window 對象中的 callback(Hybrid_時間戳)
數(shù)據(jù)返回的格式和普通的接口返回格式類似
{
errno: 1,
message: 'App版本過低,請升級App版本',
data: {}
}
這里注意:真實數(shù)據(jù)在 data 節(jié)點中。如果 errno 不為0,則需要提示 message。
簡易版本代碼實現(xiàn)。
//通用的 Hybrid call Native
window.SDGbrHybrid = window.SDGbrHybrid || {};
var loadURL = function (url) {
var iframe = document.createElement('iframe');
iframe.style.display = "none";
iframe.style.width = '1px';
iframe.style.height = '1px';
iframe.src = url;
document.body.appendChild(iframe);
setTimeout(function () {
iframe.remove();
}, 100);
};
var _getHybridUrl = function (params) {
var paramStr = '', url = 'SDGHybrid://';
url += params.tagname + "?t=" + new Date().getTime();
if (params.callback) {
url += "&callback=" + params.callback;
delete params.callback;
}
if (params.param) {
paramStr = typeof params.param == "object" ? JSON.stringify(params.param) : params.param;
url += "¶m=" + encodeURIComponent(paramStr);
}
return url;
};
var requestHybrid = function (params) {
//生成隨機函數(shù)
var tt = (new Date().getTime());
var t = "Hybrid_" + tt;
var tmpFn;
if (params.callback) {
tmpFn = params.callback;
params.callback = t;
window.SDGHybrid[t] = function (data) {
tmpFn(data);
delete window.SDGHybrid[t];
}
}
loadURL(_getHybridUrl(params));
};
//獲取版本信息,約定APP的navigator.userAgent版本包含版本信息:scheme/xx.xx.xx
var getHybridInfo = function () {
var platform_version = {};
var na = navigator.userAgent;
var info = na.match(/scheme\/\d\.\d\.\d/);
if (info && info[0]) {
info = info[0].split('/');
if (info && info.length == 2) {
platform_version.platform = info[0];
platform_version.version = info[1];
}
}
return platform_version;
};
Native 對于 H5 來說有個 Webview 容器,框架&&底層不太關心 H5 的業(yè)務實現(xiàn),所以真實業(yè)務中 Native 調用 H5 場景較少。
上面的網絡訪問 Native 代碼(iOS為例)
typedef NS_ENUM(NSInteger){
Hybrid_Request_Method_Post = 0,
Hybrid_Request_Method_Get = 1
} Hybrid_Request_Method;
@interface RequestModel : NSObject
@property (nonatomic, strong) NSString *url;
@property (nonatomic, assign) Hybrid_Request_Method Hybrid_Request_Method;
@property (nonatomic, strong) NSDictionary *params;
@end
@interface HybridRequest : NSObject
+ (void)requestWithNative:(RequestModel *)requestModel hybridRequestSuccess:(void (^)(id responseObject))success hybridRequestfail:(void (^)(void))fail;
+ (void)requestWithNative:(RequestModel *)requestModel hybridRequestSuccess:(void (^)(id responseObject))success hybridRequestfail:(void (^)(void))fail{
//處理請求不全的情況
NSAssert(requestModel || success || fail, @"Something goes wrong");
NSString *url = requestModel.url;
NSDictionary *params = requestModel.params;
if (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Get) {
[AFNetPackage getJSONWithUrl:url parameters:params success:^(id responseObject) {
success(responseObject);
} fail:^{
fail();
}];
}
else if (requestModel.Hybrid_Request_Method == Hybrid_Request_Method_Post) {
[AFNetPackage postJSONWithUrl:url parameters:params success:^(id responseObject) {
success(responseObject);
} fail:^{
fail();
}];
}
}
常用交互 Api
良好的交互設計是第一步,在真實業(yè)務開發(fā)中有一些 Api 一定會由應用場景。
跳轉
跳轉是 Hybrid 必用的 Api 之一,對前端來說有以下情況:
- 頁面內跳轉,與 Hybrid 無關
- H5 跳轉 Native 界面
- H5 新開 Webview 跳轉 H5 頁面,一般動畫切換頁面
如果使用動畫,按照業(yè)務來說分為前進、后退。forward & backword,規(guī)定如下,首先是 H5 跳 Native 某個頁面
//H5跳Native頁面
//=>SDGHybrid://forward?t=1446297487682¶m=%7B%22topage%22%3A%22home%22%2C%22type%22%3A%22h2n%22%2C%22data2%22%3A2%7D
requestHybrid({
tagname: 'forward',
param: {
// 要去到的頁面
topage: 'home',
// 跳轉方式,H5跳Native
type: 'native',
// 其它參數(shù)
data2: 2
}
});
H5 頁面要去 Native 某個頁面
//=>SDGHybrid://forward?t=1446297653344¶m=%7B%22topage%22%253A%22Goods%252Fdetail%20%20%22%252C%22type%22%253A%22h2n%22%252C%22id%22%253A20151031%7D
requestHybrid({
tagname: 'forward',
param: {
// 要去到的頁面
topage: 'Goods/detail',
// 跳轉方式,H5跳Native
type: 'native',
// 其它參數(shù)
id: 20151031
}
});
H5 新開 Webview 的方式去跳轉 H5
requestHybrid({
tagname: 'forward',
param: {
// 要去到的頁面,首先找到goods頻道,然后定位到detail模塊
topage: 'goods/detail ',
//跳轉方式,H5新開Webview跳轉,最后裝載H5頁面
type: 'webview',
//其它參數(shù)
id: 20151031
}
});
back 與 forward 一致,可能會有 animatetype 參數(shù)決定頁面切換的時候的動畫效果。真實使用的時候可能會全局封裝方法去忽略 tagname 細節(jié)。
Header 組件的設計
Native 每次改動都比較“慢”,所以類似 Header 就很需要。
1. 主流容器都是這么做的,比如微信、手機百度、攜程
2. 沒有 Header 一旦出現(xiàn)網絡錯誤或者白屏,App 將陷入假死狀態(tài)
PS:Native 打開 H5,如果 300ms 沒有響應則需要 loading 組件,避免白屏
因為 H5 App 本身就有 Header 組件,站在前端框架層來說,需要確保業(yè)務代碼是一致的,所有的差異需要在框架層做到透明化,簡單來說 Header 的設計需要遵循:
- H5 Header 組件與 Native 提供的 Header 組件使用調用層接口一致
- 前端框架層根據(jù)環(huán)境判斷選擇應該使用 H5 的 Header 組件抑或 Native 的 Header 組件
一般來說 Header 組件需要完成以下功能:
Header 左側與右側可配置,顯示為文字或者圖標(這里要求 Header 實現(xiàn)主流圖標,并且也可由業(yè)務控制圖標),并需要控制其點擊回調
Header 的 title 可設置為單標題或者主標題、子標題類型,并且可配置 lefticon 與 righticon(icon居中)
滿足一些特殊配置,比如標簽類 Header
所以,站在前端業(yè)務方來說,Header 的使用方式為(其中 tagname 是不允許重復的):
//Native以及前端框架會對特殊tagname的標識做默認回調,如果未注冊callback,或者點擊回調callback無返回則執(zhí)行默認方法
// back前端默認執(zhí)行History.back,如果不可后退則回到指定URL,Native如果檢測到不可后退則返回Naive大首頁
// home前端默認返回指定URL,Native默認返回大首頁
this.header.set({
left: [
{
//如果出現(xiàn)value字段,則默認不使用icon
tagname: 'back',
value: '回退',
//如果設置了lefticon或者righticon,則顯示icon
//native會提供常用圖標icon映射,如果找不到,便會去當前業(yè)務頻道專用目錄獲取圖標
lefticon: 'back',
callback: function () { }
}
],
right: [
{
//默認icon為tagname,這里為icon
tagname: 'search',
callback: function () { }
},
//自定義圖標
{
tagname: 'me',
//會去hotel頻道存儲靜態(tài)header圖標資源目錄搜尋該圖標,沒有便使用默認圖標
icon: 'hotel/me.png',
callback: function () { }
}
],
title: 'title',
//顯示主標題,子標題的場景
title: ['title', 'subtitle'],
//定制化title
title: {
value: 'title',
//標題右邊圖標
righticon: 'down', //也可以設置lefticon
//標題類型,默認為空,設置的話需要特殊處理
//type: 'tabs',
//點擊標題時的回調,默認為空
callback: function () { }
}
});
因為 Header 左邊一般來說只有一個按鈕,所以其對象可以使用這種形式:
this.header.set({
back: function () { },
title: ''
});
//語法糖=>
this.header.set({
left: [{
tagname: 'back',
callback: function(){}
}],
title: '',
});
為完成 Native 端的實現(xiàn),這里會新增兩個接口,向 Native 注冊事件,以及注銷事件:
var registerHybridCallback = function (ns, name, callback) {
if(!window.Hybrid[ns]) window.Hybrid[ns] = {};
window.Hybrid[ns][name] = callback;
};
var unRegisterHybridCallback = function (ns) {
if(!window.Hybrid[ns]) return;
delete window.Hybrid[ns];
};
Native Header 組件實現(xiàn):
define([], function () {
'use strict';
return _.inherit({
propertys: function () {
this.left = [];
this.right = [];
this.title = {};
this.view = null;
this.hybridEventFlag = 'Header_Event';
},
//全部更新
set: function (opts) {
if (!opts) return;
var left = [];
var right = [];
var title = {};
var tmp = {};
//語法糖適配
if (opts.back) {
tmp = { tagname: 'back' };
if (typeof opts.back == 'string') tmp.value = opts.back;
else if (typeof opts.back == 'function') tmp.callback = opts.back;
else if (typeof opts.back == 'object') _.extend(tmp, opts.back);
left.push(tmp);
} else {
if (opts.left) left = opts.left;
}
//右邊按鈕必須保持數(shù)據(jù)一致性
if (typeof opts.right == 'object' && opts.right.length) right = opts.right
if (typeof opts.title == 'string') {
title.title = opts.title;
} else if (_.isArray(opts.title) && opts.title.length > 1) {
title.title = opts.title[0];
title.subtitle = opts.title[1];
} else if (typeof opts.title == 'object') {
_.extend(title, opts.title);
}
this.left = left;
this.right = right;
this.title = title;
this.view = opts.view;
this.registerEvents();
_.requestHybrid({
tagname: 'updateheader',
param: {
left: this.left,
right: this.right,
title: this.title
}
});
},
//注冊事件,將事件存于本地
registerEvents: function () {
_.unRegisterHybridCallback(this.hybridEventFlag);
this._addEvent(this.left);
this._addEvent(this.right);
this._addEvent(this.title);
},
_addEvent: function (data) {
if (!_.isArray(data)) data = [data];
var i, len, tmp, fn, tagname;
var t = 'header_' + (new Date().getTime());
for (i = 0, len = data.length; i < len; i++) {
tmp = data[i];
tagname = tmp.tagname || '';
if (tmp.callback) {
fn = $.proxy(tmp.callback, this.view);
tmp.callback = t;
_.registerHeaderCallback(this.hybridEventFlag, t + '_' + tagname, fn);
}
}
},
//顯示header
show: function () {
_.requestHybrid({
tagname: 'showheader'
});
},
//隱藏header
hide: function () {
_.requestHybrid({
tagname: 'hideheader',
param: {
animate: true
}
});
},
//只更新title,不重置事件,不對header其它地方造成變化,僅僅最簡單的header能如此操作
update: function (title) {
_.requestHybrid({
tagname: 'updateheadertitle',
param: {
title: 'aaaaa'
}
});
},
initialize: function () {
this.propertys();
}
});
});
請求類
雖然 get 類請求可以用 jsonp 方式繞過跨域問題,但是 post 請求是一個攔路虎。為了安全性問題服務器會設置 cors 僅僅針對幾個域名,Hybrid 內嵌靜態(tài)資源可能是通過本地 file 的方式讀取,所以 cors 就行不通了。另外一個問題是防止爬蟲獲取數(shù)據(jù),由于 Native 針對網絡做了安全性設置(鑒權、防抓包等),所以 H5 的網絡請求由 Native 完成??赡苡行┤苏f H5 的網絡請求讓 Native 走就安全了嗎?我可以繼續(xù)爬取你的 Dom 節(jié)點啊。這個是針對反爬蟲的手段一。想知道更多的反爬蟲策略可以看看我這篇文章 Web反爬蟲方案

這個使用場景和 Header 組件一致,前端框架層必須做到對業(yè)務透明化,業(yè)務事實上不必關心這個網絡請求到底是由 Native 還是瀏覽器發(fā)出。
HybridGet = function (url, param, callback) {
};
HybridPost = function (url, param, callback) {
};
真實的業(yè)務場景,會將之封裝到數(shù)據(jù)請求模塊,在底層做適配,在H5站點下使用ajax請求,在Native內嵌時使用代理發(fā)出,與Native的約定為
requestHybrid({
tagname: 'NativeRequest',
param: {
url: arg.Api + "SearchInfo/getLawsInfo",
params: requestparams,
Hybrid_Request_Method: 0,
encryption: 1
},
callback: function (data) {
renderUI(data);
}
});
常用 NativeUI 組件
一般情況 Native 通常會提供常用的 UI,比如 加載層loading、消息框toast
var HybridUI = {};
HybridUI.showLoading();
//=>
requestHybrid({
tagname: 'showLoading'
});
HybridUI.showToast({
title: '111',
//幾秒后自動關閉提示框,-1需要點擊才會關閉
hidesec: 3,
//彈出層關閉時的回調
callback: function () { }
});
//=>
requestHybrid({
tagname: 'showToast',
param: {
title: '111',
hidesec: 3,
callback: function () { }
}
});
Native UI與前端UI不容易打通,所以在真實業(yè)務開發(fā)過程中,一般只會使用幾個關鍵的Native UI。
賬號系統(tǒng)的設計
Webview 中跑的網頁,賬號登錄與否由是否攜帶密鑰 cookie 決定(不能保證密鑰的有效性)。因為 Native 不關注業(yè)務實現(xiàn),所以每次載入都有可能是登錄成功跳轉回來的結果,所以每次載入都需要關注密鑰 cookie 變化,以做到登錄態(tài)數(shù)據(jù)的一致性。
使用 Native 代理做請求接口,如果沒有登錄則 Native 層喚起登錄頁
直連方式使用 ajax 請求接口,如果沒登錄則在底層喚起登錄頁(H5)
/*
無論成功與否皆會關閉登錄框
參數(shù)包括:
success 登錄成功的回調
error 登錄失敗的回調
url 如果沒有設置success,或者success執(zhí)行后沒有返回true,則默認跳往此url
*/
HybridUI.Login = function (opts) {
//...
};
//=>
requestHybrid({
tagname: 'login',
param: {
success: function () { },
error: function () { },
url: '...'
}
});
//與登錄接口一致,參數(shù)一致
HybridUI.logout = function () {
//...
};
在設計 Hybrid 層的時候,接口要做到對于處于 Hybrid 環(huán)境中的代碼樂意通過接口獲取 Native 端存儲的用戶賬號信息;對于處于傳統(tǒng)的網頁環(huán)境,可以通過接口獲取線上的賬號信息,然后將非敏感的信息存儲到 LocalStorage 中,然后每次頁面加載從 LocalStorage 讀取數(shù)據(jù)到內存中(比如 Vue.js 框架中的 Vuex,React.js 中的 Redux)
Hybrid 資源管理
Hybrid 的資源需要 增量更新 需要拆分方便,所以一個 Hybrid 資源結構類似于下面的樣子

假設有2個業(yè)務線:商城、購物車
WebApp
│- Mall
│- Cart
│ index.html //業(yè)務入口html資源,如果不是單頁應用會有多個入口
│ │ main.js //業(yè)務所有js資源打包
│ │
│ └─static //靜態(tài)樣式資源
│ ├─css
│ ├─hybrid //存儲業(yè)務定制化類Native Header圖標
│ └─images
├─libs
│ libs.js //框架所有js資源打包
│
└─static
├─css
└─images
增量更新
每次業(yè)務開發(fā)完畢后都需要在打包分發(fā)平臺進行部署上線,之后會生成一個版本號。
| Channel | Version | md5 |
|---|---|---|
| Mall | 1.0.1 | 12233000ww |
| Cart | 1.1.2 | 28211122wt2 |
當 Native App 啟動的時候會從服務端請求一個接口,接口的返回一個 json 串,內容是 App 所包含的各個 H5 業(yè)務線的版本號和 md5 信息。
拿到 json 后和 App 本地保存的版本信息作比較,發(fā)現(xiàn)變動了則去請求相應的接口,接口返回 md5 對應的文件。Native 拿到后完成解壓替換。
全部替換完畢后將這次接口請求到的資源版本號信息保存替換到 Native 本地。
因為是每個資源有版本號,所以如果線上的某個版本存在問題,那么可以根據(jù)相應的穩(wěn)定的版本號回滾到穩(wěn)定的版本。
一些零散的解決方案
靜態(tài)直出
“直出”這個概念對前端同學來說,并不陌生。為了優(yōu)化首屏體驗,大部分主流的頁面都會在服務器端拉取首屏數(shù)據(jù)后通過 NodeJs 進行渲染,然后生成一個包含了首屏數(shù)據(jù)的 Html 文件,這樣子展示首屏的時候,就可以解決內容轉菊花的問題了。
當然這種頁面“直出”的方式也會帶來一個問題,服務器需要拉取首屏數(shù)據(jù),意味著服務端處理耗時增加。
不過因為現(xiàn)在 Html 都會發(fā)布到 CDN 上,WebView 直接從 CDN 上面獲取,這塊耗時沒有對用戶造成影響。
手 Q 里面有一套自動化的構建系統(tǒng) Vnues,當產品經理修改數(shù)據(jù)發(fā)布后,可以一鍵啟動構建任務,Vnues 系統(tǒng)就會自動同步最新的代碼和數(shù)據(jù),然后生成新的含首屏 Html,并發(fā)布到 CDN 上面去。
我們可以做一個類似的事情,自動同步最新的代碼和數(shù)據(jù),然后生成新的含首屏 Html,并發(fā)布到 CDN 上面去
離線預推
頁面發(fā)布到 CDN 上面去后,那么 WebView 需要發(fā)起網絡請求去拉取。當用戶在弱網絡或者網速比較差的環(huán)境下,這個加載時間會很長。于是我們通過離線預推的方式,把頁面的資源提前拉取到本地,當用戶加載資源的時候,相當于從本地加載,即使沒有網絡,也能展示首屏頁面。這個也就是大家熟悉的離線包。
手 Q 使用 7Z 生成離線包, 同時離線包服務器將新的離線包跟業(yè)務對應的歷史離線包進行 BsDiff 做二進制差分,生成增量包,進一步降低下載離線包時的帶寬成本,下載所消耗的流量從一個完整的離線包(253KB)降低為一個增量包(3KB)。
攔截加載
事實上,在高度定制的 wap 頁面場景下,我們對于 webview 中可能出現(xiàn)的頁面類型會進行嚴格控制??梢酝ㄟ^內容的控制,避免 wap 頁中出現(xiàn)外部頁面的跳轉,也可以通過 webview 的對應代理方法,禁掉我們不希望出現(xiàn)的跳轉類型,或者同時使用,雙重保護來確保當前 webview 容器中只會出現(xiàn)我們定制過的內容。既然 wap 頁的類型是有限的,自然想到,同類型頁面大都由前端采用模板生成,頁面所使用的 html、css、js 的資源很可能是同一份,或者是有限的幾份,把它們直接隨客戶端打包在本地也就變得可行。加載對應的 url 時,直接 load 本地的資源。
對于 webview 中的網絡請求,其實也可以交由客戶端接管,比如在你所采用的 Hybrid 框架中,為前端注冊一個發(fā)起網絡請求的接口。wap 頁中的所有網絡請求,都通過這個接口來發(fā)送。這樣客戶端可以做的事情就非常多了,舉個例子,NSURLProtocol 無法攔截 WKWebview 發(fā)起的網絡請求,采用 Hybrid 方式交由客戶端來發(fā)送,便可以實現(xiàn)對應的攔截。
基于上面的方案,我們的 wap 頁的完整展示流程是這樣:客戶端在 webview 中加載某個 url,判斷符合規(guī)則,load 本地的模板 html,該頁面的內部實現(xiàn)是通過客戶端提供的網絡請求接口,發(fā)起獲取具體頁面內容的網絡請求,獲得填充的數(shù)據(jù)從而完成展示。
NSURLProtocol能夠讓你去重新定義蘋果的URL加載系統(tǒng)(URL Loading System)的行為,URL Loading System里有許多類用于處理URL請求,比如NSURL,NSURLRequest,NSURLConnection和NSURLSession等。當URL Loading System使用NSURLRequest去獲取資源的時候,它會創(chuàng)建一個NSURLProtocol子類的實例,你不應該直接實例化一個NSURLProtocol,NSURLProtocol看起來像是一個協(xié)議,但其實這是一個類,而且必須使用該類的子類,并且需要被注冊。
WKWebView 網絡請求攔截
方法一(Native 側):
原生 WKWebView 在獨立于 app 進程之外的進程中執(zhí)行網絡請求,請求數(shù)據(jù)不經過主進程,因此在 WKWebView 上直接使用 NSURLProtocol 是無法攔截請求的。
但是由于 mPaas 的離線包機制強依賴網絡攔截,所以基于此,mPaaS 利用了 WKWebview 的隱藏 api,去注冊攔截網絡請求去滿足離線包的業(yè)務場景需求,參考代碼如下:
[WKBrowsingContextController registerSchemeForCustomProtocol:@"https"]
但是因為出于性能的原因,WKWebView 的網絡請求在給主進程傳遞數(shù)據(jù)的時候會把請求的 body 去掉,導致攔截后請求的 body 參數(shù)丟失。
在離線包場景,由于頁面的資源不需要 body 數(shù)據(jù),所以離線包可以正常使用不受影響。但是在 H5 頁面內的其他 post 請求會丟失 data 參數(shù)。
為了解決 post 參數(shù)丟失的問題,mPaas 通過在 js 注入代碼,hook 了 js 上下文里的 XMLHTTPRequest 對象解決。
通過在 JS 層把方法內容組裝好,然后通過 WKWebView 的 messageHandler 機制把內容傳到主進程,把對應 HTTPBody 然后存起來,隨后通知 JS 端繼續(xù)這個請求,網絡請求到主進程后,在將 post 請求對應的 HttpBody 添加上,這樣就完成了一次 post 請求的處理。整體流程可以參考如下:

通過上面的機制,既滿足了離線包的資源攔截訴求,也解決了 post 請求 body 丟失的問題。但是在一些場景還是存在一些問題,需要開發(fā)者進行適配。
方法二(JS 側):
通過 AJAX 請求的 hook 方式,將網絡請求的信息代理到客戶端本地。能拿到 WKWebView 里面的 post 請求信息,剩下的就不是問題啦。
AJAX hook 的實現(xiàn)可以看這個 Repo.
推薦閱讀
? 從 Flutter 和前端角度出發(fā),聊聊單線程模型下如何保證 UI 流暢性
? iOS逆向 -- 逆向初探
? WebKit 源碼調試與分析
分享,收藏,點贊,在看四連,就差您了 ??????
