iOS中NSURLProtocol黑魔法的使用

前言

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

URL loading system 结构图

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

用一句话解释NSURLProtocol:就是一个苹果允许的中间人攻击。
NSURLProtocol可以劫持系统所有基于C socket的网络请求。
注意:WKWebView基于Webkit,并不走底层的C socket,所以NSURLProtocol拦截不了WKWebView中的请求。

使用场景

  不管你是通过UIWebView, NSURLConnection或者第三方库 (AFNetworkingAlamofire等),他们都是基于NSURLConnection或者 NSURLSession实现的,因此你可以通过NSURLProtocol做自定义的操作。

通过 NSURLProtocol可以比较简单地就能实现:

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

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

使用方法

1. 子类化:

由于 NSURLProtocol是一个抽象类,所以使用的时候必须定义一个它的子类:

#import <Foundation/Foundation.h>

@interface CustomURLProtocol : NSURLProtocol

@end

2. 注册:

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

//注册protocol
[NSURLProtocol registerClass:[CustomURLProtocol class]];

  对于基于NSURLSession的网络请求,如下所示需要通过配置sessionWithConfiguration:delegate:delegateQueue:初始化对象的,需要配置对象的protocolClasses属性,这个在下面会有详细的介绍。

// NSURLSession例子
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
NSArray *protocolArray = @[ [CustomURLProtocol class]];
configuration.protocolClasses = protocolArray;
NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue mainQueue]];
NSURLSessionTask *task = [session dataTaskWithRequest:_request];
[task resume];

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

[NSURLProtocol unregisterClass:[CustomURLProtocol class]];

3. 抽象对象必须实现的方法(拦截)

注册成功之后,就需要我们的子类去实现抽象方法:

//所有注册此Protocol的请求都会经过这个方法的判断
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
//可选方法,对需要拦截的请求进行自定的处理
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
//主要是用来判断两个request是否相同,这个方法基本不常用
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b;
//初始化protocol实例,所有来源的请求都以NSURLRequest形式接收
- (id)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id <NSURLProtocolClient>)client;
/**
    开始请求
    在这里需要我们手动的把请求发出去,可以使用原生的NSURLSessionDataTask,也可以使用的第三方网络库
    同时设置"NSURLSessionDataDelegate"协议,接收Server端的响应
*/
- (void)startLoading;
//请求被停止
- (void)stopLoading;

详细说明:

  • canInitWithRequest

  该方法会拿到request的对象,我们可以通过该方法的返回值来筛选request是否需要被NSURLProtocol做拦截处理。

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    
    NSString * scheme = [[request.URL scheme] lowercaseString];
    if ([scheme isEqual:@"http"]) {
        return YES;
    }

    //看看是否已经处理过了,防止无限循环 根据业务来截取 
    if ([NSURLProtocol propertyForKey: URLProtocolHandledKey inRequest:request]) {
        return NO;
    }
    return NO;
}

URLProtocolHandledKey 是:

static NSString * const URLProtocolHandledKey = @"URLProtocolHandledKey";

上面我们就只会拦截http的请求。

  • canonicalRequestForRequest:

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

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
    return request;
}
  • requestIsCacheEquivalent:toRequest:

主要判断两个request是否相同,如果相同的话可以使用缓存数据,通常只需要调用父类的实现。

+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b {
    return [super requestIsCacheEquivalent:a toRequest:b];
}

4. 转发

  在拦截到网络请求,并且对网络请求进行定制处理以后。我们需要将网络请求重新发送出去,就可以初始化一个NSURLProtocol对象了:

- (id)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id <NSURLProtocolClient>)client {
    return [super initWithRequest:request cachedResponse:cachedResponse client:client];
}

该方法会创建一个NSURLProtocol实例,在这里直接调用super的指定构造器方法,实例化一个对象。

  • startLoading

  接下来就是转发的核心方法startLoading。在该方法中,把当前请求的request拦截下来以后,可以在这里修改请求信息,重定向网络,DNS解析,使用自定义的缓存等。至于发送的形式,可以是基于NSURLConnectionNSURLSession甚至AFNetworking等网络库。对于NSURLConnection来说,就是创建一个NSURLConnection,对于NSURLSession,就是发起一个NSURLSessionTask 。一般下载前需要设置该请求正在进行下载,防止多次下载的情况发生。

重点:需要标记已经处理过的request
下面就是一个重定向的例子:

- (void)startLoading
{
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //标示该request已经处理过了,防止无限循环
    [NSURLProtocol setProperty:@(YES) forKey:URLProtocolHandledKey inRequest:mutableReqeust];

    //使用NSURLSession继续把request发送出去
    NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];
//定义全局的NSURLSession对象用于stop请求使用
    self.session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:mainQueue];
    NSURLSessionDataTask *task = [self.session dataTaskWithRequest:self.request];
    [task resume];
}

5. 回调

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

