效能提升30%以上 JDHybrid h5載入優化實踐

語言: CN / TW / HK

Hybrid技術現在已經是一個常見的技術方案,它既能享受到Native的能力,同時還能擁有H5技術低成本、高效率和跨平臺等特性。然而,H5技術的載入速度一直飽受詬病,相比於Native,app開啟H5頁面時,H5會發起多個HTTP請求去載入資源,資源大小、網路質量都會影響頁面開啟速度。為了節省這部分時間,我們可以把首屏的一些靜態資源(如img、js、css、html等)打包提前載入到本地磁碟,當載入頁面時直接從本地磁碟(或記憶體)獲取資源載入。本地的讀寫速度是遠高於網路請求的,尤其是在網路不良或資源太大的情況下,離線化方案更能展現出它的優勢。此外,在一些大促等高流量場景下,提前將活動頁面資源下載到客戶端本地,可以大幅降低活動當天的CDN峰值與頻寬,在降本提效方面有顯著的作用。

618大促效果

從去年11.11開始,JDHybrid開始承接各類大促業務,無論是沸騰之夜還是春晚專案,JDHybrid在降本提效方面都發揮了重要的作用。經過這些超級流量的大考之後,JDHybrid在業務範圍、業務服務質量、穩定性等多個方面都有了很大的進步。今年618期間,618主會場、T級互動、秒殺主會場、百億補貼等多個核心業務均使用了JDHybrid,整體首屏載入速度提升30%以上,秒開率提升20%以上,頁面錯誤率降低了60%以上。

下面我們就來看一看資料的背後JDHybrid都做了些什麼。

離線載入機制探究

0 1

Android 離線載入機制

Android實現載入本地檔案的api相對較少,主要是包括直接load本地檔案以及攔截資源請求兩個方案。

1.1 直接load本地檔案

通過WebView的loadUrl方法載入本地h5工程

webview.loadUrl(XCache.getApp(id).getHtmlPath());

對客戶端來說該方法簡單粗暴,而且載入速度非常快,但存在許多因為file協議引起的問題,本地檔案的url格式為“file:///data/data/pacakagename/xxxxx”,從url上看這類url是不存在域名的,而h5頁面的載入大多與域名相關聯。

  • Cookie在瀏覽器中是按域名進行儲存的,因為沒有域名會導致無法獲取cookie,那麼最直接的問題就是登入態的丟失。

  • 跨域,因為file域問題導致遠端請求都變成跨域請求,導致大部分介面請求無法響應。

  • scheme補齊,前端開發習慣一般網路請求url是不帶scheme的,如“loacation.href='//hybrid.jd.com'”,而真正發出請求時瀏覽器會自動補齊scheme變成“file//hybrid.jd.com”導致url無法訪問。

雖然能通過曲線救國方式解決以上問題,比如將網路請求橋接到原生,但整體改動費時費力,放棄吧。

1.2 攔截資源請求

谷歌提供了攔截資源請求的API:

@Nullable
public WebResourceResponse shouldInterceptRequest(WebView view,
WebResourceRequest request) {
return shouldInterceptRequest(view, request.getUrl().toString());
}

只需要構造WebResourceResponse物件即可實現本地檔案載入,毫無副作用,這也是目前各大廠App的方案,相比iOS,安卓在方案上相對比較簡單。京東商城App最終也是使用了該方案攔截載入本地檔案,我們在這裡提一下特別需要注意的點。

1.2.1 請求中body丟失

WebResourceRequest中不包含body,該方法中不要攔截post請求,會有丟失body的風險。京東只用該方法載入本地檔案資源,所以不存在body丟失的情況。

1.2.2 WebResourceResponse的構造

先看看WebResourceResponse的構造方法,主要包括mimeType、encoding、檔案資料流三個入引數,如谷歌原始碼註解中提到的,mimeType和encoding只能是單個值,不能是整個Content-Type值。我們嘗試發現js、css等資源如果mimeType和encoding是錯誤的,核心都按預設的utf-8編碼文字進行處理。但mimeType對於html來說必須是特定的格式,比如“text/html”,否則核心無法解析html。

   /**
* Constructs a resource response with the given MIME type, character encoding,
* and input stream. Callers must implement {@link InputStream#read(byte[])} for
* the input stream. {@link InputStream#close()} will be called after the WebView
* has finished with the response.
*
* <p class="note"><b>Note:</b> The MIME type and character encoding must
* be specified as separate parameters (for example {@code "text/html"} and
* {@code "utf-8"}), not a single value like the {@code "text/html; charset=utf-8"}
* format used in the HTTP Content-Type header. Do not use the value of a HTTP
* Content-Encoding header for {@code encoding}, as that header does not specify a
* character encoding. Content without a defined character encoding (for example
* image resources) should pass {@code null} for {@code encoding}.
*
* @param mimeType the resource response's MIME type, for example {@code "text/html"}.
* @param encoding the resource response's character encoding, for example {@code "utf-8"}.
* @param data the input stream that provides the resource response's data. Must not be a
* StringBufferInputStream.
*/
public WebResourceResponse(String mimeType, String encoding,
InputStream data) {
mMimeType = mimeType;
mEncoding = encoding;
setData(data);
}

1.2.3 跨域請求資源

除了mimeType、encoding還有一些需要特別注意的,包括access-control-allow-origin、timing-allow-origin等一些跨域的Header。一般情況下,瀏覽器核心是預設js、css等資原始檔是允許跨域的,不排除前端因為一些特殊原因強制校驗跨域。如:

<script src="user.com/index.js" crossorigin ></script>

如果前端限制了跨域,載入本地檔案也必須在Response header中增加跨域處理,否則核心會拒絕響應。

header.put("access-control-allow-origin",*);
header.put("timing-allow-origin",*);

0 2

iOS 離線載入機制

