WKWebView 请求拦截探索与实践

图片来源: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 WKWebViewhandlesURLScheme: 方法,而后返回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 数据,方案设计以下图所示:

具体流程主要为如下几点:

  • 加载 HTML 文档的时候注入自定义的 Fetch / XMLHttpRequest 对象脚本
  • 发送请求以前收集 Body 等参数经过 WKScriptMessageHandler 传递给原生应用进行存储
  • 原生应用存储完成以后调用约定好的 JavaScript 函数通知 WKWebView 保存完成
  • 调用原生 Fetch / XMLHttpRequest 等接口来发送请求
  • 请求被 WKURLSchemeHandler 管理,取出对应的 Body 等参数进行组装而后发出

脚本注入

替换 Fetch 实现

脚本注入须要修改 Fetch 接口的处理逻辑,在请求发出去以前能将 Body 等参数收集起来传递给原生应用,主要解决的问题为如下两点:

  • iOS 11.3 以前 Body 丢失问题
  • iOS 11.3 以后 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 可得知请求体的类型有七种。通过分析,能够将这七种数据类型分为三类进行解析编码处理,将 ArrayBufferArrayBufferViewBlobFile 归类为二进制类型,stringURLSearchParams 归类为字符串类型,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 时把它的做用定义为自定义请求的数据管理。那么为何不是自定义请求的数据拦截呢?理论上拦截是不须要开发者关心请求逻辑,开发者只用处理好过程当中的数据便可。而对于数据管理开发者须要关注过程当中的全部逻辑,而后将最终的数据返回。带着这两个定义,咱们再一块儿对比下 WKURLSchemeTaskNSURLProtocol 协议,可见后者比前者多了重定向、鉴权等相关请求处理逻辑。

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 文档请求重定向的问题。

  • 目前能修改的只有 FetchXMLHttpRequest 接口的实现,对于文档请求和 HTML 标签发起请求都是浏览器内部行为,修改源码成本太大。
  • FetchXMLHttpRequest 默认只会返回最终的响应,因此在服务端接口层面保证最终数据正确,丢失重定向响应影响不大。
  • 图片 / 视频 / 表单 / 样式表 / 脚本等资源同理也通常只需关系最终的数据正确便可。

接收到 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 文档重定向问题基本上暂告一段落,到本文发布以前咱们还未发现一些边界问题,固然若是你们还有其它好的想法也欢迎随时讨论。

Cookie 同步

因为 WKWebView 与咱们的应用不是同一个进程因此 WKWebViewNSHTTPCookieStorage 并不一样步。这里不展开讲 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 致使的内存泄露

经过 NSURLSessionsessionWithConfiguration:delegate:delegateQueue 构造方法来建立对象时 delegate 是被 NSURLSession 强引用的,这一点你们比较容易忽视。咱们会为每个 WKURLSchemeHandler 对象建立一个 NSURLSession 对象而后将前者设置为后者的 delegate,这样就致使循环引用的产生。建议在 WKWebView 销毁时调用 NSURLSessioninvalidateAndCancel 方法来解除对 WKURLSchemeHandler 对象的强引用。

稳定性提高

通过上文能够看出若是跟系统 “对着干”(WKWebView 自己就不支持 http/https 请求拦截),会有不少意想不到的事情发生,也可能有不少的边界地方须要覆盖,因此咱们必须得有一套完善的措施来提高拦截过程当中的稳定性。

动态下发

咱们能够经过动态下发黑名单的方式来关掉一些页面的拦截。云音乐默认会预加载两个空 WKWebView,一个是注册了 WKURLSchemeHandlerWKWebView 来加载主站页面,而且支持黑名单关闭,另一个则是普通的 WKWebView 来加载一些三方页面(由于三方页面的逻辑比较多样和复杂,并且咱们也没有必要去拦截三方页面的请求)。除此以外对于一些刚开始尝试经过脚本注入来解决请求体丢失的团队,可能覆盖不了全部的场景,能够尝试动态下发的方式更新脚本,一样要对脚本内容作好签名防止别人恶意篡改。

监控

日志收集能帮助咱们更好的去发现潜在的问题。拦截过程当中全部的请求逻辑都统一收拢在 WKURLSchemeHandler 中,咱们能够在一些关键链路上进行日志收集。好比能够收集注入的脚本是否执行异常、接收到 Body 是否丢失、返回的响应状态码是否正常等等。

彻底代理请求

除上述措施外咱们还能够将网络请求好比服务端 API 接口彻底代理给客户端。前端只用将相应的参数经过 JSBridge 方式传递给原生应用而后经过原生应用的网络请求通道来获取数据。该方式除了能减小拦截过程当中潜在问题的发生,还能复用原生应用的一些网络相关的能力好比 HTTP DNS、反做弊等。并且值得注意的是 iOS 14 苹果在 WKWebView 默认开启了 ITP (Intelligent Tracking Prevention) 智能防跟踪功能,受影响的地方主要是跨域 Cookie 和 Storage 等的使用。好比咱们应用里有一些三方页面须要经过一个 iframe 内嵌咱们的页面来达到受权能力,此时因为跨域默认是获取不到咱们主站域名下的 Cookie, 若是走原生应用的代理请求就能解决相似的问题。最后再次提醒你们若是使用这种方式记得作好鉴权校验,防止一些恶意页面调用该能力,毕竟原生应用的请求是没有跨域限制的。

小结

本文将 iOS 原生 WKURLSchemeHandlerJavaScript 脚本注入结合在一块儿,实现了 WKWebView 在离线包加载、免流等业务中须要的请求拦截能力,解决了拦截过程当中可能存在的重定向、请求体丢失、Cookie 不一样步等问题并能以页面为维度进行拦截隔离。在探索过程当中咱们愈发的感觉到技术是没有边界的,有时候可能因为平台的一些限制,单靠一方是没法实现一套完整的能力。只有将相关平台的技术能力结合在一块儿,才能制定出一套合理的技术方案。最后,本文是咱们在 WKWebView 请求拦截的一些探索实践,若有错误欢迎指正与交流。

本文发布自 网易云音乐大前端团队,文章未经受权禁止任何形式的转载。咱们常年招收前端、iOS、Android,若是你准备换工做,又刚好喜欢云音乐,那就加入咱们 grp.music-fe(at)corp.netease.com!

相关文章
相关标签/搜索