2112 字
11 分钟
iOS 网络请求耗时统计
2019-04-04

前言:最近的研发中,老大说需要监测一下每个接口的连接速度,针对一些接口优化一下。所以就需要 hook 到每个网络请求来进行监测了。通过网上一些查阅资料,初步将目标定位到NSURLProtocol这个类中了。

NSURLProtocol#

NSRULProtocolURL loadin System中的一部分,如下图

NSURLProtocol 是个抽象类,不能直接使用它的实例。需要我们声明一个子类来使用它。当网络请求发生时,系统会创建合适的协议对象来处理这些请求。我们自定义的子类需要在 app 启动时调用 registerClass方法使得系统知道我们的协议,才能使得我们自定义的协议来处理这些网络请求。

NSURLProtocol作为上层接口,它的作用就是可以检测到每个网络请求,但是由于它是属于 URL Loading System 体系的,所以支持的协议有限,只支持 FTP, HTTP, HTTPS 等几个应用层协议。具体来说NSURLProtocol属于一个中间层,当我们发起网络请求时,我们注册的子类的对象会接受到请求,接受到请求后再做一些处理,然后再转发请求。这个时候我们可以针对请求来记录我们需要的数据或或者对请求做一些修改。总的来说就是你不必修改在网络调用上的其他部分,就可以改变 URL 加载行为的全部细节,使得代码结构更加清爽健壮。