自從Apple廢棄UIWebView之後,iOS端的離線載入技術變的異常複雜,無論何種方案,都會帶有先天不足,需要通過各種補丁解決。業界目前採用的方案也不太統一,方案均帶有明顯的業務特徵。京東內部h5業務也有自身的一些特點,所以JDHybrid在方案選擇上主要考慮了以下幾點:第一、因為h5開發相對開放,所以,沒有一個相對穩定的h5開發平臺可以對接,無法形成統一的規則約束來簡化方案設計成本與使用成本;第二、大部分h5業務都是比較成熟的業務,顛覆性的方案設計會因為侵入性較強降低業務的接入意願;所以,JDHybrid的設計必須建立在“業務研發0修改”的基礎之上。當然,在方案設計過程中,我們也對現有的方案進行了摸排,有些後面被棄用的方案甚至在線上進行了驗證,這裡對相關的探索歷程也總結一下。

2.1 直接載入本地檔案

和Andriod端類似,iOS也可以通過載入本地檔案來實現離線包的載入

- (nullable WKNavigation *)loadFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL

但這個方案問題太多,無法推廣使用,具體的問題與副作用和前述Android端類似,這裡就不贅述了。

2.2 LocalServer

方案一因為頁面的協議頭是file,它的處理會給方案本身帶來大量的工作,那麼我們是否有方案可以讓webview去load一個http的連結並載入本地檔案?接下來我們來介紹本地server的方案:建立本地server來模擬http(s)場景,並在相應時機返回對應的離線資源。

可以使用CocoaHttpServer 來開啟本地服務,這個庫可以很好的支援https。 除了CocoaHTTPServer 外,我們也可以使用GCDWebServer(支援http,oc)和Telegraph (支援http(s),swift)來開啟本地server。

關於localServer在iOS端的實現以及https的支援,可以參考《 基於 LocalWebServer 實現 WKWebView 離線資源載入 》。

webview載入http(s)連結:

[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http(s)://127.0.0.1:1000/index.html"]]]

webview載入資源過程中,所有相對路徑的資源如<img src="img/icon.png">都會通過我們的本地伺服器,這時,我們就可以通過攔截這些資源並返回離線包中的資源。LocalServer方案載入離線包有幾個問題:

  • 仍然存在跨域的問題。

  • 相對路徑資源載入異常,如<img src="img/icon.png">會補全為本地host,所以對h5業務方引入資源的方式有限制。

  • 同樣存在cookie無法讀寫的場景。

  • 除了這些H5環境的問題外,我們還需要注意埠的問題,埠的衝突也會使LocalServer啟動失敗。

LocalServer可以實現http(s)環境,相比於方案一,它僅僅解決了http協議的問題,方案一的其他問題仍然存在,業務相容與開發成本並未出現明顯下降。另外它也會帶來許多額外的問題,如效能消耗、電量消耗、資源訪問許可權安全等。

2.3 NSURLProtocol

載入本地檔案和LocalServer的方案在跨域、cookie、js原生api方面的缺陷既對業務不夠友好,且可以預見其他h5層面的問題會層出不窮。所以考慮讓WKWebView正常的載入業務的h5連結,然後攔截相關請求並載入本地資源是避免上述問題的根本方案。在使用UIWebView的時候我們可以通過NSURLProtocol攔截webview中的網路請求,那麼我們是否可以通過NSURLProtocol攔截WKWebView中的網路請求呢?答案是可以的,但是因為WebKit是獨立於主app之外的進行執行的,我們不能通過簡單的NSURLProtocol註冊就能攔截到http(s)請求,在進行自定義NSURLProtocol的註冊後,我們還需要呼叫系統私有api進行處理:

Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
[(id)cls performSelector:sel withObject:@"http"];
[(id)cls performSelector:sel withObject:@"https"];
}

接下來webview傳送的網路請求便會被攔截到自定義的NSURLProtocol中,後續的流程這裡不再贅述了。

NSURLProtocol可以幫助我們攔截http(s)請求,但是在實踐過程中發現它會有以下問題:

H5 Post請求會丟失body  

WebKit是一個多程序架構,網路請求發出是在其他程序,在攔截請求後,需要通過IPC將請求從其他程序傳送到App程序,webkit出於優化的目的會丟棄HTTPBody與HTTPBodyStream欄位,這就導致了POST請求body丟失的問題。我們可以通過js hook橋接(或者乾脆提供js橋接的網路請求方式,特別適合h5開發鏈路集中的團隊),將post請求通過jsbridge轉發到原生請求。

NSURLProtocol的攔截是全域性性質的  

一旦通過私有api註冊https(s) scheme後,在登出之前,所有WKWebView發起的post網路請求都會丟失body(即使你沒有攔截它,仍然返回給webview去做請求也不例外),這會對三方webview造成影響。

私有api問題  

會有稽核被拒的風險,且WebKit官方明確對相關api標註了廢棄,提示開發者用WKURLSchemeHandler去替換,長遠看也有被官方移除的風險。

2.4 WKURLSchemeHandler

iOS11之後WebKit框架引入了新特性WKURLSchemeHandler來支援自定義請求的管理。我們可以通過customScheme來攔截webview頁面的請求,如果要攔截http(s)則需要hook WKWebView的。

+ (BOOL)handlesURLScheme:(NSString *)urlScheme

方法,在scheme為http(s)時返回NO。同時在webview初始化時,通過WKWebViewConfiguration對需要攔截的scheme進行註冊。

WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
[configuration setURLSchemeHandler:(id<WKURLSchemeHandler>)self forURLScheme:@"https"];
[configuration setURLSchemeHandler:(id<WKURLSchemeHandler>)self forURLScheme:@"http"];

WKURLSchemeHandler協議只包含兩個方法:

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;

網頁開始資料請求,我們需要在這個方法中對網頁中的請求進行處理和返回。

- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;

網頁取消資料請求,我們需要在這個方法中停止請求。

同樣是http(s)攔截方案,通過API對比我們可以發現方案攔截後雖然需要我們做更多的工作(後面會具體介紹),但是它支援WKWebView例項級別的攔截,風險相對可控。

因為WKURLSchemeHandler本身是不支援攔截Http(s)的,所以在我們用hook方法使其支援後會有很多問題:

  • iOS11.3之前post請求仍然會丟失body。

  • iOS11.3之後如果body中含有blob型別(一般情況下只有body型別為blob或Formdata中包含)的資料,會存在功能異常,低版本甚至crash。

  • 如果在stopURLSchemeTask執行之後再通過WKURLSchemeTask回撥資料會造成crash。

  • iOS13以下存在WKURLSchemeTask 析構時會有概率崩潰。

這些問題如何解決,下文我們會逐一介紹。在介紹這個方案之前,我們先來看一下為什麼要做這種方案選擇。

2.4.1 方案優缺點

我們可以從以下幾個點做下對比:

隔離性:我們希望只會對使用離線包的業務進行攔截,而非所有webview頁面。顯然以下兩種方案是不滿足的:localServer和NSURLProtocol的攔截都是全域性的。

業務無入侵:方案的實施如果要求h5層面去開發適配,將會導致方案的可用性變差,影響業務接入意願。NSURLProtocol與WKURLSchemeHandler隻影響請求方式,不影響請求內容,H5程式碼無需改動。而本地檔案與localServer則需要h5做較多的工作去適配,且對業務編碼提出了一些要求,如離線資源使用相對路徑、遠端資源使用絕對路徑,嚴重侵入H5業務的開發流程。

系統相容:方案應該可以相容主流系統,使方案發揮最大的價值。這裡需要重點考慮方案覆蓋的範圍是否“充足”,在複雜性與相容性之間平衡即可。

擴充套件性:在業務無感知的情況下,我們可以通過攔截資源,做出更多載入上的優化。顯然,方案一和方案二的擴充套件性是最差的;方案三與方案四的擴充套件性最好,可以在業務方無感知的情況下實現預載入處理、公共資源離線、資源效能監控等能力。

從前面的對比我們可以看出各種方案的優缺點。而京東的h5實際開發生態要求我們提供一個對h5無侵入、方案使用範圍可控、擴充套件性良好、長遠看穩定性佳的方案。基於這些原因我們選擇了基於WKURLSchemeHandler的攔截方案。

2.4.2 方案實現

WKURLSchemeHandler從iOS 11開始支援,需要我們提供一個例項物件遵守WKURLSchemeHandler 協議,並且通過使用 WKWebView Configuration 的方法 setURLSchemeHandler(_:forURLScheme:) 進行註冊。在這裡面有幾點需要注意:

只支援註冊自定義 scheme

根據蘋果文件的說明,這種攔截方式只支援註冊自定義 scheme ,而常見的內建協議如 http 、 https 、file等都不支援。針對自定義的scheme,這裡需要注意的是頁面的連結必須用自定義scheme。如果在https的頁面內針對個別資源新增自定義的scheme一般會被瀏覽器block。瀏覽器認為我們自定義的 scheme 不是安全的協議而禁止載入。

實現攔截非自定義 scheme

為了減少業務方接入成本,最好的方案是攔截 https,這樣不需要業務方改動程式碼即可使用離線載入能力。預設情況下,如果我們嘗試註冊https的攔截,會造成崩潰.原因也很簡單,檢視WebKit原始碼會發現。

- (void)setURLSchemeHandler:(id <WKURLSchemeHandler>)urlSchemeHandler forURLScheme:(NSString *)urlScheme
{
if ([WKWebView handlesURLScheme:urlScheme])
[NSException raise:NSInvalidArgumentException format:@"'%@' is a URL scheme that WKWebView handles natively", urlScheme];


.....
}

如果系統檢測到正在註冊內建的協議,則會丟擲異常。這裡我們直接hook WKWebView 的 handlesURLScheme: 使之在http(s)時返回NO即可。這樣我們就支援了http(s)的攔截。當然這裡存在一個疑問,handlesURLScheme:的hook是否會影響其他webview的載入過程,這個是不會的。因為載入行為的改變WebKit是通過檢測是否註冊了對應協議的handler,這裡的hook只是為https協議註冊handler掃清了障礙,如果沒有實際註冊Handler,就不會改變原來的載入流程。現在WebView的資源請求流程就如下圖所示了。

2.4.3 踩坑記錄

至此,我們終於打開了WKWebView攔截http(s)的魔盒,接下來就是逐個填坑的過程。

iOS 11.3之前丟失body

使用 WKURLSchemeHandler 方案在iOS 11.3之前的系統上攔截post請求是會丟失body的,由於11.3以下的使用者量已經佔比很少,所以我們並沒有這個點上花費太多的精力,直接將支援的系統版本提高了(實際上iOS12存在WKUrlSchemeTask析構時也會崩潰的問題,所以,我們直接將離線載入的起始版本定為了iOS13)。如果你的專案需要支援iOS11.3以下的攔截,可以考慮hook XMLHttpRequest和fetch請求通過jsBridge的方式把整個請求轉發到Native去做處理。

Blob 資料型別功能異常

在我們實踐中發現,只要攔截到的請求使用了 Blob 資料型別,就會出現異常(比如檔案上傳失敗)。通過WebKit原始碼,我們發現。

