之前写过一篇文章是关于基于 NSURLProtocol 做的 DNS 解析,其中对 NSURLProtocol 也有了简单的介绍,我们都知道他可以拦截所有基于 URL Loading System 中的请求,但是对于 WKWebview 里面所发出的请求即使他是 http/https 也无能为力,先来简单的了解下 WKWebView.

# WKWebview

iOS8 以后,苹果推出了新框架 Webkit,提供了替换 UIWebView 的组件 WKWebView。各种 UIWebView 的问题没有了,速度更快了,占用内存少了,一句话,WKWebView 是 App 内部加载网页的最佳选择!我们做开发最关系的是内存问题,基本上网上所有的资料都在说 WKWebview 的内存占用会更少,但是到底少了多少我这边做了下测试,同样是加载 163 的首页

使用UIWebView的内存
使用WKWebview的内存

从上图看出内存大概能优化百分之八十左右,而且从网页的滑动上也确实有所改善。这么明显的性能提升但是苹果并没有完全放弃 UIWebView 也一定有他的道理,就拿本文要讲的 NSURLProtocol 拦截请求来说,WKWebview 的兼容并不 UIWebView 好,还需要开发者做一些操作。

# WebKit 源码分析

由于 WKWebview 是基于 webkit 内核来做的,所以我们在使用的时候需要导入一个这样的东西。
#import <WebKit/WebKit.h>
通过这个我们可以猜到 WKWebview 中所有的请求以及一些逻辑肯定走的都是 webkit 里面的东西,所以他对于网页的加载之之类的操作也不会走系统本省的 URL Loading System,这么说来他的请求不能被 NSURLProtocol 拦截也是理所当然的了。不过 WKWebview 是否真的和 NSURLProtocol 一点关系都没有还需要去研究,幸好 webkit 是开源的,github 上很容易找到源码(大小大概是 1G 多点的 zip,花了我将近一天时间来看)。拉下代码直接搜索 NSURLProtocol,看看有没有有关的信息
搜索结果

看来的确是有和 NSURLProtocol 有关系,后面通过断点的调用栈中也找到了
+ [NSURLProtocol canInitWithRequest:]
这样的字样,再通过网上查一些资料也证实了我的猜想,其实 WKWebview 在一开始时候是会调用到 NSURLProtocol 中的入口方法 canInitWithRequest 的,但是就没有然后了,也就是说 WKWebview 是和 NSURLProtocol 有一定关联,只是在 NSURLProtocol 的入口处返回 NO 所以导致 NSURLProtocol 不接管 WKWebview 的请求。我们点进 webkit 源码中的 CustomProtocol 可以看到,整体的结构我们都差不多,但是我注意到每个 CustomProtocol 的入口函数都有这样一个判断:
入口函数1

入口函数2
(粉色的可以暂时认定为是它内部的一个 custom 字符串) 通过这个可以猜想,WKWebview 并不是不走 NSURLProtocol,而是需要满足他的一个规则,他才会在入口函数这里返回 YES 来给你放行,这个规则便是你所请求的 URL 的 Scheme 要和它内部配置的 CustomScheme 相同。不过这里有一个疑问,苹果在使用 webkit 时候为什么会把 http/https 这样大众化的 scheme 过滤掉,看来他是不建议开发者来使用 NSURLProtocol。接下来我们来看这个 CustomScheme,既然苹果内部规定好的那么一定能通过某种方式来注册一个自己的 scheme,实在不行就 hook 嘛。通过翻他的源码发现最终都指向一句代码

[WKBrowsingContextController registerSchemeForCustomProtocol:testScheme];

方法实现为
+ (void) registerSchemeForCustomProtocol:(NSString *) scheme
{
WebProcessPool::registerGlobalURLSchemeAsHavingCustomProtocolHandlers(scheme);
}

1
2
3
4
5
6
7
8
9
void WebProcessPool::registerGlobalURLSchemeAsHavingCustomProtocolHandlers(const String& urlScheme)
{
if (!urlScheme)
return;

globalURLSchemesWithCustomProtocolHandlers().add(urlScheme);
for (auto* processPool : allProcessPools())
processPool->registerSchemeForCustomProtocol(urlScheme);
}

通过方法名字可以看出这个就是那个向 webkit 注册 CustomScheme 的方法,只要我们在注册完我们自己的 CustomProtocol 之后在调用该方法应该就可以了。通过他的源码也进一步印证了我的猜想 (他也是这么写的)
webkit源码

# 具体实施

找到了方法就要去实施,不过因为 registerSchemeForCustomProtocol 是 WKBrowsingContextController 的类方法,所以只能用 WKBrowsingContextController 去调用,但是在 webkit 的头文件发现 WKBrowsingContextController 并没有开放出来,所以我们采用 NSClassFromString 和 NSSelectorFromString 方法来拿到类和对应的方法,整体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//注册自己的protocol
[NSURLProtocol registerClass:[CustomProtocol class]];

//创建WKWebview
WKWebViewConfiguration * config = [[WKWebViewConfiguration alloc] init];
WKWebView * wkWebView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height) configuration:config];
[wkWebView loadRequest:webViewReq];
[self.view addSubview:wkWebView];

//注册scheme
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([cls respondsToSelector:sel]) {
// 通过http和https的请求,同理可通过其他的Scheme 但是要满足ULR Loading System
[cls performSelector:sel withObject:@"http"];
[cls performSelector:sel withObject:@"https"];
}

实现效果。我将网页中所有的图片替换成了柴犬图片

效果

# 值得注意
  • 关于私有 API

因为 WKBrowsingContextController 和 registerSchemeForCustomProtocol 应该是私有的所以使用时候需要对字符串做下处理,用加密的方式或者其他就可以了,实测可以过审核的。

  • 关于 post 请求
    大家会发现拦截不了 post 请求 (拦截到的 post 请求 body 体为空),这个其实和 WKWebview 没有关系,这个是苹果为了提高效率加快流畅度所以在 NSURLProtocol 拦截之后索性就不复制 body 体内的东西,因为 body 的大小没有限制,开发者可能会把很大的数据放进去那就不好办了。我们可以采取 httpbodystream 的方式拿到 body,这个在之前的文章也有提过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#pragma mark -
#pragma mark 处理POST请求相关POST 用HTTPBodyStream来处理BODY体
- (NSMutableURLRequest *)handlePostRequestBodyWithRequest:(NSMutableURLRequest *)request {
NSMutableURLRequest * req = [request mutableCopy];
if ([request.HTTPMethod isEqualToString:@"POST"]) {
if (!request.HTTPBody) {
uint8_t d[1024] = {0};
NSInputStream *stream = request.HTTPBodyStream;
NSMutableData *data = [[NSMutableData alloc] init];
[stream open];
while ([stream hasBytesAvailable]) {
NSInteger len = [stream read:d maxLength:1024];
if (len > 0 && stream.streamError == nil) {
[data appendBytes:(void *)d length:len];
}
}
req.HTTPBody = [data copy];
[stream close];
}
}
return req;
}