iOS NSURLProtocol详解及使用陷阱

如果想对网络请求进行干预,使用NSURLProtocol是一个不错的选择。NSURLProtocol是iOS URL Loading System的一个功能,它提供了便捷的接口以允许开发者重新定义网络请求的行为,包括修改请求的发起和响应动作。

在iOS网络开发中,如果有类似以下需求:

  • 对全局请求增加特定的header或参数;
  • 对某些资源的请求重定向、MOCK请求或使用本地缓存;
  • 对请求的正常响应数据进行处理,如过滤关键字等。

使用NSURLProtocol是一个不错的方案。NSURLProtocol是iOS URL Loading System的一个功能,它提供了便捷的接口以允许开发者重新定义网络请求的行为,包括修改请求的发起和响应动作。

新建NSURLProtocol

NSURLProtocol是个抽象类,使用时必须子类化。

@interface EaseapiURLProtocol: NSURLProtocol
@end

不需要开发者自己实例化NSURLProtocol子类,而要将NSURLProtocol子类在系统中注册。有两种方式注册NSURLProtocol子类。

  • 使用NSURLProtocol的registerClass接口
//注册
[NSURLProtocol registerClass:[EaseapiURLProtocol class]]
//卸载
[NSURLProtocol unregisterClass:[EaseapiURLProtocol class]];

适用于使用[NSURLSession sharedSession]创建的网络请求。

  • 使用NSURLSessionConfiguration的protocolClasses接口
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration];
configuration.protocolClasses = @[[EaseapiURLProtocol class]];

适用于自定义NSURLSession的Configuration的请求。

注册成功之后,在使用NSURLSessionTask等方式发起请求之前,URL loading system会检查所有注册过的NSURLProtocol子类,直到找到一个可以处理的NSURLProtocol子类并将请求交由该类处理。

同一个NSURLSessionConfiguration可以注册多个NSURLProtocol子类,URL loading system遍历的顺序和注册顺序相反。当一个NSURLProtocol子类能处理后,就接管了该请求,后续的NSURLProtocol子类不再执行。也就是不能保证所有注册的NSURLProtocol子类都能被执行。

NSURLProtocol的内部逻辑

NSURLProtocol开发的主要工作就是实现NSURLProtocol的接口方法,包括以下几个核心接口:

判断是否需要接管请求

+ (BOOL)canInitWithRequest:(NSURLRequest *)request;

如果需要接管请求返回YES,否则返回NO。返回NO后,URL loading system会继续查询下一个NSURLProtocol或采用原有的方式发起请求。

编辑请求

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;

接管请求后,可以对原有请求进行修改,比如增加参数、添加header等。canonicalRequestForRequest方法允许对原有请求进行修改编辑,并返回修改后的NSURLRequest对象。利用这个方法,可以轻松实现请求重定向,映射本地资源等操作。

发起/停止请求

- (void)startLoading;
- (void)stopLoading;

由于已经接管了原来的请求,在对新请求编辑完成后,还需要负责新请求的执行。在startLoading方法中,可以使用NSURLSession等方式发起请求。示例:

- (void)startLoading {
 NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
 NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration];
 self.task = [session dataTaskWithRequest:self.request];
 [self.task resume];
}

这里可以使用任何网络请求方式,包括AFNetworking、Alamofire等。

NSURLProtocol和URL loading system通信

NSURLProtocol接管原有请求之后,需要一种方式实现和原有请求的无缝衔接,才能达到上层应用无感知的效果。

@property (nullable, readonly, retain) id <NSURLProtocolClient> client;

NSURLProtocol的client属性即是和URL loading system通信的对象。NSURLProtocolClient协议声明如下:

@protocol NSURLProtocolClient <NSObject>

- (void)URLProtocol:(NSURLProtocol *)protocol wasRedirectedToRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)redirectResponse;

- (void)URLProtocol:(NSURLProtocol *)protocol cachedResponseIsValid:(NSCachedURLResponse *)cachedResponse;

- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveResponse:(NSURLResponse *)response cacheStoragePolicy:(NSURLCacheStoragePolicy)policy;

- (void)URLProtocol:(NSURLProtocol *)protocol didLoadData:(NSData *)data;

- (void)URLProtocolDidFinishLoading:(NSURLProtocol *)protocol;

- (void)URLProtocol:(NSURLProtocol *)protocol didFailWithError:(NSError *)error;

- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;

- (void)URLProtocol:(NSURLProtocol *)protocol didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;

@end

NSURLProtocolClient协议定义了接收数据、请求成功/失败、以及Authentication Challeng处理,需要在适当的时候调用。以使用NSURLSessionDataTask请求为例,在响应的回调方法中调用self.client方法。

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

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

如何避免死循环

你也许已经意识到一个问题:既然在startLoading中也可以使用NSURLSession请求,这个拦截的请求也可能会受到NSURLProtocol的影响。这样可能造成死循环:在NSURLProtocol中发起的请求又被拦截。

为了避免死循环,可以对已经拦截的请求增加一个已处理的标记:

[NSURLProtocol setProperty:@YES forKey:URLEaseapiHandledKey inRequest:self.request];

在canInitWithRequest方法中检测到有这个标记,则不拦截:

+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    if ([NSURLProtocol propertyForKey:URLEaseapiHandledKey inRequest:request]) {
        return NO;
    }
    return YES;
}

使用陷阱

POST请求body为空

对一个POST请求拦截时,canonicalRequestForRequest方法的入参request的HTTPBody属性为空。如果需要body内容,要使用HTTPBodyStream属性获取。

对原有请求的影响

NSURLProtocol从原理上来讲就是对原有请求拦截并反馈响应,则原有请求的缓存策略、超时等设置可能不启作用。

无法保证自己的NSURLProtocol一定会被执行

当有多个NSURLProtocol子类时,后注册至系统的会被优先执行,这就无法保证自己的NSURLProtocol子类就一定能执行。如果需求是实现网络拦截器的功能,则NSURLProtocol的功能还是有所欠缺。特别是当需要多个拦截器处理不同的业务时,NSURLProtocol就更难胜任了。

其他文章

NSURLProtocol | Apple Developer Documentation
iOS URLSession Authentication Challenge及SSL Pinning
iOS:IDFV(identifierForVendor)使用陷阱
iOS安全:iOS APP注入动态库重打包
iOS 13 Scene Delegate and multiple windows