众所周知,WKWebView是一个独立进程,对于开发者来说,WKWebView相当于一个黑盒。那么当开发者需要监控/接管WKWebView网络请求时,我们是否有方法可循呢?如果你也有这样的疑问,那请听我娓娓道来~
背景
用户免流
在网页中,客户端对免流用户无法做到对免流,这无疑是致命的。倘若免流用户在网页观看一个视频,实际却是使用自身流量,那么势必会收到用户投诉! 轻则被喷,重则赔钱。
监控网络
我们时常收到用户投诉网页无法打开,却由于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分。
NSURLProtocol | WKURLSchemeHandler | |
---|---|---|
稳定性 | 8分 | 8分 |
安全性 | 5分 | 8分 |
扩展性 | 2分 | 4分 |
兼容性 | 5分 | 4分 |
总分 | 20分 | 24分 |
基于上述分析,最终判定 WKURLSchemeHandler将会是一个更优的选择。
执行方案
对于这种未知且具备风险的功能,往往是让人担忧的。因为一个不小心就会导致线上事故,或者碰到意料之外的崩溃(根据经验来看,这是必然事件)。并且如何确认这个未知功能是否有效,以及确认后续推进方向,这也是让人头痛的问题。所以让方案的线上策略变得尤为重要!
线上策略
灰度开关
我在功能的总入口处配置了灰度开关,且通过后台控制灰度值。通过这个灰度开关可以达到三个目的:
- 保证安全:出现严重bug时,将灰度值设置为0即可全关网络拦截方案,走原生方案。
- 保证稳定:逐步覆盖,避免出现大面积bug出现。
- 数据对比:对比执行方案与原生方案数据,让数据更有说服力。 ps: 代码上怎么做灰度开关?代码中取[1-100]随机数A,后台下发灰度值G(范围[0-100]),若A<=G则命中灰度,执行拦截方案,反之亦然。举个例子:让灰度值为20%,那么将后台下发灰度值G设置为20即可。
构建白名单
我通过后台下发网页白名单,当WKWebView加载一个HTML时,会与白名单进行匹配。当HTML与白名单匹配成功&&命中灰度值时,则执行拦截方案。构建白名单的必要性在于:
- 降低侵入:将端外网页排除在外,避免不可控风险。
- 隔离风险:当某一些网页因拦截方案出现异常时,可以将其排除白名单,确保其他网页还能正常运行。
数据统计
一个好的数据统计不仅能让拦截方案更具备说服力,更能从数据上分析出潜在问题,并为后期迭代优化指明方向。为了达到上述目的,我构建了如下指标:
- 拦截方案覆盖率:用于判断拦截方案线上影响范围。
- 网页打开成功率:用于判断拦截方案是否产生负面影响。
- 拦截请求成功率:用于证明拦截方案的可靠性。
- 拦截请求失败事件:筛选和分析失败场景,排名Top的失败事件都是后续优化的集火对象。
- 数据对比:在数据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又是开源的,所以直接在源码中搜索这段异常信息,就能快速定位到对应的代码逻辑。
然后深入到+[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状态无法及时同步。
方案:需要做如下两种保护操作
- 为WKURLSchemeTask添加关联对象(QMStopURLSchemeTask),当接收到回调
- (void)webView:(nonnull WKWebView *)webView stopURLSchemeTask:(nonnull id<WKURLSchemeTask>)urlSchemeTask
时,将QMStopURLSchemeTask设置为YES。而每次使用WKURLSchemeTask时,都必先判断QMStopURLSchemeTask状态,若为YES则停止后续流程,反之亦然。 - 建立WKWebView与网络任务的映射关系,当WKWebView-dealloc时,将其对应的网络任务都停止。
网络重定向
场景:WKURLSchemeHandler没有开放重定向API。
分析: WKURLSchemeHandler是苹果给到开发者拦截自定义的URLScheme协议,本质上就不会去关心网络相关内容。所以没有暴露重定向API也是情理之中了。
方案:所以直接去源码里面看看吧~ 很幸运的是,私有API中是有重定向的方法的。
但需要说明的两点是:
- 这里使用的是私有API,所以一定要做混淆,防止被拒审。
- 内部实现是直接修改WKURLSchemeTask的request信息,实现重定向的。 毕竟私有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)
方案:通过分析得出如下解决方案:
- 通过crash收集系统,归纳崩溃的系统版本。
- 根据线上bug系统收集的iOS版本信息,对指定iOS版本执行私有api,防止崩溃。
cookies同步问题
场景:WKWebView的cookies是由WKHTTPCookieStore进行管理,客户端的cookies是由NSHTTPCookieStoreage进行管理。执行网络拦截方案后,绝大多数场景都得到了更好的体验,如:网页拉起客户端登录场景,由于网络都由客户端进行处理,所以免去了常规的NSHTTPCookieStoreage与WKHTTPCookieStore同步操作。
但是在一些特殊情况下,如:某些网页需要获取cookies用作特殊逻辑判断,而网页只能从WKHTTPCookieStore获取到cookie,那么就会存在WKHTTPCookieStore还未更新,导致获取cookies已过期的问题。
分析:我通过对NSHTTPCookieStoreage与WKHTTPCookieStore的单元测试,得出以下结论:
- WKWebView中的WKHTTPCookieStore是共用的。
- 系统会主动同步NSHTTPCookieStoreage与WKHTTPCookieStore。
2.1 从WKHTTPCookieStore同步到NSHTTPCookieStorage的cookie操作,都是安全的,及时的,因为NSHTTPCookieStorage的cookie操作在主线程操作。
2.2 从NSHTTPCookieStorage同步到WKHTTPCookieStore的cookie操作,是不安全的,不及时的,因为WKHTTPCookieStore的cookie操作都为异步线程且时机也是无法控制的。 - H5使用
document.cookie
会修改WKHTTPCookieStore的cookie,并且还能够及时同步到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对象再传递给客户端进程。具体流程如下:
总结
本文从背景开始讲述,从用户免流以及监控网络论证了接管WKWebView网络请求的必要性。
之后从稳定性、安全性、扩展性、兼容性,四个角度对比WKURLSchemeHanlder与NSURLProtocol,最终选择WKURLSchemeHanlder为技术方案。
在真正执行方案时,我首先分享了线上策略,主要囊括为三类:灰度开关、白名单、数据统计。让我们的方案在执行时具备稳定、安全且可分析的状态。之后又例举了真实遇到的问题与解决方案,大家可以从中参考,解决实际问题。
在我看来,此问题最大的难点是其未知性,由于竞品和网络文章中没有一个稳定的技术方案,并且网页的影响面极大,导致我无法给出准确的预期以及声明所有风险。相信大家在遇到类似的问题时,都会有迷茫,纠结,甚至畏惧的心情。我通过此次问题,总结了如下几点,希望大家之后遇到类似问题时,能够助你一臂之力:
- 方案预研要全面。从多个角度进行分析。
- 从源码中找答案。(如果条件允许的情况下)
- 安全永远是第一优先级。请在上线后做好安全开关,以备不时之需。
- 小步快跑。请做好灰度策略
- 构建合理的数据统计。 这会在论证、分析问题以及后续迭代起到决定性作用。
- 胆大心细才能有所成长。不要怂,就是干。
- 实践是验证真理的唯一标准。