iOS网络性能监控

如今的Native App平台化趋势愈来愈明显,网络层架构也愈来愈复杂。一个App基本都有多个不一样的网络模块。 从简单的业务数据的HTTP/HTTPS(基于NSURLConnection或者NSURLSession),到WebView的WebCore网络层,到基于TCP长链接的推送模块,到各类第三方组件好比统计、日志上报各自的网络层,或者不少app采用基于TCP的私有协议等等,网络层愈来愈复杂,对Native开发者来讲愈来愈像一个黑盒模块。 Native开发者只能着眼于业务开发,对网络层的异常、性能等等问题一无所知。git

初识iOS网络层API

让咱们剥开网络相关的SDK,一层一层地看每一层作了些什么。github

AFNetworking

AFNetworking是对NSURLConnection/NSURLSession的封装。增长了以下逻辑web

  • 封装成NSOperation的形式,提供了resume/cancel等等处理。
  • 增长了NSData的文件处理,上传/下载
  • 方便处理JSON/XML数据
  • 方便处理HTTPS
  • 有Reachablity的API

NSURLSession/NSURLConnection

NSURLSession/NSURLConnection 都是基于CFNetwork的。json

NSURLConnection - CFURLConnection的封装。提供了create,start,cancel,send(同步或者异步),设置回调,设置runloop等函数。api

NSURLSession/NSURLSessionTask - NSCFURLSession/NSCFURLSessionTask等等的封装。浏览器

NSURLXXX这一层主要处理了:缓存

  • 把CFNetwork的blockhandler封装成delegate的方式
  • 处理NSURLProtocol相关的代理
  • 处理NSURLCache的缓存相关,是对CFURLCache的封装
  • 封装sendAsync/和sendSync方法。
  • 把CFURLResponse的statusCode转化成String

CFNetwork

CFNetwork 展现了如何把字节流封装成HTTP协议的请求收发。微信

[图片上传失败...(image-4f1c4b-1535019216015)]cookie

  • CFURLRequest由用户建立,里面包括URL/header/body这些请求的信息。而后CFURLRequest会被转换成CFHTTPMessage的格式。
  • CFHTTPMessage里主要是HTTP协议的定义和转换,把每个请求request转换成标准的HTTP格式的文本。
  • CFURLConnection 里主要是处理请求任务,包括pthread线程、CFRunloop,请求队列的管理等等。因此提供了start、cancel等等操做的api。也有操做CFReadStream等API
  • CFHost:负责DNS,在有CFHostStartInfoResolution等函数,基于dns_async_startgetaddrinfo_async_start方法。在iOS8/9基于getaddrinfo。主要是同步调用和异步调用的区别。
  • CFURLCache/CFURLCredential/CFHTTPCookie:处理缓存/证书/cookie相关的逻辑,都有对应的NS类。

主要的数据交换调用基于CFStream的API。网络

CFStream

借助CFSocketStream,封装BSD Socket,和SecurityTransport(SSL调用)。

因为BSD Socket都是同步调用。因此CFStream这一层主要是Runloop逻辑,锁,dowhile等待等等。 相似BSD socket同样是数据流输入/输出的API。

CFStream 建立时要传入一堆callback,包括open/close,read/write等等。好比CFSocketStream,封装了BSD Socket的做为callback传入CFStream

CFSocketStream 也包括了DNS、SSL链接、Connect握手等等逻辑。

BSD Socket

有多组API,包括connect/shutdown,send/recv,read/write,recvfrom/sendto,recvmsg/sendmsg. 做为客户端通常不使用accept/bind.

send/recvread/write的区别在于多了一个flags参数。当flag为0时,send等同于write。

对于发送消息。send只可用于基于链接的套接字,sendtosendmsg既可用于无链接的socket,也可用于基于链接的socket。除了socket设置为非阻塞模式,调用将会阻塞直到数据被发送完。

DNS方法: getaddrinfo 是对 gethostbyname/gethostbyaddr 的替代,支持了ipv6,返回一个地址struct链表。在iOS8/9中使用。 getaddrinfo_async_start 在iOS10中使用,支持了异步。

监控什么

iOS-Monitor-Platform 这篇文章提出了一些监控指标(然而他提供的方法并不能监控到)。

  • TCP 创建链接时间
  • DNS 时间
  • SSL 时间
  • 首包时间
  • 响应时间
  • HTTP 错误率
  • 网络错误率
  • 流量

