注:本篇研究重點不在于某個離線方案的具體使用,而在于對方案的優缺點分析、探究和選型,以及一些我個人的看法。
前言
web離線技術顧名思義就是將H5/CSS/JS和資源文件
打包提前下發到App中,這樣App在加載網頁的時候實際上加載的是本地的文件,減少網絡請求來提高網頁的渲染速度,并實現動態更新效果。
就目前情況來看,離線包的方案也是層出不窮的,本篇將列舉市面最常見的四種離線方案,進行探討分析,選擇最優方案構建離線包功能。如果你有優化h5渲染速度的需求,可以用來參考,本篇僅做技術選型和方案原理刨析,后續篇章會選出最優方案進行深入探討,加具體實現。目錄部分為后續延伸。
方案
- 通過獲取沙盒H5路徑直接加載
- 基于NSURLProtocol進行請求攔截
- 基于WKURLSchemeHandler進行自定義scheme注冊攔截
- 起本地服務器加載本地資源
選型
方案一:通過獲取沙盒H5路徑直接加載
直接加載本地h5,大名鼎鼎的《cordova》框架便是基于此實現。
1.將所有的h5文件都放入一個文件夾中。
2.將這個文件夾以相對路徑的方式倒入到工程代碼中。
3.獲取本地的文件路徑。
這個方案就是將部署在服務器上面的前端代碼直接解壓到本地沙盒。加載js的時候直接加載本地沙盒中的html進行離線加載。將每個前端的模塊都定義為一個應用,打上id下發給客戶端,當用戶點擊對應模塊的時候根據id去沙盒查找對應的離線資源進行加載實現秒開。
- 優點:簡單。
- 缺點:
- 實際上從截圖中可以看到,我們在訪問本地html的時候可以看到實際路徑為
file:///.../index.html
。這是在使用file協議
訪問html,有些html樣式并不支持file協議,在樣式和功能上會有缺失,還會有一些api上的差異,前端開發好的代碼可能下載到沙盒里導致有些資源無法使用,產生一些適配問題。
- 實際上從截圖中可以看到,我們在訪問本地html的時候可以看到實際路徑為
- 訪問本地資源還會導致資源路徑泄漏產生安全問題。
- 還會有一些瀏覽器的安全設置無法通過。
- 無法實現跨域資源請求,會讓前端開發人員無法訪問外部cdn。
file協議&http協議:file協議主要用于訪問本地計算機中的文件,好比通過資源管理器打開文件一樣,針對本地的,即file協議是訪問你本機的文件資源。http協議訪問本地html是在本地起了一臺http服務器,然后你訪問自己電腦上的本地服務器,http服務器再去訪問你本機的文件資源。
瀏覽器對兩種協議的處理有時會不同,譬如某些網頁中直接調用file協議來打開圖片,這樣的功能會被瀏覽器的安全設置阻擋,因為默認上,html是運行于客戶端的超文本語言,從安全性上來講,服務端不能對客戶端進行本地操作。即使有一些象cookie
這類的本地操作,也是需要進行安全級別設置的。倘若你需要載入外部cdn的資源,比如livereload、browserSync
等工具的使用,由于瀏覽器的同源策略,從本地文件系統載入外部文件將會失敗,會拋出安全性異常。
總的來說,這個方案會對前端產生嚴重的入侵,限制了前端只能通過相對路徑對js,css,image
等資源的加載,還有file協議的跨域問題
導致無法引入外部cdn,這樣會限制前端開發,雖然用起來最簡單,但這并不是一個好的方案。
方案二:基于NSURLProtocol進行請求攔截
既然直接加載本地資源文件不是最好方案,那我們是否可以考慮一下另一種方案基于NSURLProtocol攔截呢?當然可行了,但是往下看:
在UIWebView
上,protocol攔截確實是我們的首選方案,創建個子類,在子類里面實現protocol的代理方法即可實現對所有請求的攔截,當然也包括html里面對css、js、img等資源加載
的請求。
- (void)startLoading
{
NSData *data = [NSData dataWithContentsOfFile:filePath];
if (mimeType == nil) {
mimeType = @"text/plain";
}
NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc] initWithURL:[[self request] URL] statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:@{@"Content-Type" : mimeType}];
[[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
if (data != nil) {
[[self client] URLProtocol:self didLoadData:data];
}
[[self client] URLProtocolDidFinishLoading:self];
}
這樣即可完美解決h5的資源請求問題。
那么在WKWebView
上,這個方案是行不通的,關于這方面的解釋已經很多了,WKWebView在獨立于app進程之外的進程中執行網絡請求,請求數據不經過主進程,因此,在WKWebView上直接使用 NSURLProtocol 無法攔截請求。當然通過私有api可以解決問題:
//僅iOS8.4以上可用
Class cls = NSClassFromString(@"WKBrowsingContextController”);
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
// 注冊http(s) scheme, 把 http和https請求交給 NSURLProtocol處理
[(id)cls performSelector:sel withObject:@"http"];
[(id)cls performSelector:sel withObject:@"https"];
#pragma clang diagnostic pop
}
}
但依然存在缺陷,post請求body數據被清空。由于WKWebView在獨立進程里執行網絡請求。一旦注冊http(s) scheme后,網絡請求將從Network Process發送到App Process,這樣 NSURLProtocol 才能攔截網絡請求。在webkit2
的設計里使用MessageQueue進行進程之間的通信,Network Process會將請求encode成一個Message,然后通過 IPC 發送給 App Process。出于性能的原因,encode的時候HTTPBody和HTTPBodyStream這兩個字段被丟棄掉了。
如果使用Get請求攔截離線資源是沒有問題的,攔截到請求后映射為本地資源生成NSHTTPURLResponse* response
,像上面的方案一樣去處理就可以了。但是使用私有API又會面臨另外一個風險:被拒
。
說一點題外話,目前據我所了解到百度App安卓就是采用的請求攔截方式,但是,是安卓,看下圖:
通過上圖可以分析第11、12步
,WebView對html解析的時候可以發現資源請求并攔截,返回對應的緩存資源并渲染。實際上這個方案在iOS上是行不通
的,安卓可以使用自家瀏覽器,可以魔改瀏覽器,比如支付寶的UC,百度的T7等。iOS應用內是不允許使用魔改瀏覽器的,很遺憾,也就是說蘋果爸爸開放了什么,我們才能使用什么。
總結來說,這個方案并不會對前端產生入侵,前端依然可以不需要任何改變按部就班開發就好了。但對于body的攔截和對私有api的使用,依然是存在風險,但是據我所知這個方案也是有項目在使用的,所以選則推薦。
方案三:基于WKURLSchemeHandler進行自定義scheme注冊攔截
WKURLSchemeHandler是iOS11就推出的,用于處理自定義請求的方案,不過并不能處理Http、Https等常規scheme。
WKWebViewConfiguration開放了setURLSchemeHandler:forURLScheme:
函數,需要指定一個自定義的scheme和一個用來處理WKURLSchemeHandler回調
的自定義對象。
根據注釋來看,如果注冊了一個無效的scheme或者使用WebKit內部已經處理的scheme,例如http、https、file等將會引發異常。我們最好使用WKWebView的handlesURLScheme:
類方法來檢查給定scheme的可用性,以免帶來一些未知問題。
使用方法也很簡單:
if (@available(iOS 11.0, *)) {
BOOL allowed = [WKWebView handlesURLScheme:@""];
if (allowed) {
WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
//設置URLSchemeHandler來處理特定URLScheme的請求,CustomURLSchemeHandler需要實現WKURLSchemeHandler協議,用來攔截customScheme的請求。
[configuration setURLSchemeHandler:[CustomURLSchemeHandler new] forURLScheme: @"customScheme"];
WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
self.view = webView;
[webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"customScheme://"]]];
}
} else {
// Fallback on earlier versions
}
WKURLSchemeHandler提供了兩個回調函數由上面自定義的CustomURLSchemeHandler對象來處理:
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;
通過urlSchemeTask
的request
對象可以拿到請求對應的url
,如果是我們自定義的scheme就去攔截它,通過url映射到對應的本地資源,并加載本地資源。
如果本地資源不存在,那么通過url直接構建request對象訪問服務器,如果本地資源存在,那么就可以直接加載本地資源,和第二個方案一樣去使用它:
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
NSString *urlString = urlSchemeTask.request.URL.absoluteString;
//定位本地資源并映射到本地資源地址 filePath
NSData *data = [NSData dataWithContentsOfFile:filePath];
NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc] initWithURL:urlSchemeTask.request.URL statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:@{@"Content-Type" : @"text/plain"}];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
}
實際上這個方案很好的解決了資源攔截的問題,并且能像第二個方案一樣去做處理。看起來沒什么問題。但是它依然有短板:
- 因為使用的自定義scheme,并不是http協議,所以它依然無法解決跨域問題。
- 由于自定義了scheme,對于前端來說,需要額外將scheme設置為我們自定義的customScheme,這又會給前端帶來大量的改造,所以對前端還是產生了入侵。
- 上面提到在安卓完全不需要像iOS這樣大費周章的繞彎路,所以安卓可能就不需要這個自定義的scheme,這樣又會導致面臨著與安卓差異化嚴重問題。
- 因為API的限制,只能支持iOS11之后的系統。
所以這樣來看,WKURLSchemeHandler的攔截方案也并不是很友好。
方案四:起本地服務器加載本地資源
根據支付寶的文章《支付寶移動端動態化方案實踐》對離線包的描述:
當 H5 容器發出資源請求時,其訪問本地資源或線上資源所使用的 URL 是一致的。H5 容器會先截獲該請求,截獲請求后,發生如下情況:
1.如果本地有資源可以滿足該請求的話,H5 容器會使用本地資源。
2.如果沒有可以滿足請求的本地資源,H5 容器會使用線上資源。 因此,無論資源是在本地或者是線上,WebView 都是無感知的。
可以看出,支付寶并不是采用的上述三種方案,因為上述方案除了protocol攔截以外,都無法做到讓WebView無感知
,據我所知,支付寶目前應該采用的是起本地服務器方案
。起本地服務器自然就是http協議了,http協議和本地的file協議差異第一種方案里面已經做了詳細介紹,那么如果能夠使用http協議加載本地資源的話,這樣做能夠最大程度的讓前端對于離線包“無感”,也就是說前端不需要修改scheme,不需要考慮會不會因為file協議而帶來一些問題,也能忽略掉攔截api的平臺差異導致的框架實現差異,這樣一來前端開發好的代碼一份即可,布在服務器的同時,也上傳到我們的離線包平臺就OK了。所以稱之為“無感知”。
優點:優點前面都說了,同網絡服務器加載的樣式和功能完全一致,不入侵前端,前端并不用關心當前頁面是離線還是非離線,做到最大無感知。當然有優點就有缺點,這也并不是一個完美方案。
-
缺點:
- 需要額外搭建本地服務器,html文件的路徑需要做處理。
- 對于本地服務器的搭建存在成本問題,本地服務器的管理問題,例如服務器的打開、關閉時機等等。
- 對于本地服務器會不會帶來其他問題對于我來說也是未知的,并不是所有團隊都能像支付寶一樣搭建一個自己的服務器來處理。
這個方案的實施可以參考:《基于 LocalWebServer 實現 WKWebView 離線資源加載》的處理,但是文末也提到了幾個問題:
- 資源訪問權限安全問題。
- APP前后臺切換時,服務重啟性能耗時問題。
- 服務運行時,電量及CPU占有率問題。
- 多線程及磁盤IO問題。
這些問題對于我來說也是未知的。如果有成熟的搭建本地服務器方案歡迎留言。
本篇旨在分析一條最優方案來構建離線包核心功能,但是因為有小伙伴提出一些預加載等優化問題,所以從`bang's`的博客中摘了幾條優化方案可供參考。
Fallback 技術
題外話:從上面提到的支付寶文章來看,還有一段我們可以分析一下:
為了解決離線包不可用的場景,fallback 技術應運而生。每個離線包發布的時候,都會同步在 CDN 發布一個對應的線上版本,目錄結構和離線包結構一致。fallback 地址會隨離線包信息下發到本地。在離線包沒有下載好的場景下,客戶端會攔截頁面請求,轉向對應的 CDN 地址, 實現在線頁面和離線頁面隨時切換。
這個不可用場景
應該就是離線包不可用,未更新,資源有損壞,md5不匹配或者驗簽不通過等等。
- 如果本地離線包沒有或不是最新,就同步阻塞等待下載最新離線包。這種方案用戶體驗最差,因為離線包體積相對較大。
- 如果本地有舊包,用戶本次就直接使用舊包,如果沒有再同步阻塞等待,這種會導致更新不及時,無法確保用戶使用最新版本。(據我所知微信小程序為此方案)
- 對離線包做一個線上版本,離線包里的文件在服務端有一一對應的訪問地址,在本地沒有離線包時,直接訪問對應的線上地址,跟傳統打開一個在線頁面一樣,這種體驗相對等待下載整個離線包較好,也能保證用戶訪問到最新。
第三種方案應該就是支付寶的fallback 技術
,可以解決上述問題。當然前兩種方案也不是不可取,還是要看需求和場景。
公共資源包
每個包都會使用相同的 JS 框架和 CSS 全局樣式,這些資源重復在每一個離線包出現太浪費,可以做一個公共資源包提供這些全局文件。
預加載 webview
無論是 iOS 還是 Android,本地 Webview 初始化都要不少時間,可以預先初始化好 Webview。這里分兩種預加載:
首次預加載:在一個進程內首次初始化 Webview 與第二次初始化不同,首次會比第二次慢很多。原因預計是 Webview 首次初始化后,即使 Webview 已經釋放,但一些多 Webview 共用的全局服務或資源對象仍沒有釋放,第二次初始化時不需要再生成這些對象從而變快。我們可以在 APP 啟動時預先初始化一個 Webview 然后釋放,這樣等用戶真正走到 H5 模塊去加載 Webview時就變快了。
Webview 池:可以用兩個或多個 Webview 重復使用,而不是每次打開 H5 都新建 webview。不過這種方式要解決頁面跳轉時清空上一個頁面,另外若一個 H5 頁面上 JS 出現內存泄漏,就影響到其他頁面,在 APP 運行期間都無法釋放了。
預加載數據
理想情況下離線包的方案第一次打開時所有HTML/JS/CSS 都使用本地緩存,無需等待網絡請求,但頁面上的用戶數據還是需要實時拉,這里可以做個優化,在 Webview 初始化的同時并行去請求數據,Webview初始化是需要一些時間的,這段時間沒有任何網絡請求,在這個時機并行請求可以節省不少時間。
具體實現上,首先可以在配置表注明某個離線包需要預加載的 URL,客戶端在 Webview 初始化同時發起請求,請求由一個管理器管理,請求完成時緩存結果,然后 Webview 在初始化完畢后開始請求剛才預加載的 URL,客戶端攔截到請求,轉接到剛才提到的請求管理器,若預加載已完成就直接返回內容,若未完成則等待。
使用客戶端接口
網路和存儲接口如果使用 webkit 的 ajax 和 localStorage 會有不少限制,難以優化,可以在客戶端提供這些接口給 JS,客戶端可以在網絡請求上做像 DNS 預解析/IP直連/長連接/并行請求等更細致的優化,存儲也使用客戶端接口也能做讀寫并發/用戶隔離等針對性優化。
服務端渲染
早期 web 頁面里,JS 只是負責交互,所有內容都是直接在 HTML 里,到現代 H5 頁面,很多內容已經依賴 JS 邏輯去決定渲染什么,例如等待 JS 請求 JSON 數據,再拼接成 HTML 生成 DOM 渲染到頁面上,于是頁面的渲染展現就要等待這一整個過程,這里有一個耗時,減少這里的耗時也是白屏優化的范圍之內。
優化方法可以是人為減少 JS 渲染邏輯,也可以是更徹底地,回歸到原始,所有內容都由服務端返回的 HTML 決定,無需等待 JS 邏輯,稱之為服務端渲染。是否做這種優化視業務情況而定,畢竟這種會帶來開發模式變化/流量增大/服務端開銷增大這些負面影響。手Q的部分頁面就是使用服務端渲染的方式,稱為動態直出。
總結
關于這四種方案,都有優劣,關于選型,我偏向于NSURLProtocol攔截
和起本地服務器
的方案。當然還是要參照自己的需求,就應用來說,都是可以的。當然對于一個優秀的Hybird
框架,這些還是遠遠不夠的,不管是從支付寶的方案還是手百的方案來看,需要做的優化還有很多,不管是手Q的動態直出
,還是支付寶的Nebula
,都還有很多東西需要我們探討學習。不知道大家有沒有發現,不只是手百,包括頭條,騰訊新聞,在頁面沒有全部push出之前就已經渲染完畢了,說明都存在對h5頁面進行預加載
的處理,這也是值得我們深入探討的環節。當然這一塊還要視具體需求和人力來定了。關于離線包的處理,這是我目前能想到的所有方案,對于他們的優劣也有總結,如果你有什么建議或者更好的方案,歡迎留言。
開源地址:《WKJavaScriptBridge》(離線包后續引入)