如何接管WKWebView的网络请求?

10,540 阅读10分钟

众所周知,WKWebView是一个独立进程,对于开发者来说,WKWebView相当于一个黑盒。那么当开发者需要监控/接管WKWebView网络请求时,我们是否有方法可循呢?如果你也有这样的疑问,那请听我娓娓道来~

如何接管WKWebView的网络请求?.png

背景

用户免流
在网页中,客户端对免流用户无法做到对免流,这无疑是致命的。倘若免流用户在网页观看一个视频,实际却是使用自身流量,那么势必会收到用户投诉! 轻则被喷,重则赔钱。

监控网络
我们时常收到用户投诉网页无法打开,却由于WKWebView的黑盒属性,让我们的分析无从下手。所以我们必须监控WKWebView的网络请求!从而分析问题的严重性以及原因。

方案对比

NSURLProtocol是一个十分神奇的类,他是URL Loding System的一部分,可以拦截基于URL Loading System的网络请求,就类似于一个网络中间人。虽然WKWebView不会走APP的URL Loading System,但我们仍然可以通过一些技巧来进行拦截。这篇文章不展开分析,传送门 - NSURLProtocol对WKWebView的处理

WKURLSchemeHandler他是由苹果提供给开发者拦截自定义的URLScheme协议。传送门 - WKURLSchemeHandler介绍,通过一些技巧,我们也可以通过WKURLSchemeHandler来进行拦截。

稳定性

NSURLProtocol拦截到Post请求时,由于考虑到IPC通讯效率问题,WebKit开发者将将body进行了丢弃操作,所以会产生丢body的问题。
但我们可以通过注入js脚本,Hook-XMLHttpRequest/Fetch请求,然后通过window.webkit.messageHandlers将包装后的body传输到App端,并由APP端进行数据存储,以便NSURLProtocol拦截时使用。虽然通过这样的方式会消耗一定内存,并且使得流程变得复杂,但他是一种可行方案。

WKURLSchemeHandler对于绝大多数Post请求都是不会丢body的,但对于blob,表单等类型的请求也会有丢body的情况,具体操作和NSURLProtocol类似,后续我会展开描述。

在稳定性方面,两者相当。

安全性

NSURLProtocol侵入性略强,当NSURLProtocol打开后,所有基于URL Loading System的网络请求都将被拦截,这无疑增加了很多风险。其中最具代表的就是会侵略第三方库中的WKWebView, 导致其Post请求统统失败。

WKURLSchemeHandler通过WKWebViewConfiguration对WKWebView进行注入, 这种方式可以将拦截的粒度控制在单个WkWebView之中,从风险把控的角度来衡量,这无疑是更好的方案。

在安全性上,WkWebView无疑是更优的选择。

扩展性

NSURLProtocol只会在网络发起阶段进行拦截,所以对于那些被浏览器缓存的资源,NSURLProtocol将对其无从下手,丧失了一定的资源管控能力。

WKURLSchemeHandler拦截时机靠前,会在浏览器读取缓存之前就进行拦截。所以理论上WKURLSchemeHandler是可以对资源全权掌控,这就带来更多的可能和创想(后续我会进行这方面的分享)。

在扩展性上,我认为WKURLSchemeHandler更胜一筹。

兼容性

NSURLProtocol支持所有版本。

WKURLSchemeHandler从iOS11.0开始支持,但是根据线上数据来看,iOS11.0以下的用户占比极小,所以这个问题并不致命。

在兼容性上,NSURLProtocol表现的更加全面。

小结

我基于重要性以及现实情况对各项指标进行排序打分。稳定性总分10分,安全性总分10分,扩展性总分5分,兼容性总分5分。

NSURLProtocolWKURLSchemeHandler
稳定性8分8分
安全性5分8分
扩展性2分4分
兼容性5分4分
总分20分24分

基于上述分析,最终判定 WKURLSchemeHandler将会是一个更优的选择。

执行方案

对于这种未知且具备风险的功能,往往是让人担忧的。因为一个不小心就会导致线上事故,或者碰到意料之外的崩溃(根据经验来看,这是必然事件)。并且如何确认这个未知功能是否有效,以及确认后续推进方向,这也是让人头痛的问题。所以让方案的线上策略变得尤为重要!