ExceptionOr<void> XMLHttpRequest::send(Blob& body)
{
if (auto result = prepareToSend())
return WTFMove(result.value());


if (m_method != "GET" && m_method != "HEAD") {
if (!m_url.protocolIsInHTTPFamily()) {
// FIXME: We would like to support posting Blobs to non-http URLs (e.g. custom URL schemes)
// but because of the architecture of blob-handling that will require a fair amount of work.
ASCIILiteral consoleMessage { "POST of a Blob to non-HTTP protocols in XMLHttpRequest.send() is currently unsupported."_s };
scriptExecutionContext()->addConsoleMessage(MessageSource::JS, MessageLevel::Warning, consoleMessage);
......
}


return createRequest();
}

原來是WebKit工程師“偷懶”了,因為工作量大還沒有支援。我們的變通辦法是通過注入js程式碼,hook XHR和fetch請求解決,把帶有Blob 型別的請求轉到Native來解決。

WKURLSchemeTask協議方法回撥崩潰。

WKURLSchemeTask協議通過以下幾個方法回傳Native的資料給WebKit,但是呼叫順序一旦出錯,直接會導致crash。

- (void)didReceiveResponse:(NSURLResponse *)response;
- (void)didReceiveData:(NSData *)data;
- (void)didFinish;
- (void)didFailWithError:(NSError *)error;

這裡要注意didReceiveData因為資料可能會分段傳輸,它會呼叫多次。所以,要在邏輯和機制上保證上述順序必須無誤。

WKURLSchemeTask 生命週期問題

我們使用WKURLSchemeTask例項進行資料回撥時,如果此時例項已經被釋放就會發生crash。由於釋放的操作是在WebKit核心進行,外部無法控制其生命週期,所以需要在使用前檢測是否存活。雖然WKURLSchemeHandler提供了一個協議方法。

- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;

在WKURLSchemeTask不可用時通知我們,但是實踐下來,這個時機並不可靠,線上仍然會有一些crash發生。通過原始碼發現,這類case下WebKit丟擲的都是 NSException 異常,我們做了一層 try-catch 保護。

Cookie同步問題

cookie同步問題是WKWebview下的一個很棘手的問題。從Cookie的操作角度來看,Native、H5、服務端三方均可操作cookie。從程序的角度來看,UIProcess、WebContentProcess、NetworkProcess三種程序都有cookie的管理。所以,這就導致了Cookie同步的複雜性。我們先從程序的角度來看一下攔截前Cookie的管理模型。

首先說明一下,cookie實際上是由CFHttpCookieStorage來做的管理,而NSHttpCookieStorage是基於CFHTTPCookieStorage封裝的(可以從WebKit內部使用的一些私有API看出),這裡用NSHTTPCookieStorage表示是考慮到大多數人對它比較熟悉。從圖中可以看出NetworkProcess是實際的cookie管理者,它負責多方cookie操作的聚合,這裡就解釋了為什麼我們從App程序中通過NSHttpCookieStorage去讀取cookie時有延時。因為預設情況下只有在NetworkProcess寫入了,UIProcess才有可能獲取到(不同程序間的cookie共享應該是通過共享儲存cookie的檔案來完成的),這裡cookie檔案的寫入策略、IPC通訊等就會產生時間差。現在我們再來看cookie同步的需求。

由前面的攔截方案對比,我們知道WKURLSchemeHandler下我們攔截了所有的請求,這裡面帶來了一個cookie管理的變化。服務端操作cookie將首先在UIProcess生效,介面請求攜帶的cookie也是從UIProcess讀取。所以,我們實質上需要把cookie的管理權轉移給UIProcess。UIProcess操作cookie可以通過NSHttpsCookieStorage。服務端的cookie讀寫由NSURLSession、NSURLRequest、NSHttpsCookieStorage預設處理。現在只剩下WebContent程序和UIProcess程序之間Cookie的同步問題。

UIProcess cookie同步到WebContentProcess

UIProcess的Cookie發生變化可以通過WKHTTPCookieStore介面去設定,cookie先到NetworkProcess,然後觸發監聽,通知WebContentProcess,這樣,h5就可以訪問到這類cookie。比如我們的某個請求response會寫cookie,在UIProcess裡面,系統會預設處理,然後我們需要如下操作就會將它同步給WebContentProcess。response中的“Set-Cookie”欄位也是同樣的方式處理。

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSHTTPURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler{
NSArray <NSHTTPCookie *>*responseCookies = [NSHTTPCookie cookiesWithResponseHeaderFields:[response allHeaderFields] forURL:response.URL];
if ([responseCookies isKindOfClass:[NSArray class]] && responseCookies.count > 0) {
dispatch_async(dispatch_get_main_queue(), ^{
[responseCookies enumerateObjectsUsingBlock:^(NSHTTPCookie * _Nonnull cookie, NSUInteger idx, BOOL * _Nonnull stop) {
// 同步到WKWebView
if (@available(iOS 11.0, *)) {
[[WKWebsiteDataStore defaultDataStore].httpCookieStore setCookie:cookie completionHandler:nil];
} else {
// Fallback on earlier versions
}
}];
});
}
completionHandler(NSURLSessionResponseAllow);
}

WebContentProcess產生的Cookie同步到UIProcess

當h5寫入cookie(通過document.cookie='key=value&...'的形式),WebContentProces會先獲取到,然後會通知NetworkProcess,這個時候如果不做額外的處理又會發生NetworkProcess可能同步慢,導致UIProcess無法及時拿到cookie的問題。通過監控發現,這個不同步發生的概率在我們業務場景下大於萬分之五。關於這個問題,我們嘗試了兩個方案:一是WKHTTPCookieStore提供了cookie變化的監聽,通過這個方案我們可以在cookie發生變化時及時同步。但遺憾的是如同我們前面介紹的一樣,cookie的變化影響因子很多,所以這個系統回撥會執行的很頻繁,導致我們使用之後出現了明顯的app卡頓。所以,我們採用了另一個方案:hook document.cookie的set方法,通過js橋將h5想寫入的cookie直接傳給UIProcess,然後解析、寫入NSHttpCookieStorage以備介面請求使用。

總結下來,現在的cookie管理模型如圖所示。

請求重定向問題

由於 WKURLSchemeTask 協議沒有處理重定向的方法,所以如果在攔截後請求時發生重定向,我們只能拿到最後的結果,導致WebView是無法感知重定向的。我們的解決辦法是針對HTML的重定向,拿到目標地址location進行重新載入並取消前次載入(這裡會產生一次-999的取消請求錯誤,如果做監控的話可以遮蔽)。

2.4.4 非離線資源請求

因為WKURLSchemeHandler的攔截方案攔截了所有的http的請求,所以我們不僅需要處理離線資源,還需要處理非離線資源的請求。這裡我們也設計了自己的網路請求框架,需要注意的是WebKit預設的網路請求模組在NetworkProcess,http的快取協議的處理、磁碟快取的能力都是在這部分完成的,我們的網路框架要事實上承擔這部分職責,顯然一個基本的NSURLSessionDataTask太低效了。如圖是我們的網路請求示意圖:

這裡簡單介紹一下我們的網路層的幾點優化措施:

網路連線複用

我們知道在HTTP2.0支援TCP連線複用,但是在客戶端層面不同的NSURLSession例項是無法進行復用的。如果連線被複用,DNS解析、TCP握手、網路連線的時間都可以被節省的,這些時間耗時在50〜100ms左右。可以通過Charles抓包就可以驗證。如下圖所示:

所以我們的網路框架設計了在底層使用同一個NSURLSession例項,上層進行網路請求的管理,儘可能的去複用網路通道,當然AFNetworking等網路框架也是這麼做的。

併發控制

為了對網路框架進行併發控制,在底層使用同一個併發佇列,便於總體控制。根據當前系統狀況調整併發量,另外自定義非同步Operation進行任務的處理。

超時重試機制

超時重試機制的超時時間和重試次數的選擇沒有一個標準,針對不同的網路環境和請求型別應該有靈活的策略。我們採取快速重試和超時時間遞增的策略,可以解決部分短時網路故障下的資源重試問題。

任務優先順序

不同的資源和請求型別,優先順序顯然是不同的,我們設定HTML檔案優先順序最高,js、css以及超時重試的優先順序次之,最後才是其他資源優先順序最低。後續這部分還會進一步細化,比如將效能、異常類的埋點的優先順序調整到最低,避免它們與業務請求搶佔資源。

自定義網路快取

由於NSURLCache容量較小、不支援自定義策略以及無法使用磁碟快取等缺點,我們自己實現了一套網路快取,遵守http標準快取協議同時添加了一些自定義策略,除了支援記憶體快取和磁碟快取外,還支援自定義快取策略,比如:快取限制、可動態分配快取容量、不影響首屏載入的資源不快取、HTML不快取等,另外還實現了LRU的淘汰策略和快取清理功能。

京東商城App離線載入實踐

京東商城將h5資源分為業務離線包和公共離線包,離線檔案結構如下:

業務離線包主要包含業務開發的js、css、圖片等資源,公共離線包主要包含京東通用的功能元件,如互動類可以使用同一個公共離線包,共用互動元件資源,避免業務離線包之間資源重複造成流量及頻寬的浪費。

01

離線包的生成

離線包資源作為H5頁面資源在客戶端本地的一份拷貝,在資源內容上應該是完全一致的。所以在生成離線包的初期,我們需要接入的業務將釋出H5頁面的資源,在平臺同步上傳一份來保證資源一致。該方案需要同一份資源在兩個平臺釋出,因為不同平臺釋出策略的不同,會伴隨著帶來接入方改造成本,需要接入的H5業務同時維護兩套打包流程,以滿足離線包規範和H5頁面規範。

為了降低對H5業務帶來的額外接入成本,我們優化了離線包的生成流程。

接入方從一個可訪問的H5頁面URL入手,通過在服務端模擬瀏覽器的頁面載入過程,攔截所有的頁面載入請求,從中分析出可配置離線的資源URL列表返回到配置平臺。基於不同資源參與H5頁面首螢幕渲染的權重不一樣,在這一步我們也會提供優先css/js的返回策略。後續接入方可以通過視覺化的方式在平臺勾選希望離線載入的資源,確認後,服務端會拉取對應的URL資源生成離線包,並在包中加入一個資源描述檔案,包含使用者客戶端匹配的資源URL,以及真實的資源請求header,方便用於離線資源的回傳。

經過這個流程的優化,已經大大降低了業務接入成本,不需要再維護兩套打包策略,但是不可避免的還需要接入方手動選擇離線包資源。為進一步降低接入難度,我們提供了另一種前端工程自動化的離線包生成方式,通過提供命令列工具來融入前端H5專案中,配置生成離線包目錄,將該目錄中的資源通過一行命令生成合規離線包,並上傳到釋出平臺。

02

離線包本地管理

2.1 下載分級

為了提高離線包的使用率,對離線包的下載進行分級分類,一共分成T級、S級、A級分別對應不同的下載策略。

T級:app啟動或app切換前後臺時觸發下載,主要包括大促等入口在首頁且PV量較高的業務。

S級:首頁渲染完成後觸發下載,主要包括入口比較淺的業務,但同時避免搶佔首頁本身的資源載入。

A級:指定頁面觸發下載,適合入口比較固定、PV量相對較小的業務,避免所有使用者下載導致流量及頻寬的浪費。

同一級別下不同離線包的下載順序則採用配置權重+本地權重的方式進行權重加和計算優先順序,其中本地優先順序根據LRU演算法動態計算,使用者最近使用越多的離線包權重越高。

最終權重 = 配置權重 + 本地權重(LRU)

除了以上優先順序的處理,離線包下載也增加了部分前置下載條件,主要包括當前執行緒數、CPU使用率等,不做壓死駱駝的最後一根稻草。

2.2 差分包策略

京東商城離線包差分使用的是bsdiff差分演算法,那麼差分包如何進行管理呢?一般情況做法都是客戶端將本地離線包的版本號上傳給服務端,由服務端返回對應版本的差分包地址下發到客戶端。但是在離線包量較大的情況下,客戶端就需要獲取所有離線包版本進行上傳,對於客戶端和服務端都會增加一些邏輯。所以我們前後端做了規範約定,只需服務端按規範生成差分包地址即可,格式如下:

差分包下載url = 完整包url + "_"+服務端版本號 + "_" + 客戶端本地版本號,例如:

https://storage.360buyimg.com/hybrid/xxxxx.zip_2_1

客戶端拉到新版本離線包url後,就可以根據規範拼接出差分包下載地址。

2.3 自動灰度

通過離線包下載分級、差分包方案在一定程度上減少了離線包下載頻寬流量的減少,為了進一步減少離線包下載的流量峰值,我們增加了自動灰度能力,根據業務選擇灰度比例進行等差灰度放量。如業務選擇1小時完成全量灰度,後臺則自動按每5分鐘放量一次,12次放全量。

03

資源離線載入

3.1 資源匹配邏輯

前面提到離線包壓縮包中會包含離線包對應的資源對映配置檔案,通過h5頁面的url獲取到離線包後,讀取離線包中對映檔案內容進行資源本地載入匹配。

對映檔案內容如下:

[
{
"filename":"rp_h3aBW.html",
"originUrl":"https://h5.m.jd.com/babelDiy/index.html",
"type":"html",
"header":{
"content-type":"text/html",
"content-length":"1370"
}
},
{
"filename":"1HvyKyDC.js",
"originUrl":"https://storage.360buyimg.com/1654142210531/vendorJd.fa438901.js",
"type":"script",
"header":{
"content-type":"application/x-javascript",
"content-length":"415690",
"access-control-allow-origin":"*",
"timing-allow-origin":"*"
}
}
]

通過對映檔案的originUrl、filename欄位就可以將資源請求和本地檔案進行一一對映,對映對比也需要做一些相容處理。

  • scheme相容,url對比是需要忽略scheme,同一個連結可能存在http、https兩種訪問情況。

  • 圖片降質處理,一般圖片伺服器都會做圖片壓縮轉換等處理,h5頁面會根據當前環境請求不同的圖片,比如安卓4.4以上已經完全支援webp圖片,京東h5請求圖片時對於支援webp的環境在資源url的path末尾拼接“!webp”,客戶端匹配時會忽略"!"號以及後面的內容。

  • 域名打散,一般情況下,在超級活動期間,服務端為了減輕單個域名的壓力,會採用多域名分散的方式來降低單個域名的qps,因此,會有不同的域名連結對應同一個資源,所以,可以在資源匹配時忽略域名。

為了避免誤傷,這些措施都是通過配置下發來進行精細化控制的。

3.2 資源實時性

當前京東App的離線包配置是App啟動等特定時機請求拉取,在拉取配置到使用者實際進入頁面有一定的時間差,在京東這種電商App大促場景下,經常會有h5活動切場等情況,對實時性要求較高,要求使用者開啟頁面時請求的頁面資源必須是最新的,我們增加了版本校驗介面對離線包中包含html的離線包在進入頁面時進行離線包更新校驗。

這種方案雖然保證了實時性,但是我們通過監控發現,更新的概率非常小,大部分請求流量都是浪費的,如果有更新重新載入也會帶來不好的reload體驗。於是我們將離線包更新的資訊加入到京東閘道器介面中,只要App有閘道器請求都能獲取到是否有更新,做到了準實時。於是流程可以變成:

3.3 相容重定向

京東App使用的是登入後臺同步App WebView的登入態,載入業務落地頁時通過載入登入頁面302重定向到落地頁並同步後臺Cookie。

由於在安卓端谷歌上述攔截方法不支援302的情況,無法攔截302後的連結,導致業務html無法攔截載入本地檔案。這裡我們想到的辦法是通過原生網路請求登入打通的連結,再通過解析Header中的SetCookie獲取登入Cookie並將Cookie同步到瀏覽器核心,最後直接通過webview載入業務連結。

public void onSusses(int code, Map<String, List<String>> responseHeaders, String data)
if (header != null && (setCookies = header.get("Set-Cookie")) != null && !setCookies.isEmpty()) {
saveCookieString(url, setCookies);
}
}


