iOS 拦截 WKWebview 中的请求

3,349 阅读3分钟

原文地址 最近开发中遇到这样一个需求:需要将 WKWebview 请求的资源缓存到本地,下次请求,判断本地是否有对应的资源,如果有,就将缓存的资源返回,如果没有在去重新下载。

这时我们想到的就是使用 NSURLProtocol 拦截。

但是尝试之后发现在 WKWebview 中直接使用 NSURLProtocol 会不生效,这是因为:

WKWebview 在独立 app 进程之外的进程执行网络操作,请求数据不经过主线程。

这时,就需要一些 magic 的方案来解决此问题了。

通过查阅资料发现,在 webkit 的源码中,apple 在单元测试 TestProtocol.mm 中用到了 NSURLProtocol,用法如下:

+ (NSString *)scheme
{
    return testScheme;
}

+ (void)registerWithScheme:(NSString *)scheme
{
    testScheme = [scheme retain];
    [NSURLProtocol registerClass:[self class]];
    [WKBrowsingContextController registerSchemeForCustomProtocol:testScheme];
}

下面在看下 WKBrowsingContextController.mm 源码中 registerSchemeForCustomProtocol 的实现:

+ (void)registerSchemeForCustomProtocol:(NSString *)scheme
{
    WebProcessPool::registerGlobalURLSchemeAsHavingCustomProtocolHandlers(scheme);
}

意思是通过向 WebProcessPool 注册全局自定义 scheme ,从而实现拦截。

下面看下开篇提到的问题该如何实现:

  • 首先注册需要拦截的 schema,结束的时候需要取消注册,否则可能会导致其他页面加载不出来,代码如下:
- (void)registerCustomProtocol {
    [NSURLProtocol registerClass:[CustomURLProtocol class]];
    Class cls = NSClassFromString(@"WKBrowsingContextController");
    SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
    if ([cls respondsToSelector:sel]) {
        [cls performSelector:sel withObject:@"http"];
        [cls performSelector:sel withObject:@"https"];
    }
}

 - (void)dealloc{
    [NSURLProtocol unregisterClass:[CustomURLProtocol class]];
}
  • 然后实现自定义的 URL Protocol,需要继承 NSURLProtocol,需要重写下面几个方法:
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
- (void)startLoading;
- (void)stopLoading;

canInitWithRequest: 方法是用来判断请求是否进入自定义的 NSURLProtocol 加载器,使用方式如下:

static NSString *kURLProtocolHandledKey = @"urlProtocolHandleKey";

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    NSString *scheme = [[request URL] scheme];
    if ([scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"]) {
        // 判断是否已经处理过了,防止无限循环
        if ([NSURLProtocol propertyForKey:kURLProtocolHandledKey inRequest:request]) {
            return NO;
        }
        
        if ([request.URL.pathExtension isEqualToString:@"png"]) {
            return YES;
        }
    }
    
    return NO;
}

canonicalRequestForRequest: 方法用来重新设置 NSURLRequest 的信息, 在这个这方法里面可以对请求做些自定义操作,如添加请求头等,使用如下:

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    NSMutableURLRequest *mutableReqeust = [request mutableCopy];
    [mutableReqeust setValue:@"gzip" forHTTPHeaderField:@"Accept-Encoding"];
    return mutableReqeust;
}

startLoading 方法是被拦截请求开始执行的地方,代码如下:

- (void)startLoading {
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    // 标识该request已经处理过了
    [NSURLProtocol setProperty:@YES forKey:kURLProtocolHandledKey inRequest:mutableReqeust];
    
    NSString *fileName = @"xxx"/// 该请求对应的本地沙盒地址

    if ([[NSFileManager defaultManager] fileExistsAtPath:fileName]) {
        // 使用本地资源
        NSError *error = nil;
        NSData *data = [NSData dataWithContentsOfFile:filePath options:NSDataReadingUncached error:&error];
        /// 将 data 返回
        [self sendResponseWithData:data mimeType:[self getMimeTypeWithFilePath:filePath]];
    } else {
        /// 1、下载资源 
        /// 2、将下载的资源存到沙盒里
        /// 3、将数据返回
    }
}

下面看下如何将本地的 NSData 类型的数据返回,代码如下:

- (void)sendResponseWithData:(NSData *)data mimeType:(nullable NSString *)mimeType {
    NSURLResponse *response = [[NSURLResponse alloc] initWithURL:super.request.URL
                                                        MIMEType:mimeType
                                           expectedContentLength:-1
                                                textEncodingName:nil];
    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    [[self client] URLProtocol:self didLoadData:data];
    [[self client] URLProtocolDidFinishLoading:self];
}

stopLoading 方法,用来取消请求,代码如下:

- (void)stopLoading{
     [self.task cancel];
}

由于这是一个 magic 方法,调用的是私有 API ,会导致审核被拒,那么该如何规避这个风险呢?

答案就是 混淆,代码如下:

- (NSString *)base64DecodedString:(NSString *)origin {
    NSData *data = [[NSData alloc] initWithBase64EncodedString:origin options:0];
    return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
}

- (void)registerCustomProtocol {
    [NSURLProtocol registerClass:[CustomURLProtocol class]];
    Class cls = NSClassFromString([self base64DecodedString:@"V0tCcm93c2luZ0NvbnRleHRDb250cm9sbGVy"]);
    SEL sel = NSSelectorFromString([self base64DecodedString:@"cmVnaXN0ZXJTY2hlbWVGb3JDdXN0b21Qcm90b2NvbDo="]);    if ([cls respondsToSelector:sel]) {
        [cls performSelector:sel withObject:@"http"];
        [cls performSelector:sel withObject:@"https"];
    }
}

扫一扫关注我,get更多技术好文