线上策略

灰度开关

我在功能的总入口处配置了灰度开关,且通过后台控制灰度值。通过这个灰度开关可以达到三个目的:

  1. 保证安全:出现严重bug时,将灰度值设置为0即可全关网络拦截方案,走原生方案。
  2. 保证稳定:逐步覆盖,避免出现大面积bug出现。
  3. 数据对比:对比执行方案与原生方案数据,让数据更有说服力。 ps: 代码上怎么做灰度开关?代码中取[1-100]随机数A,后台下发灰度值G(范围[0-100]),若A<=G则命中灰度,执行拦截方案,反之亦然。举个例子:让灰度值为20%,那么将后台下发灰度值G设置为20即可。

构建白名单

我通过后台下发网页白名单,当WKWebView加载一个HTML时,会与白名单进行匹配。当HTML与白名单匹配成功&&命中灰度值时,则执行拦截方案。构建白名单的必要性在于:

  1. 降低侵入:将端外网页排除在外,避免不可控风险。
  2. 隔离风险:当某一些网页因拦截方案出现异常时,可以将其排除白名单,确保其他网页还能正常运行。

数据统计

一个好的数据统计不仅能让拦截方案更具备说服力,更能从数据上分析出潜在问题,并为后期迭代优化指明方向。为了达到上述目的,我构建了如下指标:

  1. 拦截方案覆盖率:用于判断拦截方案线上影响范围。
  2. 网页打开成功率:用于判断拦截方案是否产生负面影响。
  3. 拦截请求成功率:用于证明拦截方案的可靠性。
  4. 拦截请求失败事件:筛选和分析失败场景,排名Top的失败事件都是后续优化的集火对象。
  5. 数据对比:在数据2中增加拦截方案与原生方案进行对比,增加数据的可信度。

遇到的问题

拦截http/https时产生崩溃

场景: 按照文档使用指引,我们需要进行注册,代码如下:

WKWebViewConfiguration *wkconfig = [[WKWebViewConfiguration alloc] init];
WWKProxyWKURLSchemeHandler* schemeHandler = [[WWKProxyWKURLSchemeHandler alloc] init]; 
[wkconfig setURLSchemeHandler:schemeHandler forURLScheme:@"https"];
[wkconfig setURLSchemeHandler:schemeHandler forURLScheme:@"http"];

但是当我们真实运行时,会收到如下报错:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: ''http' is a URL scheme that WKWebView handles natively' 
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: ''https' is a URL scheme that WKWebView handles natively'

分析: 可以看到这是一个NSException异常,并且WebKit又是开源的,所以直接在源码中搜索这段异常信息,就能快速定位到对应的代码逻辑。 image.png 然后深入到+[WKWebView handlesURLScheme:]里面,研究其底层逻辑,其实就是最底层有个类型判断只要是WS、WSS、File、FTP、HTTP、HTTPS,都会报错。这篇文章就不做过多赘述了,感兴趣的朋友可以去研究下源码。

方案:通过上述分析,可以得到一个解决方案,那就是Hook +[WKWebView handlesURLScheme:],并将其重写绕过特殊判断逻辑。代码如下:

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method originalMethod = class_getClassMethod([WKWebView class], @selector(handlesURLScheme:));
        Method swizzledMethod = class_getClassMethod([self class], @selector(qm_handlesURLScheme:));
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

+ (BOOL)qm_handlesURLScheme:(NSString *)urlScheme
{
    if ([urlScheme isEqualToString:@"https"] || [urlScheme isEqualToString:@"http"])
    {
        return NO;
    }
    else
    {
        return [QMManager qm_handlesURLScheme:urlScheme];
    }
}

线程切换

场景: 退出网页时,有一定概率闪退。在正在播放视频的网页,更容易复现此bug。

分析WKURLSchemeHandler会在WKWebView进程中进行处理,然后抛出WKURLSchemeTask给到客户端。那么当网页退出时,WKURLSchemeTask会被停止,此时如果客户端网络请求返回,并且使用WKURLSchemeTask进行操作,就会出现The task has already been stopped的崩溃。究其原因是因为线程切换,导致WKURLSchemeTask状态无法及时同步。

