/ iOS 开发

iOS 网络性能监控方案

国内的网络环境比较复杂,网络质量差异比较大,还有部分运营商劫持 200 以外的请求,经常有用户反馈,使用 iOS App 过程中遇到提示 “网络连接失败”,而隔壁家的 App 就正常。遇到这种问题,没有数据可以排查,我们也很难去判断问题到底出在什么地方,所以对网络进行监控势在必行。另外,有了监控数据,也可以针对访问比较慢的请求对一些后端问题进行排查,有针对性的优化用户体验。

如何获取 iOS 网络性能参数

在 iOS 中,可以通过 NSURLProtocol 实现对网络进行监控。作为一个比较上层的抽象类,NSURLProtocol 使用起来比较简单,需要通过子类化来定义新的或已经存在的 URL 的加载行为。NSURLProtocol 支持 HTTP、HTTPS、FTP 等应用层协议,对于大多数 App 来说已经够用了,如果需要监控其他类型的协议,可以采用底层的 CFNetwork 来实现。

网络性能参数的关键指标有以下这些:

  • TCP 建立连接时间
  • DNS 时间(DNS 解析时间)
  • SSL 时间(建立 SSL 花费的时间)
  • 首包时间(开始请求的时间戳)
  • 响应时间(请求完成的时间戳)
  • 网络错误率(可以通过后端统计计算得出,前端只需要上报 HTTP Code 即可)

首先我们定义一个 Model(继承自 Realm,用来持久化),用来存储相关网络性能参数:

#import <Realm/Realm.h>

@interface NetworkMonitorModel : RLMObject
@property (nonatomic, copy) NSString *url;
@property (nonatomic, copy) NSString *networkType;
@property (nonatomic, assign) NSTimeInterval dnsStartTime;
@property (nonatomic, assign) NSTimeInterval dnsEndTime;
@property (nonatomic, assign) NSTimeInterval requestStartTime;
@property (nonatomic, assign) NSTimeInterval requestEndTime;
@property (nonatomic, assign) BOOL useProxy;
@property (nonatomic, assign) NSInteger sendDataSize;
@property (nonatomic, assign) NSInteger receiveDataSize;
@property (nonatomic, assign) NSInteger httpCode;
@property (nonatomic, copy) NSString *appName;
@property (nonatomic, copy) NSString *appVersion;
@property (nonatomic, copy) NSString *deviceType;
@property (nonatomic, copy) NSString *systemVersion;
@end

通过对 NSURLProtocol 实现子类化来完成对网络请求的截取:

#import "NetworkMonitor.h"
#import "NetworkMonitorModel.h"
#import "AppInfo.h"

static NSString *NetworkRequestIdentifier = @"NetworkRequestIdentifier";

@interface NetworkMonitor()<NSURLSessionDataDelegate, NSURLSessionTaskDelegate>
@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
@property (nonatomic, strong) NSOperationQueue     *sessionDelegateQueue;
@property (nonatomic, strong) NSURLResponse        *response;
@property (nonatomic, strong) NSMutableData        *data;
@property (nonatomic, strong) NSDate               *startDate;
@property (nonatomic, strong) NetworkMonitorModel *networkModel;
@end

@implementation NetworkMonitor

+(BOOL)canInitWithRequest:(NSURLRequest *)request {
    if([NSURLProtocol propertyForKey:NetworkRequestIdentifier inRequest:request]){
        return NO;
    }
    
    NSString *scheme = [[request URL] scheme];
    if([scheme caseInsensitiveCompare:@"http"] == NSOrderedSame || [scheme caseInsensitiveCompare:@"https"] == NSOrderedSame){
        return YES;
    }
    return NO;
}

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

-(void)startLoading {
    self.networkModel = [NetworkMonitorModel new];
    self.networkModel.url = [self.request.URL absoluteString];
    self.networkModel.appName = [AppInfo getName];
    self.networkModel.appVersion = [AppInfo getAppStoreVersion];
    self.networkModel.networkType = [AppInfo getNetWorkType];
    self.networkModel.deviceType = [AppInfo getDeviceName];
    self.networkModel.systemVersion = [UIDevice currentDevice].systemVersion;
    
    self.startDate                                        = [NSDate date];
    self.data                                             = [NSMutableData data];
    NSURLSessionConfiguration *configuration              = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSURLSession *session                                 = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:self.sessionDelegateQueue];
    self.dataTask                                         = [session dataTaskWithRequest:self.request];
    [self.dataTask resume];
}

