IOS网络:NSURLProtocol网络拦截

原创:知识进阶型文章
无私奉献,为国为民,创作不易,请珍惜,之后会持续更新,不断完善
个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

目录

  • 1、NSURLProtocol介绍
  • 2、CustomURLProtocol
  • 3、WKWebView的加载
  • 3、NSURLSession的加载
  • 4、加载AFNetworking
  • 5、使用runtime来实现加载AFNetworking
  • Demo
  • 参考文献

1、NSURLProtocol介绍

URL Loading System
含义

NSURLProtocol是苹果为我们提供的 URL Loading System 的一部分,能够让你去重新定义苹果的URL Loading System的行为。用一句话解释NSURLProtocol:就是一个苹果允许的中间人攻击。NSURLProtocol可以劫持系统所有基于CFsocket的网络请求。不管你是通过NSURLSession或者第三方库 (AFNetworkingAlamofire等),他们都是基于NSURLSession实现的,因此你可以通过NSURLProtocol做自定义的操作。WKWebView基于Webkit,并不走底层的CFsocket,所以NSURLProtocol拦截不了WKWebView中的请求。

具体流程

URL Loading System里有许多类用于处理URL请求,比如NSURLNSURLRequestNSURLSession等,当URL Loading System使用NSURLRequest去获取资源的时候,它会创建一个NSURLProtocol子类的实例,NSURLProtocol看起来像是一个协议,但其实这是一个类,你不能直接实例化一个NSURLProtocol,而是需要写一个继承自 NSURLProtocol 的子类,并通过- registerClass:方法注册我们的协议类,然后 URL 加载系统就会在请求发出时使用我们创建的协议对象对该请求进行处理。

简单归纳下,使用NSURLProtocol的主要可以分为5个步骤:注册—>拦截—>转发—>回调—>结束。即:注册NSURLProtocol子类 -> 使用NSURLProtocol子类拦截请求 -> 使用NSURLSession重新发起请求 -> 将NSURLSession请求的响应内容返回 -> 结束

使用场景

举个例子:因为DNS发生域名劫持,所以需要手动将URL请求的域名重定向到指定的IP地址,但是由于请求可能是通过NSURLSession或者AFNetworking等方式,因此要想统一进行处理,可以采用NSURLProtocol

  • 重定向网络请求(可以解决DNS域名劫持问题)
  • 忽略网络请求,使用本地缓存
  • 自定义网络请求的返回结果Response
  • 拦截图片加载请求,转为从本地文件加载
  • 一些全局的网络请求设置
  • 快速进行测试环境的切换
  • 过滤掉一些非法请求
  • 网络的缓存处理(如网络图片缓存)
  • 可以拦截基于系统的NSURLSession进行封装的网络请求。目前WKWebView无法被NSURLProtocol拦截。
  • 当有多个自定义NSURLProtocol注册到系统中的话,会按照他们注册的反向顺序依次调用URL加载流程。当其中有一个NSURLProtocol拦截到请求的话,后续的NSURLProtocol就无法拦截到该请求。

2、CustomURLProtocol

子类化

由于 NSURLProtocol是一个抽象类,所以使用的时候必须先定义一个它的子类,这里我们新建CustomURLProtocol继承自NSURLProtocol

@interface CustomURLProtocol : NSURLProtocol

@end
注册

对于基于NSURLSession或者使用[NSURLSession sharedSession]初始化对象创建的网络请求,调用registerClass方法即可

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // 注册NSURLProtocol的子类
    [NSURLProtocol registerClass:[CustomURLProtocol class]];
}

一经注册之后,所有交给URL Loading system的网络请求都会被拦截,所以当不需要拦截的时候,要进行注销

- (void)dealloc
{
    [NSURLProtocol unregisterClass:[CustomURLProtocol class]];
}
抽象对象必须实现的拦截方法