方案:需要做如下两种保护操作

  1. WKURLSchemeTask添加关联对象(QMStopURLSchemeTask),当接收到回调- (void)webView:(nonnull WKWebView *)webView stopURLSchemeTask:(nonnull id<WKURLSchemeTask>)urlSchemeTask时,将QMStopURLSchemeTask设置为YES。而每次使用WKURLSchemeTask时,都必先判断QMStopURLSchemeTask状态,若为YES则停止后续流程,反之亦然。
  2. 建立WKWebView与网络任务的映射关系,当WKWebView-dealloc时,将其对应的网络任务都停止。

网络重定向

场景WKURLSchemeHandler没有开放重定向API。

分析WKURLSchemeHandler是苹果给到开发者拦截自定义的URLScheme协议,本质上就不会去关心网络相关内容。所以没有暴露重定向API也是情理之中了。

方案:所以直接去源码里面看看吧~ 很幸运的是,私有API中是有重定向的方法的image.png 但需要说明的两点是:

  1. 这里使用的是私有API,所以一定要做混淆,防止被拒审。
  2. 内部实现是直接修改WKURLSchemeTaskrequest信息,实现重定向的。 毕竟私有API不是完美解法,若大家有更好方案,欢迎留言/私聊讨论~

xhr upload的crash

场景:功能上线后出现了一个较多的崩溃,堆栈如下。

#0 0x00007fff4b8d6a3e in WebCore::blobRegistry() () 
#1 0x00007fff4b9052ad in WebCore::createHTTPBodyCFReadStream(WebCore::FormData&) () 
#2 0x00007fff4b905d18 in WebCore::setHTTPBody(_CFURLRequest*, WebCore::FormData*) ()
#3 0x00007fff4a851e0c in WebCore::ResourceRequest::doUpdatePlatformHTTPBody() () 
#4 0x00007fff4b8fe47d in WebCore::ResourceRequestBase::updatePlatformRequest(WebCore::HTTPBodyUpdatePolicy) const () 
#5 0x00007fff4a8506de in WebCore::ResourceRequest::nsURLRequest(WebCore::HTTPBodyUpdatePolicy) const () 
#6 0x00007fff2d1950ea in WebKit::WebURLSchemeTask::nsRequest() const () 
#7 0x0000000105f082f4 in WebViewProtocol.webView(_:start:) at /Users/t_honda/iOSProjects/smartdb-mobile-ios/smartdb/DocumentDetail/webView/WebViewProtocol.swift:30 
#8 0x0000000105f09989 in @objc WebViewProtocol.webView(_:start:) () 
#9 0x00007fff2d13b41f in WebKit::WebURLSchemeHandlerCocoa::platformStartTask(WebKit::WebPageProxy&, WebKit::WebURLSchemeTask&) () 
#10 0x00007fff2d1940ca in WebKit::WebURLSchemeHandler::startTask(WebKit::WebPageProxy&, WebKit::WebProcessProxy&, unsigned long long, WebCore::ResourceRequest&&, WTF::CompletionHandler

分析:通过日志定位到出错网页,然后复现其操作。发现其在使用xhr upload时,产生了崩溃。当时比较怀疑是系统的问题,经过一系列周折,最终在WebKit的issue中找到关键点:Bug 205685 - XMLHTTPRequest POSTs blob data to a custom WKURLSchemeHandler protocol crash。这里描述的崩溃原因是:当WKWebView创建一个httpBody中的data时,会调用WebCore::blobRegistry方法,但返回的是一个空指针,从而导致了崩溃。
另外在stackoverflow中也找到对应的解决方案,其通过私有api+[WebView _setLoadResourcesSerially:]来解决这个bug:iOS WKWebView - WKURLSchemeHandler crash on posting body (EXC_BAD_ACCESS)

方案:通过分析得出如下解决方案:

  1. 通过crash收集系统,归纳崩溃的系统版本。
  2. 根据线上bug系统收集的iOS版本信息,对指定iOS版本执行私有api,防止崩溃。

cookies同步问题