APM厂听云提供的一些监控指标:

  • TOP5 响应时间最慢主机
  • TOP5吞吐率最高主机
  • TOP5 DNS时间最慢地域
  • TCP建连最慢主机
  • 链接次数最多主机

HTTP抓包工具Charles提供的监控指标:

  • Request Start Time
  • Request End Time
  • Response Start Time
  • Response End Time
  • Duration
  • DNS
  • Connect
  • SSL Handshake
  • Latency

固然若是可行的话,想每个细节都监控到。可是不少数据都有实现成本,本文用最低的成本力求收集尽量多的指标。

具体实现

HTTP 的监控

HTTP的监控最佳的实践固然就是利用NSURLSession的NSURLSessionTaskMetrics。

[图片上传失败...(image-7b6c8-1535019216015)]

想探究NSURLSessionTaskMetrics的实现,若是反编译CFNetwork的源码,能够看到-[NSURLSessionTaskMetrics _initWithPerformanceTiming] 这个方法,说明是来自一个叫TimingPerformance的类。 TimingPerformance的初始化方法代码以下,能够看到这里定义了全部NSURLSessionTaskMetrics时间节点须要的key,几乎彻底一致。而后初始化时利用CFAbsoluteTimeGetCurrent函数来记录初始化的时间。

int __ZN17PerformanceTimingC2Ev() {
    rbx = rdi;
    CFObject::CFObject();
    ...
    *(rbx + 0x20) = @"_kCFNTimingDataRedirectStart";
    *(rbx + 0x30) = @"_kCFNTimingDataRedirectEnd";
    *(rbx + 0x40) = @"_kCFNTimingDataFetchStart";
    *(rbx + 0x50) = @"_kCFNTimingDataDomainLookupStart";
    *(rbx + 0x60) = @"_kCFNTimingDataDomainLookupEnd";
    *(rbx + 0x70) = @"_kCFNTimingDataConnectStart";
    *(rbx + 0x80) = @"_kCFNTimingDataConnectEnd";
    *(rbx + 0x90) = @"_kCFNTimingDataSecureConnectionStart";
    *(rbx + 0xa8) = @"_kCFNTimingDataRequestStart";
    *(rbx + 0xb8) = @"_kCFNTimingDataRequestEnd";
    *(rbx + 0xc8) = @"_kCFNTimingDataResponseStart";
    *(rbx + 0xd8) = @"_kCFNTimingDataResponseEnd";
    *(rbx + 0xe8) = @"_kCFNTimingDataRedirectCountW3C";
    *(rbx + 0xf8) = @"_kCFNTimingDataRedirectCount";
    *(rbx + 0x108) = @"_kCFNTimingDataTaskResumed";
    *(rbx + 0x118) = @"_kCFNTimingDataConnectCreate";
    *(rbx + 0x128) = @"_kCFNTimingDataTCPConnected";
    *(rbx + 0x138) = @"_kCFNTimingDataFirstWrite";
    *(rbx + 0x148) = @"_kCFNTimingDataFirstRead";
    *(rbx + 0x158) = @"_kCFNTimingDataConnectionInit";
    *(rbx + 0x168) = @"_kCFNTimingDataConnected";
    ....
    *(rbx + 0x1f0) = @"_kCFNTimingDataTimingDataInit";
    ...
    CFAbsoluteTimeGetCurrent();
    ....
    return rax;
}
复制代码

这个类是怎么使用的呢,能够看到[NSCFURLSessionTask resume]这个方法里:

void -[__NSCFURLSessionTask resume](void * self, void * _cmd) {
    rbx = self;
            ...
            __setRecordForKeyInternalPerformanceTiming(@"streamTask-resume");
            r15 = rbx->_performanceTiming;
            if (r15 != 0x0) {
                    PerformanceTiming::Class();
                    xmm0 = intrinsic_movsd(xmm0, *(r15 + 0x110));
                    xmm0 = intrinsic_ucomisd(xmm0, 0x0);
                    if ((xmm0 == 0x0) && (!CPU_FLAGS & P)) {
                            CFAbsoluteTimeGetCurrent();
                            *(r15 + 0x110) = intrinsic_movsd(*(r15 + 0x110), xmm0);
                    }
            }
            __setRecordForKeyInternalPerformanceTiming(@"start-task-resume-to-loader-start-load");
              ...
    return;
}
复制代码

