使用WKWebview拦截替换本地资源

WKWebView替换本地资源的原理是通过NSURLProtocol代理WKWebView内的所有网络请求, 中间拿到每次加载的URL, 然后动态替换成本地沙盒内的对应路径下的资源文件.

什么是NSURLProtocol

NSURLProtocol是URL Loading System的重要组成部分。它听上去像一个协议类, 其实不是, 它是一个抽象类, 我们可以子类化来拦截网络请求。可以拦截的网络请求包括NSURLSessionNSURLConnection以及UIWebvIew。本来之前是不能拦截WKWebView的, 因为WKWebView 在独立于 app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在 WKWebView 上直接使用 NSURLProtocol无法拦截请求。

但是有大神通过阅读webkit的源码, 以及使用反射的方式拿到了WKWebView用来处理请求的上下文和注册反注册方法: WKBrowsingContextControllerregisterSchemeForCustomProtocol, unregisterSchemeForCustomProtocol. 然后通过KVC拿到browsingContextController实例, 把httphttps请求注册给NSURLProtocol处理.

NSURLProtocol结合WKWebView的使用

给NSURLProtocol写一个WKWebView的分类

头文件 NSURLProtocol+WKWebVIew.h

1
2
3
4
5
6
7
8
9
#import <Foundation/Foundation.h>

@interface NSURLProtocol (WKWebVIew)

+ (void)wk_registerScheme:(NSString*)scheme;

+ (void)wk_unregisterScheme:(NSString*)scheme;

@end

NSURLProtocol+WKWebVIew.m文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#import "NSURLProtocol+WKWebVIew.h"
#import <WebKit/WebKit.h>
//FOUNDATION_STATIC_INLINE 属于属于runtime范畴,你的.m文件需要频繁调用一个函数,可以用static inline来声明。从SDWebImage从get到的。
FOUNDATION_STATIC_INLINE Class ContextControllerClass() {
static Class cls;
if (!cls) {
cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];
}
return cls;
}

FOUNDATION_STATIC_INLINE SEL RegisterSchemeSelector() {
return NSSelectorFromString(@"registerSchemeForCustomProtocol:");
}

FOUNDATION_STATIC_INLINE SEL UnregisterSchemeSelector() {
return NSSelectorFromString(@"unregisterSchemeForCustomProtocol:");
}

@implementation NSURLProtocol (WebKitSupport)

+ (void)wk_registerScheme:(NSString *)scheme {
Class cls = ContextControllerClass();
SEL sel = RegisterSchemeSelector();
if ([(id)cls respondsToSelector:sel]) {
// 放弃编辑器警告
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
}
}

+ (void)wk_unregisterScheme:(NSString *)scheme {
Class cls = ContextControllerClass();
SEL sel = UnregisterSchemeSelector();
if ([(id)cls respondsToSelector:sel]) {
// 放弃编辑器警告
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
}
}

@end

这样我们就可以方便的给WKWebView进行注册和反注册, 毕竟我们只需要代理WKWebView内的请求, 所以当我们webview关闭的时候, 要取消注册.

子类化NSURLProtocol

如上文所说,NSURLProtocol是一个抽象类。我们要使用它的时候需要创建它的一个子类.

1
@interface LPURLProtocol : NSURLProtocol

使用NSURLProtocol的主要可以分为5个步骤:
注册—>拦截—>转发—>回调—>结束

注册:

WKWebView初始化之前:

1
2
3
[NSURLProtocol registerClass:[LPURLProtocol class]];
[NSURLProtocol wk_registerScheme:@"http"];
[NSURLProtocol wk_registerScheme:@"https"];

拦截:

然后在我们子类化的LPURLProtocol.m内加入以下代码:

1
2
3
4
5
6
7
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
NSString *extension = request.URL.pathExtension;//获取拦截URL的文件后缀
BOOL isSource = [[self resourceTypes] indexOfObjectPassingTest:^BOOL(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
return [extension compare:obj options:NSCaseInsensitiveSearch] == NSOrderedSame;
}] != NSNotFound;
return [NSURLProtocol propertyForKey:kURLProtocolMark inRequest:request] == nil && isSource;
}

其中的resourceTypes是我自定义的方法, 用来获取我需要拦截替换的后缀名数组. 当我们拦截到请求之后, 通过判断当前请求URL的后缀是否包含在此数组内, 包含在内则进行下一步资源替换的操作.以下是内容:

1
2
3
+ (NSArray *)resourceTypes{
return @[@"js", @"png", @"jpeg", @"jpg", @"ico", @"xml", @"css", @"html", @"vsh", @"fsh", @"txt", @"atlas", @"json", @"tmx", @"ExportJson", @"plist"];
}

还有一个canonicalRequestForRequest方法, 用来对request进行处理, 比如修改header信息等.这里我没做操作:

1
2
3
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
return request;
}