public void saveCookieString(String url, List<String> cookies) {
CookieManager cookieManager = CookieManager.getInstance();
if (!cookieManager.acceptCookie()) {
return;
}
for (String cookieSegment : cookies) {
if (TextUtils.isEmpty(cookieSegment)) {
continue;
}
cookieManager.setCookie(url, cookieSegment);
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
CookieSyncManager.createInstance(HybridSettings.getAppContext());
CookieSyncManager.getInstance().sync();
} else {
CookieManager.getInstance().flush();
}
}

在iOS端同樣存在重定向的問題,我們前面已經介紹過了,WKURLSchemeTask並未提供重定向的回撥協議,所以,當攔截到的請求在Native端發生重定向時,我們會先處理重定向的response裡面的cookie資訊(如果存在的話),然後直接用當前webview去load重定向之後的newRequest,這樣就可以處理重定向的問題。

更多優化措施

前面我們主要介紹了通過離線包來解決h5頁面載入過程中實時拉取資源造成耗時的問題。下面我們再來看看其他影響h5載入效能的一些步驟。讓我們再次回到h5頁面的載入流程。

如上圖所示,通常情況下從使用者點選到看到頁面內容會包括 WebView初始化 、載入HTML、解析HTML、載入、解析JS/CSS資源、資料請求、頁面渲染 等流程,我們構建的離線載入系統可以節約多個“下載”過程的耗時。但是這裡依然有兩個問題。