NSHipster列举了一些可以使用NSURLProtocol做的事情:

  1. 拦截图片加载请求,转为本地文件加载
  2. 为了测试对HTTP返回内容进行mock和`stub
  3. 对发出请求的header进行格式化
  4. 对发出的媒体请求进行签名
  5. 创建本地代理服务,用于数据变化时对URL请求的更改
  6. 故意制造畸形或者非法返回的数据来测试程序的鲁棒性
  7. 过滤请求和返回中的敏感信息
  8. 在既有协议举出上完成对 NSURLconnection的实现且与原逻辑不产生矛盾

针对这次的需求,只需要拦截住工程里面发起的 api 的网络请求,然后统计出每个请求的耗时即可。

具体的看下实现。

我们首先定义NSURLProtocol的一个子类:

static NSString * const MYHTTPHandledIdentifier = @"MYHTTPHandledIdentifier"; @interface MYHTTPProtocol : NSURLProtocol ... @end

canInitWithRequest#

+ (BOOL)canInitWithRequest:(NSURLRequest *)request { if (![request.URL.scheme isEqualToString:@"http"] && ![request.URL.scheme isEqualToString:@"https"]) { return NO; } if ([NSURLProtocol propertyForKey:MYHTTPHandledIdentifier inRequest:request] ) { return NO; } return YES; }

每一个网络请求,都会走到上述方法,我们可以拿到request的实例,这个函数的返回值代表是否处理这个请求。上述方法里面代表着我们只是监听httphttps协议的方法。下面一个if语句后面再讲,可以先不看。虽然说我们在这个方法里面拿到了 request 对象,但是我们最好不要在这个方法里面操作对象,因为可能存在语义上的问题。这个方法只是筛选哪些网络请求需要被拦截。

canonicalRequestForRequest#

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { NSMutableURLRequest *mutableReqeust = [request mutableCopy]; [NSURLProtocol setProperty:@YES forKey:MYHTTPHandledIdentifier inRequest:mutableReqeust]; return [mutableReqeust copy]; }

这个方法是针对我们筛选出来网络请求进行重新构造,NSURLProtocol提供了一些方法让我们来操作 request 的 metadata,如代码中所示,设置了一个 bool 值为 yes 的特殊属性,将这个属性添加到 request 中去了。因为在接下来我们还要使用这个 request 发起请求,所以需要添加一个标记,表示这个请求已经被处理过了不用在canInitWithRequest处理了,在canInitWithRequest方法中我们对 request中设置了特殊属性的请求不拦截,不然的话会使得这个请求在canInitWithRequestcanonicalRequestForRequest形成死循环。

startLoading#

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

这个是开始加载网络请求的方法,我们可以在这个方法里对协议对象持有的request对象进行转发。我们可以使用NSURLSessionNSURLConnection或者第三方的网络库,这里我使用了NSURLsession来进行转发这个请求。我们可以在这个请求里面记录当前的时间。后面再网络请求结束的时候再一次记录时间,就可以计算出网络加载的耗时了。

stopLoading#

- (void)stopLoading { [self.dataTask cancel]; self.dataTask = nil; // 解析 response,流量统计,网络耗时等 }

client#

每个  NSURLProtocol  的子类实例都有一个  client  属性,该属性对 URL 加载系统进行相关操作。它不是  NSURLConnection,但看起来和一个实现了  NSURLConnectionDelegate  协议的对象非常相似 。你需要在合适的时期调用client方法,将数据传回给client就行。client相当于这个网络的发起者,我们需要将网络中加载的情况返回给调用者。

#pragma mark - NSURLSessionTaskDelegate - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { if (!error) { [self.client URLProtocolDidFinishLoading:self]; } else if ([error.domain isEqualToString:NSURLErrorDomain] && error.code == NSURLErrorCancelled) { } else { [self.client URLProtocol:self didFailWithError:error]; } self.dataTask = nil; } #pragma mark - NSURLSessionDataDelegate - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data { [self.client URLProtocol:self didLoadData:data]; } - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler { [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed]; completionHandler(NSURLSessionResponseAllow); self.response = response; } - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler { if (response != nil){ self.response = response; [[self client] URLProtocol:self wasRedirectedToRequest:request redirectResponse:response]; } }

registerClass#

当然写完这些还是不够的,需要在 app 启动的地方注册我们写好的NSURLProtocol的子类:

[NSURLProtocol registerClass:[MyURLProtocol class]];

这相当于向 url 加载系统进行注册。URL protocol 会被以注册顺序的反序访问,所以当在  -application:didFinishLoadingWithOptions:  方法中调用  [NSURLProtocol registerClass:[MyURLProtocol class]];  时,你自己写的 protocol 比其他内建的 protocol 拥有更高的优先级。

问题#

URL 无法拦截#

基本的使用也就上述讲的那么多了,但是在使用的时候发现即使我注册了NSURLProtocol的子类,我还是无法拦截自己的网络请求。但是一些第三方库的请求拦截住了。在网上查找资料发现:

如果说需要支持自定义的NSURLProtocol,需要将自定义的NSURLProtocol子类赋给NSURLSessionConfigurationprotocolClassess属性。所以,如果需要NSURLProtocol来截获NSURLSession发出的请求,需要每一个NSURLSession在创建时配置的NSURLSessionConfiguration类的protocolClasses属性附上自定义的NSURLProtocol

查看AFNetworking的代码发现,它创建的session关联的NSURLSessionConfigurationdefaultSessionConfiguration 。所以我们在不能直接修改第三方的源码的时候只能使用method swizzling的方法去 hook 住defaultSessionConfiguration 的方法,然后将我们自定义的NSURLProtocol的子类注册到 url 加载系统里面去了。

@implementation NSURLSessionConfiguration (UrlProtocolSwizzling) +(void)load{ [self jr_swizzleClassMethod:@selector(defaultSessionConfiguration) withClassMethod:@selector(my_defaultSessionConfiguration) error:nil]; [NSURLProtocol registerClass:[TrackNetWorkURLProtocol class]]; } +(NSURLSessionConfiguration *)my_defaultSessionConfiguration{ NSURLSessionConfiguration *configuration = [self my_defaultSessionConfiguration]; NSArray *protocolClasses = @[[TrackNetWorkURLProtocol class]]; configuration.protocolClasses = protocolClasses; return configuration; }

POST 请求 body 为空#

通过拦截请求时,在stoploading方法里面想对请求做一些操作时发现有些post请求的body为空,这个时候发现网上也有人碰到这个问题,初步判断是苹果的 bug,这个时候我们只能通过将HTTPBodyStream中得到的数据写入到HTTPBody中了。写一个NSURLRequest的分类,将HTTPBodyStream的数据写入到HTTPBody中,在canonicalRequestForRequest方法中调用写入操作,然后返回这个request

@implementation NSURLRequest (RequestIncludeBody) -(NSURLRequest *)getPostRequestIncludeBody{ return [[self getMutablePostRequestIncludeBody] copy]; } -(NSMutableURLRequest *)getMutablePostRequestIncludeBody{ NSMutableURLRequest *request = [self mutableCopy]; if ([self.HTTPMethod isEqualToString:@"POST"]) { if (!self.HTTPBody) { NSInteger maxLength = 1024; uint8_t d[maxLength]; NSInputStream *stream = self.HTTPBodyStream; NSMutableData *data = [[NSMutableData alloc] init]; [stream open]; BOOL endOfStreamReached = NO; while (!endOfStreamReached) { NSInteger bytesRead = [stream read:d maxLength:maxLength]; if (bytesRead == 0) { //文件读取到最后 endOfStreamReached = YES; } else if (bytesRead == -1) { //文件读取错误 endOfStreamReached = YES; } else if (stream.streamError == nil) { [data appendBytes:(void *)d length:bytesRead]; } } request.HTTPBody = [data copy]; [stream close]; } } return request; } @end

无法拦截 wkwebview 中的请求#

无论是  NSURLProtocolNSURLConnection  还是  NSURLSession  都会走底层的 socket,但是  WKWebView  可能由于基于 WebKit,并不会执行 C socket 相关的函数对 HTTP 请求进行处理。所以如果要拦截WKWebview中的请求只能通过WKWebview对应的代理方法进行处理了。

GitHub - aozhimin/iOS-Monitor-Wedjat(华狄特)开发过程的调研和整理Platform: iOS 性能监控 SDK ——

NSURLProtocol 拦截 NSURLSession 请求时 body 丢失问题解决方案探讨 - 个人文章 - SegmentFault 思否

NSURLProtocol 无法截获 NSURLSession 解决方案 钟武的技术博客

https://zeeyang.com/2017/09/09/debug-tool-base-on-NSURLProtocol/

https://developer.apple.com/documentation/foundation/url_loading_system?language=objc

NSURLProtocol - NSHipster

GitHub - mattt/NSEtcHosts: /etc/hosts with NSURLProtocol

https://blog.newrelic.com/engineering/right-way-to-swizzle/

iOS 网络请求耗时统计
https://vaezc.github.io/posts/ios-网络请求耗时统计/
作者
vaezc
发布于
2019-04-04
许可协议
CC BY-NC-SA 4.0