转发:

canInitWithRequest拦截成功之后, 会走startLoading 方法, 我们可以在这个方法中做资源的替换操作.

这里提示一下, 我目前的资源替换处理方式是提前在APP启动的时候, 通过接口比对, 下载对应的资源文件到沙盒内, 沙盒内的路径遵循跟服务端URLpath路径一致, 这样替换的时候, 直接取URLpath, 然后拼接到沙盒Library/cache后, 就可以直接拿到对应的资源, 然后进行替换.

首先定义一个静态字符, 用来标记我们已经拦截处理过的request, 不然会循环引用:

1
2
3
4
5
6
7
static NSString* const kURLProtocolMark = @"kURLProtocolMark";

@interface LPURLProtocol ()<NSURLSessionDelegate>

@property (nonatomic, strong) NSURLSessionDataTask *task;

@end

以下是startLoading的实现, 其中[LPGMManager sharedManager].gmUrl是我的h5请求的域名,你们可以替换成你们自己的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
- (void)startLoading {
NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
//给我们处理过的请求设置一个标识符, 防止无限循环,
[NSURLProtocol setProperty:@YES forKey:kURLProtocolMark inRequest:mutableReqeust];
//这里最好加上缓存判断,加载本地离线文件, 如果是这个域名的URL则进行资源替换
if ([mutableReqeust.URL.absoluteString hasPrefix:[LPGMManager sharedManager].gmUrl]) {
NSString *filePath = mutableReqeust.URL.path;//拿到URL的path路径
NSString *localFilePath = [[LPGMManager sharedManager].localFolderPath stringByAppendingString:filePath];//拼接获取到本地沙盒路径
if (![[NSFileManager defaultManager] fileExistsAtPath:localFilePath]) {
//文件不存在,去下载:这里你们也可以再检测到本地没有对应文件, 实时去请求下载
//[self downloadResourcesWithRequest:[mutableReqeust copy]];
return;
}
//加载本地资源
NSData *data = [NSData dataWithContentsOfFile:localFilePath];
[self sendResponseWithData:data mimeType:[self getMimeTypeWithFilePath:mutableReqeust.URL.pathExtension]];
} else {//不需要拦截的则转发给系统去进行正常的请求流程
NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
self.task = [session dataTaskWithRequest:self.request];
[self.task resume];
}
}

- (void)sendResponseWithData:(NSData *)data mimeType:(nullable NSString *)mimeType {
// 这里需要用到MIMEType
NSURLResponse *response = [[NSURLResponse alloc] initWithURL:super.request.URL
MIMEType:mimeType
expectedContentLength:-1
textEncodingName:nil];

//硬编码 开始嵌入本地资源到web中
[[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[[self client] URLProtocol:self didLoadData:data];
[[self client] URLProtocolDidFinishLoading:self];
}

- (NSString *)getMimeTypeWithFilePath:(NSString *)filePath{
CFStringRef pathExtension = (__bridge_retained CFStringRef)[filePath pathExtension];
CFStringRef type = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension, NULL);
CFRelease(pathExtension);

//The UTI can be converted to a mime type:
NSString *mimeType = (__bridge_transfer NSString *)UTTypeCopyPreferredTagWithClass(type, kUTTagClassMIMEType);
if (type != NULL)
CFRelease(type);

return mimeType;
}

回调:

既是面向切面的编程,就不能影响到原来网络请求的逻辑。所以上一步将网络请求转发出去以后,当收到网络请求的返回,还需要再将返回值返回给原来发送网络请求的地方。
主要需要需要调用到:

1
2
3
4
[self.client URLProtocol:self didFailWithError:error];
[self.client URLProtocolDidFinishLoading:self];
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[self.client URLProtocol:self didLoadData:data];

这四个方法来回调给原来发送网络请求的地方。
这里假设我们在转发过程中是使用NSURLSession发送的网络请求,那么在NSURLSession的回调方法中,我们做相应的处理即可。并且我们也可以对这些返回,进行定制化处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
if (error) {
[self.client URLProtocol:self didFailWithError:error];
} else {
[self.client URLProtocolDidFinishLoading:self];
}
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];

completionHandler(NSURLSessionResponseAllow);
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
[self.client URLProtocol:self didLoadData:data];
}

结束:

在一个网络请求完全结束以后,NSURLProtocol回调用到

- (void)stopLoading

在该方法里,我们完成在结束网络请求的操作。

1
2
3
4
5
- (void)stopLoading {
if (self.task != nil) {
[self.task cancel];
}
}

总结:

那么以上我们就针对WKWebView实现了NSURLProtocol的代理转发.

参考文章:
NSURLProtocol全攻略
iOS WKWebview实现拦截js,css,html以及图片资源替换为本地资源的两种方式(NSUrlProtocol)

WkWebView拦截替换本地音频,图片

0%