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
相關文章
相關標籤/搜索