Hybrid App 離線包方案實踐解析(已開源)

Hybrid App 離線包方案實踐解析(已開源)


背景

在 H5 + Native 的混合開發模式中,讓人詬病最多的恐怕就是加載 H5 頁面過程中的白屏問題了。下面這張圖描述了從 WebView 初始化到 H5 頁面最終渲染的整個過程。

Hybrid App 離線包方案實踐解析(已開源)

image

其中目前主流的優化方式主要包括:

  1. 針對 WebView 初始化:該過程大致需耗費 70~700ms。當客戶端剛啟動時,可以先提前初始化一個全局的 WebView 待用並隱藏。當用戶訪問了 WebView 時,直接使用這個 WebView 加載對應網頁並展示。
  2. 針對向後端發送接口請求:在客戶端初始化 WebView 的同時,直接由 Native 開始網絡請求數據,當頁面初始化完成後,向 Native 獲取其代理請求的數據。
  3. 針對加載的 js 動態拼接 html(單頁面應用):可採用多頁面打包, 服務端渲染,以及構建時預渲染等方式。
  4. 針對加載頁面資源的大小:可採用懶加載等方式,將需要較大資源的部分分離出來,等整體頁面渲染完成後再異步請求分離出來的資源,以提升整體頁面加載速度。

當然還有很多其它方面的優化,這裡就不再贅述了。本文重點講的是,在與靜態資源服務器建立連接,然後接收前端靜態資源的過程。由於這個過程過於依賴用戶當前所處的網絡環境,因此也成了最不可控因素。當用戶處於弱網時,頁面加載速度可能會達到 4 到 5 s 甚至更久,嚴重影響用戶體驗。而離線包方案就是解決該問題的一個比較成熟的方案。

技術方案

首先闡述下大概思路:

我們可以先將頁面需要的靜態資源打包並預先加載到客戶端的安裝包中,當用戶安裝時,再將資源解壓到本地存儲中,當 WebView 加載某個 H5 頁面時,攔截髮出的所有 http 請求,查看請求的資源是否在本地存在,如果存在則直接返回資源。

下面是整體技術方案圖,其中 CI/CD 我默認使用 Jenkins,當然也可以採用其它方式。

Hybrid App 離線包方案實踐解析(已開源)

image


前端部分

相關代碼:

離線包打包插件:https://github.com/mcuking/offline-package-webpack-plugin

應用插件的前端項目:https://github.com/mcuking/mobile-web-best-practice

首先需要在前端打包的過程中同時生成離線包,我的思路是 webpack 插件在 emit 鉤子時(生成資源並輸出到目錄之前),通過 compilation 對象(代表了一次單一的版本構建和生成資源)遍歷讀取 webpack 打包生成的資源,然後將每個資源(可通過文件類型限定遍歷範圍)的信息記錄在一個資源映射的 json 裡,具體內容如下:

資源映射 json 示例

<code>{
"packageId": "mwbp",
"version": 1,
"items": [
{
"packageId": "mwbp",
"version": 1,
"remoteUrl": "http://122.51.132.117/js/app.67073d65.js",
"path": "js/app.67073d65.js",
"mimeType": "application/javascript"
},
...
]

}/<code>

其中 remoteUrl 是該資源在靜態資源服務器的地址,path 則是在客戶端本地的相對路徑(通過攔截該資源對應的服務端請求,並根據相對路徑從本地命中相關資源然後返回)。

最後將該資源映射的 json 文件和需要本地化的靜態資源打包成 zip 包,以供後面的流程使用。

離線包管理平臺

相關代碼:

離線包管理平臺前後端:https://github.com/mcuking/offline-package-admin

文件差分工具:https://github.com/Exoway/bsdiff-nodejs

從上面有關離線包的闡述中,有心者不難看出其中有個遺漏的問題,那就是當前端的靜態資源更新後,客戶端中的離線包資源如何更新?難不成要重新發一個安裝包嗎?那豈不是摒棄了 H5 動態化的特點了麼?

而離線包平臺就是為了解決這個問題。下面我以 mobile-web-best-practice 這個前端項目為例講解整個過程:

mobile-web-best-practice 項目對應的離線包名為 main,第一個版本可以如上文所述先預置到客戶端安裝包裡,同時將該離線包上傳到離線包管理平臺中,該平臺除了保存離線包文件和相關信息之外,還會生成一個名為 packageIndex 的 json 文件,即記錄所有相關離線包信息集合的文件,該文件主要是提供給客戶端下載的。大致內容如下:

<code>{
"data": [
{
"module_name": "main",
"version": 2,
"status": 1,
"origin_file_path": "/download/main/07eb239072934103ca64a9692fb20f83",
"origin_file_md5": "ec624b2395a479020d02262eee36efe4",
"patch_file_path": "/download/main/b4b8e0616e75c0cc6f34efde20fb6f36",
"patch_file_md5": "6863cdacc8ed9550e8011d2b6fffdaba"
}
],
"errorCode": 0
}/<code>

