目标
- TCP建立连接时间
- DNS时间
- SSL/TLS时间
- 响应总时间
- 请求头、请求body、响应头、响应body大小
- 支持统计原生网络请求、React Native网络请求
- 代码无侵害
方案对比
方案一:通过 NSURLProtocol
来实现
通过向 NSURLProtocol
注册自定义的 NSURLProtocol
子类,比如是 TESTURLProtocol
,然后每个由 NSURLConnection
或 NSURLSession
发起请求都会访问 TESTURLProtocol
。
注册方式
[NSURLProtocol registerClass:[TESTURLProtocol class]]
问题一:如果 NSURLSession 使用下面两个方法创建的,只注册是拦截不到的。
+ (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration;
+ (NSURLSession *)sessionWithConfiguration:(NSURLSessionConfiguration *)configuration
delegate:(nullable id )delegate
delegateQueue:(nullable NSOperationQueue *)queue;
需要把TESTURLProtocol
添加到 NSURLSessionConfiguration.protocolClasses
中,可以通过 hook 来实现。
问题二:POST 请求 body 丢失问题。
在 TESTURLProtocol
中,使用 HTTPBodyStream
获取 body,并赋值到 body 中
- (void)startLoading {
//缓存下来
TestModel.request = [self.request cyl_getPostRequestIncludeBody];
}
@interface NSURLRequest (CYLNSURLProtocolExtension)
- (NSURLRequest *)cyl_getPostRequestIncludeBody;
@end
@implementation NSURLRequest (CYLNSURLProtocolExtension)
- (NSURLRequest *)cyl_getPostRequestIncludeBody {
return [[self cyl_getMutablePostRequestIncludeBody] copy];
}
- (NSMutableURLRequest *)cyl_getMutablePostRequestIncludeBody {
NSMutableURLRequest * req = [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;
//不能用 [stream hasBytesAvailable]) 判断,处理图片文件的时候这里的[stream hasBytesAvailable]会始终返回YES,导致在while里面死循环。
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];
}
}
req.HTTPBody = [data copy];
[stream close];
}
}
return req;
}
@end
参考方案:NSURLProtocol 拦截 NSURLSession 请求时body丢失问题解决方案探讨
问题三:NSURLProtocol
可以拦截 UIWebView
的请求,无法拦截 WKWebView
。
可以通过修改WKWebView的私有方法来实现,注意如果提交 AppStore 需要加密处理
//注册scheme
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([cls respondsToSelector:sel]) {
// 通过http和https的请求,同理可通过其他的Scheme 但是要满足ULR Loading System
[cls performSelector:sel withObject:@"http"];
[cls performSelector:sel withObject:@"https"];
}
参考方案:NSURLProtocol对WKWebView的处理
NSURLProtocol 实现方案可参考 NetworkEye 可以监听 NSURLConnection
和 NSURLSession
请求,处理了问题意一和问题二。
小结
优点:
- 可以比较轻松的获取请求头、请求body、响应头、响应body、请求总时长(从开始请求到请求结束时长);
- 没有版本限制。
缺点:
- 时间方面没法获取各个阶段的时长,比如,DNS时长、TCP时长、SSL时长等;
- 流量方面需要自己计算大小,比如响应body很多时候会有压缩,所以在计算的时候需要模拟压缩,得到的结果还是有误差的;
- 需要创建新的请求来路由接口,对业务有一定的破坏性(最难接受的)。
方案二:通过监听 URLSession:task:didFinishCollectingMetrics:
中的 NSURLSessionTaskMetrics
来实现
通过 hook NSURLSession
的方法 sessionWithConfiguration:delegate:delegateQueue:
使用 NSProxy
来转发,这样就可以监听 URLSession:task:didFinishCollectingMetrics:
方法。
可参考iOS网络性能监控
问题一:需要 iOS10 之后才能使用,流量相关的统计需要 iOS13 之后才能使用
问题二:无法拦截通过 sharedSession
获取 NSURLSession
的实例
问题三:同样也有 POST 请求 body 丢失问题,以及 WKWebView
无法拦截问题, 实现方式跟上面类似
小结
优点:通过 NSURLSessionTaskMetrics
可以获取很方便的获取各个阶段的请求耗时,以及流量的使用请求。
缺点:主要是问题一和问题二带来的。
最终方案
在实际业务开发中,网络请求的方式主要是 NSURLSession
、NSURLConnection
和 AFNetworking
;AFNetworking
是基于 NSURLSession
和 NSURLConnection
实现的;业务如果是 React Native 来开发,底层的网络请求也是基于 NSURLSession
来实现的。
另外,NSURLConnection
iOS9之后就被苹果弃用了。
接下来,我们再看一下官方给的当前系统的占有率。
数据来源
只有 8% 的设备是低于 iOS13 的。
拦截目标:主要考虑 NSURLSession
的拦截;考虑到实际业务用很使用 AFNetworking
的 NSURLConnection
的请求,也还是要考虑 NSURLConnection
的拦截。
最终方案:以方案二为主,覆盖不到的地方使用方案一来补充。
方案二拦截不到有两种请求
- NSURLSession 通过 sharedSession 获取的对象
- NSURLConnection 发起的请求,虽然 iOS9 之后就已经抛弃了,但是 AFNetworking 有部分是基于 NSURLConnection,而我们很多老代码就是通过 NSURLConnection 发起的请求
所以针对这个两种情况通过方案一的 NSURLProtocol 来实现,内部生成 NSURLSession 实例,路由到方案二。
// 方案一,核心代码逻辑,目的是路由接口至方案二上
@interface MTNMURLProtocol ()
@property (nonatomic, strong) NSURLSession *session;
@end
@implementation MTNMURLProtocol
+ (void)install
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[NSURLProtocol registerClass:[MTNMURLProtocol class]];
});
}
+ (BOOL)canInitWithRequest:(NSURLRequest *)request
{
if ([MTNMDataManager.shareManager isWhitelistURL:request.URL])
{
return NO;
}
if (![request.URL.scheme isEqualToString:@"http"] &&
![request.URL.scheme isEqualToString:@"https"])
{
return NO;
}
if ([NSURLProtocol propertyForKey:@"MTNMURLProtocol" inRequest:request])
{
return NO;
}
return YES;
}
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
{
NSMutableURLRequest *mutableReqeust = [request mutableCopy];
[NSURLProtocol setProperty:@YES forKey:@"MTNMURLProtocol" inRequest:mutableReqeust];
return [mutableReqeust copy];
}
- (NSURLSession *)session
{
if (!_session)
{
_session = [NSURLSession sessionWithConfiguration:NSURLSessionConfiguration.defaultSessionConfiguration delegate:self delegateQueue:nil];
}
return _session;
}
- (void)startLoading
{
[[self.session dataTaskWithRequest:self.request] resume];
}
- (void)stopLoading
{
[self.session getTasksWithCompletionHandler:^(NSArray * _Nonnull dataTasks, NSArray * _Nonnull uploadTasks, NSArray * _Nonnull downloadTasks) {
for (NSURLSessionDataTask *task in dataTasks)
{
[task cancel];
}
}];
dispatch_async(dispatch_get_main_queue(), ^{
[self.session finishTasksAndInvalidate];
});
}
- (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 disposition))completionHandler
{
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
if (completionHandler)
{
completionHandler(NSURLSessionResponseAllow);
}
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(nullable NSError *)error
{
if (error)
{
[self.client URLProtocol:self didFailWithError:error];
}
else
{
[self.client URLProtocolDidFinishLoading:self];
}
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler
{
[self.client URLProtocol:self wasRedirectedToRequest:request redirectResponse:response];
if (completionHandler)
{
completionHandler(request);
}
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler
{
id sender = [MTNMURLAuthenticationChallengeSender senderWithCompletionHandler:completionHandler];
NSURLAuthenticationChallenge *wrappedChallenge = [[NSURLAuthenticationChallenge alloc] initWithAuthenticationChallenge:challenge sender:sender];
[self.client URLProtocol:self didReceiveAuthenticationChallenge:wrappedChallenge];
}
- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session
{
[self.client URLProtocolDidFinishLoading:self];
}
@end
// 方案二, 核心代码逻辑
@interface MTNMURLSessionMetricsProxy : NSProxy
@property (nonatomic, strong) id target;
- (instancetype)initWithTarget:(id)target;
@end
@implementation MTNMURLSessionMetricsProxy
- (instancetype)initWithTarget:(id)target
{
self.target = target;
return self;
}
- (void)dealloc
{
if (_target)
{
_target = nil;
}
}
- (BOOL)respondsToSelector:(SEL)aSelector
{
if ([NSStringFromSelector(aSelector) isEqualToString:@"URLSession:task:didFinishCollectingMetrics:"] ||
[NSStringFromSelector(aSelector) isEqualToString:@"URLSession:dataTask:didReceiveResponse:completionHandler:"] ||
[NSStringFromSelector(aSelector) isEqualToString:@"URLSession:dataTask:didReceiveData:"] ||
[NSStringFromSelector(aSelector) isEqualToString:@"URLSession:task:didCompleteWithError:"])
{
return YES;
}
return [self.target respondsToSelector:aSelector];
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector
{
if (!self.target)
{
return [NSMethodSignature signatureWithObjCTypes:"v@"];
}
return [self.target methodSignatureForSelector:selector];
}
- (void)forwardInvocation:(NSInvocation *)invocation
{
if (!self.target)
{
return;
}
if ([self.target respondsToSelector:invocation.selector])
{
[invocation invokeWithTarget:self.target];
}
}
// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler
{
session.mt_responseBodyMutableData = [NSMutableData data];
if ([self.target respondsToSelector:@selector(URLSession:dataTask:didReceiveResponse:completionHandler:)])
{
[self.target URLSession:session dataTask:dataTask didReceiveResponse:response completionHandler:completionHandler];
}
else
{
if (completionHandler)
{
completionHandler(NSURLSessionResponseAllow);
}
}
}
// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
if ([self.target respondsToSelector:@selector(URLSession:dataTask:didReceiveData:)])
{
[self.target URLSession:session dataTask:dataTask didReceiveData:data];
}
if (!session.mt_didAddData)
{
if (!session.mt_requestBodyData)
{
session.mt_requestBodyData = [self reqeustBobyForRequest:dataTask.originalRequest];
}
if (data)
{
[session.mt_responseBodyMutableData appendData:data];
}
}
}
// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(nullable NSError *)error
{
if ([self.target respondsToSelector:@selector(URLSession:task:didCompleteWithError:)])
{
[self.target URLSession:session task:task didCompleteWithError:error];
}
if (!session.mt_didAddData && error)
{
session.mt_error = error;
[session mt_checkAddRecordData];
}
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics
{
if ([self.target respondsToSelector:@selector(URLSession:task:didFinishCollectingMetrics:)])
{
[self.target URLSession:session task:task didFinishCollectingMetrics:metrics];
}
if (![MTNMDataManager.shareManager isWhitelistURL:task.originalRequest.URL])
{
for (NSURLSessionTaskTransactionMetrics *transMetric in metrics.transactionMetrics) {
if (transMetric.resourceFetchType == NSURLSessionTaskMetricsResourceFetchTypeNetworkLoad)
{
if (!session.mt_didAddData)
{
if (!session.mt_requestBodyData)
{
session.mt_requestBodyData = [self reqeustBobyForRequest:task.originalRequest];
}
session.mt_metrics = transMetric;
[session mt_checkAddRecordData];
}
}
}
}
}
- (NSData *)reqeustBobyForRequest:(NSURLRequest *)request
{
NSMutableData *data;
if ([request.HTTPMethod isEqualToString:@"POST"] && !request.HTTPBody)
{
NSInteger maxLength = 1024;
uint8_t d[maxLength];
NSInputStream *stream = request.HTTPBodyStream;
data = [[NSMutableData alloc] init];
[stream open];
BOOL endOfStreamReached = NO;
//不能用 [stream hasBytesAvailable]) 判断,处理图片文件的时候这里的[stream hasBytesAvailable]会始终返回YES,导致在while里面死循环。
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];
}
}
[stream close];
}
return request.HTTPBody ?: data;
}
@end
应用时遇到时问题及解决
问题一:在 MTNMURLSessionMetricsProxy
拦截 NSURLSession
请求时,需要获取相应body。
相应body的获取是有必要的,不仅用于 iOS13 以下的接口统计,也可以用于本地网络记录查看,方便排查问题。
NSURLSession
的回调一般有两种方式, delegate
和 block
的方式。
delegate
只需要在 NSProxy
子类中拦截即可
// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler
{
session.mt_responseBodyMutableData = [NSMutableData data];
if ([self.target respondsToSelector:@selector(URLSession:dataTask:didReceiveResponse:completionHandler:)])
{
[self.target URLSession:session dataTask:dataTask didReceiveResponse:response completionHandler:completionHandler];
}
else
{
if (completionHandler)
{
completionHandler(NSURLSessionResponseAllow);
}
}
}
// dataTaskWithRequest: 和 dataTaskWithURL: 发起的请求通过 delegate 回调
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data
{
if ([self.target respondsToSelector:@selector(URLSession:dataTask:didReceiveData:)])
{
[self.target URLSession:session dataTask:dataTask didReceiveData:data];
}
if (!session.mt_didAddData)
{
if (!session.mt_requestBodyData)
{
session.mt_requestBodyData = [self reqeustBobyForRequest:dataTask.originalRequest];
}
if (data)
{
[session.mt_responseBodyMutableData appendData:data];
}
}
}
blcok
的方式就复杂一点,需要 hook
掉 dataTaskWithRequest:completionHandler:
和 dataTaskWithURL:completionHandler:
来拦截获取。
@implementation NSURLSession (MTNetworkMonitor)
- (NSURLSessionDataTask *)hook_dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler
{
return [self hook_dataTaskWtihURL:url request:nil fromURL:YES completionHandler:completionHandler];
}
- (NSURLSessionDataTask *)hook_dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler
{
return [self hook_dataTaskWtihURL:nil request:request fromURL:NO completionHandler:completionHandler];
}
- (NSURLSessionDataTask *)hook_dataTaskWtihURL:(NSURL *)url request:(NSURLRequest *)request fromURL:(BOOL)fromURL completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler
{
[self addIfNotGuid];
void(^hookCompletionHandler)(NSData * _Nullable, NSURLResponse * _Nullable, NSError * _Nullable) = ^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error){
if (![MTNMDataManager.shareManager isWhitelistURL:url])
{
if (!self.mt_didAddData)
{
self.mt_error = error;
self.mt_responseBodyMutableData = data.mutableCopy;
[self mt_checkAddRecordData];
}
}
if (completionHandler) {
completionHandler(data, response, error);
}
};
void(^completion)(NSData * _Nullable, NSURLResponse * _Nullable, NSError * _Nullable) = completionHandler ? hookCompletionHandler : completionHandler;
if (fromURL)
{
return [self hook_dataTaskWithURL:url completionHandler:completion];
}
else
{
return [self hook_dataTaskWithRequest:request completionHandler:completion];
}
}
@end
问题二:NSURLSession
的 delegate
是强引用,会导致内存泄漏。
需要在合适的时机主动调用 finishTasksAndInvalidate
来释放 delegate
对象。
问题三:在iOS14.0和iOS14.0.1系统上闪退,原因是获取网络类型导致的
网络类型是通过 CTTelephonyNetworkInfo
获取,其中 CTRadioAccessTechnologyNRNSA
和 CTRadioAccessTechnologyNR
苹果文档是 iOS14.0 就可以用实际上,在 iOS14.0 和 iOS14.0.1 是没有的。
问题三:在iOS14.0和iOS14.0.1系统上闪退,原因是获取网络类型导致的
网络类型是通过 CTTelephonyNetworkInfo
获取,其中 CTRadioAccessTechnologyNRNSA
和 CTRadioAccessTechnologyNR
苹果文档是 iOS14.0 就可以用实际上,在 iOS14.0 和 iOS14.0.1 是没有的。