图片起源:https://unsplash.com
本文作者:谢贫贱
背景
WebView 在挪动端的利用场景随处可见,在云音乐里也作为许多外围业务的入口。为了满足云音乐日益简单的业务场景,咱们始终在继续一直的优化 WebView 的性能。其中能够短时间内晋升 WebView 加载速度的技术之一就是离线包技术。该技术可能节俭网络加载耗时,对于体积较大的网页晋升成果尤为显著。离线包技术中最要害的环节就是拦挡 WebView 收回的申请将资源映射到本地离线包,而对于 WKWebView
的申请拦挡 iOS 零碎原生并没有提供间接的能力,因而本文将围绕 WKWebView
申请拦挡进行探讨。
调研
咱们钻研了业内已有的 WKWebView
申请拦挡计划,次要分为如下两种:
NSURLProtocolNSURLProtocol
默认会拦挡所有通过 URL Loading System 的申请,因而只有 WKWebView
收回的申请通过 URL Loading System 就能够被拦挡。通过咱们的尝试,发现 WKWebView
独立于利用过程运行,收回去的申请默认是不会通过 URL Loading System,须要咱们额定进行 hook 能力反对,具体的形式能够参考 NSURLProtocol对WKWebView的解决。
WKURLSchemeHandlerWKURLSchemeHandler
是 iOS 11 引入的新个性,负责自定义申请的数据管理,如果须要反对 scheme 为 http 或 https申请的数据管理则须要 hook WKWebView
的 handlesURLScheme
: 办法,而后返回NO即可。
通过一番尝试和剖析,咱们从以下几个方面将两种计划进行比照:
- 隔离性:
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 数据,方案设计如下图所示:
具体流程次要为以下几点:
- 加载 HTML 文档的时候注入自定义的
Fetch
/XMLHttpRequest
对象脚本 - 发送申请之前收集 Body 等参数通过
WKScriptMessageHandler
传递给原生利用进行存储 - 原生利用存储实现之后调用约定好的 JavaScript 函数告诉
WKWebView
保留实现 - 调用原生
Fetch
/XMLHttpRequest
等接口来发送申请 - 申请被
WKURLSchemeHandler
治理,取出对应的 Body 等参数进行组装而后收回
脚本注入
替换 Fetch 实现
脚本注入须要批改 Fetch
接口的解决逻辑,在申请收回去之前能将 Body 等参数收集起来传递给原生利用,次要解决的问题为以下两点:
- iOS 11.3 之前 Body 失落问题
- iOS 11.3 之后 Body 中
Blob
类型数据失落问题
- 针对第一点须要判断在 iOS 11.3 之前的设施收回的申请是否蕴含申请体,如果满足则在调用原生
Fetch
接口之前须要将申请体数据收集起来传递给原生利用。 - 针对第二点同样须要判断在 iOS 11.3 之后的设施收回的申请是否蕴含申请体且申请体中是否带有
Blob
类型数据,如果满足则同上解决。
其余状况只需间接调用原生 Fetch
接口即可,放弃原生逻辑。
var nativeFetch = window.fetchvar 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
的注册和应用这里不再多加叙述,具体的能够参考上文中的调研局部以及苹果文档,这里咱们次要聊一聊拦挡过程中要留神的点
重定向
一些读者可能会留神到上文调研局部咱们在介绍 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); // 不反对307、308post跳转情景 [webView loadHTMLWithUrl:redirectUrl]; return; } } } decisionHandler(WKNavigationResponsePolicyAllow);}
至此 HTML 文档重定向问题基本上暂告一段落,到本文公布之前咱们还未发现一些边界问题,当然如果大家还有其它好的想法也欢送随时探讨。
Cookie 同步
因为 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 导致的内存泄露
通过 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!