能够看到这里rbx寄存器存储的就是NSCFURLSessionTask对象,这个对象有一个成员变量就是_performanceTiming,放在r15这个寄存器里。上面的代码能够看到(0x108)对应的就是_kCFNTimingDataTaskResumed这个key,而这里xmm0寄存器是个浮点数存储的寄存器,存储的是(r15 + 0x110),对应应该是,而后判断xmm0是否为空,若是是空的话,就调用CFAbsoluteTimeGetCurrent函数获取当前CPU时间,而后再赋给(r15 + 0x110),对应的应该就是_kCFNTimingDataTaskResumed这个key对应的value。

至于__setRecordForKeyInternalPerformanceTiming 这个函数,能够看到它的key并不存在于PerformanceTiming对象初始化的时候,它应该是InternalPerformanceTiming,这是个不一样的类,多是PerformanceTiming的子类。他的key是不一样的,判断是这个库内部使用的,并无做为NSURLSessionTaskMetrics传递出去。

发现__ZN17PerformanceTiming32fillW3NavigationTimingAWDMetricsEP27PerformanceTimingAWDMetrics,__ZN17PerformanceTiming30fillStreamTaskTimingAWDMetricsEP26StreamTaskTimingAWDMetrics这两个函数,说明PerformanceTimingW3NavigationTiming,以及StreamTaskTiming这几个东西的AWDMetrics是能够互相转化的。W3NavigationTiming很容易想到是用于WebView的Timing的API。

NSURLSessionTaskMetrics的优势是苹果帮咱实现了,可是有很严重的缺点是只能适用于iOS10之后的NSURLSession。NSURLConnection是用不了的。iOS10如下也是用不了的。 (见后文重大发现)

其它方案的分析

对于iOS10如下的NSURLSession以及NSURLConnection,想要打点统计时间点挺困难的,主要困难点在不一样SDK的API调用不一样,好比iOS8和9的DNS,能够hook到getaddrinfo函数,iOS10有时能够hook到getaddrinfo_async_start函数,可是对于iOS11,我尝试了各类跟DNS相关的函数,彻底hook不到。反编译CFNetwok出来的跟DNS相关的函数,也都没有被NSURLSession/NSURLConnection调用。 SSL的状况也很是相似,目前只知道iOS8/9会经过SecurityTransport的SSLHandshake/SSLRead/SSLWrite等函数,可是iOS10以上就彻底懵逼。这些尝试只能宣告失败,告一段落了。

有的文章认为是iOS10以后系统屏蔽了某些BSD Socket函数的hook,好比connect/read/write 等等。 据我观察并非这样,BSD socket仍是可以hook到,只是大部分状况下不调用这些API了,少数状况仍是有使用的。 若是真的被屏蔽了,应该是彻底hook不到的。

有些文章写了说监控HTTP,能够采用NSURLProtocol拦截请求的方式(好比听云)。我都是持怀疑态度的,由于监控性能,若是没有DNS/SSL相关的监控就失去了大部分意义,而监控request/response的就不须要用hook这种方式了(彻底能够在本身封装的网络层部分实现)。 而针对于NSURL相关API的hook是统计不到DNS/SSL的,由于它们不在这一层实现。

也有些文章说hook CFStream的方式(好比网易APM)。 可是若是看到CFStream的实现就知道CFStream是对BSD Socket的封装,Open/Close/read/Write 若是看CFSocketStream的源码,这些API都仍是BSD Socket实现的,就是说hook CFStream 和 BSD socket没有很大的区别。 仍是没有hook到DNS/SSL的点上。

WebView 的监控

WebView的监控是相对简单的,主要是Timing API。

[图片上传失败...(image-56e48e-1535019216015)]

好处是兼容性很好,目前UIWebView和WKWebView都支持,iOS9以上都支持。由于是浏览器的API。

WebCore里,跟这个timing相关的API主要是PerformanceTiming类:

class PerformanceTiming : public RefCounted<PerformanceTiming>, public DOMWindowProperty {
public:
    static Ref<PerformanceTiming> create(Frame* frame) { return adoptRef(*new PerformanceTiming(frame)); }