雖然我們有離線包系統,但是並非所有的業務都可以將html做到離線包內,比如一些SSR的業務,html往往不是靜態頁面,這種場景下整個流程幾乎還是序列的(html->js->資料請求)。

頁面有意義的渲染一般都是發生在業務資料請求之後,上述流程中業務資料的請求和html、js等序列載入。

針對這兩點,我們採用了以下方案來做優化:通過html預載入,解決html序列載入的問題;通過介面預載入,解決業務資料序列請求的問題。

01

html預載入

我們的目標是將html下載的時機儘量提前。一般情況下,我們在html真正載入前不管是應用層面還是系統層面或者webkit內部都有一些預處理的邏輯,這些邏輯的耗時和業務複雜度相關,少則幾十毫秒,多則幾百毫秒,提前發起html請求可以有效的利用這段時間。當攔截到html請求時要麼直接回調已下載完成的html,要麼等待html返回後再進行回撥。無論如何,相比序列請求,這種機制都有效的利用了html載入前的這段時間。具體的流程見圖

02

介面預載入

資料介面的預載入的必要性與原理和html預載入類似,我們希望在頁面初始化之前就開始請求資料,等攔截到對應的請求時直接返回預載入好的資料,以節約頁面有意義的渲染時長。相對於html預載入,介面預載入的難點在於介面請求引數的配置,在最初的版本中,我們支援通過配置來完成請求引數的下發,可配置的內容包括:

  • 業務自定義引數的固定部分

  • 裝置、使用者基本資訊的對映

  • 介面請求中業務連結中的引數

