APM - iOS 网络监控

881 阅读6分钟

简介

iOS常见网络库

  • 短链接

    • NSURLSession

      • Alamofire
      • AFNetworking
      • SDWebImage
      • Kingfisher
    • CFNetwork

    • Libcurl

    • Cronet

  • 长链接

    • Socket
    • WebSocket

URL Loading System

NSURLSession

当前iOS中网络请求最常用的是基于NSURLSession实现和封装的网络库,NSURLSession属于URL Loading System的一部分。

NSURLProtocol

URL Loading System中提供的NSURLProtocol,能够拦截所有基于URL Loading System发出的网络请求,网络请求拦截之后可以方便采集数据和做相关处理。

适用范围

由于Background Session, WKWebView由另外的进程处理,所以URLProtocol无法对其做拦截处理。由于CFNetwork, libcurl不属于URL Loading System,所以URLProtocol也无法对其处理。

一、网络流程

iOS中HTTP请求流程

二、网络监控

监控代码

注册

URLProtocol的注册有2种方式

  • registerClass

open class func registerClass(_ protocolClass: AnyClass) -> Bool

URLProtocol.registerClass(NetworkProtocol.classForCoder())

//用于sharedSession
  • protocolClasses

open var protocolClasses: [AnyClass]?

URLSessionConfiguration.default.protocolClasses = [NetworkProtocol.classForCoder()]

//用于defaultSessionConfiguration/ephemeralSessionConfiguration

对应的2种注册方式,protocol 的调用顺序都是按照 protocolClasses 数组中的顺序,从前往后依次调用各个 protocol 的 canInitWith 方法,找到第一个可以 handle 的 protocol,后续的 protocol 就不会再调用了。

  • Hook URLSessionConfiguration.protocolClasses.get()

对于URLSessionConfiguration.default,每次返回的都是copy出来的对象,所以当我们无法得到对应URLSessionConfiguration创建出来的URLSession,我们只能hook URLSessionConfiguration中protocolClasses的get方法,来实现拦截。



#import <Foundation/Foundation.h>



NS_ASSUME_NONNULL_BEGIN



 @interface SSRURLSessionConfiguration : NSObject



+ (SSRURLSessionConfiguration *)defaultConfiguration;



 @property (nonatomic, assign) BOOL isSwizzle;



 @property (nonatomic, strong) NSMutableArray *allProtocolClasses;



- (void)addURLProtocol:(Class)protocol;



- (void)swizzleURLSessionConfiguration;



 @end



NS_ASSUME_NONNULL_END


#import "SSRURLSessionConfiguration.h"

#import <objc/runtime.h>



 @implementation SSRURLSessionConfiguration



+ (SSRURLSessionConfiguration *)defaultConfiguration {

    static SSRURLSessionConfiguration *staticConfiguration;

    static dispatch_once_t onceToken;

    dispatch_once(&onceToken, ^{

        staticConfiguration = [[SSRURLSessionConfiguration alloc] init];

    });

    return staticConfiguration;

}



- (void)swizzleURLSessionConfiguration {

    self.isSwizzle = YES;

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

}



- (void)addURLProtocol:(NSURLProtocol *)protocol {

    if (self.allProtocolClasses == nil) {

        self.allProtocolClasses = [NSMutableArray array];

    }

    if (![self.allProtocolClasses containsObject:protocol]) {

        [self.allProtocolClasses addObject:protocol];

    }

}



- (NSArray *)protocolClasses {

    return [SSRURLSessionConfiguration defaultConfiguration].allProtocolClasses;

}



 @end


let sessionConfiguration = SSRURLSessionConfiguration.default()

sessionConfiguration.addURLProtocol(YourNetworkProtocol.classForCoder())

if !sessionConfiguration.isSwizzle {

    sessionConfiguration.swizzleURLSessionConfiguration()

}

转发

拦截到网络请求后,URLProtocol会依次执行下列方法:

// 该请求是否需要当前的协议对象拦截并处理

// 通常情况下判断scheme不为空,之后判断需要拦截的scheme,判断已经拦截过的scheme

override class func canInit(with request: URLRequest) -> Bool {

        if let scheme = request.url?.scheme, (scheme == "http" || scheme == "https"),

           URLProtocol.property(forKey: "Your Key Here", in: request) == nil {

            return true

        } else {

            return false

        }

    }
// 返回规范化的请求(通常只要返回原来的请求就可以)

override class func canonicalRequest(for request: URLRequest) -> URLRequest {

    return request

}
// 判断两个请求是否为同一个请求,如果为同一个请求就使用缓存数据