    unsigned long long navigationStart() const;
    unsigned long long unloadEventStart() const;
    unsigned long long unloadEventEnd() const;
    unsigned long long redirectStart() const;
    unsigned long long redirectEnd() const;
    unsigned long long fetchStart() const;
    //...省略部分函数
    unsigned long long domContentLoadedEventStart() const;
    unsigned long long domContentLoadedEventEnd() const;
    unsigned long long domComplete() const;
    unsigned long long loadEventStart() const;
    unsigned long long loadEventEnd() const;

private:
    explicit PerformanceTiming(Frame*);
    const DocumentTiming* documentTiming() const;
    DocumentLoader* documentLoader() const;
    LoadTiming* loadTiming() const;
};

} // namespace WebCore
复制代码

头文件里有一堆getter函数的定义,同时初始化方法只有一个,入参是单一的Frame对象,说明一个Frame对象就可以提供到这些全部的参数。

unsigned long long PerformanceTiming::requestStart() const
{
    DocumentLoader* loader = documentLoader();
    if (!loader)
        return connectEnd();

    const NetworkLoadMetrics& timing = loader->response().deprecatedNetworkLoadMetrics();
    ASSERT(timing.requestStart >= 0_ms);
    return resourceLoadTimeRelativeToFetchStart(timing.requestStart);
}
unsigned long long PerformanceTiming::domInteractive() const
{
   const DocumentTiming* timing = documentTiming();
   if (!timing)
       return 0;
   return monotonicTimeToIntegerMilliseconds(timing->domInteractive);
}
unsigned long long PerformanceTiming::loadEventStart() const
{
    LoadTiming* timing = loadTiming();
    if (!timing)
        return 0;
    return monotonicTimeToIntegerMilliseconds(timing->loadEventStart());
}
复制代码

再看cpp文件就知道,PerformanceTiming是对Frame类中已经统计好的参数的一个封装,内部并无逻辑。数据其实就是来自于NetworkLoadMetricsDocumentTimingLoadTiming 三部分。也很容易理解就是分别对应网络请求相关的性能统计、对应DOM加载相关的和WebView加载相关的性能统计。

有一个细节就是NetworkLoadMetrics里有0ms的判断,保证NetworkLoadMetrics返回的相关数据大于0。而DocumentTimingLoadTiming返回的数据为空时就是0。实际上使用这一系列数据时确实会出现一部分参数为0的状况,并且跟调用PerformanceTiming的接口有关。

WebCore的类的架构以下图。 [图片上传失败...(image-449013-1535019216015)] 那WebView里,网络是怎么一层层调用的呢? 追踪WKWebView的loadRequest:方法,调用栈应该是这样的:

- (WKNavigation *)loadRequest:(NSURLRequest *)request
void WebPage::loadRequest(const LoadParameters& loadParameters)
void UserInputBridge::loadRequest(FrameLoadRequest&& request, InputSource)
void FrameLoader::load(FrameLoadRequest&& request)
void FrameLoader::load(DocumentLoader* newDocumentLoader)
void FrameLoader::loadWithDocumentLoader(DocumentLoader* loader, FrameLoadType type, FormState* formState, AllowNavigationToInvalidURL allowNavigationToInvalidURL)
void FrameLoader::continueLoadAfterNavigationPolicy(const ResourceRequest& request, FormState* formState, bool shouldContinue, AllowNavigationToInvalidURL allowNavigationToInvalidURL)
void DocumentLoader::startLoadingMainResource()
void ResourceLoader::start()
void ResourceHandle::createNSURLConnection(id delegate, bool shouldUseCredentialStorage, bool shouldContentSniff, SchedulingBehavior, NSDictionary *connectionProperties);
复制代码

ResourceLoader是资源加载,而真正操做网络请求的类在ResourceHandle。看代码就发现WebCore的网络层在iOS上也是基于NSURLConnection的。能够经过AOP的方式hook到某个位置,而后使用NSURLConnection的API进行操做。

有一处比较有意思,WebCore中实现了一个NSURLSession,叫WebCoreNSURLSession。这个相似乎只在MediaPlayer里面使用。相同的是他们也有相似的API,好比dataTaskWithRequest:等等,可是内部实现不同,WebCoreNSURLSession也是基于ResourceLoader的子类。而NSURLSession是基于CFNetwork。