在今年618大促期間,秒殺主會場、百億補貼等會場使用了介面預載入技術,從資料看可提升介面載入效能50%以上。但是這裡也有一些問題需要注意:

介面資料太大,會劣化資料預載入效果

我們的介面預載入採用了Native請求,然後將結果通過jsbridge回傳給h5,這個過程涉及到資料json化、字串化、程序間通訊、js層JSON.parse等一系列操作,這些操作的耗時與資料大小成正相關,所以做好資料規模的控制很有必要。如圖:

資料在做json字串化時需要注意特殊字元處理

我們在實踐中發現,通過js通訊回傳資訊給h5時,符合兩端統一的做法是均以jsonString進行回傳,而採用系統方法直接轉化的jsonString存在一些缺陷,特別是資料中包括一些特殊字元時會導致jsonString在js層面無法解析,我們在android端就碰到了資料中包含單引號時解析異常的問題。目前的解決方案是參考了iOS端的開源庫WebViewJavaScriptBridge的相關做法,針對系統轉化的jsonString進一步進行特殊字元的處理:

messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];

當然介面預載入這個簡單的配置方案在實踐中存在一些問題,導致目前適用場景比較受限。主要問題表現在:

純配置的系統表達能力較弱,無法準確描述請求引數結構,即使一些公共的引數,不同業務也有個性化的使用方式(比如一個引數,A業務可能在params中,B業務在body中),導致我們在不同的請求欄位內添加了相同的資料,冗餘較嚴重;

裝置資訊、使用者資訊等相對穩定的欄位,不同業務會定義個性化的key值,這類引數要通過配置提供對映;

無法較好的描述介面之間的依賴關係,多介面預載入實現難度大。所以簡單起見,我們前期僅支援了一個介面的預載入。

對於以上問題,我們也正在通過輕量級的表示式框架正在改進中。

03

記憶體預熱

除了上述預載入的優化方案之外,我們在資源快取管理上也進行了對應的優化,因為離線載入的資源儲存在磁碟上,頁面載入時讀取會有一定的io消耗,特別是離線資源較多的情況下,這些消耗累加起來還是比較可觀的。針對這部分消耗,我們採用了記憶體預熱的方法來進行優化。

當頁面建立時,我們開啟子執行緒將頁面專案對應的離線資源提前讀入記憶體快取池,攔截到對應的請求後,我們先檢視記憶體快取池是否有對應的資源,如果沒有才會去讀取磁碟。記憶體快取池在專案數量和資源數量兩個維度均採用LRU的淘汰策略進行約束,保證記憶體快取的上限水位在一個合理的範圍內。

這種預熱策略可以保證大部分的資源在請求時直接從記憶體讀取。而針對訪問量巨大的超級互動業務,我們也可以選擇首頁渲染完成後就在子執行緒進行預掃描快取,這樣可以極大的優化大流量業務的載入速度。

提效工具

01

除錯工具

在Hybrid開發的場景下,業務面對的最大的困難是載入過程是黑盒的,離線包是否命中,介面預載入等效能優化措施是否生效,業務需要一個簡潔明瞭的工具來檢視。結合業務需求與h5研發的使用習慣,我們提供了Hybrid下的除錯開發工具。

我們先來看下除錯工具需要展示的資料:

離線包是否命中:我們定義的離線包命中口徑為“至少有一個資源從本地獲取成功”。

頁面效能:我們提供了fcp和頁面初始化開始〜頁面didfinish時間間隔兩種指標,以供h5業務實時檢視首屏效能與使用者體驗感受。

介面預載入是否生效:h5成功的從客戶端獲取到了預載入到的介面資料之後,介面預載入生效。

html預載入是否生效:h5成功的從客戶端獲取到了預載入到的html資料之後,html預載入生效。

除了以上概括性的資訊之外,還有一些細節資訊也會比較重要 離線包專案資訊:如當前離線包的配置版本、檔案版本 離線包載入資訊:當前離線包命中的資源列表。

而其他輔助性的資訊或測試資訊,我們直接歸為log,實時展示在除錯面板上,業務可按需檢視。

最終資料展示面板如圖所示:

