圖片來源:unsplash.comjavascript
本文做者:謝富貴前端
WebView 在移動端的應用場景隨處可見,在雲音樂裏也做爲許多核心業務的入口。爲了知足雲音樂日益複雜的業務場景,咱們一直在持續不斷的優化 WebView 的性能。其中能夠短期內提高 WebView 加載速度的技術之一就是離線包技術。該技術可以節省網絡加載耗時,對於體積較大的網頁提高效果尤其明顯。離線包技術中最關鍵的環節就是攔截 WebView 發出的請求將資源映射到本地離線包,而對於 WKWebView
的請求攔截 iOS 系統原生並無提供直接的能力,所以本文將圍繞 WKWebView
請求攔截進行探討。java
咱們研究了業內已有的 WKWebView
請求攔截方案,主要分爲以下兩種:ios
NSURLProtocolgit
NSURLProtocol
默認會攔截全部通過 URL Loading System 的請求,所以只要 WKWebView
發出的請求通過 URL Loading System 就能夠被攔截。通過咱們的嘗試,發現 WKWebView
獨立於應用進程運行,發出去的請求默認是不會通過 URL Loading System,須要咱們額外進行 hook 才能支持,具體的方式能夠參考 NSURLProtocol對WKWebView的處理。github
WKURLSchemeHandlerweb
WKURLSchemeHandler
是 iOS 11 引入的新特性,負責自定義請求的數據管理,若是須要支持 scheme 爲 http 或 https請求的數據管理則須要 hook WKWebView
的 handlesURLScheme
: 方法,而後返回NO便可。macos
通過一番嘗試和分析,咱們從如下幾個方面將兩種方案進行對比:跨域
NSURLProtocol
一經註冊就是全局開啓。通常來說咱們只會攔截本身的業務頁面,但使用了 NSURLProtocol
的方式後會致使應用內合做的三方頁面也會被攔截從而被污染。WKURLSchemeHandler
則能夠以頁面爲維度進行隔離,由於是跟隨着 WKWebViewConfiguration
進行配置。NSURLProtocol
攔截過程當中會丟失 Body,WKURLSchemeHandler
在 iOS 11.3 以前 (不包含) 也會丟失 Body,在 iOS 11.3 之後 WebKit 作了優化只會丟失 Blob 類型數據。WKWebView
發出的請求被 NSURLProtocol
攔截後行爲可能發生改變,好比想取消 video 標籤的視頻加載通常都是將資源地址 (src) 設置爲空,但此時 stopLoading
方法卻不會調用,相比而言 WKURLSchemeHandler
表現正常。調研的結論是:WKURLSchemeHandler
在隔離性、穩定性、一致性上表現優於 NSURLProtocol
,可是想在生產環境投入使用必需要解決 Body 丟失的問題。瀏覽器
經過上文能夠得知只經過 WKURLSchemeHandler
進行請求攔截是沒法覆蓋全部的請求場景,由於存在 Body 丟失的狀況。因此咱們的研究重點就是確保如何不讓 Body 數據丟失或者提早拿到 Body 數據而後再將其組裝成一個完整的請求發出,很顯然前者須要對 WebKit 源碼進行改動,成本太高,所以咱們選擇了後者。經過修改 JavaScript 原生的 Fetch / XMLHttpRequest 等接口實現來提早拿到 Body 數據,方案設計以下圖所示:
具體流程主要爲如下幾點:
Fetch
/ XMLHttpRequest
對象腳本WKScriptMessageHandler
傳遞給原生應用進行存儲WKWebView
保存完成Fetch
/ XMLHttpRequest
等接口來發送請求WKURLSchemeHandler
管理,取出對應的 Body 等參數進行組裝而後發出腳本注入須要修改 Fetch
接口的處理邏輯,在請求發出去以前能將 Body 等參數收集起來傳遞給原生應用,主要解決的問題爲如下兩點:
Blob
類型數據丟失問題1. 針對第一點須要判斷在 iOS 11.3 以前的設備發出的請求是否包含請求體,若是知足則在調用原生 Fetch
接口以前須要將請求體數據收集起來傳遞給原生應用。
2. 針對第二點一樣須要判斷在 iOS 11.3 以後的設備發出的請求是否包含請求體且請求體中是否帶有 Blob
類型數據,若是知足則同上處理。
其他狀況只需直接調用原生 Fetch
接口便可,保持原生邏輯。
var nativeFetch = window.fetch
var interceptMethodList = ['POST', 'PUT', 'PATCH', 'DELETE'];
window.fetch = function(url, opts) {
// 判斷是否包含請求體
var hasBodyMethod = opts != null && opts.method != null && (interceptMethodList.indexOf(opts.method.toUpperCase()) !== -1);
if (hasBodyMethod) {
// 判斷是否爲iOS 11.3以前(可經過navigate.userAgent判斷)
var shouldSaveParamsToNative = isLessThan11_3;
if (!shouldSaveParamsToNative) {
// 若是爲iOS 11.3以後請求體是否帶有Blob類型數據
shouldSaveParamsToNative = opts != null ? isBlobBody(opts) : false;
}
if (shouldSaveParamsToNative) {
// 此時須要收集請求體數據保存到原生應用
return saveParamsToNative(url, opts).then(function (newUrl) {
// 應用保存完成後調用原生fetch接口
return nativeFetch(newUrl, opts)
});
}
}
// 調用原生fetch接口
return nativeFetch(url, opts);
}
複製代碼
經過 WKScriptMessageHandler
接口就能將請求體數據保存到原生應用,而且須要生成一個惟一標識符對應到具體的請求體數據以便後續取出。咱們的思路是生成標準的 UUID 做爲標識符而後隨着請求體數據一塊兒傳遞給原生應用進行保存,而後再將 UUID 標識符拼接到請求連接後,請求被 WKURLSchemeHandler
管理後會經過該標識符去獲取具體的請求體數據而後組裝成請求發出。
function saveParamsToNative(url, opts) {
return new Promise(function (resolve, reject) {
// 構造標識符
var identifier = generateUUID();
var appendIdentifyUrl = urlByAppendIdentifier(url, "identifier", identifier)
// 解析body數據並保存到原生應用
if (opts && opts.body) {
getBodyString(opts.body, function(body) {
// 設置保存完成回調,原生應用保存完成後調用此js函數後將請求發出
finishSaveCallbacks[identifier] = function() {
resolve(appendIdentifyUrl)
}
// 通知原生應用保存請求體數據
window.webkit.messageHandlers.saveBodyMessageHandler.postMessage({'body': body, 'identifier': identifier}})
});
}else {
resolve(url);
}
});
}
複製代碼
在 Fetch
接口中能夠經過第二個 opts 參數拿到請求體參數即 opts.body,參考 MDN Fetch Body 可得知請求體的類型有七種。通過分析,能夠將這七種數據類型分爲三類進行解析編碼處理,將 ArrayBuffer
、ArrayBufferView
、Blob
、File
歸類爲二進制類型,string
、URLSearchParams
歸類爲字符串類型,FormData
歸類爲複合類型,最後統一轉換成字符串類型返回給原生應用。
function getBodyString(body, callback) {
if (typeof body == 'string') {
callback(body)
}else if(typeof body == 'object') {
if (body instanceof ArrayBuffer) body = new Blob([body])
if (body instanceof Blob) {
// 將Blob類型轉換爲base64
var reader = new FileReader()
reader.addEventListener("loadend", function() {
callback(reader.result.split(",")[1])
})
reader.readAsDataURL(body)
} else if(body instanceof FormData) {
generateMultipartFormData(body)
.then(function(result) {
callback(result)
});
} else if(body instanceof URLSearchParams) {
// 遍歷URLSearchParams進行鍵值對拼接
var resultArr = []
for (pair of body.entries()) {
resultArr.push(pair[0] + '=' + pair[1])
}
callback(resultArr.join('&'))
} else {
callback(body);
}
}else {
callback(body);
}
}
複製代碼
二進制類型爲了方便傳輸統一轉換成 Base64 編碼。字符串類型中 URLSearchParams
遍歷以後可獲得鍵值對。複合類型存儲結構相似爲字典,值可能爲 string
或者 Blob
類型,因此須要遍歷而後按照 Multipart/form-data 格式進行拼接。
注入的腳本主要內容如上述所示,示例中只是替換了 Fetch
的實現,XMLHttpRequest
也是按照一樣的思路進行替換便可。雲音樂因爲最低版本支持到 iOS 11.0,而 FormData.prototype.entries
是在 iOS 11.2 之後的版本才支持,對於以前的版本能夠修改 FormData.prototype.set
方法的實現來保存鍵值對,這裏很少加贅述。除此以外,請求多是由內嵌的 iframe
發出,此時直接調用 finishSaveCallbacks[identifier]()
是無效的,由於 finishSaveCallbacks 是掛載在 Main Window 上的,能夠考慮使用 window.postMessage
方法來跟子 Window 進行通訊。
WKURLSchemeHandler
的註冊和使用這裏再也不多加敘述,具體的能夠參考上文中的調研部分以及蘋果文檔,這裏咱們主要聊一聊攔截過程當中要注意的點
一些讀者可能會注意到上文調研部分咱們在介紹 WKURLSchemeHandler
時把它的做用定義爲自定義請求的數據管理。那麼爲何不是自定義請求的數據攔截呢?理論上攔截是不須要開發者關心請求邏輯,開發者只用處理好過程當中的數據便可。而對於數據管理開發者須要關注過程當中的全部邏輯,而後將最終的數據返回。帶着這兩個定義,咱們再一塊兒對比下 WKURLSchemeTask
和 NSURLProtocol
協議,可見後者比前者多了重定向、鑑權等相關請求處理邏輯。
API_AVAILABLE(macos(10.13), ios(11.0))
@protocol WKURLSchemeTask <NSObject>
@property (nonatomic, readonly, copy) NSURLRequest *request;
- (void)didReceiveResponse:(NSURLResponse *)response;
- (void)didReceiveData:(NSData *)data;
- (void)didFinish;
- (void)didFailWithError:(NSError *)error;
@end
複製代碼
API_AVAILABLE(macos(10.2), ios(2.0), watchos(2.0), tvos(9.0))
@protocol NSURLProtocolClient <NSObject>
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveResponse:(NSURLResponse *)response cacheStoragePolicy:(NSURLCacheStoragePolicy)policy;
- (void)URLProtocol:(NSURLProtocol *)protocol didLoadData:(NSData *)data;
- (void)URLProtocolDidFinishLoading:(NSURLProtocol *)protocol;
- (void)URLProtocol:(NSURLProtocol *)protocol didFailWithError:(NSError *)error;
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
- (void)URLProtocol:(NSURLProtocol *)protocol didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
@end
複製代碼
那麼該如何在攔截過程當中處理重定向響應?咱們嘗試着每次收到響應時都調用 didReceiveResponse:
方法,發現中間的重定向響應都會被最後接收到的響應覆蓋掉,這樣則會致使 WKWebView
沒法感知到重定向,從而不會改變地址等相關信息,對於一些有判斷路由的頁面可能會帶來一些意想不到的影響。 此時咱們再次陷入困境,能夠看出 WKURLSchemeHandler
在獲取數據時並不支持重定向,由於蘋果當初設計的時候只是把它做爲單純的數據管理。其實每次響應咱們都能拿到,只不過不能完整的傳遞給 WKWebView
而已。通過一番衡量,咱們基於如下三點緣由最終選擇了從新加載的方式來解決 HTML 文檔請求重定向的問題。
Fetch
和 XMLHttpRequest
接口的實現,對於文檔請求和 HTML 標籤發起請求都是瀏覽器內部行爲,修改源碼成本太大。Fetch
和 XMLHttpRequest
默認只會返回最終的響應,因此在服務端接口層面保證最終數據正確,丟失重定向響應影響不大。接收到 HTML 文檔的重定向響應則直接返回給 WKWebView
並取消後續加載。而對於其它資源的重定向,則選擇丟棄。
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler {
NSString *originUrl = task.originalRequest.URL.absoluteString;
if ([originUrl isEqualToString:currentWebViewUrl]) {
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didFinish];
completionHandler(nil);
}else {
completionHandler(request);
}
}
複製代碼
WKWebView
收到響應數據後會調用 webView:decidePolicyForNavigationResponse:decisionHandler
方法來決定最後的跳轉,在該方法中能夠拿到重定向的目標地址 Location 進行從新加載。
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler
{
// 開啓了攔截
if (enableNetworkIntercept) {
if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)navigationResponse.response;
NSInteger statusCode = httpResp.statusCode;
NSString *redirectUrl = [httpResp.allHeaderFields stringForKey:@"Location"];
if (statusCode >= 300 && statusCode < 400 && redirectUrl) {
decisionHandler(WKNavigationActionPolicyCancel);
// 不支持30七、308post跳轉情景
[webView loadHTMLWithUrl:redirectUrl];
return;
}
}
}
decisionHandler(WKNavigationResponsePolicyAllow);
}
複製代碼
至此 HTML 文檔重定向問題基本上暫告一段落,到本文發佈以前咱們還未發現一些邊界問題,固然若是你們還有其它好的想法也歡迎隨時討論。
因爲 WKWebView
與咱們的應用不是同一個進程因此 WKWebView
和 NSHTTPCookieStorage
並不一樣步。這裏不展開講 WKWebView Cookie 同步的整個過程,只重點討論下攔截過程當中的 Cookie 同步。因爲請求最終是由原生應用發出的,因此 Cookie 讀取和存儲都是走 NSHTTPCookieStorage
。值得注意的是,WKURLSchemeHandler
返回給 WKWebView
的響應中包含 Set-Cookie
信息,可是 WKWebView 並未設置到 document.cookie
上。在這裏也能夠佐證上文所述: WKURLSchemeHandler
只是負責數據管理,請求中涉及的邏輯須要開發者自行處理。
WKWebView
的 Cookie 同步能夠經過 WKHTTPCookieStore
對象來實現
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)response;
NSArray <NSHTTPCookie *>*responseCookies = [NSHTTPCookie cookiesWithResponseHeaderFields:[httpResp 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
[[WKWebsiteDataStore defaultDataStore].httpCookieStore setCookie:cookie completionHandler:nil];
}];
});
}
}
completionHandler(NSURLSessionResponseAllow);
}
複製代碼
攔截過程當中除了把原生應用的 Cookie 同步到 WKWebView
, 在修改 document.cookie
時也要同步到原生應用。通過嘗試發現真機設備上 document.cookie
在修改後會主動延遲同步到 NSHTTPCookieStorage
中,可是模擬器並未作任何同步。對於一些修改完 document.cookie
就馬上發出去的請求可能不會當即帶上改動的 Cookie 信息,由於攔截以後 Cookie
是走 NSHTTPCookieStorage
的。
咱們的方案是修改 document.cookie
setter 方法實現,在 Cookie 設置完成以前先同步到原生應用。注意原生應用此時須要作好跨域校驗,防止惡意頁面對 Cookie 進行任意修改。
(function() {
var cookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie');
if (cookieDescriptor && cookieDescriptor.configurable) {
Object.defineProperty(document, 'cookie', {
configurable: true,
enumerable: true,
set: function (val) {
// 設置時先傳遞給原生應用才生效
window.webkit.messageHandlers.save.postMessage(val);
cookieDescriptor.set.call(document, val);
},
get: function () {
return cookieDescriptor.get.call(document);
}
});
}
})()
複製代碼
經過 NSURLSession
的 sessionWithConfiguration:delegate:delegateQueue
構造方法來建立對象時 delegate 是被 NSURLSession
強引用的,這一點你們比較容易忽視。咱們會爲每個 WKURLSchemeHandler
對象建立一個 NSURLSession
對象而後將前者設置爲後者的 delegate,這樣就致使循環引用的產生。建議在 WKWebView
銷燬時調用 NSURLSession
的 invalidateAndCancel
方法來解除對 WKURLSchemeHandler
對象的強引用。
通過上文能夠看出若是跟系統 「對着幹」(WKWebView
自己就不支持 http/https 請求攔截),會有不少意想不到的事情發生,也可能有不少的邊界地方須要覆蓋,因此咱們必須得有一套完善的措施來提高攔截過程當中的穩定性。
咱們能夠經過動態下發黑名單的方式來關掉一些頁面的攔截。雲音樂默認會預加載兩個空 WKWebView
,一個是註冊了 WKURLSchemeHandler
的 WKWebView
來加載主站頁面,而且支持黑名單關閉,另一個則是普通的 WKWebView
來加載一些三方頁面(由於三方頁面的邏輯比較多樣和複雜,並且咱們也沒有必要去攔截三方頁面的請求)。除此以外對於一些剛開始嘗試經過腳本注入來解決請求體丟失的團隊,可能覆蓋不了全部的場景,能夠嘗試動態下發的方式更新腳本,一樣要對腳本內容作好籤名防止別人惡意篡改。
日誌收集能幫助咱們更好的去發現潛在的問題。攔截過程當中全部的請求邏輯都統一收攏在 WKURLSchemeHandler
中,咱們能夠在一些關鍵鏈路上進行日誌收集。好比能夠收集註入的腳本是否執行異常、接收到 Body 是否丟失、返回的響應狀態碼是否正常等等。
除上述措施外咱們還能夠將網絡請求好比服務端 API 接口徹底代理給客戶端。前端只用將相應的參數經過 JSBridge 方式傳遞給原生應用而後經過原生應用的網絡請求通道來獲取數據。該方式除了能減小攔截過程當中潛在問題的發生,還能複用原生應用的一些網絡相關的能力好比 HTTP DNS、反做弊等。並且值得注意的是 iOS 14 蘋果在 WKWebView
默認開啓了 ITP (Intelligent Tracking Prevention) 智能防跟蹤功能,受影響的地方主要是跨域 Cookie 和 Storage 等的使用。好比咱們應用裏有一些三方頁面須要經過一個 iframe
內嵌咱們的頁面來達到受權能力,此時因爲跨域默認是獲取不到咱們主站域名下的 Cookie, 若是走原生應用的代理請求就能解決相似的問題。最後再次提醒你們若是使用這種方式記得作好鑑權校驗,防止一些惡意頁面調用該能力,畢竟原生應用的請求是沒有跨域限制的。
本文將 iOS 原生 WKURLSchemeHandler
與 JavaScript
腳本注入結合在一塊兒,實現了 WKWebView
在離線包加載、免流等業務中須要的請求攔截能力,解決了攔截過程當中可能存在的重定向、請求體丟失、Cookie 不一樣步等問題並能以頁面爲維度進行攔截隔離。在探索過程當中咱們愈發的感覺到技術是沒有邊界的,有時候可能因爲平臺的一些限制,單靠一方是沒法實現一套完整的能力。只有將相關平臺的技術能力結合在一塊兒,才能制定出一套合理的技術方案。最後,本文是咱們在 WKWebView
請求攔截的一些探索實踐,若有錯誤歡迎指正與交流。
本文發佈自 網易雲音樂大前端團隊,文章未經受權禁止任何形式的轉載。咱們常年招收前端、iOS、Android,若是你準備換工做,又剛好喜歡雲音樂,那就加入咱們 grp.music-fe(at)corp.netease.com!