使用Timing系列的API也有须要注意的细节。WebCore内核在iOS上和在Mac的Safari上是不同的。iOS10之后的WKWebView才实现.toJSON()。若是是UIWebView,或者是iOS10如下的WKWebView,须要先执行一段js脚本,方便咱们把js对象转换为json。

NSString *funcStr = @"function flatten(obj) {"
        "var ret = {}; "
        "for (var i in obj) { "
        "ret[i] = obj[i];"
        "}"
        "return ret;}";
[webView stringByEvaluatingJavaScriptFromString:funcStr];
复制代码

TCP的监控

通常App的网络层长链接,会基于TCP实现自定义的协议或者使用Websocket。有的app会基于BSD Socket封装(好比微信的mars)。有的会先利用一些开源的框架好比 CocoaAsyncSocket 或者 SocketRocket,而后再进行封装。

CocoaAsyncSocket

CocoaAsyncSocket是基于BSD Socket,CFStream,SecurityTransport的封装,封装成TCP/UDP协议。这几个API的共同之处在于都是数据流读写的形式。BSD Socket主要是同步阻塞,而CFStream是异步的。

既然是数据流读写,因此CocoaAsyncSocket确定是包括数据流的处理和转换了。主要是缓冲区,ReadBuffer/WriteBuffer,判断读取的结尾CRLF, 读取的长度length和读取的超时机制等等。 CocoaAsyncSocket也封装了DNS、ipv4和ipv六、SSL等等逻辑。

SocketRocket

是基于NSStream 的封装,不一样于CocoaAsyncSocket的传输层协议, 支持HTTP/WebSocket的应用层协议,定义了header的字段等等。 因为是基于数据流读写的,因此也包括readBuffer/WriteBuffer等数据处理逻辑。 也包括Runloop,线程等异步处理逻辑和阻塞同步逻辑。 还实现了PingPong这样的,跟服务端配合的保活逻辑。

因此TCP的监控能够hook BSD Socket 的API,包括 connect/disconnect/read/write等等调用,若是是同步调用,因此能够在执行函数先后埋点计算时间。 也须要hook DNS方面的API,好比 gethostbyname/getaddrinfo等同步调用的以及getaddrinfo_async_start等等异步调用的API。 也能够hook SSL 方面的API,好比 SSLHandshake/SSLRead/SSLWrite,实现对SSL链接的监测。

剧情反转,重大发现 (这一段是后来加的)

HTTP的性能监控由于NSURLSessionTaskMetrics的兼容性问题彷佛已经穷途末路了,可是在写第二部分WebView的时候忽然有了一个巨大的发现,这个发现来自于在看WebCore的源码的时候发现了一些神奇的东西。

#if !HAVE(TIMINGDATAOPTIONS)
void setCollectsTimingData()
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [NSURLConnection _setCollectsTimingData:YES];
        ...
    });
}
#endif
复制代码

这是说NSURLConnection自己有一套TimingData的收集API,只是没有暴露给开发者而已,可是WebCore里一直在用...苹果你为啥这么小气?! 而后就很轻易地在runtime header里找到了NSURLConnection的_setCollectsTimingData: API,还有_timingData的API。 这货iOS8之后都是支持的,iOS8以前也许也支持了。

那么NSURLSession呢,是否是也相似?果真。在iOS9以前,也只须要设置_setCollectsTimingData:就行了。 搜了一下google和github,我应该是第一个发现这个私有API的人...

因此很神奇地,很轻易地,就实现了NSURLConnection和NSURLSession全套的支持....

总结

咱们几乎能够用不多的代码实现HTTP/WebView/TCP跨框架的大部分网络性能数据收集。若是把兼容性整理成一张表的话能够看到咱们几乎支持了大部分的场景。

iOS SDK NSURLConnetion NSURLSession UIWebView WKWebView TCP
8.4 YES YES via TCP via TCP YES
9.3 YES YES YES YES YES
10.3 YES YES YES YES YES
11.3 YES YES YES YES YES

NetworkTracker 是我封装的一部分代码。并将监控结果简单地画了个图表。仍是比较直观的。

Group.png
相关文章
相关标签/搜索