场景WKWebViewcookies是由WKHTTPCookieStore进行管理,客户端的cookies是由NSHTTPCookieStoreage进行管理。执行网络拦截方案后,绝大多数场景都得到了更好的体验,如:网页拉起客户端登录场景,由于网络都由客户端进行处理,所以免去了常规的NSHTTPCookieStoreageWKHTTPCookieStore同步操作。
但是在一些特殊情况下,如:某些网页需要获取cookies用作特殊逻辑判断,而网页只能从WKHTTPCookieStore获取到cookie,那么就会存在WKHTTPCookieStore还未更新,导致获取cookies已过期的问题。

分析:我通过对NSHTTPCookieStoreageWKHTTPCookieStore的单元测试,得出以下结论:

  1. WKWebView中的WKHTTPCookieStore是共用的。
  2. 系统会主动同步NSHTTPCookieStoreageWKHTTPCookieStore
    2.1 从WKHTTPCookieStore同步到NSHTTPCookieStoragecookie操作,都是安全的,及时的,因为NSHTTPCookieStoragecookie操作在主线程操作。
    2.2 从NSHTTPCookieStorage同步到WKHTTPCookieStorecookie操作,是不安全的,不及时的,因为WKHTTPCookieStorecookie操作都为异步线程且时机也是无法控制的。
  3. H5使用document.cookie会修改WKHTTPCookieStorecookie,并且还能够及时同步到NSHTTPCookieStore
    更多详情点这里: 关于WKHTTPCookieStore与NSHTTPCookieStorage同步问题
    注意:自测时请使用真机,模拟器是不会同步

方案:考虑到此处为小事件,为了缩小影响范围,我选择放弃对NSHTTPCookieStorage进行监听。转而为网页定制JSAPI,由网页来主动同步。
核心流程为
H5调用定制JSAPI
-> 传入当前需要同步的URL
-> 客户端接受到URL后,根据URL在NSHTTPCookieStorage中搜索对应的cookies,并回传给H5
-> H5获取到cookies,并将cookies存储到webStorage中。
至此完成cookies同步。

Blob类型丢body问题

场景:初次发现是H5裁剪图片后,并使用Blob类型上传裁剪图片,此时WKURLSchemeHandler拦截到的request请求中的body为空。

分析Blob是一个对象类型,且是在WKWebView进程中产生。当客户端通过WKURLSchemeHandler拦截时,由于此时Blob只是一个对象,且这个对象的地址是在WKWebView的进程之中,而我们无法在客户端进程去读取WKWebView进程中的内存信息,从而导致无法通过Blob对象获取真正的data数据。然后就会出现丢body的情况。这类情况都属于流式上传,所以像表单也会有丢body的问题。

方案:既然客户端进程无法使用Blob对象,那么就只能在WKWebView进程中操作Blob对象再传递给客户端进程。具体流程如下:

image.png

总结

本文从背景开始讲述,从用户免流以及监控网络论证了接管WKWebView网络请求的必要性。
之后从稳定性、安全性、扩展性、兼容性,四个角度对比WKURLSchemeHanlderNSURLProtocol,最终选择WKURLSchemeHanlder为技术方案。
在真正执行方案时,我首先分享了线上策略,主要囊括为三类:灰度开关、白名单、数据统计。让我们的方案在执行时具备稳定、安全且可分析的状态。之后又例举了真实遇到的问题与解决方案,大家可以从中参考,解决实际问题。
在我看来,此问题最大的难点是其未知性,由于竞品和网络文章中没有一个稳定的技术方案,并且网页的影响面极大,导致我无法给出准确的预期以及声明所有风险。相信大家在遇到类似的问题时,都会有迷茫,纠结,甚至畏惧的心情。我通过此次问题,总结了如下几点,希望大家之后遇到类似问题时,能够助你一臂之力:

  1. 方案预研要全面。从多个角度进行分析。
  2. 从源码中找答案。(如果条件允许的情况下)
  3. 安全永远是第一优先级。请在上线后做好安全开关,以备不时之需。
  4. 小步快跑。请做好灰度策略
  5. 构建合理的数据统计。 这会在论证、分析问题以及后续迭代起到决定性作用。
  6. 胆大心细才能有所成长。不要怂,就是干。
  7. 实践是验证真理的唯一标准