web離線技術原理

Sevin發表於2019-05-16

前言

web離線技術顧名思義就是將H5/CSS/JS和資原始檔打包提前下發到App中,這樣App在載入網頁的時候實際上載入的是本地的檔案,減少網路請求來提高網頁的渲染速度,並實現動態更新效果。

就目前情況來看,離線包的方案也是層出不窮的,本篇將列舉市面最常見的四種離線方案,進行探討分析,選擇最優方案構建離線包功能。如果你有優化h5渲染速度的需求,可以用來參考,本篇僅做技術選型和方案原理刨析,後續篇章會選出最優方案進行深入探討,加具體實現。目錄部分為後續延伸。

方案

  1. 通過獲取沙盒H5路徑直接載入
  2. 基於NSURLProtocol進行請求攔截
  3. 基於WKURLSchemeHandler進行自定義scheme註冊攔截
  4. 起本地伺服器載入本地資源

選型

方案一:通過獲取沙盒H5路徑直接載入

直接載入本地h5,大名鼎鼎的《cordova》框架便是基於此實現。

  • 1.將所有的h5檔案都放入一個資料夾中。

  • 2.將這個資料夾以相對路徑的方式倒入到工程程式碼中。

  • 3.獲取本地的檔案路徑。

這個方案就是將部署在伺服器上面的前端程式碼直接解壓到本地沙盒。載入js的時候直接載入本地沙盒中的html進行離線載入。將每個前端的模組都定義為一個應用,打上id下發給客戶端,當使用者點選對應模組的時候根據id去沙盒查詢對應的離線資源進行載入實現秒開。

  • 優點:簡單。
  • 缺點:

web離線技術原理

    1. 實際上從截圖中可以看到,我們在訪問本地html的時候可以看到實際路徑為file:///.../index.html。這是在使用file協議訪問html,有些html樣式並不支援file協議,在樣式和功能上會有缺失,還會有一些api上的差異,前端開發好的程式碼可能下載到沙盒裡導致有些資源無法使用,產生一些適配問題。
    1. 訪問本地資源還會導致資源路徑洩漏產生安全問題。
    1. 還會有一些瀏覽器的安全設定無法通過。
    1. 無法實現跨域資源請求,會讓前端開發人員無法訪問外部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安卓就是採用的請求攔截方式,但是,是安卓,看下圖:

web離線技術原理

圖片來源《百度APP-Android H5首屏優化實踐》

通過上圖可以分析第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;
複製程式碼

通過urlSchemeTaskrequest物件可以拿到請求對應的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];
}
複製程式碼

實際上這個方案很好的解決了資源攔截的問題,並且能像第二個方案一樣去做處理。看起來沒什麼問題。但是它依然有短板:

    1. 因為使用的自定義scheme,並不是http協議,所以它依然無法解決跨域問題。
    1. 由於自定義了scheme,對於前端來說,需要額外將scheme設定為我們自定義的customScheme,這又會給前端帶來大量的改造,所以對前端還是產生了入侵。
    1. 上面提到在安卓完全不需要像iOS這樣大費周章的繞彎路,所以安卓可能就不需要這個自定義的scheme,這樣又會導致面臨著與安卓差異化嚴重問題。
    1. 因為API的限制,只能支援iOS11之後的系統。

所以這樣來看,WKURLSchemeHandler的攔截方案也並不是很友好。

方案四:起本地伺服器載入本地資源

根據支付寶的文章《支付寶移動端動態化方案實踐》對離線包的描述:

當 H5 容器發出資源請求時,其訪問本地資源或線上資源所使用的 URL 是一致的。H5 容器會先截獲該請求,截獲請求後,發生如下情況:

1.如果本地有資源可以滿足該請求的話,H5 容器會使用本地資源。

2.如果沒有可以滿足請求的本地資源,H5 容器會使用線上資源。 因此,無論資源是在本地或者是線上,WebView 都是無感知的。

可以看出,支付寶並不是採用的上述三種方案,因為上述方案除了protocol攔截以外,都無法做到讓WebView無感知,據我所知,支付寶目前應該採用的是起本地伺服器方案。起本地伺服器自然就是http協議了,http協議和本地的file協議差異第一種方案裡面已經做了詳細介紹,那麼如果能夠使用http協議載入本地資源的話,這樣做能夠最大程度的讓前端對於離線包“無感”,也就是說前端不需要修改scheme,不需要考慮會不會因為file協議而帶來一些問題,也能忽略掉攔截api的平臺差異導致的框架實現差異,這樣一來前端開發好的程式碼一份即可,布在伺服器的同時,也上傳到我們的離線包平臺就OK了。所以稱之為“無感知”。

  • 優點:優點前面都說了,同網路伺服器載入的樣式和功能完全一致,不入侵前端,前端並不用關心當前頁面是離線還是非離線,做到最大無感知。當然有優點就有缺點,這也並不是一個完美方案。

  • 缺點:

      1. 需要額外搭建本地伺服器,html檔案的路徑需要做處理。
      1. 對於本地伺服器的搭建存在成本問題,本地伺服器的管理問題,例如伺服器的開啟、關閉時機等等。
      1. 對於本地伺服器會不會帶來其他問題對於我來說也是未知的,並不是所有團隊都能像支付寶一樣搭建一個自己的伺服器來處理。

這個方案的實施可以參考:《基於 LocalWebServer 實現 WKWebView 離線資源載入》的處理,但是文末也提到了幾個問題:

  • 資源訪問許可權安全問題。
  • APP前後臺切換時,服務重啟效能耗時問題。
  • 服務執行時,電量及CPU佔有率問題。
  • 多執行緒及磁碟IO問題。

這些問題對於我來說也是未知的。如果有成熟的搭建本地伺服器方案歡迎留言。

本篇旨在分析一條最優方案來構建離線包核心功能,但是因為有小夥伴提出一些預載入等優化問題,所以從`bang's`的部落格中摘了幾條優化方案可供參考。

Fallback 技術

題外話:從上面提到的支付寶文章來看,還有一段我們可以分析一下:

為了解決離線包不可用的場景,fallback 技術應運而生。每個離線包釋出的時候,都會同步在 CDN 釋出一個對應的線上版本,目錄結構和離線包結構一致。fallback 地址會隨離線包資訊下發到本地。在離線包沒有下載好的場景下,客戶端會攔截頁面請求,轉向對應的 CDN 地址, 實現線上頁面和離線頁面隨時切換。

這個不可用場景應該就是離線包不可用,未更新,資源有損壞,md5不匹配或者驗籤不通過等等。

    1. 如果本地離線包沒有或不是最新,就同步阻塞等待下載最新離線包。這種方案使用者體驗最差,因為離線包體積相對較大。
    1. 如果本地有舊包,使用者本次就直接使用舊包,如果沒有再同步阻塞等待,這種會導致更新不及時,無法確保使用者使用最新版本。(據我所知微信小程式為此方案)
    1. 對離線包做一個線上版本,離線包裡的檔案在服務端有一一對應的訪問地址,在本地沒有離線包時,直接訪問對應的線上地址,跟傳統開啟一個線上頁面一樣,這種體驗相對等待下載整個離線包較好,也能保證使用者訪問到最新。

第三種方案應該就是支付寶的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》(離線包後續引入)

相關文章