這樣,一個簡單的工具就可以有效的提升業務研發效率,降低團隊對外的答疑與諮詢量。

02

js api自動化測試

越是複雜的系統越應該重視測試的手段與質量,自動化的測試能力是最常見的保障系統穩定性的手段,Hybrid的功能又會涉及到多端,且功能眾多,日常迭代與修改很容易引發邊界類的bug,所以,我們將hybrid的各項功能聚合成了一個自動化執行的頁面,通過一鍵執行,即可覆蓋已知的核心case的測試,如圖:

對於Hybrid的UI類功能,我們正在結合集團的雲測平臺,通過指令碼執行、截圖、影象識別等能力打通相關UI類的功能的自動化測試能力。爭取儘早完成Hybrid能力自動化測試全覆蓋。

資料監控

為了監控優化成果,並進一步幫助h5頁面深入分析效能,我們與燭龍監控平臺合作,增加了多維度的效能監控。

0 1

多維度監控

監控指標主要包括效能監控、異常監控和離線包載入監控,每項指標都能夠多維度進行聚合分析,包括時間、客戶端、版本、系統、廠商、型別、核心等維度。

02

離線包資訊

離線包資訊包括離線包從拉取配置、到下載、再到使用更新整個鏈路的監控,能夠實時反饋線上使用者使用下載和使用離線包的情況。

0 3

效能監控

效能監控通過原生和JS同時採集的方式,更完整的監控h5載入過程,原生層面通過WebView回撥等方式進行資料採集,主要節點包括:

initStart:WebView例項開始初始化的時間節點。

loadUrl:WebView執行load(loadRequest)方法。

pageStart:安卓對應WebViewClient.onPageStart方法,iOS對應didStartProvisionalNavigation。

pageCommit:安卓對應WebViewClient.onPageCommitVisible,iOS對應didCommitNavigation。

colorRequestStart:業務介面請求開始。

colorRequestEnd:業務介面請求結束。

pageFinish:安卓對應WebViewClient.onPageFinish,iOS對應didFinishNavigation。

除此以外在頁面載入結束時,也就是上述的pageFinish節點通過執行js獲取更多效能資料,主要是通過Performance API獲取PerformanceTiming、FP、FCP、LCP、資源效能等。

PerformanceTiming:w3c引入的api,可獲取h5頁面載入的各個節點時間。 

FP:(First Paint)用於記錄頁面第一次繪製畫素的時間。

FCP:(First Contentful Paint)用於記錄頁面首次繪製文字、圖片、非空白 Canvas 或 SVG 的時間。

LCP:(Largest Contentful Paint)用於記錄視窗內最大的元素繪製的時間,該時間會隨著頁面渲染變化而變化,因為頁面中的最大元素在渲染過程中可能會發生改變,該指標獲取的是時間區間內的節點,所以需要在頁面開始載入時開始監聽,頁面載入結束後獲取。

以安卓為例,先註冊JS橋:

/**
*
* @param timing 頁面載入效能
* @param resource 資源網路請求效能(包括網路介面)
* @param paint fp、fcp
* @param lcp lcp
*/
@JavascriptInterface
public void sendResource(String timing, String resource, String paint, String lcp) {
//資料上報
}

頁面開始載入時(上述所提到的pageStart節點),開始注入LCP監聽。

String js = "try{" +
"const po = new PerformanceObserver((entryList) => {" +
"const entries = entryList.getEntries();" +
"const lastEntry = entries[entries.length - 1];" +
"window.jdhybrid_performance_lcp = lastEntry.renderTime || lastEntry.loadTime;});" +
"po.observe({type: 'largest-contentful-paint', buffered: true});" +
"}catch (e) {}";
webView.evaluateJavascript(js,null);

頁面載入結束時(上述所提到的pageFinish節點),獲取所有js效能資料。

String js = "try{" +
"window.hybridPerformance.sendResource(" +
"JSON.stringify(window.performance.timing)," +
"JSON.stringify(window.performance.getEntriesByType('resource'))," +
"JSON.stringify(window.performance.getEntriesByType('paint'))," +
"window.jdhybrid_performance_lcp ? window.jdhybrid_performance_lcp.toString():'');" +
"}catch (e) {}";
webView.evaluateJavascript(js,null);

0 4

異常監控

異常監控主要包括JS橋執行異常、頁面載入錯誤、頁面響應錯誤、資源請求失敗、JS Exception,webview白屏等,這些指標即可以用來日常排查問題,也可以作為度量Hybrid離線包接入之後的業務價值,關於h5頁面載入異常的處理策略後續我們再專門介紹。

開源計劃

我們計劃在下半年對上述寫到的主要能力在github進行開源,也誠邀感興趣的大佬們到時候一起進來完善、交流。

寫在最後

感謝互動、大促、頻道的開發團隊對京東h5頁面效能優化作出的貢獻,現在通過各種優化手段已經將整體效能有了一定程度的提升,我們也有更多的優化方案在輸出中,包括NSR、預渲染等。歡迎感興趣的小夥伴、有更多更好的優化方案的小夥伴可以留言一起討論。

參考文獻

WebKit官方文件:

https://developer.apple.com/documentation/webkit

MDN:

https://developer.mozilla.org/zh-CN/

WKWebView請求攔截探索與實踐:

https://juejin.cn/post/6922625242796032007

深入理解 WKWebView(基礎篇)—— 聊聊 cookie 管理那些事:

https://mp.weixin.qq.com/s/jZP2DsAa5OV91wdNMw39cA

WKURLSchemeHandler的能與不能:

https://www.jianshu.com/p/6bae04c91297

Webkit原始碼:

https://github.com/WebKit/webkit

Ajax-hook:

https://github.com/wendux/Ajax-hook

基於 LocalWebServer 實現 WKWebView 離線資源載入:

https://www.jianshu.com/p/a69e77bf680c