-(void)stopLoading {
    [self.dataTask cancel];
    self.dataTask           = nil;
    NSString *mimeType      = self.response.MIMEType;
    
    if([self.response isKindOfClass:[NSHTTPURLResponse class]]){
        self.networkModel.httpCode = ((NSHTTPURLResponse *)self.response).statusCode;
    }
    
    [self saveToRealm];
}

-(void)saveToRealm {
    RLMRealm *realm = [RLMRealm defaultRealm];
    [realm transactionWithBlock:^{
        [realm addObject:self.networkModel];
    }];
}

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

-(void)URLSession:(NSURLSession *)session dataTask:(nonnull NSURLSessionDataTask *)dataTask didReceiveResponse:(nonnull NSURLResponse *)response completionHandler:(nonnull void (^)(NSURLSessionResponseDisposition))completionHandler {
    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
    completionHandler(NSURLSessionResponseAllow);
    self.response = response;
}

-(void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(nonnull NSHTTPURLResponse *)response newRequest:(nonnull NSURLRequest *)request completionHandler:(nonnull void (^)(NSURLRequest * _Nullable))completionHandler {
    if (response != nil){
        self.response = response;
        [[self client] URLProtocol:self wasRedirectedToRequest:request redirectResponse:response];
    }
}

-(void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics {
    [self setMetrics:metrics];
}

-(void)setMetrics:(NSURLSessionTaskMetrics *)metrics {
    NSURLSessionTaskTransactionMetrics *requestMetrics = metrics.transactionMetrics.firstObject;
    NSURLSessionTaskTransactionMetrics *responseMetrics = metrics.transactionMetrics.lastObject;
    
    //获取 DNS 解析时间,请求时间,响应时间,请求数据大小,响应数据大小,是否使用代理。
    self.networkModel.dnsStartTime = [responseMetrics.domainLookupStartDate timeIntervalSince1970];
    self.networkModel.dnsEndTime = [responseMetrics.domainLookupEndDate timeIntervalSince1970];
    self.networkModel.requestStartTime = [responseMetrics.requestStartDate timeIntervalSince1970];
    self.networkModel.requestEndTime = [responseMetrics.responseEndDate timeIntervalSince1970];
    self.networkModel.useProxy = responseMetrics.proxyConnection;
    self.networkModel.receiveDataSize = responseMetrics.response.expectedContentLength;
    self.networkModel.sendDataSize = requestMetrics.request.HTTPBody.length;
}

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

@end

didFinishCollectingMetrics 方法中完成性能参数的获取,该方法仅在 iOS10 以上可用。

上传数据

获取完之后,可以将数据保存到 Realm 中。由于每次网络请求都会产生一条性能数据,如果立即发送的话,相当于服务器的请求直接翻倍,发起了一次 DDOS 攻击。所以目前我们的发送策略是等收集到了 20 条之后再一起发送。Realm 提供了一个非常方便的 Notification 功能,当 Realm 中的数据有变更的时候,可以触发通知,所以可以添加一个 Realm 通知来判断数据库的变更。当满足 20 条之后,取出这些数据,一起上传。

-(void)uploadStart {
    RLMResults *models = [PNNetworkMonitorModel allObjects];
    self.token = [models addNotificationBlock:^(RLMResults * _Nullable results, RLMCollectionChange * _Nullable change, NSError * _Nullable error) {
        dispatch_async(dispatch_get_main_queue(), ^{
            if(results.count >= 20){
                NSMutableArray *array = [NSMutableArray array];
                for(PNNetworkMonitorModel *model in results) {
                    [array addObject:[model modelToJSONObject]];
                }
                [self uploadNetworkMonitorData:array];
                [[RLMRealm defaultRealm] beginWriteTransaction];
                [[RLMRealm defaultRealm] deleteObjects:results];
                [[RLMRealm defaultRealm] commitWriteTransaction];
            }
        });
    }];
}

-(void)uploadNetworkMonitorData:(NSArray *)models {
    AFHTTPSessionManager *sessionManager = [[AFHTTPSessionManager alloc] initWithBaseURL:[NSURL URLWithString:@"http://track.xxx.com"] sessionConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]];
    sessionManager.requestSerializer = [[AFJSONRequestSerializer alloc] init];
    sessionManager.requestSerializer.timeoutInterval = 10;
    sessionManager.responseSerializer = [[AFJSONResponseSerializer alloc] init];
    [sessionManager POST:@"/network" parameters:models success:^(NSURLSessionDataTask * _Nonnull task, id  _Nonnull responseObject) {
    } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) {
    }];
}

上传完成之后,删除已经上传的数据。

[[RLMRealm defaultRealm] beginWriteTransaction];
[[RLMRealm defaultRealm] deleteObjects:results];
[[RLMRealm defaultRealm] commitWriteTransaction];