canInitWithRequest:所有注册此Protocol的请求都会经过这个方法的判断,该方法会拿到request的对象,我们可以通过该方法的返回值来筛选request是否需要被NSURLProtocol做拦截处理。

百度Logo.png

此处尝试拦截 http://www.baidu.com/ 即百度搜索首页其中的标题栏的Logo图片,首先需要在打印出来的absoluteString找到我们想要的Logo图片的URL,接着通过判断是否相等进行拦截,返回YES即进入拦截流程。

// 通过该方法的返回值来筛选request是否需要被NSURLProtocol做拦截处理
+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{
    // 获取所有的absoluteString
    NSString *absoluteString = [[request URL] absoluteString];
    NSLog(@"absoluteString--%@",absoluteString);
    // 拦截百度标题栏的logo图片,返回YES进行拦截,目的是替换为自己的海贼王图片
    if ([absoluteString isEqualToString:@"https://www.baidu.com/img/flexible/logo/plus_logo_web.png"])
    {
        return YES;
    }
    
    // 默认返回NO,不进行拦截
    return NO;
}

canonicalRequestForRequest:可选方法,对需要拦截的请求进行自定义的处理,这个方法用来统一处理请求request对象的,可以修改头信息,或者重定向。没有特殊需要,则直接return request。通常我们的做法是直接return request,在后面的startLoading方法中进行拦截处理。还有一点需要注意的是,如果要在这里做重定向以及添加头信息的时候注意检查是否已经添加,因为这个方法可能被调用多次。

// 可选方法,对需要拦截的请求进行自定的处理,没有特殊需要,则直接return request
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
{
    return request;
}

requestIsCacheEquivalent:用来判断两个request请求是否相同,这个方法基本不常用。如果相同,则可以使用缓存数据。通常只需要调用父类的实现即可,默认为YES

// 用来判断两个request请求是否相同,这个方法基本不常用,通常只需要调用父类的实现即可
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b
{
    return [super requestIsCacheEquivalent:a toRequest:b];
}

initWithRequest:在拦截到网络请求,并且对网络请求进行定制处理以后。我们需要将网络请求重新发送出去,就可以初始化一个NSURLProtocol对象了。该方法会创建一个NSURLProtocol实例,在这里直接调用super的指定构造器方法,实例化一个对象。

// 该方法会创建一个NSURLProtocol实例,在这里直接调用super的指定构造器方法,将网络请求重新发送出去
- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client
{
    return [super initWithRequest:request cachedResponse:cachedResponse client:client];
}
转发的核心方法startLoading

开始请求的方法。在该方法中,把当前请求的request拦截下来以后,可以在这里修改请求信息,重定向网络,DNS解析,使用自定义的缓存等。

在这里需要我们手动的把请求发出去,可以使用原生NSURLSession,也可以使用第三方网络库AFNetworking。对于NSURLSession,就是发起一个NSURLSessionDataTask,同时设置NSURLSessionDataDelegate协议,接收Server端的响应。

一般下载前需要设置该请求正在进行下载,防止多次下载的情况发生。这个方法之后,会回调<NSURLProtocolClient>协议中的方法。

此处我们想要拦截百度标题栏的logo图片,再将其替换为自己本地的海贼王图片,所以首先我们需要一个获取本地图片的方法。

// 取出本地图片
- (NSData *)getImageData
{
    NSString *fileName = [[NSBundle mainBundle] pathForResource:@"haizeiwang.jpg" ofType:@""];
    return [NSData dataWithContentsOfFile:fileName];
}

接着调用clientdidLoadData加载数据方法。

- (void)startLoading
{
    // 获取所有的absoluteString
    NSString *absoluteString = [[self.request URL] absoluteString];
    // 拦截百度标题栏的logo图片,替换为自己本地的海贼王图片
    if ([absoluteString isEqualToString:@"https://www.baidu.com/img/flexible/logo/plus_logo_web.png"])
    {
        // 取出本地图片
        NSData *data = [self getImageData];
        // 接着调用client的didLoadData加载数据方法
        [self.client URLProtocol:self didLoadData:data];
    }
}

stopLoading:请求被停止,结束网络请求的操作。当NSURLProtocolClient的协议方法都回调完毕后,就会开始执行这个方法了。


3、WKWebView的加载

引入#import <WebKit/WebKit.h>框架,再声明wk变量

@interface NSURLProtocolViewController ()
@property (nonatomic, strong) WKWebView *wk;
@end

实现webViewButton的回调事件loadWebView,首先需要移除之前的WKWebView,并进行网络请求,这里为百度首页。

// 加载WKWebView
- (void)loadWKWebView
{
    // 移除旧的
    [self.wk removeFromSuperview];
    self.wk = nil;
    
    // 创建新的WKWebView
    self.wk = [[WKWebView alloc] initWithFrame:CGRectMake(0, 300, self.view.bounds.size.width, 600)];
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.baidu.com"]];
    [self.wk loadRequest:request];
    [self.view addSubview:self.wk];
}

WKWebView打印出absoluteString,无法找到百度Logo图片,所以不能进行拦截替换图片。原因是WKWebView在独立于app 进程之外的进程(webkit)中执行网络请求,请求数据不经过主进程(URL Loading System),因此,在WKWebView上直接使用 NSURLProtocol无法拦截请求。

WKWebView无法实现拦截.png

其实WKWebview在一开始时候是会调用到NSURLProtocol中的入口方法canInitWithRequest的,但是就没有然后了,也就是说WKWebview是和NSURLProtocol有一定关联,只是在NSURLProtocol的入口处返回NO所以导致NSURLProtocol不接管WKWebview的请求。

返回YES的规则便是你所请求的URLScheme要和它内部配置的CustomScheme相同。不过这里有一个疑问,苹果在使用webkit时候为什么会把http/https这样大众化的scheme过滤掉,看来他是不建议开发者来使用NSURLProtocol

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

- (void)loadWKWebView
{
    // 移除旧的
    [self.wk removeFromSuperview];
    self.wk = nil;
    
    // 创建新的WKWebView
    self.wk = [[WKWebView alloc] initWithFrame:CGRectMake(0, 300, self.view.bounds.size.width, 600)];
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.baidu.com"]];
    [self.wk loadRequest:request];
    [self.view addSubview:self.wk];
    
    //注册scheme
    Class cls = NSClassFromString(@"WKBrowsingContextController");
    SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
    // cls 是否包含 sel方法
    if ([cls respondsToSelector:sel]) {
        // 通过http和https的请求,同理可通过其他的Scheme 但是要满足ULR Loading System
        [cls performSelector:sel withObject:@"http"];
        [cls performSelector:sel withObject:@"https"];
    }
}

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

百度首页.png

拦截成功后替换为了我们自己的海贼王图片

替换成功后的海贼王图片.png

刚才只是替换了一张图片,如果我想一次性替换所有图片为我的海贼王呢?只需要修改下

+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{
    // 获取所有的absoluteString
    NSString *absoluteString = [[request URL] absoluteString];
    NSLog(@"absoluteString--%@",absoluteString);
    
    /* 拦截百度标题栏的logo图片,返回YES进行拦截,目的是替换为自己的海贼王图片
    if ([absoluteString isEqualToString:@"https://www.baidu.com/img/flexible/logo/plus_logo_web.png"])
    {
        return YES;
    }
    */
    
    // 直接hook所有图片:比较URL的后缀是否属于图片,是则自定义忽略掉
    NSString* extension = request.URL.pathExtension;
    NSArray *array = @[@"png", @"jpeg", @"gif", @"jpg"];
    if([array containsObject:extension]){
        return YES;
    }
 
    // 默认返回NO,不进行拦截
    return NO;
}

图片加载的一般都是广告,实体数据有一层model包装,所以只会去除掉广告而不会打扰到实体数据。

- (void)startLoading
{
    // 获取所有的absoluteString
    NSString *absoluteString = [[self.request URL] absoluteString];
    
    /* 拦截百度标题栏的logo图片,替换为自己本地的海贼王图片
    if ([absoluteString isEqualToString:@"https://www.baidu.com/img/flexible/logo/plus_logo_web.png"])
    {
        // 取出本地图片
        NSData *data = [self getImageData];
        // 接着调用client的didLoadData加载数据方法
        [self.client URLProtocol:self didLoadData:data];
    }
    */
    
    // 只要是图片,全部替换为海贼王
    NSString* extension = self.request.URL.pathExtension;
    NSArray *array = @[@"png", @"jpeg", @"gif", @"jpg"];
    if([array containsObject:extension])
    {
        // 取出本地图片
        NSData *data = [self getImageData];
        // 接着调用client的didLoadData加载数据方法
        [self.client URLProtocol:self didLoadData:data];
    }
}
广告等图片全部替换为自定义图片.png

3、NSURLSession的加载

声明要实现的委托<NSURLSessionDataDelegate>

@interface NSURLProtocolViewController ()<NSURLSessionDataDelegate>

实现URLSessionButton的点击方法loadNSURLSession。创建NSURLSeesionConfiguration,注意到一点,此处在config中注册我们的自定义协议,之前[NSURLProtocol registerClass:[CustomURLProtocol class]];已不再起作用,可以直接注释掉。

NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
config.protocolClasses = @[[CustomURLProtocol class]];

创建会话对象:delegateQueue 网络请求都是在后台进行,但是当网络请求完成后,可能会需要回到主线程进行刷新界面操作,所以此时可以设置代理回调方法所执行的队列为主队列。

NSURLSession *session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:[NSOperationQueue mainQueue]];

创建并启动网络任务

NSURLSessionDataTask *task = [session dataTaskWithURL:url];
[task resume];

总的来说如下:

- (void)loadNSURLSession
{
    NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.baidu.com"]];
    NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
    config.protocolClasses = @[[CustomURLProtocol class]];
    NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];
    NSURLSession *session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:mainQueue];
    NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:request];
    NSLog(@"黑魔法视图控制器: 加载NSURLSession");
    [dataTask resume];
}

实现NSURLSessionDataDelegate的已经接收到响应时调用的代理方法didReceiveData方法。

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
    NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
    if (httpResponse.statusCode == 200)
    {
        NSLog(@"请求成功");
        NSLog(@"%@", httpResponse.allHeaderFields);// 响应头
        
        // 初始化接收数据的NSData变量
        _data = [[NSMutableData alloc] init];
        
        //执行Block回调来继续接收响应体数据
        //执行completionHandler 用于使网络连接继续接受数据
        completionHandler(NSURLSessionResponseAllow);
    }
    else
    {
        NSLog(@"请求失败");
    }
}

didReceiveData:接收到数据包时调用的代理方法

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    NSLog(@"收到了一个数据包 data == %@,接受到了%li字节的数据",data,data.length);
    
    //拼接完整数据
    [_data appendData:data];
    NSLog(@"拼接完后为:%@", _data);
}

didCompleteWithError:数据接收完毕时调用的代理方法

// 数据接收完毕时调用的代理方法
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
    NSLog(@"数据接收完成");
    
    if (error)
    {
        NSLog(@"数据接收出错!");
        _data = nil;// 清空出错的数据
    }
    else
    {
        //数据传输成功无误,JSON解析数据
        NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:_data options:NSJSONReadingMutableLeaves error:nil];
        NSLog(@"%@", dic);
    }
}
运行结果.png

配置CustomURLProtocol中的startLoadingcanInitWithRequest方法。关于死循环了的问题,因为NSURLSessionDataTask发的请求还会被拦截到,拦截到后再发再拦,所以我们要对在startLoading里的请求做一下标识不让它被拦截,原理就是我们在request对象里人为添加键值进行标识是否被处理了,如果被处理了就在canInitWithRequest方法里返回NO不拦截。

定义一个字符串做key

static NSString *URLProtocolHandledKey = @"URLProtocolHandledKey";

标示该request已经处理过了,防止无限循环

[NSURLProtocol setProperty:@(YES) forKey:URLProtocolHandledKey inRequest:mutableReqeust];

canInitWithRequest方法里返回NO不拦截

+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{
    // 发现是处理过的请求直接返回NO不拦截此请求
    if ([NSURLProtocol propertyForKey:URLProtocolHandledKey inRequest:request])
    {
        return NO;
    }
    return YES;
}

在创建request时,可以设置属性cachePolicy,决定从本地还是网络上获取内容。那么如果是从本地取的话,是从哪取呢?NSURLCahe实现了response的缓存机制,将NSURLRequestNSCachedURLResponse映射起来。默认情况下,Memory cache=4MDisk cache=20M。可以子类化NSURLCahe实现自己的缓存逻辑。如果responsehttpHeaderCache-control/expires设置为可以被缓存,iOS会自动的将其存到本地数据库中。路径是沙盒路径下Library/Caches/bundid/Cache.db。对于webview的缓存也一样,因为它也是用的NSURLCache

判断requestcachePolicy是否== NSURLRequestUseProtocolCachePolicy。取responseheader,是否有cache-controlexpire字段。存在cache-control则缓存。存在expires则缓存。cache-controlexpire都没有,认为不缓存。

这里在startLoading中假定一个需求:拦截网络数据,返回本地的模拟数据,进行测试。不需要进行调用本地测试数据则直接继续进行网络请求,否则创建新的NSURLResponseNSData,将其传给client

// 二、加载NSURLSession
- (void)startLoading
{
    // 拦截的请求的request对象
    NSMutableURLRequest *mutableReqeust = [self.request mutableCopy];
    // 标示该request已经处理过了,防止无限循环
    [NSURLProtocol setProperty:@(YES) forKey:URLProtocolHandledKey inRequest:mutableReqeust];
    
    //这个enableDebug随便根据自己的需求了,可以直接拦截到数据返回本地的模拟数据,进行测试
    BOOL enableDebug = NO;
    if (enableDebug)
    {
        NSString *str = @"测试数据";
        // 将NSString转换为UTF-8数据
        NSData *data = [str dataUsingEncoding:NSUTF8StringEncoding];
        // 新的response
        NSURLResponse *response = [[NSURLResponse alloc] initWithURL:mutableReqeust.URL
                                                            MIMEType:@"text/plain"
                                               expectedContentLength:data.length
                                                    textEncodingName:nil];
        // 将新的response作为request对应的response,不缓存
        [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        // 设置request对应的 响应数据 response data
        [self.client URLProtocol:self didLoadData:data];
        // 标记请求结束
        [self.client URLProtocolDidFinishLoading:self];
    }
    else
    {
        //使用NSURLSession继续把request发送出去
        NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
        NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];
        self.session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:mainQueue];
        NSURLSessionDataTask *task = [self.session dataTaskWithRequest:self.request];
        [task resume];
    }
}

上面采用NSURLSession发送的网络请求,所以实现NSURLSessionDelegate代理方法进行回调。NSURLSessionDelegate走的是继续路线,所以需要和截取路线各自写一份client的三个方法。

接收到返回信息时(还未开始下载)执行的代理方法:

- (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
{
    // 打印返回数据
    NSString *dataStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    if (dataStr)
    {
        NSLog(@"***截取数据***: %@", dataStr);
    }
    [self.client URLProtocol:self didLoadData:data];
}

请求结束或者是失败的时候调用:

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
    if (error)
    {
        [self.client URLProtocol:self didFailWithError:error];
    }
    else
    {
        [self.client URLProtocolDidFinishLoading:self];
    }
}

stopLoading停止方法为:

- (void)stopLoading
{
    // NSURLSession的停止方法
    [self.session invalidateAndCancel];
    self.session = nil;
}

现在基本完成了,需要注意下控制台输出的流程,我们很明显看到,因为NSURLProtocolViewControllerCustomURLProtocol均各自实现了一套NSURLSessionDelegate协议以及创建NSURLSessionDataTask,其存在明显的调用先后的顺序问题。

大致顺序如下:NSURLProtocolViewController创建NSURLSessionDataTask--->跳到CustomURLProtocol执行其NSURLSessionDataTask----->回到NSURLProtocolViewController继续自己之前的NSURLSessionDataTask

loadNSURLSession---------黑魔法视图控制器: 加载NSURLSession
startLoading----------------自定义协议: 使用NSURLSession继续把request发送出去
didReceiveResponse--------自定义协议: 接收到返回信息时(还未开始下载)
didReceiveData-------------自定义协议: 截取数据: <!DOCTYPE html>
didCompleteWithError------ 自定义协议: 请求结束
didReceiveResponse--------黑魔法视图控制器: 请求成功
didReceiveResponse--------黑魔法视图控制器: 响应头 {
didReceiveData-------------黑魔法视图控制器: 收到了一个数据包 data
didReceiveData-------------黑魔法视图控制器: 拼接完后为 {length =
didCompleteWithError------黑魔法视图控制器: 数据接收完成
didCompleteWithError------黑魔法视图控制器: 数据传输成功无误,JSON解析数据后

注意下控制台输出的流程.png

续.png

注意一点,上面是当enableDebug = NO的时候,使用NSURLSession继续把request发送出去,并不是最初我们提到的需求直接拦截到数据返回本地的模拟数据,进行测试。当设置enableDebug = YES,便不会走CustomURLProtocolNSURLSessionDelegate代理方法了,而是在client拿到本地新创建的dataresponse后,直接进入NSURLProtocolViewControllerNSURLSessionDelegate运行。

    BOOL enableDebug = YES;
    if (enableDebug)
    {
        NSString *str = @"测试数据";
        // 将NSString转换为UTF-8数据
        NSData *data = [str dataUsingEncoding:NSUTF8StringEncoding];
        // 新的response
        NSURLResponse *response = [[NSURLResponse alloc] initWithURL:mutableReqeust.URL
                                                            MIMEType:@"text/plain"
                                               expectedContentLength:data.length
                                                    textEncodingName:nil];
        // 将新的response作为request对应的response,不缓存
        [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        // 设置request对应的 响应数据 response data
        [self.client URLProtocol:self didLoadData:data];
        // 标记请求结束
        [self.client URLProtocolDidFinishLoading:self];
    }

需要调整下NSURLSessionDelegate中的方法,didReceiveResponse删除掉之前的httpResponse判断statusCode状态码代码段,因为此时的response是我们自定义的,不再是httpResponse类型的了

// 已经接收到响应时调用的代理方法
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
    NSLog(@"黑魔法视图控制器:URL---%@, expectedContentLength----%lld",response.URL, response.expectedContentLength);
    _data = [[NSMutableData alloc] init];
    completionHandler(NSURLSessionResponseAllow);
}

同样的,需要修改下didReceiveData方法,将接收到的data转化为字符串输出,可以看到控制图顺利输出了我们的data测试数据字符串。

// 接收到数据包时调用的代理方法
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
    NSLog(@"黑魔法视图控制器: 收到了一个数据包 data == %@,接受到了%li字节的数据",data,data.length);
    
    //拼接完整数据
    [_data appendData:data];
    NSString *dataStr = [[NSString alloc] initWithData:_data encoding:NSUTF8StringEncoding];
    NSLog(@"黑魔法视图控制器: 拼接完后为 %@", dataStr);
}
控制图输出结果.png

4、加载AFNetworking

目前为止,我们上面的代码已经能够监控到绝大部分的网络请求,但是呢,有一个却是特殊的,比如AFNetworking请求。因为AFNetworking网络请求的NSURLSession实例方法都是通过sessionWithConfiguration:delegate:delegateQueue:方法获得的,我们是不能监听到的,然而我们通过[NSURLSession sharedSession]生成session就可以拦截到请求,原因就出在NSURLSessionConfiguration上,我们进到NSURLSessionConfiguration里面看一下,他有一个属性:

@property (nullable, copy) NSArray<Class> *protocolClasses;

我们能够看出,这是一个NSURLProtocol数组,上面我们提到了,我们监控网络是通过注册NSURLProtocol来进行网络监控的,但是通过sessionWithConfiguration:delegate:delegateQueue:得到的session,他的configuration中已经有一个NSURLProtocol,所以他不会走我们的protocol来,怎么解决这个问题呢? 其实很简单,我们将NSURLSessionConfiguration的属性protocolClassesget方法hook掉,通过返回我们自己的protocol,这样,我们就能够监控到通过sessionWithConfiguration:delegate:delegateQueue:得到的session的网络请求。所以对于AFNetworking中网络请求初始化方法可以修改为:

// 加载AFNetworking
- (void)loadAFNetworking
{
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    //指定其protocolClasses
    configuration.protocolClasses = @[[CustomURLProtocol class]];
    
    // 不采用manager初始化,改为以下方式
    //AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
    AFHTTPSessionManager *manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:configuration];
    [manager GET:@"http://www.baidu.com" parameters:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
        NSLog(@"responseObject:%@",responseObject);
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
        NSLog(@"error:%@",error);
    }];
}

很简单是吧,看看运行结果是否真的实现了,如果是应该也能和NSURLSession一样拦截成功,输出自定义协议CustomURLProtocolNSURLSessionDelegate的一大堆东西,但是与加载NSURLSession不同的是,因为变成了AFNetworking,所以不会打印出NSURLProtocolViewControllerNSURLSessionDelegate的一大堆东西。

控制台输出,AF拦截成功了.png

5、使用runtime来实现加载AFNetworking

新建一个FFSessionConfiguration类,作为我们自定义的SessionConfiguration,用来做方法交换。首先在该类中创建一个单例,用来在其他类中调用交换方法和还原方法。

// 单例
+ (FFSessionConfiguration *)defaultConfiguration
{
    static FFSessionConfiguration *staticConfiguration;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        staticConfiguration = [[FFSessionConfiguration alloc] init];
    });
    return staticConfiguration;
}

创建一个isExchanged属性,用于判断是否已经交换过了,现在对它进行初始化为NO。

// 初始化
- (instancetype)init
{
    self = [super init];
    if (self) {
        self.isExchanged = NO;
    }
    return self;
}

实现一个交换两个类中同一个方法名的具体实现的方法,即swizzleSelector来实现方法混淆,此处需要引入#import <objc/runtime.h>

// 交换两个方法,此处运用到runtime
- (void)swizzleSelector:(SEL)selector fromClass:(Class)original toClass:(Class)stub
{
    Method originalMethod = class_getInstanceMethod(original, selector);
    Method stubMethod = class_getInstanceMethod(stub, selector);
    
    // 有一个找不到就抛出异常
    if (!originalMethod || !stubMethod)
    {
        [NSException raise:NSInternalInconsistencyException format:@"Couldn't load NEURLSessionConfiguration."];
    }
    
    // 交换二者的实现方法,即方法混淆
    method_exchangeImplementations(originalMethod, stubMethod);
}

然后实现我们需要交换的那个同名方法,即protocolClasses

// 如果还有其他的监控protocol,也可以在这里加进去
// 此处用到了CustomURLProtocol
- (NSArray *)protocolClasses
{
    return @[[CustomURLProtocol class]];
}

我们最终的目的是要将NSURLSessionConfigurationFFSessionConfiguration中的protocolClasses方法进行交换,于是写出我们的核心方法load

// 交换掉 NSURLSessionConfiguration的protocolClasses方法
- (void)load
{
    // 是否交换方法 YES
    self.isExchanged = YES;
    // NSURLSessionConfiguration
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    
    // 将NSURLSessionConfiguration 和 FFSessionConfiguration中的protocolClasses方法进行交换
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
}

最后需要有还原为初始化状态,不再拦截的方法unload

// 还原初始化
- (void)unload
{
    // 是否交换方法 NO
    self.isExchanged = NO;
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    // 再替换一次就回来了
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
}

还好,这个方法混淆的实现并没有我想象的困难。接下来在NSURLProtocolViewController看下具体如何使用。我们需要实现一个方法来取得单例并在判断没有交换后进行protocolClasses的交换。

// 开始监听
+ (void)startMonitor
{
    // 取得单例
    FFSessionConfiguration *sessionConfiguration = [FFSessionConfiguration defaultConfiguration];
    // 注册
    [NSURLProtocol registerClass:[CustomURLProtocol class]];
    // 还没有交换就交换
    if (![sessionConfiguration isExchanged])
    {
        // 交换
        [sessionConfiguration load];
    }
}

同样地,我们也需要实现一个类似方法来取消交换

// 停止监听
+ (void)stopMonitor
{
    // 取得单例
    FFSessionConfiguration *sessionConfiguration = [FFSessionConfiguration defaultConfiguration];
    // 当不需要拦截的时候,要进行注销
    [NSURLProtocol unregisterClass:[CustomURLProtocol class]];
    // 已经交换过了就还原
    if ([sessionConfiguration isExchanged])
    {
        // 还原
        [sessionConfiguration unload];
    }
}

然后进入关键的NSURLProtocolViewController中来实现调用。首先因为我们在startMonitor已经注册过了,所以需要注释掉之前viewDidLoad中的[NSURLProtocol registerClass:[CustomURLProtocol class]];,直接改为[CustomURLProtocol startMonitor];即可

- (void)viewDidLoad
{
    [super viewDidLoad];
    [self createSubviews];
    
    // 注册NSURLProtocol的子类
    // 当NSURLSeesionConfiguration使用protocolClasses注册的时候,此处不再起作用,可以直接注释掉
    // 当使用runtime拦截AFNetworking时,此处也需要注释掉,因为在自定义协议里已经配置过了
    // [NSURLProtocol registerClass:[CustomURLProtocol class]];
    
    // 使用runtime拦截AFNetworking时,使用这句话
    [CustomURLProtocol startMonitor];
}

同样的原因,dealloc也做相应修改

- (void)dealloc
{
    // 一经注册之后,所有交给URL Loading system的网络请求都会被拦截,所以当不需要拦截的时候,要进行注销
    // 当使用runtime拦截AFNetworking时,此处也需要注释掉,因为在自定义协议里已经配置过了
    // [NSURLProtocol unregisterClass:[CustomURLProtocol class]];
    
    // 使用runtime拦截AFNetworking时,使用这句话
    [CustomURLProtocol stopMonitor];
}

接下来最后一步啦,实现AFNetworkingRuntimeButtonruntimeLoadAFNetworking方法,使用默认的manager进行初始化,为什么需要这个方法呢?因为之前针对AFNetworking,我们的拦截方式是将NSURLSessionConfiguration的属性protocolClassesget方法hook掉,通过返回我们自己的protocol

- (void)runtimeLoadAFNetworking
{
    AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
    [manager GET:@"http://www.baidu.com" parameters:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id  _Nullable responseObject) {
        NSLog(@"responseObject:%@",responseObject);
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
        NSLog(@"error:%@",error);
    }];
}
runtime加载AFNetworking成功了.png

Demo

Demo在我的Github上,欢迎下载。
IOSAdvancedDemo

参考文献

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,717评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,501评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,311评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,417评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,500评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,538评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,557评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,310评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,759评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,065评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,233评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,909评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,548评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,172评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,420评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,103评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,098评论 2 352

推荐阅读更多精彩内容