override class func requestIsCacheEquivalent(_ a: URLRequest, to b: URLRequest) -> Bool {

    return super.requestIsCacheEquivalent(a, to: b)

}

在startLoading方法中,我们需要标记已拦截的请求,发起新的请求。

新的请求可以通过多种方式发起,URLSession, CFNetwork, Libcurl, Cronet等都可以,这边网络监控我们继续基于URLSession实现,iOS10以后URLSession提供了Metircs网络性能数据,iOS提供了更加丰富的网络性能数据,更加方便。

这边创建新的请求的时候,需要使用sharedSession,如果每次都创建新的Session,TCP连接将无法复用,每次都创建新的连接。



override func startLoading() {

    if let mutableReqeust = (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest {

        // 标记已拦截的请求

        URLProtocol.setProperty(true, forKey: "Your Key Here", in: mutableReqeust)

        

        // 创建新的请求

        let task = SSRNetworkSessionManager.shared.session.dataTask(with: mutableReqeust as URLRequest)

        

        // 设置task回调

        SSRNetworkSessionManager.shared.setDelegate(self, task: task)

            

        sessionDataTask = task

        sessionDataTask?.resume()

    }

}


override func stopLoading() {

    sessionDataTask?.cancel()

}

由于使用了sharedSession来发起新的请求,所以在回调的时候,需要根据task.identifier来找到对应的delegate,所以创建了task与delegate对应的dict



class SSRNetworkSessionManager: NSObject {

    @objc public static dynamic let shared = SSRNetworkSessionManager()

    

    weak var delegate: (SessionDelegate)?

    

    var taskIdDelegateDict = [String: SessionDelegate]()

    

    @objc dynamic let lock = NSLock()

    

    lazy var session: URLSession = {

        let configuration: URLSessionConfiguration = URLSessionConfiguration.ephemeral

        let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)

        return session

    }()

    

    func setDelegate(_ delegate: SessionDelegate, task: URLSessionTask) {

        lock.lock()

        defer {

            lock.unlock()

        }

        taskIdDelegateDict[String(task.taskIdentifier)] = delegate

    }

    

    func delegate(_ task: URLSessionTask) -> SessionDelegate? {

        lock.lock()

        defer {

            lock.unlock()

        }

        return taskIdDelegateDict[String(task.taskIdentifier)]

    }

    

    func removeDelegate(task: URLSessionTask) {

        lock.lock()

        defer {

            lock.unlock()

        }

        taskIdDelegateDict.removeValue(forKey: String(task.taskIdentifier))

    }

}


extension SSRNetworkSessionManager: URLSessionDataDelegate {

    

    func urlSession(_ session: URLSession,

                    dataTask: URLSessionDataTask,

                    didReceive response: URLResponse,

                    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {

        if let delegate = SSRNetworkSessionManager.shared.delegate(dataTask) {

            delegate.urlSession?(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler)

        }

    }

    

    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {

        if let delegate = SSRNetworkSessionManager.shared.delegate(dataTask) {

            delegate.urlSession?(session, dataTask: dataTask, didReceive: data)

        }

    }

}



extension SSRNetworkSessionManager: URLSessionTaskDelegate {

    

    func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {

        if let delegate = SSRNetworkSessionManager.shared.delegate(task) {

            delegate.urlSession?(session, task: task, didFinishCollecting: metrics)

        }

    }

    

    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {

        if let delegate = SSRNetworkSessionManager.shared.delegate(task) {

            delegate.urlSession?(session, task: task, didCompleteWithError: error)

        }

    }

    

    func urlSession(_ session: URLSession,

                    task: URLSessionTask,

                    willPerformHTTPRedirection response: HTTPURLResponse,

                    newRequest request: URLRequest,

                    completionHandler: @escaping (URLRequest?) -> Void) {

        if let delegate = SSRNetworkSessionManager.shared.delegate(task) {

            delegate.urlSession?(session, task: task, willPerformHTTPRedirection: response, newRequest: request, completionHandler: completionHandler)

        }

    }

}

回调

  • URLSessionDataDelegate


func urlSession(_ session: URLSession,

                    dataTask: URLSessionDataTask,

                    didReceive response: URLResponse,

                    completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {

        self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: URLCache.StoragePolicy.allowed)

        completionHandler(URLSession.ResponseDisposition.allow)

    }
// 该方法会多次回调,Body数据需要进行叠加

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {

        

        if self.responseBodyData == nil {

            self.responseBodyData = data

        } else {

            self.responseBodyData?.append(data)

        }

        

        self.client?.urlProtocol(self, didLoad: data)

    }
  • URLSessionTaskDelegate

URLSessionTaskMetrics中提供了丰富的性能数据,在下方提供了对应的结构体信息



func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {}

didCompleteWithError是整个Task回调最后调用的方法,可以在这个方法里对数据进行计算和处理



func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {}


func urlSession(_ session: URLSession,

                    task: URLSessionTask,

                    willPerformHTTPRedirection response: HTTPURLResponse,

                    newRequest request: URLRequest,

                    completionHandler: @escaping (URLRequest?) -> Void) {

                    

    self.client?.urlProtocol(self, wasRedirectedTo: request, redirectResponse: response)

}

URLSessionTaskMetrics数据结构