//将新的response作为request对应的response
[self.client URLProtocol:self
       didReceiveResponse:response
       cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        
//设置request对应的 响应数据 response data
[self.client URLProtocol:self didLoadData:data];
        
//标记请求结束
[self.client URLProtocolDidFinishLoading:self];

所以上面的startLoading的完整版本应该是

- (void)startLoading
{
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //标示该request已经处理过了,防止无限循环
    [NSURLProtocol setProperty:@(YES) forKey:URLProtocolHandledKey inRequest:mutableReqeust];

    //这个enableDebug随便根据自己的需求了,可以直接拦截到数据返回本地的模拟数据,进行测试
    BOOL enableDebug = NO;
    if (enableDebug) {
        
        NSString *str = @"测试数据";
        NSData *data = [str dataUsingEncoding:NSUTF8StringEncoding];
        NSURLResponse *response = [[NSURLResponse alloc] initWithURL:mutableReqeust.URL
                                                            MIMEType:@"text/plain"
                                               expectedContentLength:data.length
                                                    textEncodingName:nil];
        [self.client URLProtocol:self
              didReceiveResponse:response
              cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        [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的代理方法:

//接收到返回信息时(还未开始下载), 执行的代理方法
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
                                 didReceiveResponse:(NSURLResponse *)response
                                  completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler;
//接收到服务器返回的数据 调用多次
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
                                     didReceiveData:(NSData *)data;
//请求结束或者是失败的时候调用
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
                           didCompleteWithError:(nullable NSError *)error;

一般默认使用方式为:

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

6. 结束

  • stopLoading

  在一个网络请求完全结束以后,NSURLProtocol回调用到。在该方法里,我们完成在结束网络请求的操作,以NSURLSession为例:

- (void)stopLoading {
    [self.session invalidateAndCancel];
    self.session = nil;
}

注意点:

1. 如果startLoading中网络请求采用的是NSURLConnection,例如:

- (void)startLoading
{
    NSMutableURLRequest *mutableReqeust = [[self request] mutableCopy];
    //做下标记,防止递归调用
    [NSURLProtocol setProperty:@YES forKey:hasInitKey inRequest:mutableReqeust];
    
    //这个enableDebug随便根据自己的需求了,可以直接拦截到数据返回本地的模拟数据,进行测试
    BOOL enableDebug = NO;
    if (enableDebug) {
        NSString *str = @"测试数据";
        NSData *data = [str dataUsingEncoding:NSUTF8StringEncoding];
        NSURLResponse *response = [[NSURLResponse alloc] initWithURL:mutableReqeust.URL
                                                            MIMEType:@"text/plain"
                                               expectedContentLength:data.length
                                                    textEncodingName:nil];
        [self.client URLProtocol:self
              didReceiveResponse:response
              cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        [self.client URLProtocol:self didLoadData:data];
        [self.client URLProtocolDidFinishLoading:self];
    }
    else {
        //采用了NSURLConnection方式请求
        self.connection = [NSURLConnection connectionWithRequest:mutableReqeust delegate:self];
    }
}

停止方法为

- (void)stopLoading
{
   [self.connection cancel];
  self.connection = nil ;
}

那么相应的实现的代理方法为NSURLConnectionDataDelegate

//接收到Response信息
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
    [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
}
//接收到服务器的数据(可能调用多次)
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
    // 打印返回数据
    NSString *dataStr = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    if (dataStr) {
        NSLog(@"***截取数据***: %@", dataStr);
    }
    [self.client URLProtocol:self didLoadData:data];
}
//结束
- (void)connectionDidFinishLoading:(NSURLConnection *)connection {
    [self.client URLProtocolDidFinishLoading:self];
}
//出错
- (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
    [self.client URLProtocol:self didFailWithError:error];
}

2. 拦截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中网络请求初始化方法可以修改为:

NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
//指定其protocolClasses
configuration.protocolClasses = @[[CustomURLProtocol class]];
AFHTTPSessionManager *manager = [[AFHTTPSessionManager alloc] initWithBaseURL:nil sessionConfiguration:configuration];
//不采用manager初始化
//AFHTTPSessionManager *manager = [AFHTTPSessionManager manager];
[manager GET:@"http://www.baidu.com" parameters:nil success:^(NSURLSessionDataTask *task, id responseObject) {
        
} failure:^(NSURLSessionDataTask *task, NSError *error) {
        
}];

也可以通过runtime来面向切面编程

#import <Foundation/Foundation.h>

@interface FFSessionConfiguration : NSObject
//是否交换方法
@property (nonatomic,assign) BOOL isExchanged;

+ (FFSessionConfiguration *)defaultConfiguration;
// 交换掉NSURLSessionConfiguration的 protocolClasses方法
- (void)load;
// 还原初始化
- (void)unload;

@end
#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;
}

- (void)load {
    self.isExchanged=YES;
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
    
}

- (void)unload {
    self.isExchanged=NO;
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
}

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

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

@end

在使用时也很简单,采用下面方式注册

FFSessionConfiguration *sessionConfiguration = [FFSessionConfiguration defaultConfiguration];
[NSURLProtocol registerClass:[CustomURLProtocol class]];
if (![sessionConfiguration isExchanged]) {
    [sessionConfiguration load];
}

3. 关于不能拦截WKWebView

  原因是WKWebView在独立于app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在WKWebView上直接使用 NSURLProtocol无法拦截请求。
具体可以参考NSURLProtocol对WKWebView的处理

Demo下载

参考文献

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

推荐阅读更多精彩内容