文章首發於個人博客 https://github.com/mcuking/bl...
在 H5 + Native 的混合開發模式中,讓人詬病最多的恐怕就是加載 H5 頁面過程當中的白屏問題了。下面這張圖描述了從 WebView 初始化到 H5 頁面最終渲染的整個過程。javascript
其中目前主流的優化方式主要包括:css
固然還有不少其它方面的優化,這裏就再也不贅述了。本文重點講的是,在與靜態資源服務器創建鏈接,而後接收前端靜態資源的過程。因爲這個過程過於依賴用戶當前所處的網絡環境,所以也成了最不可控因素。當用戶處於弱網時,頁面加載速度可能會達到 4 到 5 s 甚至更久,嚴重影響用戶體驗。而離線包方案就是解決該問題的一個比較成熟的方案。html
首先闡述下大概思路:前端
咱們能夠先將頁面須要的靜態資源打包並預先加載到客戶端的安裝包中,當用戶安裝時,再將資源解壓到本地存儲中,當 WebView 加載某個 H5 頁面時,攔截髮出的全部 http 請求,查看請求的資源是否在本地存在,若是存在則直接返回資源。java
下面是總體技術方案圖,其中 CI/CD 我默認使用 Jenkins,固然也能夠採用其它方式。node
相關代碼:android
離線包打包插件:https://github.com/mcuking/of...webpack
應用插件的前端項目:https://github.com/mcuking/mo...git
首先須要在前端打包的過程當中同時生成離線包,個人思路是 webpack 插件在 emit 鉤子時(生成資源並輸出到目錄以前),經過 compilation 對象(表明了一次單一的版本構建和生成資源)遍歷讀取 webpack 打包生成的資源,而後將每一個資源(可經過文件類型限定遍歷範圍)的信息記錄在一個資源映射的 json 裏,具體內容以下:github
資源映射 json 示例
{ "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" }, ... ] }
其中 remoteUrl 是該資源在靜態資源服務器的地址,path 則是在客戶端本地的相對路徑(經過攔截該資源對應的服務端請求,並根據相對路徑從本地命中相關資源而後返回)。
最後將該資源映射的 json 文件和須要本地化的靜態資源打包成 zip 包,以供後面的流程使用。
相關代碼:
離線包管理平臺先後端:https://github.com/mcuking/of...
文件差分工具:https://github.com/Exoway/bsd...
從上面有關離線包的闡述中,有心者不難看出其中有個遺漏的問題,那就是當前端的靜態資源更新後,客戶端中的離線包資源如何更新?難不成要從新發一個安裝包嗎?那豈不是摒棄了 H5 動態化的特色了麼?
而離線包平臺就是爲了解決這個問題。下面我以 mobile-web-best-practice 這個前端項目爲例講解整個過程:
mobile-web-best-practice 項目對應的離線包名爲 main,第一個版本能夠如上文所述先預置到客戶端安裝包裏,同時將該離線包上傳到離線包管理平臺中,該平臺除了保存離線包文件和相關信息以外,還會生成一個名爲 packageIndex 的 json 文件,即記錄全部相關離線包信息集合的文件,該文件主要是提供給客戶端下載的。大體內容以下:
{ "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 }
其中 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 的離線包。
到此離線包管理平臺大體原理就講完了,但仍有待完善的地方,例如:
...
相關項目:
集成離線包庫的安卓項目:https://github.com/mcuking/mo...
客戶端的離線包庫目前僅開發了 android 平臺,該庫是在
webpackagekit(我的開發的安卓離線包庫)基礎上進行的二次開發,主要實現了一個多版本文件資源管理器,能夠支持多個前端離線包預置到客戶端中。其中攔截請求的源碼以下:
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; } }
經過對 WebviewClient 類的 shouldInterceptRequest 方法的複寫來攔截 http 請求,並從本地查找是否有相應的前端靜態資源,若是有則直接返回。
當前端資源經過 CI 機自動打包後部署到靜態資源服務器,那麼又如何上傳到離線包平臺呢?我曾經考慮過當前端資源打包好時,經過接口自動上傳到離線包平臺。但後來發現可行性不高,由於咱們的前端資源是須要通過測試階段後,經過運維手動修改 docker 版原本更新前端資源。若是自動上傳,則會出現離線包平臺已經上傳了了未經驗證的前端資源,而靜態資源服務器卻沒有更新的狀況。所以仍須要手動上傳離線包。固然讀者能夠根據實際狀況選擇合適的上傳方式。
在上傳的離線包填寫信息的時候,增長了 appName 字段。當請求離線包列表 json 文件時,在 query 中添加 appName 字段,離線包平臺會只返回屬於該 App 的離線包列表。
固然能夠作的更豐富些,好比能夠選擇在客戶端鏈接到 Wi-Fi 的時候,或者從後臺切換到前臺並超過 10 分鐘時候。該設置項能夠放在離線包平臺中進行配置,能夠作成全局有效的設置或者針對不一樣的離線包進行個性化設置。
這個大可沒必要擔憂,上面的代碼中若是 http 請求沒有命中任何前端資源,則會放過該請求,讓它去請求遠端的服務器。所以即便本地離線包資源沒有及時更新,仍然能夠保證頁面的靜態資源是最新的。也就是說有一個兜底的方案,出了問題大不了回到原來的請求服務器的加載模式。
筆者開發的離線包平臺目前僅對相鄰版本進行了差分,因此若是客戶端本地離線包版本和離線包平臺最新版本不相鄰,會下載最新版本的全量包。固然,各位能夠根據須要,能夠將上傳的離線包和過去 3 個版本或者更多版本進行差分,這樣客戶端能夠選擇下載對應版本的差分包便可,例以下載 1->3 的差分包。
這裏筆者舉個例子方便闡述,假設客戶端請求線上離線包版本的時機是在 app 啓動時和定時每兩個小時請求一次。當 app 剛剛請求線上離線包版本完沒多久,線上的前端頁面資源更新了,同時線上離線包也會更新。這個時候用戶訪問頁面時,客戶端並不知道線上資源已經更新,因此仍舊會攔截 html 資源請求,並從本地離線包中查找。因爲 html 文件名中沒有 hash,即便頁面更新內容變化,文件名稱仍然不變,因此仍是能夠從本地離線包中找到對應的 html 文件並返回,雖然這個 html 文件相對於線上已是較舊的文件了。而舊的 html 中引用的 js/css 等資源也會是舊的資源,由此便致使用戶看到的頁面始終是舊的。只有等到 app 從新啓動或者等待將近兩個小時後,客戶端從新請求線上離線包版本後,才能更新到最新的頁面。
對此主要問題根源在於,客戶端並不知道線上資源的更新時機,只能經過定時輪詢。若是服務端主動通知客戶端,例如經過推送方式,當線上離線包一更新,便通知客戶端請求最新版本離線包,就能夠保證儘可能的及時更新。(固然下載離線包也會須要一些時間)
講到這裏讀者能夠思考一個問題,前端的頁面更新是否及時真的是很是重要的事情麼?這裏涉及到用戶打開頁面的體驗和頁面及時更新二者的取捨問題,能夠類比下原生 app,原生 app 通常只有用戶贊成更新以後纔會下載更新,不少用戶使用的版本可能並非最新的。因此筆者認爲,只要可以作好後端接口的兼容性,不至於出現頁面不更新的話,請求的線上接口參數變動甚者被廢除,致使頁面報錯這種狀況,頁面的沒法及時更新仍是能夠容忍的。
何況通常用戶使用 app 的時間不會太長,當下一次再打開的時候客戶端就會下載最新的離線包了。筆者所在公司也有這樣的問題,但並無影響到用戶的實際使用。因此仍是建議離線 html 文件,以完全提高頁面的打開速度。
筆者詢問了下雲音樂的 iOS 端離線包方案,是經過私有 API -- registerSchemeForCustomProtocol
註冊了 http(s) scheme,進而能夠獲取到全部的 http(s) 請求,更多信息可參考下面這篇文章:
http://nanhuacoder.top/2019/0...
文中提到由於WKWebView 在獨立於主進程 NSURLProtocol 進程 Network Process 裏執行網絡請求,正常狀況 NSURLProtocol 進程是沒法攔截到 webview 中網頁發起的請求的。(注:UIWebView 發出的 request,NSURLProtocol 是能夠攔截到的)
若是經過 registerSchemeForCustomProtocol 註冊了 http(s) scheme, 那麼由 WKWebView 發起的全部 http(s)請求都會經過 IPC 從 網絡進程 Network Process 傳給主進程 NSURLProtocol 處理,就能夠攔截全部的網絡請求了。
可是進程之間的通訊使用了 MessageQueue,網絡進程 Network Process 會將請求 encode 成一個 Message,而後經過 IPC(進程間通訊) 發送給 主進程 NSURLProtocol。出於性能的緣由,encode 的時候 將 HTTPBody 和 HTTPBodyStream 這兩個字段丟棄掉。
文中提到裏一個解決辦法,以下所示:
可是仍是會遇到一個問題,那就是 http 的 header 自己的大小會有限制,致使例如上傳圖片等場景會失敗。筆者這裏提一個能夠走通的方式:
在初始化 wkWebview 的時候,注入並執行一段 js,這段 js 主要邏輯是複寫掛載在全局上的 XMLHttpRequest 原型上的 open 和 send 方法。
在 open 方法裏基於時間戳生成一串字符串 identifier,掛載到 XMLHttpRequest 的實例對象上,同時添加到第二個參數 Url 上,而後再執行原有的 open 方法。
至於 send 方法,主要是拿到 http 請求的 body,以及 open 方法中掛載到實例對象的 identifier 屬性,組合成一個對象並調用原生方法保存到客戶端的存儲中。
當在主進程 NSURLProtocol 中攔截到 XHR 請求時,先從請求的 Url 獲取到 identifier,而後根據 identifier 從客戶端的存儲中找到以前保存的 body。這樣就解決了 body 丟失的問題。
固然若是項目中用到了瀏覽器原生提供的 fetch 方法的話,記得也要將 fetch 方法複寫下哦。
至此整個方案的大體原理已經闡述完了,更多細節問題讀者能夠參考文中提供的項目連接,全部端的代碼都已經託管到了個人 github 上了。
這也算完成了我一個夙願:實現一套離線包方案而且徹底開源出來。最後但願對你們有所幫助~