 @available(iOS 10.0, *)

open class URLSessionTaskTransactionMetrics : NSObject, @unchecked Sendable {



    

    open var request: URLRequest { get }



    

    @NSCopying open var response: URLResponse? { get }



    

    open var fetchStartDate: Date? { get }



    

    open var domainLookupStartDate: Date? { get }



    

    open var domainLookupEndDate: Date? { get }



    

    open var connectStartDate: Date? { get }



    

    open var secureConnectionStartDate: Date? { get }



    

    open var secureConnectionEndDate: Date? { get }



    

    open var connectEndDate: Date? { get }



    

    open var requestStartDate: Date? { get }



    

    open var requestEndDate: Date? { get }



    

    open var responseStartDate: Date? { get }



    

    open var responseEndDate: Date? { get }



    

    open var networkProtocolName: String? { get }



    

    open var isProxyConnection: Bool { get }



    

    open var isReusedConnection: Bool { get }



    

    open var resourceFetchType: URLSessionTaskMetrics.ResourceFetchType { get }



    

    @available(iOS 13.0, *)

    open var countOfRequestHeaderBytesSent: Int64 { get }



    

    @available(iOS 13.0, *)

    open var countOfRequestBodyBytesSent: Int64 { get }



    

    @available(iOS 13.0, *)

    open var countOfRequestBodyBytesBeforeEncoding: Int64 { get }



    

    @available(iOS 13.0, *)

    open var countOfResponseHeaderBytesReceived: Int64 { get }



    

    @available(iOS 13.0, *)

    open var countOfResponseBodyBytesReceived: Int64 { get }



    

    @available(iOS 13.0, *)

    open var countOfResponseBodyBytesAfterDecoding: Int64 { get }



    

    @available(iOS 13.0, *)

    open var localAddress: String? { get }



    

    @available(iOS 13.0, *)

    open var remoteAddress: String? { get }



    

    @available(iOS 13.0, *)

    open var isCellular: Bool { get }



    

    @available(iOS 13.0, *)

    open var isExpensive: Bool { get }



    

    @available(iOS 13.0, *)

    open var isConstrained: Bool { get }



    

    @available(iOS 13.0, *)

    open var isMultipath: Bool { get }



    

    @available(iOS 14.0, *)

    open var domainResolutionProtocol: URLSessionTaskMetrics.DomainResolutionProtocol { get }



    

    @available(iOS, introduced: 10.0, deprecated: 13.0, message: "Not supported")

    public init()



    @available(iOS, introduced: 10.0, deprecated: 13.0, message: "Not supported")

    open class func new() -> Self

}

数据指标

基础信息和源信息

  • port
  • remoteAddress
  • localAddress
  • tlsVersionCode
  • networkProtocolName
  • resourceFetchType
  • isProxyConnection
  • requestHeadSize
  • requestBodySize
  • responseHeadSize
  • responseBodySize

成功率

Status/HTTP Code

200..<300 的占比可以作为成功的网络请求

Business Code

部分失败的网络请求中,又可以按照业务状态码聚合和分类

耗时

整体耗时
阶段耗时
连接复用率

在连接复用的情况下,TCP+TLS建连这个阶段是没有的,可以节约很多的耗时,所以连接复用率是一个很重要的指标

三、网络优化

ROI

耗时类的ROI基本可以按照阶段耗时/总耗时来计算,之后降序排列,再依次按照难易度来解决和优化即可。

网络请求类型

  • 接口请求
  • 图片请求
  • 日志请求

接口请求按照下述做优化,图片请求侧重源和硬件侧的优化,日志请求侧重压缩和聚合上传

阶段优化

TCP+TLS

HTTP2.0,QUIC等提升连接复用率,优化连接耗时

OCSP提升证书校验和TLS的耗时

TLS升级至1.3版本,提升TLS耗时

DNS

HTTPDNS,IP直连

HTTP服务端响应耗时

推进服务端处理

Task

缓存池复用

网络监控中我们得到Reqeust Size,Response Size,可以通过排序逐步排查优化Type和Size

硬件

CDN,全站加速等云方案

引用

URLProtocol 注册方式的细节问题