其中 data 中就是所有相關離線包的信息集合,包括了離線包的版本、狀態、以及文件的 url 地址和 md5 值等。

當 mobile-web-best-practice 更新後,會通過 offline-package-webpack-plugin 插件打包出一個新的離線包。這個時候我們就可以將這個離線包上傳到管理平臺,此時 packageIndex 中離線包 main 的版本就會更新成 2。

當客戶端啟動並請求最新的 packageIndex 文件時,發現離線包 main 的版本比本地對應離線包的版本大時,會從離線包平臺下載最新的版本,並以此作為查詢本地靜態資源文件的資源池。

講到這裡讀者可能還會有一個疑問,那就是如果前端僅僅是改動了某一處,客戶端仍舊需要下載完整的新包,豈不是很浪費流量同時也延長了文件下載的時間?

針對這個問題我們可以使用一個文件差分工具 - bsdiff-nodejs,該 node 工具調用了 c 語言實現的 bsdiff 算法(基於二進制進行文件比對算出 diff/patch 包)。當上傳版本為 2 的離線包到管理平臺時,平臺會與之前保存的版本為 1 的離線包進行 diff ,算出 1 到 2 的差分包。而客戶端僅僅需要下載差分包,然後同樣使用基於 bsdiff 算法的工具,和本地版本 1 的離線包進行 patch 生成版本 2 的離線包。

到此離線包管理平臺大致原理就講完了,但仍有待完善的地方,例如:

  1. 增加日誌功能
  2. 增加離線包達到率的統計功能

...

客戶端

相關項目:

集成離線包庫的安卓項目:https://github.com/mcuking/mobile-web-best-practice-container

客戶端的離線包庫目前僅開發了 android 平臺,該庫是在webpackagekit(個人開發的安卓離線包庫)基礎上進行的二次開發,主要實現了一個多版本文件資源管理器,可以支持多個前端離線包預置到客戶端中。其中攔截請求的源碼如下:

<code>public class OfflineWebViewClient extends WebViewClient {
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
final String url = request.getUrl().toString();
WebResourceResponse resourceResponse = getWebResourceResponse(url);
if (resourceResponse == null) {
return super.shouldInterceptRequest(view, request);
}
return resourceResponse;
}
/**
* 從本地命中並返回資源

* @param url 資源地址
*/
private WebResourceResponse getWebResourceResponse(String url) {
try {
WebResourceResponse resourceResponse = PackageManager.getInstance().getResource(url);
return resourceResponse;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}/<code>

通過對 WebviewClient 類的 shouldInterceptRequest 方法的複寫來攔截 http 請求,並從本地查找是否有相應的前端靜態資源,如果有則直接返回。

部分問題解答

1. 離線包是否可以自動更新?

當前端資源通過 CI 機自動打包後部署到靜態資源服務器,那麼又如何上傳到離線包平臺呢?我曾經考慮過當前端資源打包好時,通過接口自動上傳到離線包平臺。但後來發現可行性不高,因為我們的前端資源是需要經過測試階段後,通過運維手動修改 docker 版本來更新前端資源。如果自動上傳,則會出現離線包平臺已經上傳了了未經驗證的前端資源,而靜態資源服務器卻沒有更新的情況。因此仍需要手動上傳離線包。當然讀者可以根據實際情況選擇合適的上傳方式。

2. 多 App 情況下如何區分離線包屬於哪個 App?

在上傳的離線包填寫信息的時候,增加了 appName 字段。當請求離線包列表 json 文件時,在 query 中添加 appName 字段,離線包平臺會只返回屬於該 App 的離線包列表。

3. 一定要在 App 啟動的時候下載離線包嗎?

當然可以做的更豐富些,比如可以選擇在客戶端連接到 Wi-Fi 的時候,或者從後臺切換到前臺並超過 10 分鐘時候。該設置項可以放在離線包平臺中進行配置,可以做成全局有效的設置或者針對不同的離線包進行個性化設置。

4. 如果客戶端離線包還沒有下載完成,而靜態資源服務器已經部署了最新的版本,那麼是否會出現客戶端展示的頁面仍然是舊的版本呢?如果這次改動的是接口請求的變動,那豈不是還會引起接口報錯?

這個大可不必擔心,上面的代碼中如果 http 請求沒有命中任何前端資源,則會放過該請求,讓它去請求遠端的服務器。因此即使本地離線包資源沒有及時更新,仍然可以保證頁面的靜態資源是最新的。也就是說有一個兜底的方案,出了問題大不了回到原來的請求服務器的加載模式。

結束語

至此整個方案的大致原理已經闡述完了,更多細節問題讀者可以參考文中提供的項目鏈接,所有端的代碼都已經託管到了我的 github 上了。

這也算完成了我一個夙願:實現一套離線包方案並且完全開源出來。最後希望對大家有所幫助~


來源:https://github.com/mcuking/blog/issues/63


分享到:


相關文章: