美文网首页
IOS进阶:(网络篇)NSURLProtocol网络拦截

IOS进阶:(网络篇)NSURLProtocol网络拦截

作者: 时光啊混蛋_97boy | 来源:发表于2020-06-17 11:20 被阅读0次

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

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

    目录

    • NSURLProtocol介绍
      • 含义
      • 具体流程
      • 使用场景
    • Demo实战
      • CustomURLProtocol
      • 加载WebView和WKWebView
      • 加载NSURLSession
      • 加载NSURLConnection
      • 加载AFNetworking
      • 拓展:使用runtime来实现加载AFNetworking

    NSURLProtocol介绍

    URL Loading System

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

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

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

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

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

    Demo实战

    CustomURLProtocol

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

    @interface CustomURLProtocol : NSURLProtocol
    
    @end
    

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

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

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

    - (void)dealloc
    {
        // 一经注册之后,所有交给URL Loading system的网络请求都会被拦截,所以当不需要拦截的时候,要进行注销
        [NSURLProtocol unregisterClass:[CustomURLProtocol class]];
    }
    

    c、抽象对象必须实现的拦截方法

    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解析,使用自定义的缓存等。

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

    ⚠️

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

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

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

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

    // 转发的核心方法,在这里需要我们手动的把请求发出去
    // 可以使用原生的NSURLConnection、NSURLSessionDataTask,也可以使用的第三方网络库AFNetworking
    - (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的协议方法都回调完毕后,就会开始执行这个方法了。

    WebView和WKWebView的加载

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

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

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

    // 加载WebView
    - (void)loadWebView
    {
        // 移除旧的
        [self.webView removeFromSuperview];
        [self.wk removeFromSuperview];
        self.webView = nil;
        self.wk = nil;
        
        // 创建新的UIWebView
        self.webView = [[UIWebView alloc] initWithFrame:CGRectMake(0, self.view.bounds.size.height - 300, self.view.bounds.size.width, 300)];
        NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.baidu.com"]];
        [self.webView loadRequest:request];
        [self.view addSubview:self.webView];
    }
    
    百度首页.png

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

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

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

    + (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;
    }
    
    - (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

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

    d、接着实现wkWebViewButton的回调事件loadWKWebView,过程同上

    // 加载WKWebView
    - (void)loadWKWebView
    {
        // 移除旧的
        [self.webView removeFromSuperview];
        [self.wk removeFromSuperview];
        self.webView = nil;
        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无法实现拦截.png

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

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

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

    解决方案
    只要我们在注册完我们自己的CustomProtocol之后在调用该方法应该就可以了,因为registerSchemeForCustomProtocolWKBrowsingContextController的类方法,所以只能用WKBrowsingContextController去调用,但是在webkit的头文件发现WKBrowsingContextController并没有开放出来,所以我们采用NSClassFromStringNSSelectorFromString方法来拿到类和对应的方法。

    // 加载WKWebView
    - (void)loadWKWebView
    {
        // 移除旧的
        [self.webView removeFromSuperview];
        [self.wk removeFromSuperview];
        self.webView = nil;
        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"];
        }
    }
    
    WKWebView拦截成功.png

    注意点

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

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

    // 处理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;
    }
    

    NSURLSession的加载

    a、声明要实现的委托<NSURLSessionDataDelegate>

    @interface NSURLProtocolViewController ()<NSURLSessionDataDelegate>
    

    b、实现URLSessionButton的点击方法loadNSURLSession,不过先科普一下NSURLSession的知识点

    NSURLSeesionConfiguration类型及属性包括:

    • defaultSessionConfiguration:默认会话类型,能够进行磁盘缓存
    • ephemeralSessionConfiguration: 临时会话类型,不进行任何的磁盘缓存
    • backgroundSessionConfigurationWithIdentifier: 后台类型,用于后台下载上传
        config.requestCachePolicy = NSURLRequestUseProtocolCachePolicy;//设置缓存策略
        config.networkServiceType = NSURLNetworkServiceTypeDefault;//设置网络服务类型 决定了网络请求的形式
        config.timeoutIntervalForRequest = 15;//设置请求超时时间
        // config.HTTPAdditionalHeaders = //设置请求头
        config.allowsCellularAccess = YES;//网络属性  是否使用移动流量
    

    创建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];
    

    总的来说如下:

    // 加载NSURLSession
    - (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];    
        [dataTask resume];
    }
    

    c、实现NSURLSessionDataDelegatedidReceiveData方法。同时也科普一下其他方法。

    didReceiveResponse:已经接收到响应时调用的代理方法

    • NSURLSessionResponseCancel取消接受
    • NSURLSessionResponseAllow 继续接受
    • NSURLSessionResponseBecomeDownload 将当前任务转化为一个下载任务
    • NSURLSessionResponseBecomeStream 将当前任务转化为流任务
    // 已经接收到响应时调用的代理方法
    - (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
    d、配置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;
    }
    

    e、这里在startLoading中假定一个需求:拦截网络数据,返回本地的模拟数据,进行测试

    补充个关于NSURLCache缓存的知识点:
    在创建request时,可以设置属性cachePolicy,决定从本地还是网络上获取内容。那么如果是从本地取的话,是从哪取呢?

    NSURLCahe实现了response的缓存机制,将NSURLRequestNSCachedURLResponse映射起来。默认情况下,Memory cache=4MDisk cache=20M。可以子类化NSURLCahe实现自己的缓存逻辑。

    如果responsehttpHeaderCache-control/expires设置为可以被缓存,iOS会自动的将其存到本地数据库中。路径是沙盒路径下Library/Caches/bundid/Cache.db。,对于webview的缓存,也一样,因为它也是用的NSURLCache

    NSCachedURLResponse是包含了NSURLResponse和缓存data的类。当数据返回时,将要缓存时会调这个方法。如果返回nil,则不缓存。

    • 判断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的三个方法

    #pragma mark - NSURLSessionDelegate
    
    // 接收到返回信息时(还未开始下载), 执行的代理方法
    - (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];
        }
    }
    

    f、stopLoading停止方法为

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

    g、现在基本完成了,需要注意下控制台输出的流程,我们很明显看到,因为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
    h、注意一点,上面是当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

    加载NSURLConnection

    累死我了......

    sendAsynchronousRequest:queue:completionHandler:' is deprecated: first deprecated in iOS 9.0 - Use [NSURLSession dataTaskWithRequest:completionHandler:] (see NSURLSession.h
    看来苹果不支持老掉牙的NSURLConnection了......那么我还写个锤子,直接进入拦截AFNetworking

    加载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

    拓展:使用runtime来实现加载AFNetworking

    a、新建一个FFSessionConfiguration类,作为我们自定义的SessionConfiguration,用来做方法交换

    b、首先在该类中创建一个单例,用来在其他类中调用交换方法和还原方法

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

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

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

    d、实现一个交换两个类中同一个方法名的具体实现的方法,即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);
    }
    

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

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

    f、我们最终的目的是要将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]];
    }
    

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

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

    综上,.m文件如下:

    #import "FFSessionConfiguration.h"
    #import <objc/runtime.h>
    #import "CustomURLProtocol.h"
    
    @implementation FFSessionConfiguration
    
    // 单例
    + (FFSessionConfiguration *)defaultConfiguration
    {
        static FFSessionConfiguration *staticConfiguration;
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            staticConfiguration = [[FFSessionConfiguration alloc] init];
        });
        return staticConfiguration;
    }
    
    // 初始化
    - (instancetype)init
    {
        self = [super init];
        if (self) {
            self.isExchanged = NO;
        }
        return self;
    }
    
    // 交换掉 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]];
    }
    
    // 还原初始化
    - (void)unload
    {
        // 是否交换方法 NO
        self.isExchanged = NO;
        Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
        // 再替换一次就回来了
        [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
    }
    
    // 交换两个方法,此处运用到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);
    }
    
    // 如果还有其他的监控protocol,也可以在这里加进去
    // 此处用到了CustomURLProtocol
    - (NSArray *)protocolClasses
    {
        return @[[CustomURLProtocol class]];
    }
    
    @end
    

    而提供给外界的接口.h文件如下:

    @interface FFSessionConfiguration : NSObject
    
    @property (nonatomic,assign) BOOL isExchanged;// 是否交换方法
    + (FFSessionConfiguration *)defaultConfiguration;// 单例
    
    // 交换掉NSURLSessionConfiguration的 protocolClasses方法
    - (void)load;
    
    // 还原初始化
    - (void)unload;
    
    
    @end
    

    还好,这个方法混淆的实现并没有我想象的困难。接下来在NSURLProtocolViewController看下具体如何使用。

    a、我们需要实现一个方法来取得单例并在判断没有交换后进行protocolClasses的交换。

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

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

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

    然后进入关键的NSURLProtocolViewController中来实现调用。

    a、首先因为我们在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];
    }
    

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

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

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

    // 加载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);
        }];
    }
    

    这些我们统统需要进行修改,即删除不必要的东西,使用默认的manager进行初始化,结果如下:

    // runtime加载AFNetworking
    - (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认真研读。

    参考文献

    NSURLCache缓存的位置
    iOS中NSURLProtocol黑魔法的使用
    NSURLProtocol对WKWebView的处理

    相关文章

      网友评论

          本文标题:IOS进阶:(网络篇)NSURLProtocol网络拦截

          本文链接:https://www.haomeiwen.com/subject/dxocxktx.html