帶你打造一套 APM 監控系統(一)

文章將近50000字,拆分爲。Github 上完整文章閱讀體驗更佳,請點擊訪問 Githubnode

APM 是 Application Performance Monitoring 的縮寫,監視和管理軟件應用程序的性能和可用性。應用性能管理對一個應用的持續穩定運行相當重要。因此這篇文章就從一個 iOS App 的性能管理的緯度談談如何精確監控以及數據如何上報等技術點ios

App 的性能問題是影響用戶體驗的重要因素之一。性能問題主要包含:Crash、網絡請求錯誤或者超時、UI 響應速度慢、主線程卡頓、CPU 和內存使用率高、耗電量大等等。大多數的問題緣由在於開發者錯誤地使用了線程鎖、系統函數、編程規範問題、數據結構等等。解決問題的關鍵在於儘早的發現和定位問題。c++

本篇文章着重總結了 APM 的緣由以及如何收集數據。APM 數據收集後結合數據上報機制,按照必定策略上傳數據到服務端。服務端消費這些信息併產出報告。請結合姊妹篇, 總結了如何打造一款靈活可配置、功能強大的數據上報組件。git

1、卡頓監控

卡頓問題,就是在主線程上沒法響應用戶交互的問題。影響着用戶的直接體驗,因此針對 App 的卡頓監控是 APM 裏面重要的一環。github

FPS(frame per second)每秒鐘的幀刷新次數,iPhone 手機以 60 爲最佳,iPad 某些型號是 120,也是做爲卡頓監控的一項參考參數,爲何說是參考參數?由於它不許確。先說說怎麼獲取到 FPS。CADisplayLink 是一個系統定時器,會以幀刷新頻率同樣的速率來刷新視圖。 [CADisplayLink displayLinkWithTarget:self selector:@selector(###:)]。至於爲何不許咱們來看看下面的示例代碼web

_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(p_displayLinkTick:)];
[_displayLink setPaused:YES];
[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
複製代碼

代碼所示,CADisplayLink 對象是被添加到指定的 RunLoop 的某個 Mode 下。因此仍是 CPU 層面的操做,卡頓的體驗是整個圖像渲染的結果:CPU + GPU。請繼續往下看ajax

1. 屏幕繪製原理

老式 CRT 顯示器原理

講講老式的 CRT 顯示器的原理。 CRT 電子槍按照上面方式,從上到下一行行掃描,掃面完成後顯示器就呈現一幀畫面,隨後電子槍回到初始位置繼續下一次掃描。爲了把顯示器的顯示過程和系統的視頻控制器進行同步,顯示器(或者其餘硬件)會用硬件時鐘產生一系列的定時信號。當電子槍換到新的一行,準備進行掃描時,顯示器會發出一個水平同步信號(horizonal synchronization),簡稱 HSync;當一幀畫面繪製完成後,電子槍恢復到原位,準備畫下一幀前,顯示器會發出一個垂直同步信號(Vertical synchronization),簡稱 VSync。顯示器一般以固定的頻率進行刷新,這個固定的刷新頻率就是 VSync 信號產生的頻率。雖然如今的顯示器基本都是液晶顯示屏,可是原理保持不變。objective-c

顯示器和 CPU、GPU 關係

一般,屏幕上一張畫面的顯示是由 CPU、GPU 和顯示器是按照上圖的方式協同工做的。CPU 根據工程師寫的代碼計算好須要現實的內容(好比視圖建立、佈局計算、圖片解碼、文本繪製等),而後把計算結果提交到 GPU,GPU 負責圖層合成、紋理渲染,隨後 GPU 將渲染結果提交到幀緩衝區。隨後視頻控制器會按照 VSync 信號逐行讀取幀緩衝區的數據,通過數模轉換傳遞給顯示器顯示。算法

在幀緩衝區只有一個的狀況下,幀緩衝區的讀取和刷新都存在效率問題,爲了解決效率問題,顯示系統會引入2個緩衝區,即雙緩衝機制。在這種狀況下,GPU 會預先渲染好一幀放入幀緩衝區,讓視頻控制器來讀取,當下一幀渲染好後,GPU 直接把視頻控制器的指針指向第二個緩衝區。提高了效率。chrome

目前來看,雙緩衝區提升了效率,可是帶來了新的問題:當視頻控制器還未讀取完成時,即屏幕內容顯示了部分,GPU 將新渲染好的一幀提交到另外一個幀緩衝區並把視頻控制器的指針指向新的幀緩衝區,視頻控制器就會把新的一幀數據的下半段顯示到屏幕上,形成畫面撕裂的狀況。

爲了解決這個問題,GPU 一般有一個機制叫垂直同步信號(V-Sync),當開啓垂直同步信號後,GPU 會等到視頻控制器發送 V-Sync 信號後,才進行新的一幀的渲染和幀緩衝區的更新。這樣的幾個機制解決了畫面撕裂的狀況,也增長了畫面流暢度。但須要更多的計算資源

IPC喚醒 RunLoop

答疑

可能有些人會看到「當開啓垂直同步信號後,GPU 會等到視頻控制器發送 V-Sync 信號後,才進行新的一幀的渲染和幀緩衝區的更新」這裏會想,GPU 收到 V-Sync 才進行新的一幀渲染和幀緩衝區的更新,那是否是雙緩衝區就失去意義了?

設想一個顯示器顯示第一幀圖像和第二幀圖像的過程。首先在雙緩衝區的狀況下,GPU 首先渲染好一幀圖像存入到幀緩衝區,而後讓視頻控制器的指針直接直接這個緩衝區,顯示第一幀圖像。第一幀圖像的內容顯示完成後,視頻控制器發送 V-Sync 信號,GPU 收到 V-Sync 信號後渲染第二幀圖像並將視頻控制器的指針指向第二個幀緩衝區。

看上去第二幀圖像是在等第一幀顯示後的視頻控制器發送 V-Sync 信號。是嗎?真是這樣的嗎? 😭 想啥呢,固然不是。 🐷 否則雙緩衝區就沒有存在的意義了

揭祕。請看下圖

多緩衝區顯示原理

當第一次 V-Sync 信號到來時,先渲染好一幀圖像放到幀緩衝區,可是不展現,當收到第二個 V-Sync 信號後讀取第一次渲染好的結果(視頻控制器的指針指向第一個幀緩衝區),並同時渲染新的一幀圖像並將結果存入第二個幀緩衝區,等收到第三個 V-Sync 信號後,讀取第二個幀緩衝區的內容(視頻控制器的指針指向第二個幀緩衝區),並開始第三幀圖像的渲染並送入第一個幀緩衝區,依次不斷循環往復。

請查看資料:Multiple buffering

2. 卡頓產生的緣由

卡頓緣由

VSync 信號到來後,系統圖形服務會經過 CADisplayLink 等機制通知 App,App 主線程開始在 CPU 中計算顯示內容(視圖建立、佈局計算、圖片解碼、文本繪製等)。而後將計算的內容提交到 GPU,GPU 通過圖層的變換、合成、渲染,隨後 GPU 把渲染結果提交到幀緩衝區,等待下一次 VSync 信號到來再顯示以前渲染好的結果。在垂直同步機制的狀況下,若是在一個 VSync 時間週期內,CPU 或者 GPU 沒有完成內容的提交,就會形成該幀的丟棄,等待下一次機會再顯示,這時候屏幕上仍是以前渲染的圖像,因此這就是 CPU、GPU 層面界面卡頓的緣由。

目前 iOS 設備有雙緩存機制,也有三緩衝機制,Android 如今主流是三緩衝機制,在早期是單緩衝機制。 iOS 三緩衝機制例子

CPU 和 GPU 資源消耗緣由不少,好比對象的頻繁建立、屬性調整、文件讀取、視圖層級的調整、佈局的計算(AutoLayout 視圖個數多了就是線性方程求解難度變大)、圖片解碼(大圖的讀取優化)、圖像繪製、文本渲染、數據庫讀取(多讀仍是多寫樂觀鎖、悲觀鎖的場景)、鎖的使用(舉例:自旋鎖使用不當會浪費 CPU)等方面。開發者根據自身經驗尋找最優解(這裏不是本文重點)。

3. APM 如何監控卡頓並上報

CADisplayLink 確定不用了,這個 FPS 僅做爲參考。通常來說,卡頓的監測有2種方案:監聽 RunLoop 狀態回調、子線程 ping 主線程

3.1 RunLoop 狀態監聽的方式

RunLoop 負責監聽輸入源進行調度處理。好比網絡、輸入設備、週期性或者延遲事件、異步回調等。RunLoop 會接收2種類型的輸入源:一種是來自另外一個線程或者來自不一樣應用的異步消息(source0事件)、另外一種是來自預約或者重複間隔的事件。

RunLoop 狀態以下圖

RunLoop

第一步:通知 Observers,RunLoop 要開始進入 loop,緊接着進入 loop

if (currentMode->_observerMask & kCFRunLoopEntry )
    // 通知 Observers: RunLoop 即將進入 loop
    __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopEntry);
// 進入loop
result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);
複製代碼

第二步:開啓 do while 循環保活線程,通知 Observers,RunLoop 觸發 Timer 回調、Source0 回調,接着執行被加入的 block

if (rlm->_observerMask & kCFRunLoopBeforeTimers)
    //  通知 Observers: RunLoop 即將觸發 Timer 回調
    __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeTimers);
if (rlm->_observerMask & kCFRunLoopBeforeSources)
    //  通知 Observers: RunLoop 即將觸發 Source 回調
    __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeSources);
// 執行被加入的block
__CFRunLoopDoBlocks(rl, rlm);
複製代碼

第三步:RunLoop 在觸發 Source0 回調後,若是 Source1 是 ready 狀態,就會跳轉到 handle_msg 去處理消息。

//  若是有 Source1 (基於port) 處於 ready 狀態,直接處理這個 Source1 而後跳轉去處理消息
if (MACH_PORT_NULL != dispatchPort && !didDispatchPortLastTime) {
#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI
    msg = (mach_msg_header_t *)msg_buffer;
    
    if (__CFRunLoopServiceMachPort(dispatchPort, &msg, sizeof(msg_buffer), &livePort, 0, &voucherState, NULL)) {
        goto handle_msg;
    }
#elif DEPLOYMENT_TARGET_WINDOWS
    if (__CFRunLoopWaitForMultipleObjects(NULL, &dispatchPort, 0, 0, &livePort, NULL)) {
        goto handle_msg;
    }
#endif
}
複製代碼

第四步:回調觸發後,通知 Observers 即將進入休眠狀態

Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR);
// 通知 Observers: RunLoop 的線程即將進入休眠(sleep)
if (!poll && (rlm->_observerMask & kCFRunLoopBeforeWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopBeforeWaiting);
	__CFRunLoopSetSleeping(rl);
複製代碼

第五步:進入休眠後,會等待 mach_port 消息,以便再次喚醒。只有如下4種狀況才能夠被再次喚醒。

  • 基於 port 的 source 事件
  • Timer 時間到
  • RunLoop 超時
  • 被調用者喚醒
do {
    if (kCFUseCollectableAllocator) {
        // objc_clear_stack(0);
        // <rdar://problem/16393959>
        memset(msg_buffer, 0, sizeof(msg_buffer));
    }
    msg = (mach_msg_header_t *)msg_buffer;
    
    __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort, poll ? 0 : TIMEOUT_INFINITY, &voucherState, &voucherCopy);
    
    if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) {
        // Drain the internal queue. If one of the callout blocks sets the timerFired flag, break out and service the timer.
        while (_dispatch_runloop_root_queue_perform_4CF(rlm->_queue));
        if (rlm->_timerFired) {
            // Leave livePort as the queue port, and service timers below
            rlm->_timerFired = false;
            break;
        } else {
            if (msg && msg != (mach_msg_header_t *)msg_buffer) free(msg);
        }
    } else {
        // Go ahead and leave the inner loop.
        break;
    }
} while (1);
複製代碼

第六步:喚醒時通知 Observer,RunLoop 的線程剛剛被喚醒了

// 通知 Observers: RunLoop 的線程剛剛被喚醒了
if (!poll && (rlm->_observerMask & kCFRunLoopAfterWaiting)) __CFRunLoopDoObservers(rl, rlm, kCFRunLoopAfterWaiting);
    // 處理消息
    handle_msg:;
    __CFRunLoopSetIgnoreWakeUps(rl);
複製代碼

第七步:RunLoop 喚醒後,處理喚醒時收到的消息

  • 若是是 Timer 時間到,則觸發 Timer 的回調
  • 若是是 dispatch,則執行 block
  • 若是是 source1 事件,則處理這個事件
#if USE_MK_TIMER_TOO
        // 若是一個 Timer 到時間了,觸發這個Timer的回調
        else if (rlm->_timerPort != MACH_PORT_NULL && livePort == rlm->_timerPort) {
            CFRUNLOOP_WAKEUP_FOR_TIMER();
            // On Windows, we have observed an issue where the timer port is set before the time which we requested it to be set. For example, we set the fire time to be TSR 167646765860, but it is actually observed firing at TSR 167646764145, which is 1715 ticks early. The result is that, when __CFRunLoopDoTimers checks to see if any of the run loop timers should be firing, it appears to be 'too early' for the next timer, and no timers are handled.
            // In this case, the timer port has been automatically reset (since it was returned from MsgWaitForMultipleObjectsEx), and if we do not re-arm it, then no timers will ever be serviced again unless something adjusts the timer list (e.g. adding or removing timers). The fix for the issue is to reset the timer here if CFRunLoopDoTimers did not handle a timer itself. 9308754
            if (!__CFRunLoopDoTimers(rl, rlm, mach_absolute_time())) {
                // Re-arm the next timer
                __CFArmNextTimerInMode(rlm, rl);
            }
        }
#endif
        //  若是有dispatch到main_queue的block,執行block
        else if (livePort == dispatchPort) {
            CFRUNLOOP_WAKEUP_FOR_DISPATCH();
            __CFRunLoopModeUnlock(rlm);
            __CFRunLoopUnlock(rl);
            _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)6, NULL);
#if DEPLOYMENT_TARGET_WINDOWS
            void *msg = 0;
#endif
            __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
            _CFSetTSD(__CFTSDKeyIsInGCDMainQ, (void *)0, NULL);
            __CFRunLoopLock(rl);
            __CFRunLoopModeLock(rlm);
            sourceHandledThisLoop = true;
            didDispatchPortLastTime = true;
        }
        // 若是一個 Source1 (基於port) 發出事件了,處理這個事件
        else {
            CFRUNLOOP_WAKEUP_FOR_SOURCE();
            
            // If we received a voucher from this mach_msg, then put a copy of the new voucher into TSD. CFMachPortBoost will look in the TSD for the voucher. By using the value in the TSD we tie the CFMachPortBoost to this received mach_msg explicitly without a chance for anything in between the two pieces of code to set the voucher again.
            voucher_t previousVoucher = _CFSetTSD(__CFTSDKeyMachMessageHasVoucher, (void *)voucherCopy, os_release);

            CFRunLoopSourceRef rls = __CFRunLoopModeFindSourceForMachPort(rl, rlm, livePort);
            if (rls) {
#if DEPLOYMENT_TARGET_MACOSX || DEPLOYMENT_TARGET_EMBEDDED || DEPLOYMENT_TARGET_EMBEDDED_MINI
		mach_msg_header_t *reply = NULL;
		sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls, msg, msg->msgh_size, &reply) || sourceHandledThisLoop;
		if (NULL != reply) {
		    (void)mach_msg(reply, MACH_SEND_MSG, reply->msgh_size, 0, MACH_PORT_NULL, 0, MACH_PORT_NULL);
		    CFAllocatorDeallocate(kCFAllocatorSystemDefault, reply);
		}
#elif DEPLOYMENT_TARGET_WINDOWS
                sourceHandledThisLoop = __CFRunLoopDoSource1(rl, rlm, rls) || sourceHandledThisLoop;
#endif
複製代碼

第八步:根據當前 RunLoop 狀態判斷是否須要進入下一個 loop。當被外部強制中止或者 loop 超時,就不繼續下一個 loop,不然進入下一個 loop

if (sourceHandledThisLoop && stopAfterHandle) {
    // 進入loop時參數說處理完事件就返回
    retVal = kCFRunLoopRunHandledSource;
    } else if (timeout_context->termTSR < mach_absolute_time()) {
        // 超出傳入參數標記的超時時間了
        retVal = kCFRunLoopRunTimedOut;
} else if (__CFRunLoopIsStopped(rl)) {
        __CFRunLoopUnsetStopped(rl);
    // 被外部調用者強制中止了
    retVal = kCFRunLoopRunStopped;
} else if (rlm->_stopped) {
    rlm->_stopped = false;
    retVal = kCFRunLoopRunStopped;
} else if (__CFRunLoopModeIsEmpty(rl, rlm, previousMode)) {
    // source/timer一個都沒有
    retVal = kCFRunLoopRunFinished;
}
複製代碼

完整且帶有註釋的 RunLoop 代碼見此處。 Source1 是 RunLoop 用來處理 Mach port 傳來的系統事件的,Source0 是用來處理用戶事件的。收到 Source1 的系統事件後本質仍是調用 Source0 事件的處理函數。

RunLoop 狀態
RunLoop 6個狀態

typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
    kCFRunLoopEntry ,           // 進入 loop
    kCFRunLoopBeforeTimers ,    // 觸發 Timer 回調
    kCFRunLoopBeforeSources ,   // 觸發 Source0 回調
    kCFRunLoopBeforeWaiting ,   // 等待 mach_port 消息
    kCFRunLoopAfterWaiting ),   // 接收 mach_port 消息
    kCFRunLoopExit ,            // 退出 loop
    kCFRunLoopAllActivities     // loop 全部狀態改變
}
複製代碼

RunLoop 在進入睡眠前的方法執行時間過長而致使沒法進入睡眠,或者線程喚醒後接收消息時間過長而沒法進入下一步,都會阻塞線程。若是是主線程,則表現爲卡頓。

一旦發現進入睡眠前的 KCFRunLoopBeforeSources 狀態,或者喚醒後 KCFRunLoopAfterWaiting,在設置的時間閾值內沒有變化,則可判斷爲卡頓,此時 dump 堆棧信息,還原案發現場,進而解決卡頓問題。

開啓一個子線程,不斷進行循環監測是否卡頓了。在 n 次都超過卡頓閾值後則認爲卡頓了。卡頓以後進行堆棧 dump 並上報(具備必定的機制,數據處理在下一 part 講)。

WatchDog 在不一樣狀態下具備不一樣的值。

  • 啓動(Launch):20s
  • 恢復(Resume):10s
  • 掛起(Suspend):10s
  • 退出(Quit):6s
  • 後臺(Background):3min(在 iOS7 以前能夠申請 10min;以後改成 3min;可連續申請,最多到 10min)

卡頓閾值的設置的依據是 WatchDog 的機制。APM 系統裏面的閾值須要小於 WatchDog 的值,因此取值範圍在 [1, 6] 之間,業界一般選擇3秒。

經過 long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout) 方法判斷是否阻塞主線程,Returns zero on success, or non-zero if the timeout occurred. 返回非0則表明超時阻塞了主線程。

RunLoop-ANR

可能不少人納悶 RunLoop 狀態那麼多,爲何選擇 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting?由於大部分卡頓都是在 KCFRunLoopBeforeSources 和 KCFRunLoopAfterWaiting 之間。好比 Source0 類型的 App 內部事件等

Runloop 檢測卡頓流程圖以下:

RunLoop ANR

關鍵代碼以下:

// 設置Runloop observer的運行環境
CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL};
// 建立Runloop observer對象
_observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                    kCFRunLoopAllActivities,
                                    YES,
                                    0,
                                    &runLoopObserverCallBack,
                                    &context);
// 將新建的observer加入到當前thread的runloop
CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
// 建立信號
_semaphore = dispatch_semaphore_create(0);

__weak __typeof(self) weakSelf = self;
// 在子線程監控時長
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    __strong __typeof(weakSelf) strongSelf = weakSelf;
    if (!strongSelf) {
        return;
    }
    while (YES) {
        if (strongSelf.isCancel) {
            return;
        }
        // N次卡頓超過閾值T記錄爲一次卡頓
        long semaphoreWait = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, strongSelf.limitMillisecond * NSEC_PER_MSEC));
        if (semaphoreWait != 0) {
            if (self->_activity == kCFRunLoopBeforeSources || self->_activity == kCFRunLoopAfterWaiting) {
                if (++strongSelf.countTime < strongSelf.standstillCount){
                    continue;
                }
                // 堆棧信息 dump 並結合數據上報機制,按照必定策略上傳數據到服務器。堆棧 dump 會在下面講解。數據上報會在 [打造功能強大、靈活可配置的數據上報組件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 講
            }
        }
        strongSelf.countTime = 0;
    }
});
複製代碼

3.2 子線程 ping 主線程監聽的方式

開啓一個子線程,建立一個初始值爲0的信號量、一個初始值爲 YES 的布爾值類型標誌位。將設置標誌位爲 NO 的任務派發到主線程中去,子線程休眠閾值時間,時間到後判斷標誌位是否被主線程成功(值爲 NO),若是沒成功則認爲豬線程發生了卡頓狀況,此時 dump 堆棧信息並結合數據上報機制,按照必定策略上傳數據到服務器。數據上報會在 打造功能強大、靈活可配置的數據上報組件

while (self.isCancelled == NO) {
        @autoreleasepool {
            __block BOOL isMainThreadNoRespond = YES;
            
            dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
            
            dispatch_async(dispatch_get_main_queue(), ^{
                isMainThreadNoRespond = NO;
                dispatch_semaphore_signal(semaphore);
            });
            
            [NSThread sleepForTimeInterval:self.threshold];
            
            if (isMainThreadNoRespond) {
                if (self.handlerBlock) {
                    self.handlerBlock(); // 外部在 block 內部 dump 堆棧(下面會講),數據上報
                }
            }
            
            dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        }
    }
複製代碼

4. 堆棧 dump

方法堆棧的獲取是一個麻煩事。理一下思路。[NSThread callStackSymbols] 能夠獲取當前線程的調用棧。可是當監控到卡頓發生,須要拿到主線程的堆棧信息就無能爲力了。從任何線程回到主線程這條路走不通。先作個知識回顧。

在計算機科學中,調用堆棧是一種棧類型的數據結構,用於存儲有關計算機程序的線程信息。這種棧也叫作執行堆棧、程序堆棧、控制堆棧、運行時堆棧、機器堆棧等。調用堆棧用於跟蹤每一個活動的子例程在完成執行後應該返回控制的點。

維基百科搜索到 「Call Stack」 的一張圖和例子,以下

調用棧
上圖表示爲一個棧。分爲若干個棧幀(Frame),每一個棧幀對應一個函數調用。下面藍色部分表示 DrawSquare 函數,它在執行的過程當中調用了 DrawLine 函數,用綠色部分表示。

能夠看到棧幀由三部分組成:函數參數、返回地址、局部變量。好比在 DrawSquare 內部調用了 DrawLine 函數:第一先把 DrawLine 函數須要的參數入棧;第二把返回地址(控制信息。舉例:函數 A 內調用函數 B,調用函數B 的下一行代碼的地址就是返回地址)入棧;第三函數內部的局部變量也在該棧中存儲。

棧指針 Stack Pointer 表示當前棧的頂部,大多部分操做系統都是棧向下生長,因此棧指針是最小值。幀指針 Frame Pointer 指向的地址中,存儲了上一次 Stack Pointer 的值,也就是返回地址。

大多數操做系統中,每一個棧幀還保存了上一個棧幀的幀指針。所以知道當前棧幀的 Stack Pointer 和 Frame Pointer 就能夠不斷回溯,遞歸獲取棧底的幀。

接下來的步驟就是拿到全部線程的 Stack Pointer 和 Frame Pointer。而後不斷回溯,還原案發現場。

5. Mach Task 知識

Mach task:

App 在運行的時候,會對應一個 Mach Task,而 Task 下可能有多條線程同時執行任務。《OS X and iOS Kernel Programming》 中描述 Mach Task 爲:任務(Task)是一種容器對象,虛擬內存空間和其餘資源都是經過這個容器對象管理的,這些資源包括設備和其餘句柄。簡單歸納爲:Mack task 是一個機器無關的 thread 的執行環境抽象。

做用: task 能夠理解爲一個進程,包含它的線程列表。

結構體:task_threads,將 target_task 任務下的全部線程保存在 act_list 數組中,數組個數爲 act_listCnt

kern_return_t task_threads
(
  task_t traget_task,
  thread_act_array_t *act_list,                     //線程指針列表
  mach_msg_type_number_t *act_listCnt  //線程個數
)
複製代碼

thread_info:

kern_return_t thread_info
(
  thread_act_t target_act,
  thread_flavor_t flavor,
  thread_info_t thread_info_out,
  mach_msg_type_number_t *thread_info_outCnt
);
複製代碼

如何獲取線程的堆棧數據:

系統方法 kern_return_t task_threads(task_inspect_t target_task, thread_act_array_t *act_list, mach_msg_type_number_t *act_listCnt); 能夠獲取到全部的線程,不過這種方法獲取到的線程信息是最底層的 mach 線程

對於每一個線程,能夠用 kern_return_t thread_get_state(thread_act_t target_act, thread_state_flavor_t flavor, thread_state_t old_state, mach_msg_type_number_t *old_stateCnt); 方法獲取它的全部信息,信息填充在 _STRUCT_MCONTEXT 類型的參數中,這個方法中有2個參數隨着 CPU 架構不一樣而不一樣。因此須要定義宏屏蔽不一樣 CPU 之間的區別。

_STRUCT_MCONTEXT 結構體中,存儲了當前線程的 Stack Pointer 和最頂部棧幀的 Frame pointer,進而回溯整個線程調用堆棧。

可是上述方法拿到的是內核線程,咱們須要的信息是 NSThread,因此須要將內核線程轉換爲 NSThread。

pthread 的 p 是 POSIX 的縮寫,表示「可移植操做系統接口」(Portable Operating System Interface)。設計初衷是每一個系統都有本身獨特的線程模型,且不一樣系統對於線程操做的 API 都不同。因此 POSIX 的目的就是提供抽象的 pthread 以及相關 API。這些 API 在不一樣的操做系統中有不一樣的實現,可是完成的功能一致。

Unix 系統提供的 task_threadsthread_get_state 操做的都是內核系統,每一個內核線程由 thread_t 類型的 id 惟一標識。pthread 的惟一標識是 pthread_t 類型。其中內核線程和 pthread 的轉換(即 thread_t 和 pthread_t)很容易,由於 pthread 設計初衷就是「抽象內核線程」。

memorystatus_action_neededpthread_create 方法建立線程的回調函數爲 nsthreadLauncher

static void *nsthreadLauncher(void* thread)  
{
    NSThread *t = (NSThread*)thread;
    [nc postNotificationName: NSThreadDidStartNotification object:t userInfo: nil];
    [t _setName: [t name]];
    [t main];
    [NSThread exit];
    return NULL;
}
複製代碼

NSThreadDidStartNotification 其實就是字符串 @"_NSThreadDidStartNotification"。

<NSThread: 0x...>{number = 1, name = main}  
複製代碼

爲了 NSThread 和內核線程對應起來,只能經過 name 一一對應。 pthread 的 API pthread_getname_np 也可獲取內核線程名字。np 表明 not POSIX,因此不能跨平臺使用。

思路歸納爲:將 NSThread 的原始名字存儲起來,再將名字改成某個隨機數(時間戳),而後遍歷內核線程 pthread 的名字,名字匹配則 NSThread 和內核線程對應了起來。找到後將線程的名字還原成本來的名字。對於主線程,因爲不能使用 pthread_getname_np,因此在當前代碼的 load 方法中獲取到 thread_t,而後匹配名字。

static mach_port_t main_thread_id;  
+ (void)load {
    main_thread_id = mach_thread_self();
}
複製代碼

2、 App 啓動時間監控

1. App 啓動時間的監控

應用啓動時間是影響用戶體驗的重要因素之一,因此咱們須要量化去衡量一個 App 的啓動速度到底有多快。啓動分爲冷啓動和熱啓動。

App 啓動時間

冷啓動:App 還沒有運行,必須加載並構建整個應用。完成應用的初始化。冷啓動存在較大優化空間。冷啓動時間從 application: didFinishLaunchingWithOptions: 方法開始計算,App 通常在這裏進行各類 SDK 和 App 的基礎初始化工做。

熱啓動:應用已經在後臺運行(常見場景:好比用戶使用 App 過程當中點擊 Home 鍵,再打開 App),因爲某些事件將應用喚醒到前臺,App 會在 applicationWillEnterForeground: 方法接受應用進入前臺的事件

思路比較簡單。以下

  • 在監控類的 load 方法中先拿到當前的時間值
  • 監聽 App 啓動完成後的通知 UIApplicationDidFinishLaunchingNotification
  • 收到通知後拿到當前的時間
  • 步驟1和3的時間差就是 App 啓動時間。

mach_absolute_time 是一個 CPU/總線依賴函數,返回一個 CPU 時鐘週期數。系統休眠時不會增長。是一個納秒級別的數字。獲取先後2個納秒後須要轉換到秒。須要基於系統時間的基準,經過 mach_timebase_info 得到。

mach_timebase_info_data_t g_cmmStartupMonitorTimebaseInfoData = 0;
mach_timebase_info(&g_cmmStartupMonitorTimebaseInfoData);
uint64_t timelapse = mach_absolute_time() - g_cmmLoadTime;
double timeSpan = (timelapse * g_cmmStartupMonitorTimebaseInfoData.numer) / (g_cmmStartupMonitorTimebaseInfoData.denom * 1e9);
複製代碼

2. 線上監控啓動時間就好,可是在開發階段須要對啓動時間作優化。

要優化啓動時間,就先得知道在啓動階段到底作了什麼事情,針對現狀做出方案。

pre-main 階段定義爲 App 開始啓動到系統調用 main 函數這個階段;main 階段定義爲 main 函數入口到主 UI 框架的 viewDidAppear。

App 啓動過程:

  • 解析 Info.plist:加載相關信息例如閃屏;沙盒創建、權限檢查;
  • Mach-O 加載:若是是胖二進制文件,尋找合適當前 CPU 架構的部分;加載全部依賴的 Mach-O 文件(遞歸調用 Mach-O 加載的方法);定義內部、外部指針引用,例如字符串、函數等;加載分類中的方法;c++ 靜態對象加載、調用 Objc 的 +load() 函數;執行聲明爲 _attribute((constructor)) 的 c 函數;
  • 程序執行:調用 main();調用 UIApplicationMain();調用 applicationWillFinishLaunching();

Pre-Main 階段

Pre-Main 階段

Main 階段

Main 階段

2.1 加載 Dylib

每一個動態庫的加載,dyld 須要

  • 分析所依賴的動態庫
  • 找到動態庫的 Mach-O 文件
  • 打開文件
  • 驗證文件
  • 在系統核心註冊文件簽名
  • 對動態庫的每個 segment 調用 mmap()

優化:

  • 減小非系統庫的依賴
  • 使用靜態庫而不是動態庫
  • 合併不是系統動態庫爲一個動態庫

2.2 Rebase && Binding

優化:

  • 減小 Objc 類數量,減小 selector 數量,把未使用的類和函數均可以刪掉
  • 減小 c++ 虛函數數量
  • 轉而使用 Swift struct(本質就是減小符號的數量)

2.3 Initializers

優化:

  • 使用 +initialize 代替 +load
  • 不要使用過 attribute*((constructor)) 將方法顯示標記爲初始化器,而是讓初始化方法調用時才執行。好比使用 dispatch_one、pthread_once() 或 std::once()。也就是第一次使用時才初始化,推遲了一部分工做耗時也儘可能不要使用 c++ 的靜態對象

2.4 pre-main 階段影響因素

  • 動態庫加載越多,啓動越慢。
  • ObjC 類越多,函數越多,啓動越慢。
  • 可執行文件越大啓動越慢。
  • C 的 constructor 函數越多,啓動越慢。
  • C++ 靜態對象越多,啓動越慢。
  • ObjC 的 +load 越多,啓動越慢。

優化手段:

  • 減小依賴沒必要要的庫,無論是動態庫仍是靜態庫;若是能夠的話,把動態庫改形成靜態庫;若是必須依賴動態庫,則把多個非系統的動態庫合併成一個動態庫
  • 檢查下 framework應當設爲optional和required,若是該framework在當前App支持的全部iOS系統版本都存在,那麼就設爲required,不然就設爲optional,由於optional會有些額外的檢查
  • 合併或者刪減一些OC類和函數。關於清理項目中沒用到的類,使用工具AppCode代碼檢查功能,查到當前項目中沒有用到的類(也能夠用根據linkmap文件來分析,可是準確度不算很高) 有一個叫作FUI的開源項目能很好的分析出再也不使用的類,準確率很是高,惟一的問題是它處理不了動態庫和靜態庫裏提供的類,也處理不了C++的類模板
  • 刪減一些無用的靜態變量
  • 刪減沒有被調用到或者已經廢棄的方法
  • 將沒必要須在 +load 方法中作的事情延遲到 +initialize中,儘可能不要用 C++ 虛函數(建立虛函數表有開銷)
  • 類和方法名不要太長:iOS每一個類和方法名都在 __cstring 段裏都存了相應的字符串值,因此類和方法名的長短也是對可執行文件大小是有影響的 因仍是 Object-c 的動態特性,由於須要經過類/方法名反射找到這個類/方法進行調用,Object-c 對象模型會把類/方法名字符串都保存下來;
  • 用 dispatch_once() 代替全部的 attribute((constructor)) 函數、C++ 靜態對象初始化、ObjC 的 +load 函數;
  • 在設計師可接受的範圍內壓縮圖片的大小,會有意外收穫。 壓縮圖片爲何能加快啓動速度呢?由於啓動的時候大大小小的圖片加載個十來二十個是很正常的, 圖片小了,IO操做量就小了,啓動固然就會快了,比較靠譜的壓縮算法是 TinyPNG。

2.5 main 階段優化

  • 減小啓動初始化的流程。能懶加載就懶加載,能放後臺初始化就放後臺初始化,能延遲初始化的就延遲初始化,不要卡主線程的啓動時間,已經下線的業務代碼直接刪除
  • 優化代碼邏輯。去除一些非必要的邏輯和代碼,減少每一個流程所消耗的時間
  • 啓動階段使用多線程來進行初始化,把 CPU 性能發揮最大
  • 使用純代碼而不是 xib 或者 storyboard 來描述 UI,尤爲是主 UI 框架,好比 TabBarController。由於 xib 和 storyboard 仍是須要解析成代碼來渲染頁面,多了一步。

3、 CPU 使用率監控

1. CPU 架構

CPU(Central Processing Unit)中央處理器,市場上主流的架構有 ARM(arm64)、Intel(x86)、AMD 等。其中 Intel 使用 CISC(Complex Instruction Set Computer),ARM 使用 RISC(Reduced Instruction Set Computer)。區別在於不一樣的 CPU 設計理念和方法

早期 CPU 所有是 CISC 架構,設計目的是用最少的機器語言指令來完成所需的計算任務。好比對於乘法運算,在 CISC 架構的 CPU 上。一條指令 MUL ADDRA, ADDRB 就能夠將內存 ADDRA 和內存 ADDRB 中的數香乘,並將結果存儲在 ADDRA 中。作的事情就是:將 ADDRA、ADDRB 中的數據讀入到寄存器,相乘的結果寫入到內存的操做依賴於 CPU 設計,因此 CISC 架構會增長 CPU 的複雜性和對 CPU 工藝的要求。

RISC 架構要求軟件來指定各個操做步驟。好比上面的乘法,指令實現爲 MOVE A, ADDRA; MOVE B, ADDRB; MUL A, B; STR ADDRA, A;。這種架構能夠下降 CPU 的複雜性以及容許在一樣的工藝水平下生產出功能更增強大的 CPU,可是對於編譯器的設計要求更高。

目前市場是大部分的 iPhone 都是基於 arm64 架構的。且 arm 架構能耗低。

2. 獲取線程信息

講完了區別來說下如何作 CPU 使用率的監控

  • 開啓定時器,按照設定的週期不斷執行下面的邏輯
  • 獲取當前任務 task。從當前 task 中獲取全部的線程信息(線程個數、線程數組)
  • 遍歷全部的線程信息,判斷是否有線程的 CPU 使用率超過設置的閾值
  • 假若有線程使用率超過閾值,則 dump 堆棧
  • 組裝數據,上報數據

線程信息結構體

struct thread_basic_info {
	time_value_t    user_time;      /* user run time(用戶運行時長) */
	time_value_t    system_time;    /* system run time(系統運行時長) */ 
	integer_t       cpu_usage;      /* scaled cpu usage percentage(CPU使用率,上限1000) */
	policy_t        policy;         /* scheduling policy in effect(有效調度策略) */
	integer_t       run_state;      /* run state (運行狀態,見下) */
	integer_t       flags;          /* various flags (各類各樣的標記) */
	integer_t       suspend_count;  /* suspend count for thread(線程掛起次數) */
	integer_t       sleep_time;     /* number of seconds that thread
	                                 *  has been sleeping(休眠時間) */
};
複製代碼

代碼在講堆棧還原的時候講過,忘記的看一下上面的分析

thread_act_array_t threads;
mach_msg_type_number_t threadCount = 0;
const task_t thisTask = mach_task_self();
kern_return_t kr = task_threads(thisTask, &threads, &threadCount);
if (kr != KERN_SUCCESS) {
    return ;
}
for (int i = 0; i < threadCount; i++) {
    thread_info_data_t threadInfo;
    thread_basic_info_t threadBaseInfo;
    mach_msg_type_number_t threadInfoCount;
    
    kern_return_t kr = thread_info((thread_inspect_t)threads[i], THREAD_BASIC_INFO, (thread_info_t)threadInfo, &threadInfoCount);
    
    if (kr == KERN_SUCCESS) {
        
        threadBaseInfo = (thread_basic_info_t)threadInfo;
        // todo:條件判斷,看不明白
        if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) {
            integer_t cpuUsage = threadBaseInfo->cpu_usage / 10;
            if (cpuUsage > CPUMONITORRATE) {
                
                NSMutableDictionary *CPUMetaDictionary = [NSMutableDictionary dictionary];
                NSData *CPUPayloadData = [NSData data];
                
                NSString *backtraceOfAllThread = [BacktraceLogger backtraceOfAllThread];
                // 1. 組裝卡頓的 Meta 信息
                CPUMetaDictionary[@"MONITOR_TYPE"] = CMMonitorCPUType;
            
                // 2. 組裝卡頓的 Payload 信息(一個JSON對象,對象的 Key 爲約定好的 STACK_TRACE, value 爲 base64 後的堆棧信息)
                NSData *CPUData = [SAFE_STRING(backtraceOfAllThread) dataUsingEncoding:NSUTF8StringEncoding];
                NSString *CPUDataBase64String = [CPUData base64EncodedStringWithOptions:0];
                NSDictionary *CPUPayloadDictionary = @{@"STACK_TRACE": SAFE_STRING(CPUDataBase64String)};
                
                NSError *error;
                // NSJSONWritingOptions 參數必定要傳0,由於服務端須要根據 \n 處理邏輯,傳遞 0 則生成的 json 串不帶 \n
                NSData *parsedData = [NSJSONSerialization dataWithJSONObject:CPUPayloadDictionary options:0 error:&error];
                if (error) {
                    CMMLog(@"%@", error);
                    return;
                }
                CPUPayloadData = [parsedData copy];
                
                // 3. 數據上報會在 [打造功能強大、靈活可配置的數據上報組件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 講
                [[PrismClient sharedInstance] sendWithType:CMMonitorCPUType meta:CPUMetaDictionary payload:CPUPayloadData]; 
            }
        }
    }
}
複製代碼

4、 OOM 問題

1. 基礎知識準備

硬盤:也叫作磁盤,用於存儲數據。你存儲的歌曲、圖片、視頻都是在硬盤裏。

內存:因爲硬盤讀取速度較慢,若是 CPU 運行程序期間,全部的數據都直接從硬盤中讀取,則很是影響效率。因此 CPU 會將程序運行所須要的數據從硬盤中讀取到內存中。而後 CPU 與內存中的數據進行計算、交換。內存是易失性存儲器(斷電後,數據消失)。內存條區是計算機內部(在主板上)的一些存儲器,用來保存 CPU 運算的中間數據和結果。內存是程序與 CPU 之間的橋樑。從硬盤讀取出數據或者運行程序提供給 CPU。

虛擬內存 是計算機系統內存管理的一種技術。它使得程序認爲它擁有連續的可用內存,而實際上,它一般被分割成多個物理內存碎片,可能部分暫時存儲在外部磁盤(硬盤)存儲器上(當須要使用時則用硬盤中數據交換到內存中)。Windows 系統中稱爲 「虛擬內存」,Linux/Unix 系統中稱爲 」交換空間「。

iOS 不支持交換空間?不僅是 iOS 不支持交換空間,大多數手機系統都不支持。由於移動設備的大量存儲器是閃存,它的讀寫速度遠遠小電腦所使用的硬盤,也就是說手機即便使用了交換空間技術,也由於閃存慢的問題,不能提高性能,因此索性就沒有交換空間技術。

2. iOS 內存知識

內存(RAM)與 CPU 同樣都是系統中最稀少的資源,也很容易發生競爭,應用內存與性能直接相關。iOS 沒有交換空間做爲備選資源,因此內存資源尤其重要。

什麼是 OOM?是 out-of-memory 的縮寫,字面意思是超過了內存限制。分爲 FOOM(foreground OOM)和 BOOM(background OOM)。它是由 iOS 的 Jetsam 機制形成的一種非主流 Crash,它不能經過 Signal 這種監控方案所捕獲。

什麼是 Jetsam 機制?Jetsam 機制能夠理解爲系統爲了控制內存資源過分使用而採用的一種管理機制。Jetsam 機制是運行在一個獨立的進程中,每一個進程都有一個內存閾值,一旦超過這個內存閾值,Jetsam 會當即殺掉這個進程。

爲何設計 Jetsam 機制?由於設備的內存是有限的,因此內存資源很是重要。系統進程以及其餘使用的 App 都會搶佔這個資源。因爲 iOS 不支持交換空間,一旦觸發低內存事件,Jetsam 就會盡量多的釋放 App 所在內存,這樣 iOS 系統上出現內存不足時,App 就會被系統殺掉,變現爲 crash。

2種狀況觸發 OOM:系統因爲總體內存使用太高,會基於優先級策略殺死優先級較低的 App;當前 App 達到了 "highg water mark" ,系統也會強殺當前 App(超過系統對當前單個 App 的內存限制值)。

讀了源碼(xnu/bsd/kern/kern_memorystatus.c)會發現內存被殺也有2種機制,以下

highwater 處理 -> 咱們的 App 佔用內存不能超過單個限制

  1. 從優先級列表裏循環尋找線程
  2. 判斷是否知足 p_memstat_memlimit 的限制條件
  3. DiagonoseActive、FREEZE 過濾
  4. 殺進程,成功則 exit,不然循環

memorystatus_act_aggressive 處理 -> 內存佔用高,按照優先級殺死

  1. 根據 policy 家在 jld_bucket_count,用來判斷是否被殺
  2. 從 JETSAM_PRIORITY_ELEVATED_INACTIVE 開始殺
  3. Old_bucket_count 和 memorystatus_jld_eval_period_msecs 判斷是否開殺
  4. 根據優先級從低到高開始殺,直到 memorystatus_avail_pages_below_pressure

內存過大的幾種狀況

  • App 內存消耗較低,同時其餘 App 內存管理也很棒,那麼即便切換到其餘 App,咱們本身的 App 依舊是「活着」的,保留了用戶狀態。體驗好
  • App 內存消耗較低,但其餘 App 內存消耗太大(多是內存管理糟糕,也多是自己就耗費資源,好比遊戲),那麼除了在前臺的線程,其餘 App 都會被系統殺死,回收內存資源,用來給活躍的進程提供內存。
  • App 內存消耗較大,切換到其餘 App 後,即便其餘 App 向系統申請的內存不大,系統也會由於內存資源緊張,優先把內存消耗大的 App 殺死。表現爲用戶將 App 退出到後臺,過會兒再次打開會發現 App 從新加載啓動。
  • App 內存消耗很是大,在前臺運行時就被系統殺死,形成閃退。

App 內存不足時,系統會按照必定策略來騰出更多的空間供使用。比較常見的作法是將一部分優先級低的數據挪到磁盤上,該操做爲稱爲 page out。以後再次訪問這塊數據的時候,系統會負責將它從新搬回到內存中,該操做被稱爲 page in

Memory page** 是內存管理中的最小單位,是系統分配的,可能一個 page 持有多個對象,也可能一個大的對象跨越多個 page。一般它是 16KB 大小,且有3種類型的 page。

內存page種類

  • Clean Memory Clean memory 包括3類:能夠 page out 的內存、內存映射文件、App 使用到的 framework(每一個 framework 都有 _DATA_CONST 段,一般都是 clean 狀態,但使用 runtime swizling,那麼變爲 dirty)。

    一開始分配的 page 都是乾淨的(堆裏面的對象分配除外),咱們 App 數據寫入時候變爲 dirty。從硬盤讀進內存的文件,也是隻讀的、clean page。

    Clean memory

  • Dirty Memory

    Dirty memory 包括4類:被 App 寫入過數據的內存、全部堆區分配的對象、圖像解碼緩衝區、framework(framework 都有 _DATA 段和 _DATA_DIRTY 段,它們的內存都是 dirty)。

    在使用 framework 的過程當中會產生 Dirty memory,使用單例或者全局初始化方法有助於幫助減小 Dirty memory(由於單例一旦建立就不銷燬,一直在內存中,系統不認爲是 Dirty memory)。

    Dirty memory

  • Compressed Memory

    因爲閃存容量和讀寫限制,iOS 沒有交換空間機制,而是在 iOS7 引入了 memory compressor。它是在內存緊張時候可以將最近一段時間未使用過的內存對象,內存壓縮器會把對象壓縮,釋放出更多的 page。在須要時內存壓縮器對其解壓複用。在節省內存的同時提升了響應速度。

    好比 App 使用某 Framework,內部有個 NSDictionary 屬性存儲數據,使用了 3 pages 內存,在近期未被訪問的時候 memory compressor 將其壓縮爲 1 page,再次使用的時候還原爲 3 pages。

App 運行內存 = pageNumbers * pageSize。由於 Compressed Memory 屬於 Dirty memory。因此 Memory footprint = dirtySize + CompressedSize

設備不一樣,內存佔用上限不一樣,App 上限較高,extension 上限較低,超過上限 crash 到 EXC_RESOURCE_EXCEPTION

Memory footprint

接下來談一下如何獲取內存上限,以及如何監控 App 由於佔用內存過大而被強殺。

3. 獲取內存信息

3.1 經過 JetsamEvent 日誌計算內存限制值

當 App 被 Jetsam 機制殺死時,手機會生成系統日誌。查看路徑:Settings-Privacy-Analytics & Improvements- Analytics Data(設置-隱私- 分析與改進-分析數據),能夠看到 JetsamEvent-2020-03-14-161828.ips 形式的日誌,以 JetsamEvent 開頭。這些 JetsamEvent 日誌都是 iOS 系統內核強殺掉那些優先級不高(idle、frontmost、suspended)且佔用內存超過系統內存限制的 App 留下的。

日誌包含了 App 的內存信息。能夠查看到 日誌最頂部有 pageSize 字段,查找到 per-process-limit,該節點所在結構裏的 rpages ,將 rpages * pageSize 便可獲得 OOM 的閾值。

日誌中 largestProcess 字段表明 App 名稱;reason 字段表明內存緣由;states 字段表明奔潰時 App 的狀態( idle、suspended、frontmost...)。

爲了測試數據的準確性,我將測試2臺設備(iPhone 6s plus/13.3.1,iPhone 11 Pro/13.3.1)的全部 App 完全退出,只跑了一個爲了測試內存臨界值的 Demo App。 循環申請內存,ViewController 代碼以下

- (void)viewDidLoad {
    [super viewDidLoad];
    NSMutableArray *array = [NSMutableArray array];
    for (NSInteger index = 0; index < 10000000; index++) {
        UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
        UIImage *image = [UIImage imageNamed:@"AppIcon"];
        imageView.image = image;
        [array addObject:imageView];
    }
}
複製代碼

iPhone 6s plus/13.3.1 數據以下:

{"bug_type":"298","timestamp":"2020-03-19 17:23:45.94 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"DA8AF66D-24E8-458C-8734-981866942168"}
{
  "crashReporterKey" : "fc9b659ce486df1ed1b8062d5c7c977a7eb8c851",
  "kernel" : "Darwin Kernel Version 19.3.0: Thu Jan  9 21:10:44 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_S8000",
  "product" : "iPhone8,2",
  "incident" : "DA8AF66D-24E8-458C-8734-981866942168",
  "date" : "2020-03-19 17:23:45.93 +0800",
  "build" : "iPhone OS 13.3.1 (17D50)",
  "timeDelta" : 332,
  "memoryStatus" : {
  "compressorSize" : 48499,
  "compressions" : 7458651,
  "decompressions" : 5190200,
  "zoneMapCap" : 744407040,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 41402368,
  "pageSize" : 16384,
  "uncompressed" : 104065,
  "zoneMapSize" : 141606912,
  "memoryPages" : {
    "active" : 26214,
    "throttled" : 0,
    "fileBacked" : 14903,
    "wired" : 20019,
    "anonymous" : 37140,
    "purgeable" : 142,
    "inactive" : 23669,
    "free" : 2967,
    "speculative" : 2160
  }
},
  "largestProcess" : "Test",
  "genCounter" : 0,
  "processes" : [
  {
    "uuid" : "39c5738b-b321-3865-a731-68064c4f7a6f",
    "states" : [
      "daemon",
      "idle"
    ],
    "lifetimeMax" : 188,
    "age" : 948223699030,
    "purgeable" : 0,
    "fds" : 25,
    "coalition" : 422,
    "rpages" : 177,
    "pid" : 282,
    "idleDelta" : 824711280,
    "name" : "com.apple.Safari.SafeBrowsing.Se",
    "cpuTime" : 10.275422000000001
  },
  // ...
  {
    "uuid" : "83dbf121-7c0c-3ab5-9b66-77ee926e1561",
    "states" : [
      "frontmost"
    ],
    "killDelta" : 2592,
    "genCount" : 0,
    "age" : 1531004794,
    "purgeable" : 0,
    "fds" : 50,
    "coalition" : 1047,
    "rpages" : 92806,
    "reason" : "per-process-limit",
    "pid" : 2384,
    "cpuTime" : 59.464373999999999,
    "name" : "Test",
    "lifetimeMax" : 92806
  },
  // ...
 ]
}
複製代碼

iPhone 6s plus/13.3.1 手機 OOM 臨界值爲:(16384*92806)/(1024*1024)=1450.09375M

iPhone 11 Pro/13.3.1 數據以下:

{"bug_type":"298","timestamp":"2020-03-19 17:30:28.39 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"7F111601-BC7A-4BD7-A468-CE3370053057"}
{
  "crashReporterKey" : "bc2445adc164c399b330f812a48248e029e26276",
  "kernel" : "Darwin Kernel Version 19.3.0: Thu Jan  9 21:11:10 PST 2020; root:xnu-6153.82.3~1\/RELEASE_ARM64_T8030",
  "product" : "iPhone12,3",
  "incident" : "7F111601-BC7A-4BD7-A468-CE3370053057",
  "date" : "2020-03-19 17:30:28.39 +0800",
  "build" : "iPhone OS 13.3.1 (17D50)",
  "timeDelta" : 189,
  "memoryStatus" : {
  "compressorSize" : 66443,
  "compressions" : 25498129,
  "decompressions" : 15532621,
  "zoneMapCap" : 1395015680,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 41222144,
  "pageSize" : 16384,
  "uncompressed" : 127027,
  "zoneMapSize" : 169639936,
  "memoryPages" : {
    "active" : 58652,
    "throttled" : 0,
    "fileBacked" : 20291,
    "wired" : 45838,
    "anonymous" : 96445,
    "purgeable" : 4,
    "inactive" : 54368,
    "free" : 5461,
    "speculative" : 3716
  }
},
  "largestProcess" : "杭城小劉",
  "genCounter" : 0,
  "processes" : [
  {
    "uuid" : "2dd5eb1e-fd31-36c2-99d9-bcbff44efbb7",
    "states" : [
      "daemon",
      "idle"
    ],
    "lifetimeMax" : 171,
    "age" : 5151034269954,
    "purgeable" : 0,
    "fds" : 50,
    "coalition" : 66,
    "rpages" : 164,
    "pid" : 11276,
    "idleDelta" : 3801132318,
    "name" : "wcd",
    "cpuTime" : 3.430787
  },
  // ...
  {
    "uuid" : "63158edc-915f-3a2b-975c-0e0ac4ed44c0",
    "states" : [
      "frontmost"
    ],
    "killDelta" : 4345,
    "genCount" : 0,
    "age" : 654480778,
    "purgeable" : 0,
    "fds" : 50,
    "coalition" : 1718,
    "rpages" : 134278,
    "reason" : "per-process-limit",
    "pid" : 14206,
    "cpuTime" : 23.955463999999999,
    "name" : "杭城小劉",
    "lifetimeMax" : 134278
  },
  // ...
 ]
}
複製代碼

iPhone 11 Pro/13.3.1 手機 OOM 臨界值爲:(16384*134278)/(1024*1024)=2098.09375M

iOS 系統如何發現 Jetsam ?

MacOS/iOS 是一個 BSD 衍生而來的系統,其內核是 Mach,可是對於上層暴露的接口通常是基於 BSD 層對 Mach 的包裝後的。Mach 是一個微內核的架構,真正的虛擬內存管理也是在其中進行的,BSD 對內存管理提供了上層接口。Jetsam 事件也是由 BSD 產生的。bsd_init 函數是入口,其中基本都是在初始化各個子系統,好比虛擬內存管理等。

// 1. Initialize the kernel memory allocator, 初始化 BSD 內存 Zone,這個 Zone 是基於 Mach 內核的zone 構建
kmeminit();

// 2. Initialise background freezing, iOS 上獨有的特性,內存和進程的休眠的常駐監控線程
#if CONFIG_FREEZE
#ifndef CONFIG_MEMORYSTATUS
    #error "CONFIG_FREEZE defined without matching CONFIG_MEMORYSTATUS"
#endif
    /* Initialise background freezing */
    bsd_init_kprintf("calling memorystatus_freeze_init\n");
    memorystatus_freeze_init();
#endif>

// 3. iOS 獨有,JetSAM(即低內存事件的常駐監控線程)
#if CONFIG_MEMORYSTATUS
    /* Initialize kernel memory status notifications */
    bsd_init_kprintf("calling memorystatus_init\n");
    memorystatus_init();
#endif /* CONFIG_MEMORYSTATUS */
複製代碼

主要做用就是開啓了2個優先級最高的線程,來監控整個系統的內存狀況。

CONFIG_FREEZE 開啓時,內核對進程進行冷凍而不是殺死。冷凍功能是由內核中啓動一個 memorystatus_freeze_thread 進行,這個進程在收到信號後調用 memorystatus_freeze_top_process 進行冷凍。

iOS 系統會開啓優先級最高的線程 vm_pressure_monitor 來監控系統的內存壓力狀況,並經過一個堆棧來維護全部 App 進程。iOS 系統還會維護一個內存快照表,用於保存每一個進程內存頁的消耗狀況。有關 Jetsam 也就是 memorystatus 相關的邏輯,能夠在 XNU 項目中的 kern_memorystatus.h 和 **kern_memorystatus.c **源碼中查看。

iOS 系統因內存佔用太高會強殺 App 前,至少有 6秒鐘能夠用來作優先級判斷,JetsamEvent 日誌也是在這6秒內生成的。

上文提到了 iOS 系統沒有交換空間,因而引入了 MemoryStatus 機制(也稱爲 Jetsam)。也就是說在 iOS 系統上釋放盡量多的內存供當前 App 使用。這個機制表如今優先級上,就是先強殺後臺應用;若是內存仍是不夠多,就強殺掉當前應用。在 MacOS 中,MemoryStatus 只會強殺掉標記爲空閒退出的進程。

MemoryStatus 機制會開啓一個 memorystatus_jetsam_thread 的線程,它負責強殺 App 和記錄日誌,不會發送消息,因此內存壓力檢測線程沒法獲取到強殺 App 的消息。

當監控線程發現某 App 有內存壓力時,就發出通知,此時有內存的 App 就去執行 didReceiveMemoryWarning 代理方法。在這個時機,咱們還有機會作一些內存資源釋放的邏輯,也許會避免 App 被系統殺死。

源碼角度查看問題

iOS 系統內核有一個數組,專門維護線程的優先級。數組的每一項是一個包含進程鏈表的結構體。結構體以下:

#define MEMSTAT_BUCKET_COUNT (JETSAM_PRIORITY_MAX + 1)

typedef struct memstat_bucket {
    TAILQ_HEAD(, proc) list;
    int count;
} memstat_bucket_t;

memstat_bucket_t memstat_bucket[MEMSTAT_BUCKET_COUNT];
複製代碼

在 kern_memorystatus.h 中能夠看到進行優先級信息

#define JETSAM_PRIORITY_IDLE_HEAD                -2
/* The value -1 is an alias to JETSAM_PRIORITY_DEFAULT */
#define JETSAM_PRIORITY_IDLE                      0
#define JETSAM_PRIORITY_IDLE_DEFERRED		  1 /* Keeping this around till all xnu_quick_tests can be moved away from it.*/
#define JETSAM_PRIORITY_AGING_BAND1		  JETSAM_PRIORITY_IDLE_DEFERRED
#define JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC  2
#define JETSAM_PRIORITY_AGING_BAND2		  JETSAM_PRIORITY_BACKGROUND_OPPORTUNISTIC
#define JETSAM_PRIORITY_BACKGROUND                3
#define JETSAM_PRIORITY_ELEVATED_INACTIVE	  JETSAM_PRIORITY_BACKGROUND
#define JETSAM_PRIORITY_MAIL                      4
#define JETSAM_PRIORITY_PHONE                     5
#define JETSAM_PRIORITY_UI_SUPPORT                8
#define JETSAM_PRIORITY_FOREGROUND_SUPPORT        9
#define JETSAM_PRIORITY_FOREGROUND               10
#define JETSAM_PRIORITY_AUDIO_AND_ACCESSORY      12
#define JETSAM_PRIORITY_CONDUCTOR                13
#define JETSAM_PRIORITY_HOME                     16
#define JETSAM_PRIORITY_EXECUTIVE                17
#define JETSAM_PRIORITY_IMPORTANT                18
#define JETSAM_PRIORITY_CRITICAL                 19

#define JETSAM_PRIORITY_MAX                      21
複製代碼

能夠明顯的看到,後臺 App 優先級 JETSAM_PRIORITY_BACKGROUND 爲3,前臺 App 優先級 JETSAM_PRIORITY_FOREGROUND 爲10。

優先級規則是:內核線程優先級 > 操做系統優先級 > App 優先級。且前臺 App 優先級高於後臺運行的 App;當線程的優先級相同時, CPU 佔用多的線程的優先級會被下降。

在 kern_memorystatus.c 中能夠看到 OOM 可能的緣由:

/* For logging clarity */
static const char *memorystatus_kill_cause_name[] = {
	""								,		/* kMemorystatusInvalid							*/
	"jettisoned"					,		/* kMemorystatusKilled							*/
	"highwater"						,		/* kMemorystatusKilledHiwat						*/
	"vnode-limit"					,		/* kMemorystatusKilledVnodes					*/
	"vm-pageshortage"				,		/* kMemorystatusKilledVMPageShortage			*/
	"proc-thrashing"				,		/* kMemorystatusKilledProcThrashing				*/
	"fc-thrashing"					,		/* kMemorystatusKilledFCThrashing				*/
	"per-process-limit"				,		/* kMemorystatusKilledPerProcessLimit			*/
	"disk-space-shortage"			,		/* kMemorystatusKilledDiskSpaceShortage			*/
	"idle-exit"						,		/* kMemorystatusKilledIdleExit					*/
	"zone-map-exhaustion"			,		/* kMemorystatusKilledZoneMapExhaustion			*/
	"vm-compressor-thrashing"		,		/* kMemorystatusKilledVMCompressorThrashing		*/
	"vm-compressor-space-shortage"	,		/* kMemorystatusKilledVMCompressorSpaceShortage	*/
};
複製代碼

查看 memorystatus_init 這個函數中初始化 Jetsam 線程的關鍵代碼

__private_extern__ void memorystatus_init(void) {
	// ...
  /* Initialize the jetsam_threads state array */
	jetsam_threads = kalloc(sizeof(struct jetsam_thread_state) * max_jetsam_threads);
  
	/* Initialize all the jetsam threads */
	for (i = 0; i < max_jetsam_threads; i++) {

		result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &jetsam_threads[i].thread);
		if (result == KERN_SUCCESS) {
			jetsam_threads[i].inited = FALSE;
			jetsam_threads[i].index = i;
			thread_deallocate(jetsam_threads[i].thread);
		} else {
			panic("Could not create memorystatus_thread %d", i);
		}
	}
}
複製代碼
/*
 *	High-level priority assignments
 *
 *************************************************************************
 * 127		Reserved (real-time)
 *				A
 *				+
 *			(32 levels)
 *				+
 *				V
 * 96		Reserved (real-time)
 * 95		Kernel mode only
 *				A
 *				+
 *			(16 levels)
 *				+
 *				V
 * 80		Kernel mode only
 * 79		System high priority
 *				A
 *				+
 *			(16 levels)
 *				+
 *				V
 * 64		System high priority
 * 63		Elevated priorities
 *				A
 *				+
 *			(12 levels)
 *				+
 *				V
 * 52		Elevated priorities
 * 51		Elevated priorities (incl. BSD +nice)
 *				A
 *				+
 *			(20 levels)
 *				+
 *				V
 * 32		Elevated priorities (incl. BSD +nice)
 * 31		Default (default base for threads)
 * 30		Lowered priorities (incl. BSD -nice)
 *				A
 *				+
 *			(20 levels)
 *				+
 *				V
 * 11		Lowered priorities (incl. BSD -nice)
 * 10		Lowered priorities (aged pri's)
 *				A
 *				+
 *			(11 levels)
 *				+
 *				V
 * 0		Lowered priorities (aged pri's / idle)
 *************************************************************************
 */
複製代碼

能夠看出:用戶態的應用程序的線程不可能高於操做系統和內核。並且,用戶態的應用程序間的線程優先級分配也有區別,好比處於前臺的應用程序優先級高於處於後臺的應用程序優先級。iOS 上應用程序優先級最高的是 SpringBoard;此外線程的優先級不是一成不變的。Mach 會根據線程的利用率和系統總體負載動態調整線程優先級。若是耗費 CPU 太多就下降線程優先級,若是線程過分捱餓,則會提高線程優先級。可是不管怎麼變,程序都不能超過其所在線程的優先級區間範圍。

能夠看出,系統會根據內核啓動參數和設備性能,開啓 max_jetsam_threads 個(通常狀況爲1,特殊狀況下可能爲3)jetsam 線程,且這些線程的優先級爲 95,也就是 MAXPRI_KERNEL(注意這裏的 95 是線程的優先級,XNU 的線程優先級區間爲:0~127。上文的宏定義是進程優先級,區間爲:-2~19)。

緊接着,分析下 memorystatus_thread 函數,主要負責線程啓動的初始化

static void memorystatus_thread(void *param __unused, wait_result_t wr __unused) {
  //...
  while (memorystatus_action_needed()) {
		boolean_t killed;
		int32_t priority;
		uint32_t cause;
		uint64_t jetsam_reason_code = JETSAM_REASON_INVALID;
		os_reason_t jetsam_reason = OS_REASON_NULL;

		cause = kill_under_pressure_cause;
		switch (cause) {
			case kMemorystatusKilledFCThrashing:
				jetsam_reason_code = JETSAM_REASON_MEMORY_FCTHRASHING;
				break;
			case kMemorystatusKilledVMCompressorThrashing:
				jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_THRASHING;
				break;
			case kMemorystatusKilledVMCompressorSpaceShortage:
				jetsam_reason_code = JETSAM_REASON_MEMORY_VMCOMPRESSOR_SPACE_SHORTAGE;
				break;
			case kMemorystatusKilledZoneMapExhaustion:
				jetsam_reason_code = JETSAM_REASON_ZONE_MAP_EXHAUSTION;
				break;
			case kMemorystatusKilledVMPageShortage:
				/* falls through */
			default:
				jetsam_reason_code = JETSAM_REASON_MEMORY_VMPAGESHORTAGE;
				cause = kMemorystatusKilledVMPageShortage;
				break;
		}

		/* Highwater */
		boolean_t is_critical = TRUE;
		if (memorystatus_act_on_hiwat_processes(&errors, &hwm_kill, &post_snapshot, &is_critical)) {
			if (is_critical == FALSE) {
				/* * For now, don't kill any other processes. */
				break;
			} else {
				goto done;
			}
		}

		jetsam_reason = os_reason_create(OS_REASON_JETSAM, jetsam_reason_code);
		if (jetsam_reason == OS_REASON_NULL) {
			printf("memorystatus_thread: failed to allocate jetsam reason\n");
		}

		if (memorystatus_act_aggressive(cause, jetsam_reason, &jld_idle_kills, &corpse_list_purged, &post_snapshot)) {
			goto done;
		}

		/* * memorystatus_kill_top_process() drops a reference, * so take another one so we can continue to use this exit reason * even after it returns */
		os_reason_ref(jetsam_reason);

		/* LRU */
		killed = memorystatus_kill_top_process(TRUE, sort_flag, cause, jetsam_reason, &priority, &errors);
		sort_flag = FALSE;

		if (killed) {
			if (memorystatus_post_snapshot(priority, cause) == TRUE) {

        			post_snapshot = TRUE;
			}

			/* Jetsam Loop Detection */
			if (memorystatus_jld_enabled == TRUE) {
				if ((priority == JETSAM_PRIORITY_IDLE) || (priority == system_procs_aging_band) || (priority == applications_aging_band)) {
					jld_idle_kills++;
				} else {
					/* * We've reached into bands beyond idle deferred. * We make no attempt to monitor them */
				}
			}

			if ((priority >= JETSAM_PRIORITY_UI_SUPPORT) && (total_corpses_count() > 0) && (corpse_list_purged == FALSE)) {
				/* * If we have jetsammed a process in or above JETSAM_PRIORITY_UI_SUPPORT * then we attempt to relieve pressure by purging corpse memory. */
				task_purge_all_corpses();
				corpse_list_purged = TRUE;
			}
			goto done;
		}
		
		if (memorystatus_avail_pages_below_critical()) {
			/* * Still under pressure and unable to kill a process - purge corpse memory */
			if (total_corpses_count() > 0) {
				task_purge_all_corpses();
				corpse_list_purged = TRUE;
			}

			if (memorystatus_avail_pages_below_critical()) {
				/* * Still under pressure and unable to kill a process - panic */
				panic("memorystatus_jetsam_thread: no victim! available pages:%llu\n", (uint64_t)memorystatus_available_pages);
			}
		}
			
done:	

}
複製代碼

能夠看到它開啓了一個 循環,memorystatus_action_needed() 來做爲循環條件,持續釋放內存。

static boolean_t memorystatus_action_needed(void) {
#if CONFIG_EMBEDDED
	return (is_reason_thrashing(kill_under_pressure_cause) ||
			is_reason_zone_map_exhaustion(kill_under_pressure_cause) ||
	       memorystatus_available_pages <= memorystatus_available_pages_pressure);
#else /* CONFIG_EMBEDDED */
	return (is_reason_thrashing(kill_under_pressure_cause) ||
			is_reason_zone_map_exhaustion(kill_under_pressure_cause));
#endif /* CONFIG_EMBEDDED */
}
複製代碼

它經過 vm_pagepout 發送的內存壓力來判斷當前內存資源是否緊張。幾種狀況:頻繁的頁面換出換進 is_reason_thrashing, Mach Zone 耗盡了 is_reason_zone_map_exhaustion、以及可用的頁低於了 memory status_available_pages 這個門檻。

繼續看 memorystatus_thread,會發現內存緊張時,將先觸發 High-water 類型的 OOM,也就是說假如某個進程使用過程當中超過了其使用內存的最高限制 hight water mark 時會發生 OOM。在 memorystatus_act_on_hiwat_processes() 中,經過 memorystatus_kill_hiwat_proc() 在優先級數組 memstat_bucket 中查找優先級最低的進程,若是進程的內存小於閾值(footprint_in_bytes <= memlimit_in_bytes)則繼續尋找次優先級較低的進程,直到找到佔用內存超過閾值的進程並殺死。

一般來講單個 App 很難觸碰到 high water mark,若是不能結束任何進程,最終走到 memorystatus_act_aggressive,也就是大多數 OOM 發生的地方。

static boolean_t memorystatus_act_aggressive(uint32_t cause, os_reason_t jetsam_reason, int *jld_idle_kills, boolean_t *corpse_list_purged, boolean_t *post_snapshot) {
	// ...
  if ( (jld_bucket_count == 0) || 
		     (jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs))) {

			/* * Refresh evaluation parameters */
			jld_timestamp_msecs	 = jld_now_msecs;
			jld_idle_kill_candidates = jld_bucket_count;
			*jld_idle_kills		 = 0;
			jld_eval_aggressive_count = 0;
			jld_priority_band_max	= JETSAM_PRIORITY_UI_SUPPORT;
		}
  //...
}
複製代碼

上述代碼看到,判斷要不要真正執行 kill 是根據必定的時間間判斷的,條件是 jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs。 也就是在 memorystatus_jld_eval_period_msecs 後才發生條件裏面的 kill。

/* Jetsam Loop Detection */
if (max_mem <= (512 * 1024 * 1024)) {
	/* 512 MB devices */
memorystatus_jld_eval_period_msecs = 8000;	/* 8000 msecs == 8 second window */
} else {
	/* 1GB and larger devices */
memorystatus_jld_eval_period_msecs = 6000;	/* 6000 msecs == 6 second window */
}
複製代碼

其中 memorystatus_jld_eval_period_msecs 取值最小6秒。因此咱們能夠在6秒內作些處理。

3.2 開發者們整理所得

stackoverflow 上有一份數據,整理了各類設備的 OOM 臨界值

device crash amount:MB total amount:MB percentage of total
iPad1 127 256 49%
iPad2 275 512 53%
iPad3 645 1024 62%
iPad4(iOS 8.1) 585 1024 57%
Pad Mini 1st Generation 297 512 58%
iPad Mini retina(iOS 7.1) 696 1024 68%
iPad Air 697 1024 68%
iPad Air 2(iOS 10.2.1) 1383 2048 68%
iPad Pro 9.7"(iOS 10.0.2 (14A456)) 1395 1971 71%
iPad Pro 10.5」(iOS 11 beta4) 3057 4000 76%
iPad Pro 12.9」 (2015)(iOS 11.2.1) 3058 3999 76%
iPad 10.2(iOS 13.2.3) 1844 2998 62%
iPod touch 4th gen(iOS 6.1.1) 130 256 51%
iPod touch 5th gen 286 512 56%
iPhone4 325 512 63%
iPhone4s 286 512 56%
iPhone5 645 1024 62%
iPhone5s 646 1024 63%
iPhone6(iOS 8.x) 645 1024 62%
iPhone6 Plus(iOS 8.x) 645 1024 62%
iPhone6s(iOS 9.2) 1396 2048 68%
iPhone6s Plus(iOS 10.2.1) 1396 2048 68%
iPhoneSE(iOS 9.3) 1395 2048 68%
iPhone7(iOS 10.2) 1395 2048 68%
iPhone7 Plus(iOS 10.2.1) 2040 3072 66%
iPhone8(iOS 12.1) 1364 1990 70%
iPhoneX(iOS 11.2.1) 1392 2785 50%
iPhoneXS(iOS 12.1) 2040 3754 54%
iPhoneXS Max(iOS 12.1) 2039 3735 55%
iPhoneXR(iOS 12.1) 1792 2813 63%
iPhone11(iOS 13.1.3) 2068 3844 54%
iPhone11 Pro Max(iOS 13.2.3) 2067 3740 55%

3.3 觸發當前 App 的 high water mark

咱們能夠寫定時器,不斷的申請內存,以後再經過 phys_footprint 打印當前佔用內存,按道理來講不斷申請內存便可觸發 Jetsam 機制,強殺 App,那麼最後一次打印的內存佔用也就是當前設備的內存上限值

timer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(allocateMemory) userInfo:nil repeats:YES];

- (void)allocateMemory {
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
    UIImage *image = [UIImage imageNamed:@"AppIcon"];
    imageView.image = image;
    [array addObject:imageView];
    
    memoryLimitSizeMB = [self usedSizeOfMemory];
    if (memoryWarningSizeMB && memoryLimitSizeMB) {
        NSLog(@"----- memory warnning:%dMB, memory limit:%dMB", memoryWarningSizeMB, memoryLimitSizeMB);
    }
}

- (int)usedSizeOfMemory {
    task_vm_info_data_t taskInfo;
    mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
    kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);

    if (kernReturn != KERN_SUCCESS) {
        return 0;
    }
    return (int)(taskInfo.phys_footprint/1024.0/1024.0);
}
複製代碼

3.4 適用於 iOS13 系統的獲取方式

iOS13 開始 <os/proc.h> 中 size_t os_proc_available_memory(void); 能夠查看當前可用內存。

Return Value

The number of bytes that the app may allocate before it hits its memory limit. If the calling process isn't an app, or if the process has already exceeded its memory limit, this function returns 0.

Discussion

Call this function to determine the amount of memory available to your app. The returned value corresponds to the current memory limit minus the memory footprint of your app at the time of the function call. Your app's memory footprint consists of the data that you allocated in RAM, and that must stay in RAM (or the equivalent) at all times. Memory limits can change during the app life cycle and don't necessarily correspond to the amount of physical memory available on the device.

Use the returned value as advisory information only and don't cache it. The precise value changes when your app does any work that affects memory, which can happen frequently.

Although this function lets you determine the amount of memory your app may safely consume, don't use it to maximize your app's memory usage. Significant memory use, even when under the current memory limit, affects system performance. For example, when your app consumes all of its available memory, the system may need to terminate other apps and system processes to accommodate your app's requests. Instead, always consume the smallest amount of memory you need to be responsive to the user's needs.

If you need more detailed information about the available memory resources, you can call task_info. However, be aware that task_info is an expensive call, whereas this function is much more efficient.

if (@available(iOS 13.0, *)) {
	return os_proc_available_memory() / 1024.0 / 1024.0;
}
複製代碼

App 內存信息的 API 能夠在 Mach 層找到,mach_task_basic_info 結構體存儲了 Mach task 的內存使用信息,其中 phys_footprint 就是應用使用的物理內存大小,virtual_size 是虛擬內存大小。

#define MACH_TASK_BASIC_INFO     20         /* always 64-bit basic info */
struct mach_task_basic_info {
    mach_vm_size_t  virtual_size;       /* virtual memory size (bytes) */
    mach_vm_size_t  resident_size;      /* resident memory size (bytes) */
    mach_vm_size_t  resident_size_max;  /* maximum resident memory size (bytes) */
    time_value_t    user_time;          /* total user run time for
                                            terminated threads */
    time_value_t    system_time;        /* total system run time for
                                            terminated threads */
    policy_t        policy;             /* default policy for new threads */
    integer_t       suspend_count;      /* suspend count for task */
};
複製代碼

因此獲取代碼爲

task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vmInfo, &count);

if (kr != KERN_SUCCESS) {
    return ;
}
CGFloat memoryUsed = (CGFloat)(vmInfo.phys_footprint/1024.0/1024.0);
複製代碼

可能有人好奇不該該是 resident_size 這個字段獲取內存的使用狀況嗎?一開始測試後發現 resident_size 和 Xcode 測量結果差距較大。而使用 phys_footprint 則接近於 Xcode 給出的結果。且能夠從 WebKit 源碼中獲得印證。

因此在 iOS13 上,咱們能夠經過 os_proc_available_memory 獲取到當前能夠用內存,經過 phys_footprint 獲取到當前 App 佔用內存,2者的和也就是當前設備的內存上限,超過即觸發 Jetsam 機制。

- (CGFloat)limitSizeOfMemory {
    if (@available(iOS 13.0, *)) {
        task_vm_info_data_t taskInfo;
        mach_msg_type_number_t infoCount = TASK_VM_INFO_COUNT;
        kern_return_t kernReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&taskInfo, &infoCount);

        if (kernReturn != KERN_SUCCESS) {
            return 0;
        }
        return (CGFloat)((taskInfo.phys_footprint + os_proc_available_memory()) / (1024.0 * 1024.0);
    }
    return 0;
}
複製代碼

當前可使用內存:1435.936752MB;當前 App 已佔用內存:14.5MB,臨界值:1435.936752MB + 14.5MB= 1450.436MB, 和 3.1 方法中獲取到的內存臨界值同樣「iPhone 6s plus/13.3.1 手機 OOM 臨界值爲:(16384*92806)/(1024*1024)=1450.09375M」。

3.5 經過 XNU 獲取內存限制值

在 XNU 中,有專門用於獲取內存上限值的函數和宏,能夠經過 memorystatus_priority_entry 這個結構體獲得全部進程的優先級和內存限制值。

typedef struct memorystatus_priority_entry {
  pid_t pid;
  int32_t priority;
  uint64_t user_data;
  int32_t limit;
  uint32_t state;
} memorystatus_priority_entry_t;
複製代碼

其中,priority 表明進程優先級,limit 表明進程的內存限制值。可是這種方式須要 root 權限,因爲沒有越獄設備,我沒有嘗試過。

相關代碼可查閱 kern_memorystatus.h 文件。須要用到函數 int memorystatus_control(uint32_t command, int32_t pid, uint32_t flags, void *buffer, size_t buffersize);

/* Commands */
#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST 1
#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES 2
#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT 3
#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS 4
#define MEMORYSTATUS_CMD_SET_JETSAM_HIGH_WATER_MARK 5 /* Set active memory limit = inactive memory limit, both non-fatal */
#define MEMORYSTATUS_CMD_SET_JETSAM_TASK_LIMIT 6 /* Set active memory limit = inactive memory limit, both fatal */
#define MEMORYSTATUS_CMD_SET_MEMLIMIT_PROPERTIES 7 /* Set memory limits plus attributes independently */
#define MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES 8 /* Get memory limits plus attributes */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_ENABLE 9 /* Set the task's status as a privileged listener w.r.t memory notifications */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_DISABLE 10 /* Reset the task's status as a privileged listener w.r.t memory notifications */
#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_ENABLE 11 /* Enable the 'lenient' mode for aggressive jetsam. See comments in kern_memorystatus.c near the top. */
#define MEMORYSTATUS_CMD_AGGRESSIVE_JETSAM_LENIENT_MODE_DISABLE 12 /* Disable the 'lenient' mode for aggressive jetsam. */
#define MEMORYSTATUS_CMD_GET_MEMLIMIT_EXCESS 13 /* Compute how much a process's phys_footprint exceeds inactive memory limit */
#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_ENABLE 14 /* Set the inactive jetsam band for a process to JETSAM_PRIORITY_ELEVATED_INACTIVE */
#define MEMORYSTATUS_CMD_ELEVATED_INACTIVEJETSAMPRIORITY_DISABLE 15 /* Reset the inactive jetsam band for a process to the default band (0)*/
#define MEMORYSTATUS_CMD_SET_PROCESS_IS_MANAGED 16 /* (Re-)Set state on a process that marks it as (un-)managed by a system entity e.g. assertiond */
#define MEMORYSTATUS_CMD_GET_PROCESS_IS_MANAGED 17 /* Return the 'managed' status of a process */
#define MEMORYSTATUS_CMD_SET_PROCESS_IS_FREEZABLE 18 /* Is the process eligible for freezing? Apps and extensions can pass in FALSE to opt out of freezing, i.e., 複製代碼

僞代碼

struct memorystatus_priority_entry memStatus[NUM_ENTRIES];
size_t count = sizeof(struct memorystatus_priority_entry) * NUM_ENTRIES;
int kernResult = memorystatus_control(MEMORYSTATUS_CMD_GET_PRIORITY_LIST, 0, 0, memStatus, count);
if (rc < 0) {
  NSLog(@"memorystatus_control"); 
	return ;
}

int entry = 0;
for (; rc > 0; rc -= sizeof(struct memorystatus_priority_entry)){
  printf ("PID: %5d\tPriority:%2d\tUser Data: %llx\tLimit:%2d\tState:%s\n",
          memstatus[entry].pid,
          memstatus[entry].priority,
          memstatus[entry].user_data,
          memstatus[entry].limit,
          state_to_text(memstatus[entry].state));
  entry++;
}
複製代碼

for 循環打印出每一個進程(也就是 App)的 pid、Priority、User Data、Limit、State 信息。從 log 中找出優先級爲10的進程,即咱們前臺運行的 App。爲何是10? 由於 #define JETSAM_PRIORITY_FOREGROUND 10 咱們的目的就是獲取前臺 App 的內存上限值。

4. 如何斷定發生了 OOM

OOM 致使 crash 前,app 必定會收到低內存警告嗎?

作2組對比實驗:

// 實驗1
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
  NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"];
  NSData *data = [NSData dataWithContentsOfFile:filePath];
  [array addObject:data];
}
複製代碼
// 實驗2
// ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSMutableArray *array = [NSMutableArray array];
        for (NSInteger index = 0; index < 10000000; index++) {
            NSString *filePath = [[NSBundle mainBundle] pathForResource:@"Info" ofType:@"plist"];
            NSData *data = [NSData dataWithContentsOfFile:filePath];
            [array addObject:data];
        }
    });
}
- (void)didReceiveMemoryWarning
{
    NSLog(@"2");
}

// AppDelegate.m
- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application
{
    NSLog(@"1");
}
複製代碼

現象:

  1. 在 viewDidLoad 也就是主線程中內存消耗過大,系統並不會發出低內存警告,直接 Crash。由於內存增加過快,主線程很忙。
  2. 多線程的狀況下,App 因內存增加過快,會收到低內存警告,AppDelegate 中的applicationDidReceiveMemoryWarning 先執行,隨後是當前 VC 的 didReceiveMemoryWarning

結論:

收到低內存警告不必定會 Crash,由於有6秒鐘的系統判斷時間,6秒內內存降低了則不會 crash。發生 OOM 也不必定會收到低內存警告。

5. 內存信息收集

要想精確的定位問題,就須要 dump 全部對象及其內存信息。當內存接近系統內存上限的時候,收集並記錄所需信息,結合必定的數據上報機制,上傳到服務器,分析並修復。

還須要知道每一個對象具體是在哪一個函數裏建立出來的,以便還原「案發現場」。

源代碼(libmalloc/malloc),內存分配函數 malloc 和 calloc 等默認使用 nano_zone,nano_zone 是小於 256B 如下的內存分配,大於 256B 則使用 scalable_zone 來分配。

主要針對大內存的分配監控。malloc 函數用的是 malloc_zone_malloc, calloc 用的是 malloc_zone_calloc。

使用 scalable_zone 分配內存的函數都會調用 malloc_logger 函數,由於系統爲了有個地方專門統計並管理內存分配狀況。這樣的設計也知足「收口原則」。

void * malloc(size_t size) {
	void *retval;
	retval = malloc_zone_malloc(default_zone, size);
	if (retval == NULL) {
		errno = ENOMEM;
	}
	return retval;
}

void * calloc(size_t num_items, size_t size) {
	void *retval;
	retval = malloc_zone_calloc(default_zone, num_items, size);
	if (retval == NULL) {
		errno = ENOMEM;
	}
	return retval;
}
複製代碼

首先來看看這個 default_zone 是什麼東西, 代碼以下

typedef struct {
	malloc_zone_t malloc_zone;
	uint8_t pad[PAGE_MAX_SIZE - sizeof(malloc_zone_t)];
} virtual_default_zone_t;

static virtual_default_zone_t virtual_default_zone
__attribute__((section("__DATA,__v_zone")))
__attribute__((aligned(PAGE_MAX_SIZE))) = {
	NULL,
	NULL,
	default_zone_size,
	default_zone_malloc,
	default_zone_calloc,
	default_zone_valloc,
	default_zone_free,
	default_zone_realloc,
	default_zone_destroy,
	DEFAULT_MALLOC_ZONE_STRING,
	default_zone_batch_malloc,
	default_zone_batch_free,
	&default_zone_introspect,
	10,
	default_zone_memalign,
	default_zone_free_definite_size,
	default_zone_pressure_relief,
	default_zone_malloc_claimed_address,
};

static malloc_zone_t *default_zone = &virtual_default_zone.malloc_zone;

static void * default_zone_malloc(malloc_zone_t *zone, size_t size) {
	zone = runtime_default_zone();
	
	return zone->malloc(zone, size);
}


MALLOC_ALWAYS_INLINE static inline malloc_zone_t * runtime_default_zone() {
	return (lite_zone) ? lite_zone : inline_malloc_default_zone();
}
複製代碼

能夠看到 default_zone 經過這種方式來初始化

static inline malloc_zone_t * inline_malloc_default_zone(void) {
	_malloc_initialize_once();
	// malloc_report(ASL_LEVEL_INFO, "In inline_malloc_default_zone with %d %d\n", malloc_num_zones, malloc_has_debug_zone);
	return malloc_zones[0];
}
複製代碼

隨後的調用以下 _malloc_initialize -> create_scalable_zone -> create_scalable_szone 最終咱們建立了 szone_t 類型的對象,經過類型轉換,獲得了咱們的 default_zone。

malloc_zone_t *
create_scalable_zone(size_t initial_size, unsigned debug_flags) {
	return (malloc_zone_t *) create_scalable_szone(initial_size, debug_flags);
}
複製代碼
void *malloc_zone_malloc(malloc_zone_t *zone, size_t size) {
  MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);
  void *ptr;
  if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {
    internal_check();
  }
  if (size > MALLOC_ABSOLUTE_MAX_SIZE) {
    return NULL;
  }
  ptr = zone->malloc(zone, size);
  // 在 zone 分配完內存後就開始使用 malloc_logger 進行進行記錄
  if (malloc_logger) {
    malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
  }
  MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);
  return ptr;
}
複製代碼

其分配實現是 zone->malloc 根據以前的分析,就是szone_t結構體對象中對應的malloc實現。

在建立szone以後,作了一系列以下的初始化操做。

// Initialize the security token.
szone->cookie = (uintptr_t)malloc_entropy[0];

szone->basic_zone.version = 12;
szone->basic_zone.size = (void *)szone_size;
szone->basic_zone.malloc = (void *)szone_malloc;
szone->basic_zone.calloc = (void *)szone_calloc;
szone->basic_zone.valloc = (void *)szone_valloc;
szone->basic_zone.free = (void *)szone_free;
szone->basic_zone.realloc = (void *)szone_realloc;
szone->basic_zone.destroy = (void *)szone_destroy;
szone->basic_zone.batch_malloc = (void *)szone_batch_malloc;
szone->basic_zone.batch_free = (void *)szone_batch_free;
szone->basic_zone.introspect = (struct malloc_introspection_t *)&szone_introspect;
szone->basic_zone.memalign = (void *)szone_memalign;
szone->basic_zone.free_definite_size = (void *)szone_free_definite_size;
szone->basic_zone.pressure_relief = (void *)szone_pressure_relief;
szone->basic_zone.claimed_address = (void *)szone_claimed_address;
複製代碼

其餘使用 scalable_zone 分配內存的函數的方法也相似,因此大內存的分配,無論外部函數如何封裝,最終都會調用到 malloc_logger 函數。因此咱們能夠用 fishhook 去 hook 這個函數,而後記錄內存分配狀況,結合必定的數據上報機制,上傳到服務器,分析並修復。

// For logging VM allocation and deallocation, arg1 here
// is the mach_port_name_t of the target task in which the
// alloc or dealloc is occurring. For example, for mmap()
// that would be mach_task_self(), but for a cross-task-capable
// call such as mach_vm_map(), it is the target task.

typedef void (malloc_logger_t)(uint32_t type, uintptr_t arg1, uintptr_t arg2, uintptr_t arg3, uintptr_t result, uint32_t num_hot_frames_to_skip);

extern malloc_logger_t *__syscall_logger;
複製代碼

當 malloc_logger 和 __syscall_logger 函數指針不爲空時,malloc/free、vm_allocate/vm_deallocate 等內存分配/釋放經過這兩個指針通知上層,這也是內存調試工具 malloc stack 的實現原理。有了這兩個函數指針,咱們很容易記錄當前存活對象的內存分配信息(包括分配大小和分配堆棧)。分配堆棧能夠用 backtrace 函數捕獲,但捕獲到的地址是虛擬內存地址,不能從符號表 dsym 解析符號。因此還要記錄每一個 image 加載時的偏移 slide,這樣 符號表地址 = 堆棧地址 - slide。

小 tips:

ASLR(Address space layout randomization):常見稱呼爲位址空間隨機載入、位址空間配置隨機化、位址空間佈局隨機化,是一種防止內存損壞漏洞被利用的計算機安全技術,經過隨機放置進程關鍵數據區域的定址空間來放置攻擊者能可靠地跳轉到內存的特定位置來操做函數。現代做業系統通常都具有該機制。

函數地址 add: 函數真實的實現地址;

函數虛擬地址:vm_add;

ASLR: slide 函數虛擬地址加載到進程內存的隨機偏移量,每一個 mach-o 的 slide 各不相同。vm_add + slide = add。也就是:*(base +offset)= imp

因爲騰訊也開源了本身的 OOM 定位方案- OOMDetector ,有了現成的輪子,那麼用好就能夠了,因此對於內存的監控思路就是找到系統給 App 的內存上限,而後當接近內存上限值的時候,dump 內存狀況,組裝基礎數據信息成一個合格的上報數據,通過必定的數據上報策略到服務端,服務端消費數據,分析產生報表,客戶端工程師根據報表分析問題。不一樣工程的數據以郵件、短信、企業微信等形式通知到該項目的 owner、開發者。(狀況嚴重的會直接電話給開發者,並給主管跟進每一步的處理結果)。 問題分析處理後要麼發佈新版本,要麼 hot fix。

6. 開發階段針對內存咱們能作些什麼

  1. 圖片縮放

    WWDC 2018 Session 416 - iOS Memory Deep Dive,處理圖片縮放的時候直接使用 UIImage 會在解碼時讀取文件而佔用一部份內存,還會生成中間位圖 bitmap 消耗大量內存。而 ImageIO 不存在上述2種弊端,只會佔用最終圖片大小的內存

    作了2組對比實驗:給 App 顯示一張圖片

    // 方法1: 19.6M
    UIImage *imageResult = [self scaleImage:[UIImage imageNamed:@"test"]                                                  newSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height)];
    self.imageView.image = imageResult;
    
    // 方法2: 14M
    NSData *data = UIImagePNGRepresentation([UIImage imageNamed:@"test"]);
    UIImage *imageResult = [self scaledImageWithData:data 				    withSize:CGSizeMake(self.view.frame.size.width, self.view.frame.size.height) scale:3 orientation:UIImageOrientationUp];
    self.imageView.image = imageResult;
    
    - (UIImage *)scaleImage:(UIImage *)image newSize:(CGSize)newSize
    {
        UIGraphicsBeginImageContextWithOptions(newSize, NO, 0);
        [image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)];
        UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();
        UIGraphicsEndImageContext();
        return newImage;
    }
    
    - (UIImage *)scaledImageWithData:(NSData *)data withSize:(CGSize)size scale:(CGFloat)scale orientation:(UIImageOrientation)orientation
    {
        CGFloat maxPixelSize = MAX(size.width, size.height);
        CGImageSourceRef sourceRef = CGImageSourceCreateWithData((__bridge CFDataRef)data, nil);
        NSDictionary *options = @{(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : (__bridge id)kCFBooleanTrue,
                                  (__bridge id)kCGImageSourceThumbnailMaxPixelSize : [NSNumber numberWithFloat:maxPixelSize]};
        CGImageRef imageRef = CGImageSourceCreateThumbnailAtIndex(sourceRef, 0, (__bridge CFDictionaryRef)options);
        UIImage *resultImage = [UIImage imageWithCGImage:imageRef scale:scale orientation:orientation];
        CGImageRelease(imageRef);
        CFRelease(sourceRef);
        return resultImage;
    }
    複製代碼

    能夠看出使用 ImageIO 比使用 UIImage 直接縮放佔用內存更低。

  2. 合理使用 autoreleasepool

咱們知道 autoreleasepool 對象是在 RunLoop 結束時才釋放。在 ARC 下,咱們若是在不斷申請內存,好比各類循環,那麼咱們就須要手動添加 autoreleasepool,避免短期內內存猛漲發生 OOM。

對比實驗

// 實驗1
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
  NSString *indexStrng = [NSString stringWithFormat:@"%zd", index];
  NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng];
  [array addObject:resultString];
}

// 實驗2
NSMutableArray *array = [NSMutableArray array];
for (NSInteger index = 0; index < 10000000; index++) {
  @autoreleasepool {
    NSString *indexStrng = [NSString stringWithFormat:@"%zd", index];
    NSString *resultString = [NSString stringWithFormat:@"%zd-%@", index, indexStrng];
    [array addObject:resultString];
  }
}
複製代碼

實驗1消耗內存 739.6M,實驗2消耗內存 587M。

  1. UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必須成雙出現,否則會形成 context 泄漏。另外 XCode 的 Analyze 也能掃出這類問題。

  2. 無論是打開網頁,仍是執行 js,都應該使用 WKWebView。UIWebView 會佔用大量內存,從而致使 App 發生 OOM 的概率增長,而 WKWebView 是一個多進程組件,Network Loading 以及 UI Rendering 在其它進程中執行,比 UIWebView 佔用更低的內存開銷。

  3. 在作 SDK 或者 App,若是場景是緩存相關,儘可能使用 NSCache 而不是 NSMutableDictionary。它是系統提供的專門處理緩存的類,NSCache 分配的內存是 Purgeable Memory,能夠由系統自動釋放。NSCache 與 NSPureableData 的結合使用可讓系統根據狀況回收內存,也能夠在內存清理時移除對象。

    其餘的開發習慣就不一一描述了,良好的開發習慣和代碼意識是須要平時注意修煉的。

5、 App 網絡監控

移動網絡環境一直很複雜,WIFI、2G、3G、4G、5G 等,用戶使用 App 的過程當中可能在這幾種類型之間切換,這也是移動網絡和傳統網絡間的一個區別,被稱爲「Connection Migration」。此外還存在 DNS 解析緩慢、失敗率高、運營商劫持等問題。用戶在使用 App 時由於某些緣由致使體驗不好,要想針對網絡狀況進行改善,必須有清晰的監控手段。

1. App 網絡請求過程

網絡請求各階段

App 發送一次網絡請求通常會經歷下面幾個關鍵步驟:

  • DNS 解析

    Domain Name system,網絡域名名稱系統,本質上就是將域名IP 地址 相互映射的一個分佈式數據庫,令人們更方便的訪問互聯網。首先會查詢本地的 DNS 緩存,查找失敗就去 DNS 服務器查詢,這其中可能會通過很是多的節點,涉及到遞歸查詢和迭代查詢的過程。運營商可能不幹人事:一種狀況就是出現運營商劫持的現象,表現爲你在 App 內訪問某個網頁的時候會看到和內容不相關的廣告;另外一種可能的狀況就是把你的請求丟給很是遠的基站去作 DNS 解析,致使咱們 App 的 DNS 解析時間較長,App 網絡效率低。通常作 HTTPDNS 方案去自行解決 DNS 的問題。

  • TCP 3次握手

    關於 TCP 握手過程當中爲何是3次握手而不是2次、4次,能夠查看這篇文章

  • TLS 握手

    對於 HTTPS 請求還須要作 TLS 握手,也就是密鑰協商的過程。

  • 發送請求

    鏈接創建好以後就能夠發送 request,此時能夠記錄下 request start 時間

  • 等待迴應

    等待服務器返回響應。這個時間主要取決於資源大小,也是網絡請求過程當中最爲耗時的一個階段。

  • 返回響應

    服務端返回響應給客戶端,根據 HTTP header 信息中的狀態碼判斷本次請求是否成功、是否走緩存、是否須要重定向。

2. 監控原理

名稱 說明
NSURLConnection 已經被廢棄。用法簡單
NSURLSession iOS7.0 推出,功能更強大
CFNetwork NSURL 的底層,純 C 實現

iOS 網絡框架層級關係以下:

Network Level

iOS 網絡現狀是由4層組成的:最底層的 BSD Sockets、SecureTransport;次級底層是 CFNetwork、NSURLSession、NSURLConnection、WebView 是用 Objective-C 實現的,且調用 CFNetwork;應用層框架 AFNetworking 基於 NSURLSession、NSURLConnection 實現。

目前業界對於網絡監控主要有2種:一種是經過 NSURLProtocol 監控、一種是經過 Hook 來監控。下面介紹幾種辦法來監控網絡請求,各有優缺點。

2.1 方案一:NSURLProtocol 監控 App 網絡請求

NSURLProtocol 做爲上層接口,使用較爲簡單,但 NSURLProtocol 屬於 URL Loading System 體系中。應用協議的支持程度有限,支持 FTP、HTTP、HTTPS 等幾個應用層協議,對於其餘的協議則沒法監控,存在必定的侷限性。若是監控底層網絡庫 CFNetwork 則沒有這個限制。

對於 NSURLProtocol 的具體作法在這篇文章中講過,繼承抽象類並實現相應的方法,自定義去發起網絡請求來實現監控的目的。

iOS 10 以後,NSURLSessionTaskDelegate 中增長了一個新的代理方法:

/*
 * Sent when complete statistics information has been collected for the task.
 */
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
複製代碼

能夠從 NSURLSessionTaskMetrics 中獲取到網絡狀況的各項指標。各項參數以下

@interface NSURLSessionTaskMetrics : NSObject

/*
 * transactionMetrics array contains the metrics collected for every request/response transaction created during the task execution.
 */
@property (copy, readonly) NSArray<NSURLSessionTaskTransactionMetrics *> *transactionMetrics;

/*
 * Interval from the task creation time to the task completion time.
 * Task creation time is the time when the task was instantiated.
 * Task completion time is the time when the task is about to change its internal state to completed.
 */
@property (copy, readonly) NSDateInterval *taskInterval;

/*
 * redirectCount is the number of redirects that were recorded.
 */
@property (assign, readonly) NSUInteger redirectCount;

- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));
+ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));

@end
複製代碼

其中:taskInterval 表示任務從建立到完成話費的總時間,任務的建立時間是任務被實例化時的時間,任務完成時間是任務的內部狀態將要變爲完成的時間;redirectCount 表示被重定向的次數;transactionMetrics 數組包含了任務執行過程當中每一個請求/響應事務中收集的指標,各項參數以下:

/*
 * This class defines the performance metrics collected for a request/response transaction during the task execution.
 */
API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0))
@interface NSURLSessionTaskTransactionMetrics : NSObject

/*
 * Represents the transaction request. 請求事務
 */
@property (copy, readonly) NSURLRequest *request;

/*
 * Represents the transaction response. Can be nil if error occurred and no response was generated. 響應事務
 */
@property (nullable, copy, readonly) NSURLResponse *response;

/*
 * For all NSDate metrics below, if that aspect of the task could not be completed, then the corresponding 「EndDate」 metric will be nil.
 * For example, if a name lookup was started but the name lookup timed out, failed, or the client canceled the task before the name could be resolved -- then while domainLookupStartDate may be set, domainLookupEndDate will be nil along with all later metrics.
 */

/*
 * 客戶端開始請求的時間,不管是從服務器仍是從本地緩存中獲取
 * fetchStartDate returns the time when the user agent started fetching the resource, whether or not the resource was retrieved from the server or local resources.
 *
 * The following metrics will be set to nil, if a persistent connection was used or the resource was retrieved from local resources:
 *
 *   domainLookupStartDate
 *   domainLookupEndDate
 *   connectStartDate
 *   connectEndDate
 *   secureConnectionStartDate
 *   secureConnectionEndDate
 */
@property (nullable, copy, readonly) NSDate *fetchStartDate;

/*
 * domainLookupStartDate returns the time immediately before the user agent started the name lookup for the resource. DNS 開始解析的時間
 */
@property (nullable, copy, readonly) NSDate *domainLookupStartDate;

/*
 * domainLookupEndDate returns the time after the name lookup was completed. DNS 解析完成的時間
 */
@property (nullable, copy, readonly) NSDate *domainLookupEndDate;

/*
 * connectStartDate is the time immediately before the user agent started establishing the connection to the server.
 *
 * For example, this would correspond to the time immediately before the user agent started trying to establish the TCP connection. 客戶端與服務端開始創建 TCP 鏈接的時間
 */
@property (nullable, copy, readonly) NSDate *connectStartDate;

/*
 * If an encrypted connection was used, secureConnectionStartDate is the time immediately before the user agent started the security handshake to secure the current connection. HTTPS 的 TLS 握手開始的時間
 *
 * For example, this would correspond to the time immediately before the user agent started the TLS handshake. 
 *
 * If an encrypted connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSDate *secureConnectionStartDate;

/*
 * If an encrypted connection was used, secureConnectionEndDate is the time immediately after the security handshake completed. HTTPS 的 TLS 握手結束的時間
 *
 * If an encrypted connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSDate *secureConnectionEndDate;

/*
 * connectEndDate is the time immediately after the user agent finished establishing the connection to the server, including completion of security-related and other handshakes. 客戶端與服務器創建 TCP 鏈接完成的時間,包括 TLS 握手時間
 */
@property (nullable, copy, readonly) NSDate *connectEndDate;

/*
 * requestStartDate is the time immediately before the user agent started requesting the source, regardless of whether the resource was retrieved from the server or local resources.
 客戶端請求開始的時間,能夠理解爲開始傳輸 HTTP 請求的 header 的第一個字節時間
 *
 * For example, this would correspond to the time immediately before the user agent sent an HTTP GET request.
 */
@property (nullable, copy, readonly) NSDate *requestStartDate;

/*
 * requestEndDate is the time immediately after the user agent finished requesting the source, regardless of whether the resource was retrieved from the server or local resources.
 客戶端請求結束的時間,能夠理解爲 HTTP 請求的最後一個字節傳輸完成的時間
 *
 * For example, this would correspond to the time immediately after the user agent finished sending the last byte of the request.
 */
@property (nullable, copy, readonly) NSDate *requestEndDate;

/*
 * responseStartDate is the time immediately after the user agent received the first byte of the response from the server or from local resources.
 客戶端從服務端接收響應的第一個字節的時間
 *
 * For example, this would correspond to the time immediately after the user agent received the first byte of an HTTP response.
 */
@property (nullable, copy, readonly) NSDate *responseStartDate;

/*
 * responseEndDate is the time immediately after the user agent received the last byte of the resource. 客戶端從服務端接收到最後一個請求的時間
 */
@property (nullable, copy, readonly) NSDate *responseEndDate;

/*
 * The network protocol used to fetch the resource, as identified by the ALPN Protocol ID Identification Sequence [RFC7301].
 * E.g., h2, http/1.1, spdy/3.1.
 網絡協議名,好比 http/1.1, spdy/3.1
 *
 * When a proxy is configured AND a tunnel connection is established, then this attribute returns the value for the tunneled protocol.
 *
 * For example:
 * If no proxy were used, and HTTP/2 was negotiated, then h2 would be returned.
 * If HTTP/1.1 were used to the proxy, and the tunneled connection was HTTP/2, then h2 would be returned.
 * If HTTP/1.1 were used to the proxy, and there were no tunnel, then http/1.1 would be returned.
 *
 */
@property (nullable, copy, readonly) NSString *networkProtocolName;

/*
 * This property is set to YES if a proxy connection was used to fetch the resource.
	該鏈接是否使用了代理
 */
@property (assign, readonly, getter=isProxyConnection) BOOL proxyConnection;

/*
 * This property is set to YES if a persistent connection was used to fetch the resource.
 是否複用了現有鏈接
 */
@property (assign, readonly, getter=isReusedConnection) BOOL reusedConnection;

/*
 * Indicates whether the resource was loaded, pushed or retrieved from the local cache.
 獲取資源來源
 */
@property (assign, readonly) NSURLSessionTaskMetricsResourceFetchType resourceFetchType;

/*
 * countOfRequestHeaderBytesSent is the number of bytes transferred for request header.
 請求頭的字節數
 */
@property (readonly) int64_t countOfRequestHeaderBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfRequestBodyBytesSent is the number of bytes transferred for request body.
 請求體的字節數
 * It includes protocol-specific framing, transfer encoding, and content encoding.
 */
@property (readonly) int64_t countOfRequestBodyBytesSent API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfRequestBodyBytesBeforeEncoding is the size of upload body data, file, or stream.
 上傳體數據、文件、流的大小
 */
@property (readonly) int64_t countOfRequestBodyBytesBeforeEncoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfResponseHeaderBytesReceived is the number of bytes transferred for response header.
 響應頭的字節數
 */
@property (readonly) int64_t countOfResponseHeaderBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfResponseBodyBytesReceived is the number of bytes transferred for response body.
 響應體的字節數
 * It includes protocol-specific framing, transfer encoding, and content encoding.
 */
@property (readonly) int64_t countOfResponseBodyBytesReceived API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * countOfResponseBodyBytesAfterDecoding is the size of data delivered to your delegate or completion handler.
給代理方法或者完成後處理的回調的數據大小
 
 */
@property (readonly) int64_t countOfResponseBodyBytesAfterDecoding API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * localAddress is the IP address string of the local interface for the connection.
  當前鏈接下的本地接口 IP 地址
 *
 * For multipath protocols, this is the local address of the initial flow.
 *
 * If a connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSString *localAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * localPort is the port number of the local interface for the connection.
 當前鏈接下的本地端口號
 
 *
 * For multipath protocols, this is the local port of the initial flow.
 *
 * If a connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSNumber *localPort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * remoteAddress is the IP address string of the remote interface for the connection.
 當前鏈接下的遠端 IP 地址
 *
 * For multipath protocols, this is the remote address of the initial flow.
 *
 * If a connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSString *remoteAddress API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * remotePort is the port number of the remote interface for the connection.
  當前鏈接下的遠端端口號
 *
 * For multipath protocols, this is the remote port of the initial flow.
 *
 * If a connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSNumber *remotePort API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * negotiatedTLSProtocolVersion is the TLS protocol version negotiated for the connection.
  鏈接協商用的 TLS 協議版本號
 * It is a 2-byte sequence in host byte order.
 *
 * Please refer to tls_protocol_version_t enum in Security/SecProtocolTypes.h
 *
 * If an encrypted connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSNumber *negotiatedTLSProtocolVersion API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * negotiatedTLSCipherSuite is the TLS cipher suite negotiated for the connection.
 鏈接協商用的 TLS 密碼套件
 * It is a 2-byte sequence in host byte order.
 *
 * Please refer to tls_ciphersuite_t enum in Security/SecProtocolTypes.h
 *
 * If an encrypted connection was not used, this attribute is set to nil.
 */
@property (nullable, copy, readonly) NSNumber *negotiatedTLSCipherSuite API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * Whether the connection is established over a cellular interface.
 是不是經過蜂窩網絡創建的鏈接
 */
@property (readonly, getter=isCellular) BOOL cellular API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * Whether the connection is established over an expensive interface.
 是否經過昂貴的接口創建的鏈接
 */
@property (readonly, getter=isExpensive) BOOL expensive API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * Whether the connection is established over a constrained interface.
 是否經過受限接口創建的鏈接
 */
@property (readonly, getter=isConstrained) BOOL constrained API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));

/*
 * Whether a multipath protocol is successfully negotiated for the connection.
 是否爲了鏈接成功協商了多路徑協議
 */
@property (readonly, getter=isMultipath) BOOL multipath API_AVAILABLE(macos(10.15), ios(13.0), watchos(6.0), tvos(13.0));


- (instancetype)init API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));
+ (instancetype)new API_DEPRECATED("Not supported", macos(10.12,10.15), ios(10.0,13.0), watchos(3.0,6.0), tvos(10.0,13.0));

@end
複製代碼

網絡監控簡單代碼

// 監控基礎信息
@interface  NetworkMonitorBaseDataModel : NSObject
// 請求的 URL 地址
@property (nonatomic, strong) NSString *requestUrl;
//請求頭
@property (nonatomic, strong) NSArray *requestHeaders;
//響應頭
@property (nonatomic, strong) NSArray *responseHeaders;
//GET方法 的請求參數
@property (nonatomic, strong) NSString *getRequestParams;
//HTTP 方法, 好比 POST
@property (nonatomic, strong) NSString *httpMethod;
//協議名,如http1.0 / http1.1 / http2.0
@property (nonatomic, strong) NSString *httpProtocol;
//是否使用代理
@property (nonatomic, assign) BOOL useProxy;
//DNS解析後的 IP 地址
@property (nonatomic, strong) NSString *ip;
@end

// 監控信息模型
@interface  NetworkMonitorDataModel : NetworkMonitorBaseDataModel
//客戶端發起請求的時間
@property (nonatomic, assign) UInt64 requestDate;
//客戶端開始請求到開始dns解析的等待時間,單位ms 
@property (nonatomic, assign) int waitDNSTime;
//DNS 解析耗時
@property (nonatomic, assign) int dnsLookupTime;
//tcp 三次握手耗時,單位ms
@property (nonatomic, assign) int tcpTime;
//ssl 握手耗時
@property (nonatomic, assign) int sslTime;
//一個完整請求的耗時,單位ms
@property (nonatomic, assign) int requestTime;
//http 響應碼
@property (nonatomic, assign) NSUInteger httpCode;
//發送的字節數
@property (nonatomic, assign) UInt64 sendBytes;
//接收的字節數
@property (nonatomic, assign) UInt64 receiveBytes;


// 錯誤信息模型
@interface  NetworkMonitorErrorModel : NetworkMonitorBaseDataModel
//錯誤碼
@property (nonatomic, assign) NSInteger errorCode;
//錯誤次數
@property (nonatomic, assign) NSUInteger errCount;
//異常名
@property (nonatomic, strong) NSString *exceptionName;
//異常詳情
@property (nonatomic, strong) NSString *exceptionDetail;
//異常堆棧
@property (nonatomic, strong) NSString *stackTrace;
@end

  
// 繼承自 NSURLProtocol 抽象類,實現響應方法,代理網絡請求
@interface CustomURLProtocol () <NSURLSessionTaskDelegate>

@property (nonatomic, strong) NSURLSessionDataTask *dataTask;
@property (nonatomic, strong) NSOperationQueue *sessionDelegateQueue;
@property (nonatomic, strong) NetworkMonitorDataModel *dataModel;
@property (nonatomic, strong) NetworkMonitorErrorModel *errModel;

@end

//使用NSURLSessionDataTask請求網絡
- (void)startLoading {
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
  	NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration
                                                          delegate:self
                                                     delegateQueue:nil];
    NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
  	self.sessionDelegateQueue = [[NSOperationQueue alloc] init];
    self.sessionDelegateQueue.maxConcurrentOperationCount = 1;
    self.sessionDelegateQueue.name = @"com.networkMonitor.session.queue";
    self.dataTask = [session dataTaskWithRequest:self.request];
    [self.dataTask resume];
}

#pragma mark - NSURLSessionTaskDelegate
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
    if (error) {
        [self.client URLProtocol:self didFailWithError:error];
    } else {
        [self.client URLProtocolDidFinishLoading:self];
    }
    if (error) {
        NSURLRequest *request = task.currentRequest;
        if (request) {
            self.errModel.requestUrl  = request.URL.absoluteString;        
            self.errModel.httpMethod = request.HTTPMethod;
            self.errModel.requestParams = request.URL.query;
        }
        self.errModel.errorCode = error.code;
        self.errModel.exceptionName = error.domain;
        self.errModel.exceptionDetail = error.description;
      // 上傳 Network 數據到數據上報組件,數據上報會在 [打造功能強大、靈活可配置的數據上報組件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 講
    }
    self.dataTask = nil;
}


- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics {
       if (@available(iOS 10.0, *) && [metrics.transactionMetrics count] > 0) {
        [metrics.transactionMetrics enumerateObjectsUsingBlock:^(NSURLSessionTaskTransactionMetrics *_Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
            if (obj.resourceFetchType == NSURLSessionTaskMetricsResourceFetchTypeNetworkLoad) {
                if (obj.fetchStartDate) {
                    self.dataModel.requestDate = [obj.fetchStartDate timeIntervalSince1970] * 1000;
                }
                if (obj.domainLookupStartDate && obj.domainLookupEndDate) {
                    self.dataModel. waitDNSTime = ceil([obj.domainLookupStartDate timeIntervalSinceDate:obj.fetchStartDate] * 1000);
                    self.dataModel. dnsLookupTime = ceil([obj.domainLookupEndDate timeIntervalSinceDate:obj.domainLookupStartDate] * 1000);
                }
                if (obj.connectStartDate) {
                    if (obj.secureConnectionStartDate) {
                        self.dataModel. waitDNSTime = ceil([obj.secureConnectionStartDate timeIntervalSinceDate:obj.connectStartDate] * 1000);
                    } else if (obj.connectEndDate) {
                        self.dataModel.tcpTime = ceil([obj.connectEndDate timeIntervalSinceDate:obj.connectStartDate] * 1000);
                    }
                }
                if (obj.secureConnectionEndDate && obj.secureConnectionStartDate) {
                    self.dataModel.sslTime = ceil([obj.secureConnectionEndDate timeIntervalSinceDate:obj.secureConnectionStartDate] * 1000);
                }

                if (obj.fetchStartDate && obj.responseEndDate) {
                    self.dataModel.requestTime = ceil([obj.responseEndDate timeIntervalSinceDate:obj.fetchStartDate] * 1000);
                }

                self.dataModel.httpProtocol = obj.networkProtocolName;

                NSHTTPURLResponse *response = (NSHTTPURLResponse *)obj.response;
                if ([response isKindOfClass:NSHTTPURLResponse.class]) {
                    self.dataModel.receiveBytes = response.expectedContentLength;
                }

                if ([obj respondsToSelector:@selector(_remoteAddressAndPort)]) {
                    self.dataModel.ip = [obj valueForKey:@"_remoteAddressAndPort"];
                }

                if ([obj respondsToSelector:@selector(_requestHeaderBytesSent)]) {
                    self.dataModel.sendBytes = [[obj valueForKey:@"_requestHeaderBytesSent"] unsignedIntegerValue];
                }
                if ([obj respondsToSelector:@selector(_responseHeaderBytesReceived)]) {
                    self.dataModel.receiveBytes = [[obj valueForKey:@"_responseHeaderBytesReceived"] unsignedIntegerValue];
                }

               self.dataModel.requestUrl = [obj.request.URL absoluteString];
                self.dataModel.httpMethod = obj.request.HTTPMethod;
                self.dataModel.useProxy = obj.isProxyConnection;
            }
        }];
				// 上傳 Network 數據到數據上報組件,數據上報會在 [打造功能強大、靈活可配置的數據上報組件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md) 講
    }
}
複製代碼

2.2 方案二:NSURLProtocol 監控 App 網絡請求之黑魔法篇

文章上面 2.1 分析到了 NSURLSessionTaskMetrics 因爲兼容性問題,對於網絡監控來講彷佛不太完美,可是自後在搜資料的時候看到了一篇文章。文章在分析 WebView 的網絡監控的時候分析 Webkit 源碼的時候發現了下面代碼

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

也就是說明 NSURLConnection 自己有一套 TimingData 的收集 API,只是沒有暴露給開發者,蘋果本身在用而已。在 runtime header 中找到了 NSURLConnection 的 _setCollectsTimingData:_timingData 2個 api(iOS8 之後可使用)。

NSURLSession 在 iOS9 以前使用 _setCollectsTimingData: 就可使用 TimingData 了。

注意:

  • 由於是私有 API,因此在使用的時候注意混淆。好比 [[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"]
  • 不推薦私有 API,通常作 APM 的屬於公共團隊,你想一想看雖然你作的 SDK 達到網絡監控的目的了,可是萬一給業務線的 App 上架形成了問題,那就得不償失了。通常這種投機取巧,不是百分百肯定的事情能夠在玩具階段使用。
@interface _NSURLConnectionProxy : DelegateProxy

@end

@implementation _NSURLConnectionProxy

- (BOOL)respondsToSelector:(SEL)aSelector
{
    if ([NSStringFromSelector(aSelector) isEqualToString:@"connectionDidFinishLoading:"]) {
        return YES;
    }
    return [self.target respondsToSelector:aSelector];
}

- (void)forwardInvocation:(NSInvocation *)invocation
{
    [super forwardInvocation:invocation];
    if ([NSStringFromSelector(invocation.selector) isEqualToString:@"connectionDidFinishLoading:"]) {
        __unsafe_unretained NSURLConnection *conn;
        [invocation getArgument:&conn atIndex:2];
        SEL selector = NSSelectorFromString([@"_timin" stringByAppendingString:@"gData"]);
        NSDictionary *timingData = [conn performSelector:selector];
        [[NTDataKeeper shareInstance] trackTimingData:timingData request:conn.currentRequest];
    }
}

@end

@implementation NSURLConnection(tracker)

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];
        
        SEL originalSelector = @selector(initWithRequest:delegate:);
        SEL swizzledSelector = @selector(swizzledInitWithRequest:delegate:);
        
        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
        method_exchangeImplementations(originalMethod, swizzledMethod);
        
        NSString *selectorName = [[@"_setC" stringByAppendingString:@"ollectsT"] stringByAppendingString:@"imingData:"];
        SEL selector = NSSelectorFromString(selectorName);
        [NSURLConnection performSelector:selector withObject:@(YES)];
    });
}

- (instancetype)swizzledInitWithRequest:(NSURLRequest *)request delegate:(id<NSURLConnectionDelegate>)delegate
{
    if (delegate) {
        _NSURLConnectionProxy *proxy = [[_NSURLConnectionProxy alloc] initWithTarget:delegate];
        objc_setAssociatedObject(delegate ,@"_NSURLConnectionProxy" ,proxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
        return [self swizzledInitWithRequest:request delegate:(id<NSURLConnectionDelegate>)proxy];
    }else{
        return [self swizzledInitWithRequest:request delegate:delegate];
    }
}

@end
複製代碼

2.3 方案三:Hook

iOS 中 hook 技術有2類,一種是 NSProxy,一種是 method swizzling(isa swizzling)

2.3.1 方法一

寫 SDK 確定不可能手動侵入業務代碼(你沒那個權限提交到線上代碼 😂),因此無論是 APM 仍是無痕埋點都是經過 Hook 的方式。

面向切面程序設計(Aspect-oriented Programming,AOP)是計算機科學中的一種程序設計範型,將橫切關注點與業務主體進一步分離,以提升程序代碼的模塊化程度。在不修改源代碼的狀況下給程序動態增長功能。其核心思想是將業務邏輯(核心關注點,系統主要功能)與公共功能(橫切關注點,好比日誌系統)進行分離,下降複雜性,保持系統模塊化程度、可維護性、可重用性。常被用在日誌系統、性能統計、安全控制、事務處理、異常處理等場景下。

在 iOS 中 AOP 的實現是基於 Runtime 機制,目前由3種方式:Method Swizzling、NSProxy、FishHook(主要用用於 hook c 代碼)。

文章上面 2.1 討論了知足大多數的需求的場景,NSURLProtocol 監控了 NSURLConnection、NSURLSession 的網絡請求,自身代理後能夠發起網絡請求並獲得諸如請求開始時間、請求結束時間、header 信息等,可是沒法獲得很是詳細的網絡性能數據,好比 DNS 開始解析時間、DNS 解析用了多久、reponse 開始返回的時間、返回了多久等。 iOS10 以後 NSURLSessionTaskDelegate 增長了一個代理方法 - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));,能夠獲取到精確的各項網絡數據。可是具備兼容性。文章上面 2.2 討論了從 Webkit 源碼中獲得的信息,經過私有方法 _setCollectsTimingData:_timingData 能夠獲取到 TimingData。

可是若是須要監所有的網絡請求就不能知足需求了,查閱資料後發現了阿里百川有 APM 的解決方案,因而有了方案3,對於網絡監控須要作以下的處理

network hook

可能對於 CFNetwork 比較陌生,能夠看一下 CFNetwork 的層級和簡單用法

CFNetwork Structure

CFNetwork 的基礎是 CFSocket 和 CFStream。

CFSocket:Socket 是網絡通訊的底層基礎,可讓2個 socket 端口互發數據,iOS 中最經常使用的 socket 抽象是 BSD socket。而 CFSocket 是 BSD socket 的 OC 包裝,幾乎實現了全部的 BSD 功能,此外加入了 RunLoop。

CFStream:提供了與設備無關的讀寫數據方法,使用它能夠爲內存、文件、網絡(使用 socket)的數據創建流,使用 stream 能夠沒必要將全部數據寫入到內存中。CFStream 提供 API 對2種 CFType 對象提供抽象:CFReadStream、CFWriteStream。同時也是 CFHTTP、CFFTP 的基礎。

簡單 Demo

- (void)testCFNetwork
{
    CFURLRef urlRef = CFURLCreateWithString(kCFAllocatorDefault, CFSTR("https://httpbin.org/get"), NULL);
    CFHTTPMessageRef httpMessageRef = CFHTTPMessageCreateRequest(kCFAllocatorDefault, CFSTR("GET"), urlRef, kCFHTTPVersion1_1);
    CFRelease(urlRef);
    
    CFReadStreamRef readStream = CFReadStreamCreateForHTTPRequest(kCFAllocatorDefault, httpMessageRef);
    CFRelease(httpMessageRef);
    
    CFReadStreamScheduleWithRunLoop(readStream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
    
    CFOptionFlags eventFlags = (kCFStreamEventHasBytesAvailable | kCFStreamEventErrorOccurred | kCFStreamEventEndEncountered);
    CFStreamClientContext context = {
        0,
        NULL,
        NULL,
        NULL,
       NULL
    } ;
    // Assigns a client to a stream, which receives callbacks when certain events occur.
    CFReadStreamSetClient(readStream, eventFlags, CFNetworkRequestCallback, &context);
    // Opens a stream for reading.
    CFReadStreamOpen(readStream);
}
// callback
void CFNetworkRequestCallback (CFReadStreamRef _Null_unspecified stream, CFStreamEventType type, void * _Null_unspecified clientCallBackInfo) {
    CFMutableDataRef responseBytes = CFDataCreateMutable(kCFAllocatorDefault, 0);
    CFIndex numberOfBytesRead = 0;
    do {
        UInt8 buffer[2014];
        numberOfBytesRead = CFReadStreamRead(stream, buffer, sizeof(buffer));
        if (numberOfBytesRead > 0) {
            CFDataAppendBytes(responseBytes, buffer, numberOfBytesRead);
        }
    } while (numberOfBytesRead > 0);
    
    
    CFHTTPMessageRef response = (CFHTTPMessageRef)CFReadStreamCopyProperty(stream, kCFStreamPropertyHTTPResponseHeader);
    if (responseBytes) {
        if (response) {
            CFHTTPMessageSetBody(response, responseBytes);
        }
        CFRelease(responseBytes);
    }
    
    // close and cleanup
    CFReadStreamClose(stream);
    CFReadStreamUnscheduleFromRunLoop(stream, CFRunLoopGetCurrent(), kCFRunLoopCommonModes);
    CFRelease(stream);
    
    // print response
    if (response) {
        CFDataRef reponseBodyData = CFHTTPMessageCopyBody(response);
        CFRelease(response);
        
        printResponseData(reponseBodyData);
        CFRelease(reponseBodyData);
    }
}

void printResponseData (CFDataRef responseData) {
    CFIndex dataLength = CFDataGetLength(responseData);
    UInt8 *bytes = (UInt8 *)malloc(dataLength);
    CFDataGetBytes(responseData, CFRangeMake(0, CFDataGetLength(responseData)), bytes);
    CFStringRef responseString = CFStringCreateWithBytes(kCFAllocatorDefault, bytes, dataLength, kCFStringEncodingUTF8, TRUE);
    CFShow(responseString);
    CFRelease(responseString);
    free(bytes);
}
// console
{
  "args": {}, 
  "headers": {
    "Host": "httpbin.org", 
    "User-Agent": "Test/1 CFNetwork/1125.2 Darwin/19.3.0", 
    "X-Amzn-Trace-Id": "Root=1-5e8980d0-581f3f44724c7140614c2564"
  }, 
  "origin": "183.159.122.102", 
  "url": "https://httpbin.org/get"
}
複製代碼

咱們知道 NSURLSession、NSURLConnection、CFNetwork 的使用都須要調用一堆方法進行設置而後須要設置代理對象,實現代理方法。因此針對這種狀況進行監控首先想到的是使用 runtime hook 掉方法層級。可是針對設置的代理對象的代理方法沒辦法 hook,由於不知道代理對象是哪一個類。因此想辦法能夠 hook 設置代理對象這個步驟,將代理對象替換成咱們設計好的某個類,而後讓這個類去實現 NSURLConnection、NSURLSession、CFNetwork 相關的代理方法。而後在這些方法的內部都去調用一下原代理對象的方法實現。因此咱們的需求得以知足,咱們在相應的方法裏面能夠拿到監控數據,好比請求開始時間、結束時間、狀態碼、內容大小等。

NSURLSession、NSURLConnection hook 以下。

NSURLSession Hook

NSURLConnection Hook

業界有 APM 針對 CFNetwork 的方案,整理描述下:

CFNetwork 是 c 語言實現的,要對 c 代碼進行 hook 須要使用 Dynamic Loader Hook 庫 - fishhook

Dynamic Loader(dyld)經過更新 Mach-O 文件中保存的指針的方法來綁定符號。借用它能夠在 Runtime 修改 C 函數調用的函數指針。fishhook 的實現原理:遍歷 __DATA segment 裏面 __nl_symbol_ptr__la_symbol_ptr 兩個 section 裏面的符號,經過 Indirect Symbol Table、Symbol Table 和 String Table 的配合,找到本身要替換的函數,達到 hook 的目的。

/* Returns the number of bytes read, or -1 if an error occurs preventing any

bytes from being read, or 0 if the stream's end was encountered.

It is an error to try and read from a stream that hasn't been opened first.

This call will block until at least one byte is available; it will NOT block

until the entire buffer can be filled. To avoid blocking, either poll using

CFReadStreamHasBytesAvailable() or use the run loop and listen for the

kCFStreamEventHasBytesAvailable event for notification of data available. */

CF_EXPORT

CFIndex CFReadStreamRead(CFReadStreamRef _Null_unspecified stream, UInt8 * _Null_unspecified buffer, CFIndex bufferLength);

CFNetwork 使用 CFReadStreamRef 來傳遞數據,使用回調函數的形式來接受服務器的響應。當回調函數受到

具體步驟及其關鍵代碼以下,以 NSURLConnection 舉例

  • 由於要 Hook 挺多地方,因此寫一個 method swizzling 的工具類

    #import <Foundation/Foundation.h>
    
    NS_ASSUME_NONNULL_BEGIN
    
    @interface NSObject (hook)
    
    /**
     hook對象方法
    
     @param originalSelector 須要hook的原始對象方法
     @param swizzledSelector 須要替換的對象方法
     */
    + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector;
    
    /**
     hook類方法
    
     @param originalSelector 須要hook的原始類方法
     @param swizzledSelector 須要替換的類方法
     */
    + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector;
    
    @end
    
    NS_ASSUME_NONNULL_END
      
    + (void)apm_swizzleMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
    {
        class_swizzleInstanceMethod(self, originalSelector, swizzledSelector);
    }
    
    + (void)apm_swizzleClassMethod:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
    {
        //類方法其實是儲存在類對象的類(即元類)中,即類方法至關於元類的實例方法,因此只須要把元類傳入,其餘邏輯和交互實例方法同樣。
        Class class2 = object_getClass(self);
        class_swizzleInstanceMethod(class2, originalSelector, swizzledSelector);
    }
    
    void class_swizzleInstanceMethod(Class class, SEL originalSEL, SEL replacementSEL)
    {
        Method originMethod = class_getInstanceMethod(class, originalSEL);
        Method replaceMethod = class_getInstanceMethod(class, replacementSEL);
        
        if(class_addMethod(class, originalSEL, method_getImplementation(replaceMethod),method_getTypeEncoding(replaceMethod)))
        {
            class_replaceMethod(class,replacementSEL, method_getImplementation(originMethod), method_getTypeEncoding(originMethod));
        }else {
            method_exchangeImplementations(originMethod, replaceMethod);
        }
    }
    複製代碼
  • 創建一個繼承自 NSProxy 抽象類的類,實現相應方法。

    #import <Foundation/Foundation.h>
    
    NS_ASSUME_NONNULL_BEGIN
    
    // 爲 NSURLConnection、NSURLSession、CFNetwork 代理設置代理轉發
    @interface NetworkDelegateProxy : NSProxy
    
    + (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate;
    
    @end
    
    NS_ASSUME_NONNULL_END
      
    // .m
    @interface NetworkDelegateProxy () {
        id _originalTarget;
        id _NewDelegate;
    }
    
    @end
    
    
    @implementation NetworkDelegateProxy
    
    #pragma mark - life cycle
    
    + (instancetype)sharedInstance {
        static NetworkDelegateProxy *_sharedInstance = nil;
        
        static dispatch_once_t onceToken;
        
        dispatch_once(&onceToken, ^{
            _sharedInstance = [NetworkDelegateProxy alloc];
        });
        
        return _sharedInstance;
    }
    
    
    #pragma mark - public Method
    
    + (instancetype)setProxyForObject:(id)originalTarget withNewDelegate:(id)newDelegate
    {
        NetworkDelegateProxy *instance = [NetworkDelegateProxy sharedInstance];
        instance->_originalTarget = originalTarget;
        instance->_NewDelegate = newDelegate;
        return instance;
    }
    
    - (void)forwardInvocation:(NSInvocation *)invocation
    {
        if ([_originalTarget respondsToSelector:invocation.selector]) {
            [invocation invokeWithTarget:_originalTarget];
            [((NSURLSessionAndConnectionImplementor *)_NewDelegate) invoke:invocation];
        }
    }
    
    - (nullable NSMethodSignature *)methodSignatureForSelector:(SEL)sel
    {
        return [_originalTarget methodSignatureForSelector:sel];
    }
    
    @end
    複製代碼
  • 建立一個對象,實現 NSURLConnection、NSURLSession、NSIuputStream 代理方法

    // NetworkImplementor.m
    
    #pragma mark-NSURLConnectionDelegate
    - (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error {
        NSLog(@"%s", __func__);
    }
    
    - (nullable NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(nullable NSURLResponse *)response {
        NSLog(@"%s", __func__);
        return request;
    }
    
    #pragma mark-NSURLConnectionDataDelegate
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response {
        NSLog(@"%s", __func__);
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data {
       NSLog(@"%s", __func__);
    }
    
    - (void)connection:(NSURLConnection *)connection   didSendBodyData:(NSInteger)bytesWritten
     totalBytesWritten:(NSInteger)totalBytesWritten
    totalBytesExpectedToWrite:(NSInteger)totalBytesExpectedToWrite {
        NSLog(@"%s", __func__);
    }
    
    - (void)connectionDidFinishLoading:(NSURLConnection *)connection {
        NSLog(@"%s", __func__);
    }
    
    #pragma mark-NSURLConnectionDownloadDelegate
    - (void)connection:(NSURLConnection *)connection didWriteData:(long long)bytesWritten totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes {
        NSLog(@"%s", __func__);
    }
    
    - (void)connectionDidResumeDownloading:(NSURLConnection *)connection totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long) expectedTotalBytes {
        NSLog(@"%s", __func__);
    }
    
    - (void)connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *) destinationURL {
        NSLog(@"%s", __func__);
    }
    // 根據需求本身去寫須要監控的數據項
    複製代碼
  • 給 NSURLConnection 添加 Category,專門設置 hook 代理對象、hook NSURLConnection 對象方法

    // NSURLConnection+Monitor.m
    @implementation NSURLConnection (Monitor)
    
    + (void)load
    {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            @autoreleasepool {
                [[self class] apm_swizzleMethod:@selector(apm_initWithRequest:delegate:) swizzledSelector:@selector(initWithRequest: delegate:)];
            }
        });
    }
    
    - (_Nonnull instancetype)apm_initWithRequest:(NSURLRequest *)request delegate:(nullable id)delegate
    {
        /*
         1. 在設置 Delegate 的時候替換 delegate。
         2. 由於要在每一個代理方法裏面,監控數據,因此須要將代理方法都 hook 下
         3. 在原代理方法執行的時候,讓新的代理對象裏面,去執行方法的轉發,
         */
        NSString *traceId = @"traceId";
        NSMutableURLRequest *rq = [request mutableCopy];
        NSString *preTraceId = [request.allHTTPHeaderFields valueForKey:@"head_key_traceid"];
        if (preTraceId) {
            // 調用 hook 以前的初始化方法,返回 NSURLConnection
            return [self apm_initWithRequest:rq delegate:delegate];
        } else {
            [rq setValue:traceId forHTTPHeaderField:@"head_key_traceid"];
               
            NSURLSessionAndConnectionImplementor *mockDelegate = [NSURLSessionAndConnectionImplementor new];
            [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
    
            [self registerDelegateMethod:@"connection:didReceiveResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
            [self registerDelegateMethod:@"connection:didReceiveData:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
            [self registerDelegateMethod:@"connection:didFailWithError:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@@"];
    
            [self registerDelegateMethod:@"connectionDidFinishLoading:" originalDelegate:delegate newDelegate:mockDelegate flag:"v@:@"];
            [self registerDelegateMethod:@"connection:willSendRequest:redirectResponse:" originalDelegate:delegate newDelegate:mockDelegate flag:"@@:@@"];
            delegate = [NetworkDelegateProxy setProxyForObject:delegate withNewDelegate:mockDelegate];
    
            // 調用 hook 以前的初始化方法,返回 NSURLConnection
            return [self apm_initWithRequest:rq delegate:delegate];
        }
    }
    
    - (void)registerDelegateMethod:(NSString *)methodName originalDelegate:(id<NSURLConnectionDelegate>)originalDelegate newDelegate:(NSURLSessionAndConnectionImplementor *)newDelegate flag:(const char *)flag
    {
        if ([originalDelegate respondsToSelector:NSSelectorFromString(methodName)]) {
            IMP originalMethodImp = class_getMethodImplementation([originalDelegate class], NSSelectorFromString(methodName));
            IMP newMethodImp = class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName));
            if (originalMethodImp != newMethodImp) {
                [newDelegate registerSelector: methodName];
                NSLog(@"");
            }
        } else {
            class_addMethod([originalDelegate class], NSSelectorFromString(methodName), class_getMethodImplementation([newDelegate class], NSSelectorFromString(methodName)), flag);
        }
    }
    
    @end
    複製代碼

這樣下來就是能夠監控到網絡信息了,而後將數據交給數據上報 SDK,按照下發的數據上報策略去上報數據。

2.3.2 方法二

其實,針對上述的需求還有另外一種方法同樣能夠達到目的,那就是 isa swizzling

順道說一句,上面針對 NSURLConnection、NSURLSession、NSInputStream 代理對象的 hook 以後,利用 NSProxy 實現代理對象方法的轉發,有另外一種方法能夠實現,那就是 isa swizzling

  • Method swizzling 原理

    struct old_method {
        SEL method_name;
        char *method_types;
        IMP method_imp;
    };
    複製代碼

    method swizzling

    method swizzling 改進版以下

    Method originalMethod = class_getInstanceMethod(aClass, aSEL);
    IMP originalIMP = method_getImplementation(originalMethod);
    char *cd = method_getTypeEncoding(originalMethod);
    IMP newIMP = imp_implementationWithBlock(^(id self) {
      void (*tmp)(id self, SEL _cmd) = originalIMP;
      tmp(self, aSEL);
    });
    class_replaceMethod(aClass, aSEL, newIMP, cd);
    複製代碼
  • isa swizzling

    /// Represents an instance of a class.
    struct objc_object {
        Class _Nonnull isa  OBJC_ISA_AVAILABILITY;
    };
    
    /// A pointer to an instance of a class.
    typedef struct objc_object *id;
    
    複製代碼

isa swizzling

咱們來分析一下爲何修改 isa 能夠實現目的呢?

  1. 寫 APM 監控的人沒辦法肯定業務代碼
  2. 不可能爲了方便監控 APM,寫某些類,讓業務線開發者別使用系統 NSURLSession、NSURLConnection 類

想一想 KVO 的實現原理?結合上面的圖

  • 建立監控對象子類
  • 重寫子類中屬性的 getter、seeter
  • 將監控對象的 isa 指針指向新建立的子類
  • 在子類的 getter、setter 中攔截值的變化,通知監控對象值的變化
  • 監控完以後將監控對象的 isa 還原回去

按照這個思路,咱們也能夠對 NSURLConnection、NSURLSession 的 load 方法中動態建立子類,在子類中重寫方法,好比 - (**nullable** **instancetype**)initWithRequest:(NSURLRequest *)request delegate:(**nullable** **id**)delegate startImmediately:(**BOOL**)startImmediately; ,而後將 NSURLSession、NSURLConnection 的 isa 指向動態建立的子類。在這些方法處理完以後還本來身的 isa 指針。

不過 isa swizzling 針對的仍是 method swizzling,代理對象不肯定,仍是須要 NSProxy 進行動態處理。

至於如何修改 isa,我寫一個簡單的 Demo 來模擬 KVO

- (void)lbpKVO_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context {
    //生成自定義的名稱
    NSString *className = NSStringFromClass(self.class);
    NSString *currentClassName = [@"LBPKVONotifying_" stringByAppendingString:className];
    //1. runtime 生成類
    Class myclass = objc_allocateClassPair(self.class, [currentClassName UTF8String], 0);
    // 生成後不能立刻使用,必須先註冊
    objc_registerClassPair(myclass);
    
    //2. 重寫 setter 方法
    class_addMethod(myclass,@selector(say) , (IMP)say, "v@:@");
    
//    class_addMethod(myclass,@selector(setName:) , (IMP)setName, "v@:@");
    //3. 修改 isa
    object_setClass(self, myclass);
    
    //4. 將觀察者保存到當前對象裏面
    objc_setAssociatedObject(self, "observer", observer, OBJC_ASSOCIATION_ASSIGN);
    
    //5. 將傳遞的上下文綁定到當前對象裏面
    objc_setAssociatedObject(self, "context", (__bridge id _Nullable)(context), OBJC_ASSOCIATION_RETAIN);
}


void say(id self, SEL _cmd)
{
   // 調用父類方法一
    struct objc_super superclass = {self, [self superclass]};
    ((void(*)(struct objc_super *,SEL))objc_msgSendSuper)(&superclass,@selector(say));
    NSLog(@"%s", __func__);
// 調用父類方法二
//    Class class = [self class];
//    object_setClass(self, class_getSuperclass(class));
//    objc_msgSend(self, @selector(say));
}

void setName (id self, SEL _cmd, NSString *name) {
    NSLog(@"come here");
    //先切換到當前類的父類,而後發送消息 setName,而後切換當前子類
    //1. 切換到父類
    Class class = [self class];
    object_setClass(self, class_getSuperclass(class));
    //2. 調用父類的 setName 方法
    objc_msgSend(self, @selector(setName:), name);
    
    //3. 調用觀察
    id observer = objc_getAssociatedObject(self, "observer");
    id context = objc_getAssociatedObject(self, "context");
    if (observer) {
        objc_msgSend(observer, @selector(observeValueForKeyPath:ofObject:change:context:), @"name", self, @{@"new": name, @"kind": @1 } , context);
    }
    //4. 改回子類
    object_setClass(self, class);
}

@end
複製代碼

2.4 方案四:監控 App 常見網絡請求

本着成本的緣由,因爲如今大多數的項目的網絡能力都是經過 AFNetworking 完成的,因此本文的網絡監控能夠快速完成。

AFNetworking 在發起網絡的時候會有相應的通知。AFNetworkingTaskDidResumeNotificationAFNetworkingTaskDidCompleteNotification。經過監聽通知攜帶的參數獲取網絡狀況信息。

self.didResumeObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidResumeNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) {
    // 開始
    __strong __typeof(weakSelf)strongSelf = weakSelf;
    NSURLSessionTask *task = note.object;
    NSString *requestId = [[NSUUID UUID] UUIDString];
    task.apm_requestId = requestId;
    [strongSelf.networkRecoder recordStartRequestWithRequestID:requestId task:task];
}];

self.didCompleteObserver = [[NSNotificationCenter defaultCenter] addObserverForName:AFNetworkingTaskDidCompleteNotification object:nil queue:self.queue usingBlock:^(NSNotification * _Nonnull note) {
    
    __strong __typeof(weakSelf)strongSelf = weakSelf;
    
    NSError *error = note.userInfo[AFNetworkingTaskDidCompleteErrorKey];
    NSURLSessionTask *task = note.object;
    if (!error) {
        // 成功
        [strongSelf.networkRecoder recordFinishRequestWithRequestID:task.cmn_requestId task:task];
    } else {
        // 失敗
        [strongSelf.networkRecoder recordResponseErrorWithRequestID:task.cmn_requestId task:task error:error];
    }
}];
複製代碼

在 networkRecoder 的方法裏面去組裝數據,交給數據上報組件,等到合適的時機策略去上報。

由於網絡是一個異步的過程,因此當網絡請求開始的時候須要爲每一個網絡設置惟一標識,等到網絡請求完成後再根據每一個請求的標識,判斷該網絡耗時多久、是否成功等。因此措施是爲 NSURLSessionTask 添加分類,經過 runtime 增長一個屬性,也就是惟一標識。

這裏插一嘴,爲 Category 命名、以及內部的屬性和方法命名的時候須要注意下。假如不注意會怎麼樣呢?假如你要爲 NSString 類增長身份證號碼中間位數隱藏的功能,那麼寫代碼久了的老司機 A,爲 NSString 增長了一個方法名,叫作 getMaskedIdCardNumber,可是他的需求是從 [9, 12] 這4位字符串隱藏掉。過了幾天同事 B 也遇到了相似的需求,他也是一位老司機,爲 NSString 增長了一個也叫 getMaskedIdCardNumber 的方法,可是他的需求是從 [8, 11] 這4位字符串隱藏,可是他引入工程後發現輸出並不符合預期,爲該方法寫的單測沒經過,他覺得本身寫錯了截取方法,檢查了幾遍才發現工程引入了另外一個 NSString 分類,裏面的方法同名 😂 真坑。

下面的例子是 SDK,可是平常開發也是同樣。

  • Category 類名:建議按照當前 SDK 名稱的簡寫做爲前綴,再加下劃線,再加當前分類的功能,也就是類名+SDK名稱簡寫_功能名稱。好比當前 SDK 叫 JuhuaSuanAPM,那麼該 NSURLSessionTask Category 名稱就叫作 NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h
  • Category 屬性名:建議按照當前 SDK 名稱的簡寫做爲前綴,再加下劃線,再加屬性名,也就是SDK名稱簡寫_屬性名稱。好比 JuhuaSuanAPM_requestId`
  • Category 方法名:建議按照當前 SDK 名稱的簡寫做爲前綴,再加下劃線,再加方法名,也就是SDK名稱簡寫_方法名稱。好比 -(BOOL)JuhuaSuanAPM__isGzippedData

例子以下:

#import <Foundation/Foundation.h>

@interface NSURLSessionTask (JuhuaSuanAPM_NetworkMonitor)

@property (nonatomic, copy) NSString* JuhuaSuanAPM_requestId;

@end

#import "NSURLSessionTask+JuHuaSuanAPM_NetworkMonitor.h"
#import <objc/runtime.h>

@implementation NSURLSessionTask (JuHuaSuanAPM_NetworkMonitor)

- (NSString*)JuhuaSuanAPM_requestId
{
    return objc_getAssociatedObject(self, _cmd);
}

- (void)setJuhuaSuanAPM_requestId:(NSString*)requestId
{
    objc_setAssociatedObject(self, @selector(JuhuaSuanAPM_requestId), requestId, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
@end
複製代碼

2.5 iOS 流量監控

2.5.1 HTTP 請求、響應數據結構

HTTP 請求報文結構

請求報文結構

響應報文的結構

響應報文結構

  1. HTTP 報文是格式化的數據塊,每條報文由三部分組成:對報文進行描述的起始行、包含屬性的首部塊、以及可選的包含數據的主體部分。
  2. 起始行和手部就是由行分隔符的 ASCII 文本,每行都以一個由2個字符組成的行終止序列做爲結束(包括一個回車符、一個換行符)
  3. 實體的主體或者報文的主體是一個可選的數據塊。與起始行和首部不一樣的是,主體中能夠包含文本或者二進制數據,也能夠爲空。
  4. HTTP 首部(也就是 Headers)老是應該以一個空行結束,即便沒有實體部分。瀏覽器發送了一個空白行來通知服務器,它已經結束了該頭信息的發送。

請求報文的格式

<method> <request-URI> <version>
<headers>

<entity-body>
複製代碼

響應報文的格式

<version> <status> <reason-phrase>
<headers>

<entity-body>
複製代碼

下圖是打開 Chrome 查看極課時間網頁的請求信息。包括響應行、響應頭、響應體等信息。

請求數據結構

下圖是在終端使用 curl 查看一個完整的請求和響應數據

curl查看HTTP響應

咱們都知道在 HTTP 通訊中,響應數據會使用 gzip 或其餘壓縮方式壓縮,用 NSURLProtocol 等方案監聽,用 NSData 類型去計算分析流量等會形成數據的不精確,由於正常一個 HTTP 響應體的內容是使用 gzip 或其餘壓縮方式壓縮的,因此使用 NSData 會偏大。

2.5.2 問題
  1. Request 和 Response 不必定成對存在

    好比網絡斷開、App 忽然 Crash 等,因此 Request 和 Response 監控後不該該記錄在一條記錄裏

  2. 請求流量計算方式不精確

    主要緣由有:

    • 監控技術方案忽略了請求頭和請求行部分的數據大小
    • 監控技術方案忽略了 Cookie 部分的數據大小
    • 監控技術方案在對請求體大小計算的時候直接使用 HTTPBody.length,致使不夠精確
  3. 響應流量計算方式不精確

    主要緣由有:

    • 監控技術方案忽略了響應頭和響應行部分的數據大小
    • 監控技術方案在對 body 部分的字節大小計算,因採用 exceptedContentLength 致使不夠準確
    • 監控技術方案忽略了響應體使用 gzip 壓縮。真正的網絡通訊過程當中,客戶端在發起請求的請求頭中 Accept-Encoding 字段表明客戶端支持的數據壓縮方式(代表客戶端能夠正常使用數據時支持的壓縮方法),一樣服務端根據客戶端想要的壓縮方式、服務端當前支持的壓縮方式,最後處理數據,在響應頭中Content-Encoding 字段表示當前服務器採用了什麼壓縮方式。
2.5.3 技術實現

第五部分講了網絡攔截的各類原理和技術方案,這裏拿 NSURLProtocol 來講實現流量監控(Hook 的方式)。從上述知道了咱們須要什麼樣的,那麼就逐步實現吧。

2.5.3.1 Request 部分
  1. 先利用網絡監控方案將 NSURLProtocol 管理 App 的各類網絡請求

  2. 在各個方法內部記錄各項所需參數(NSURLProtocol 不能分析請求握手、揮手等數據大小和時間消耗,不過對於正常狀況的接口流量分析足夠了,最底層須要 Socket 層)

    @property(nonatomic, strong) NSURLConnection *internalConnection;
    @property(nonatomic, strong) NSURLResponse *internalResponse;
    @property(nonatomic, strong) NSMutableData *responseData;
    @property (nonatomic, strong) NSURLRequest *internalRequest;
    複製代碼
    - (void)startLoading
    {
        NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
        self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self];
        self.internalRequest = self.request;
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
    {
        [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        self.internalResponse = response;
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data 
    {
        [self.responseData appendData:data];
        [self.client URLProtocol:self didLoadData:data];
    }
    複製代碼
  3. Status Line 部分

NSURLResponse 沒有 Status Line 等屬性或者接口,HTTP Version 信息也沒有,因此要想獲取 Status Line 想辦法轉換到 CFNetwork 層試試看。發現有私有 API 能夠實現。

思路:將 NSURLResponse 經過 _CFURLResponse 轉換爲 CFTypeRef,而後再將 CFTypeRef 轉換爲 CFHTTPMessageRef,再經過 CFHTTPMessageCopyResponseStatusLine 獲取 CFHTTPMessageRef 的 Status Line 信息。

將讀取 Status Line 的功能添加一個 NSURLResponse 的分類。

// NSURLResponse+cm_FetchStatusLineFromCFNetwork.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface NSURLResponse (cm_FetchStatusLineFromCFNetwork)

- (NSString *)cm_fetchStatusLineFromCFNetwork;

@end

NS_ASSUME_NONNULL_END

// NSURLResponse+cm_FetchStatusLineFromCFNetwork.m
#import "NSURLResponse+cm_FetchStatusLineFromCFNetwork.h"
#import <dlfcn.h>


#define SuppressPerformSelectorLeakWarning(Stuff) \
do { \
    _Pragma("clang diagnostic push") \
    _Pragma("clang diagnostic ignored \"-Warc-performSelector-leaks\"") \
    Stuff; \
    _Pragma("clang diagnostic pop") \
} while (0)

typedef CFHTTPMessageRef (*CMURLResponseFetchHTTPResponse)(CFURLRef response);

@implementation NSURLResponse (cm_FetchStatusLineFromCFNetwork)

- (NSString *)cm_fetchStatusLineFromCFNetwork
{
    NSString *statusLine = @"";
    NSString *funcName = @"CFURLResponseGetHTTPResponse";
    CMURLResponseFetchHTTPResponse originalURLResponseFetchHTTPResponse = dlsym(RTLD_DEFAULT, [funcName UTF8String]);
    
    SEL getSelector = NSSelectorFromString(@"_CFURLResponse");
    if ([self respondsToSelector:getSelector] && NULL != originalURLResponseFetchHTTPResponse) {
        CFTypeRef cfResponse;
        SuppressPerformSelectorLeakWarning(
            cfResponse = CFBridgingRetain([self performSelector:getSelector]);
        );
        if (NULL != cfResponse) {
            CFHTTPMessageRef messageRef = originalURLResponseFetchHTTPResponse(cfResponse);
            statusLine = (__bridge_transfer NSString *)CFHTTPMessageCopyResponseStatusLine(messageRef);
            CFRelease(cfResponse);
        }
    }
    return statusLine;
}

@end
複製代碼
  1. 將獲取到的 Status Line 轉換爲 NSData,再計算大小

    - (NSUInteger)cm_getLineLength {
        NSString *statusLineString = @"";
        if ([self isKindOfClass:[NSHTTPURLResponse class]]) {
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self;
            statusLineString = [self cm_fetchStatusLineFromCFNetwork];
        }
        NSData *lineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding];
        return lineData.length;
    }
    複製代碼
  2. Header 部分

    allHeaderFields 獲取到 NSDictionary,而後按照 key: value 拼接成字符串,而後轉換成 NSData 計算大小

    注意:key: value key 後是有空格的,curl 或者 chrome Network 面板能夠查看印證下。

    - (NSUInteger)cm_getHeadersLength
    {
        NSUInteger headersLength = 0;
        if ([self isKindOfClass:[NSHTTPURLResponse class]]) {
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self;
            NSDictionary *headerFields = httpResponse.allHeaderFields;
            NSString *headerString = @"";
            for (NSString *key in headerFields.allKeys) {
                headerString = [headerStr stringByAppendingString:key];
                headheaderStringerStr = [headerString stringByAppendingString:@": "];
                if ([headerFields objectForKey:key]) {
                    headerString = [headerString stringByAppendingString:headerFields[key]];
                }
                headerString = [headerString stringByAppendingString:@"\n"];
            }
            NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding];
            headersLength = headerData.length;
        }
        return headersLength;
    }
    複製代碼
  3. Body 部分

    Body 大小的計算不能直接使用 excepectedContentLength,官方文檔說明了其不許確性,只能夠做爲參考。或者 allHeaderFields 中的 Content-Length 值也是不夠準確的。

    /*!

    @abstract Returns the expected content length of the receiver.

    @discussion Some protocol implementations report a content length

    as part of delivering load metadata, but not all protocols

    guarantee the amount of data that will be delivered in actuality.

    Hence, this method returns an expected amount. Clients should use

    this value as an advisory, and should be prepared to deal with

    either more or less data.

    @result The expected content length of the receiver, or -1 if

    there is no expectation that can be arrived at regarding expected

    content length.

    */

    @property (readonly) long long expectedContentLength;

    • HTTP 1.1 版本規定,若是存在 Transfer-Encoding: chunked,則在 header 中不能有 Content-Length,有也會被忽視。
    • 在 HTTP 1.0及以前版本中,content-length 字段無關緊要
    • 在 HTTP 1.1及以後版本。若是是 keep alive,則 Content-Lengthchunked 必然是二選一。如果非keep alive,則和 HTTP 1.0同樣。Content-Length 無關緊要。

    什麼是 Transfer-Encoding: chunked

    數據以一系列分塊的形式進行發送 Content-Length 首部在這種狀況下不被髮送. 在每個分塊的開頭須要添加當前分塊的長度, 以十六進制的形式表示,後面緊跟着 \r\n , 以後是分塊自己, 後面也是 \r\n ,終止塊是一個常規的分塊, 不一樣之處在於其長度爲0.

    咱們以前拿 NSMutableData 記錄了數據,因此咱們能夠在 stopLoading方法中計算出 Body 大小。步驟以下:

    • didReceiveData 中不斷添加 data

      - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
      {
          [self.responseData appendData:data];
          [self.client URLProtocol:self didLoadData:data];
      }
      複製代碼
    • stopLoading 方法中拿到 allHeaderFields 字典,獲取 Content-Encoding key 的值,若是是 gzip,則在 stopLoading 中將 NSData 處理爲 gzip 壓縮後的數據,再計算大小。(gzip 相關功能可使用這個工具

      須要額外計算一個空白行的長度

      - (void)stopLoadi
      {
          [self.internalConnection cancel];
      
          PCTNetworkTrafficModel *model = [[PCTNetworkTrafficModel alloc] init];
          model.path = self.request.URL.path;
          model.host = self.request.URL.host;
          model.type = DMNetworkTrafficDataTypeResponse;
          model.lineLength = [self.internalResponse cm_getStatusLineLength];
          model.headerLength = [self.internalResponse cm_getHeadersLength];
          model.emptyLineLength = [self.internalResponse cm_getEmptyLineLength];
          if ([self.dm_response isKindOfClass:[NSHTTPURLResponse class]]) {
              NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self.dm_response;
              NSData *data = self.dm_data;
              if ([[httpResponse.allHeaderFields objectForKey:@"Content-Encoding"] isEqualToString:@"gzip"]) {
                  data = [self.dm_data gzippedData];
              }
              model.bodyLength = data.length;
          }
          model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength;
          NSDictionary *networkTrafficDictionary = [model convertToDictionary];
          [[PrismClient sharedInstance] sendWithType:CMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil];
      }
      複製代碼
2.5.3.2 Resquest 部分
  1. 先利用網絡監控方案將 NSURLProtocol 管理 App 的各類網絡請求

  2. 在各個方法內部記錄各項所需參數(NSURLProtocol 不能分析請求握手、揮手等數據大小和時間消耗,不過對於正常狀況的接口流量分析足夠了,最底層須要 Socket 層)

    @property(nonatomic, strong) NSURLConnection *internalConnection;
    @property(nonatomic, strong) NSURLResponse *internalResponse;
    @property(nonatomic, strong) NSMutableData *responseData;
    @property (nonatomic, strong) NSURLRequest *internalRequest;
    複製代碼
    - (void)startLoading
    {
        NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
        self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self];
        self.internalRequest = self.request;
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
    {
        [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        self.internalResponse = response;
    }
    
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data 
    {
        [self.responseData appendData:data];
        [self.client URLProtocol:self didLoadData:data];
    }
    複製代碼
  3. Status Line 部分

    對於 NSURLRequest 沒有像 NSURLResponse 同樣的方法找到 StatusLine。因此兜底方案是本身根據 Status Line 的結構,本身手動構造一個。結構爲:協議版本號+空格+狀態碼+空格+狀態文本+換行

    爲 NSURLRequest 添加一個專門獲取 Status Line 的分類。

    // NSURLResquest+cm_FetchStatusLineFromCFNetwork.m
    - (NSUInteger)cm_fetchStatusLineLength
    {
      NSString *statusLineString = [NSString stringWithFormat:@"%@ %@ %@\n", self.HTTPMethod, self.URL.path, @"HTTP/1.1"];
      NSData *statusLineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding];
      return statusLineData.length;
    }
    複製代碼
  4. Header 部分

    一個 HTTP 請求會先構建判斷是否存在緩存,而後進行 DNS 域名解析以獲取請求域名的服務器 IP 地址。若是請求協議是 HTTPS,那麼還須要創建 TLS 鏈接。接下來就是利用 IP 地址和服務器創建 TCP 鏈接。鏈接創建以後,瀏覽器端會構建請求行、請求頭等信息,並把和該域名相關的 Cookie 等數據附加到請求頭中,而後向服務器發送構建的請求信息。

    因此一個網絡監控不考慮 cookie 😂,借用王多魚的一句話「那不完犢子了嗎」。

    看過一些文章說 NSURLRequest 不能完整獲取到請求頭信息。其實問題不大, 幾個信息獲取不徹底也沒辦法。衡量監控方案自己就是看接口在不一樣版本或者某些狀況下數據消耗是否異常,WebView 資源請求是否過大,相似於控制變量法的思想。

    因此獲取到 NSURLRequest 的 allHeaderFields 後,加上 cookie 信息,計算完整的 Header 大小

    // NSURLResquest+cm_FetchHeaderWithCookies.m
    - (NSUInteger)cm_fetchHeaderLengthWithCookie
    {
        NSDictionary *headerFields = self.allHTTPHeaderFields;
        NSDictionary *cookiesHeader = [self cm_fetchCookies];
    
        if (cookiesHeader.count) {
            NSMutableDictionary *headerDictionaryWithCookies = [NSMutableDictionary dictionaryWithDictionary:headerFields];
            [headerDictionaryWithCookies addEntriesFromDictionary:cookiesHeader];
            headerFields = [headerDictionaryWithCookies copy];
        }
        
        NSString *headerString = @"";
    
        for (NSString *key in headerFields.allKeys) {
            headerString = [headerString stringByAppendingString:key];
            headerString = [headerString stringByAppendingString:@": "];
            if ([headerFields objectForKey:key]) {
                headerString = [headerString stringByAppendingString:headerFields[key]];
            }
            headerString = [headerString stringByAppendingString:@"\n"];
        }
        NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding];
        headersLength = headerData.length;
        return headerString;
    }
    
    - (NSDictionary *)cm_fetchCookies
    {
        NSDictionary *cookiesHeaderDictionary;
        NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
        NSArray<NSHTTPCookie *> *cookies = [cookieStorage cookiesForURL:self.URL];
        if (cookies.count) {
            cookiesHeaderDictionary = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
        }
        return cookiesHeaderDictionary;
    }
    複製代碼
  5. Body 部分

    NSURLConnection 的 HTTPBody 有可能獲取不到,問題相似於 WebView 上 ajax 等狀況。因此能夠經過 HTTPBodyStream 讀取 stream 來計算 body 大小.

    - (NSUInteger)cm_fetchRequestBody
    {
        NSDictionary *headerFields = self.allHTTPHeaderFields;
        NSUInteger bodyLength = [self.HTTPBody length];
    
        if ([headerFields objectForKey:@"Content-Encoding"]) {
            NSData *bodyData;
            if (self.HTTPBody == nil) {
                uint8_t d[1024] = {0};
                NSInputStream *stream = self.HTTPBodyStream;
                NSMutableData *data = [[NSMutableData alloc] init];
                [stream open];
                while ([stream hasBytesAvailable]) {
                    NSInteger len = [stream read:d maxLength:1024];
                    if (len > 0 && stream.streamError == nil) {
                        [data appendBytes:(void *)d length:len];
                    }
                }
                bodyData = [data copy];
                [stream close];
            } else {
                bodyData = self.HTTPBody;
            }
            bodyLength = [[bodyData gzippedData] length];
        }
        return bodyLength;
    }
    複製代碼
  6. - (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response 方法中將數據上報會在 打造功能強大、靈活可配置的數據上報組件

    -(NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response
    {
        if (response != nil) {
            self.internalResponse = response;
            [self.client URLProtocol:self wasRedirectedToRequest:request redirectResponse:response];
        }
    
        PCTNetworkTrafficModel *model = [[PCTNetworkTrafficModel alloc] init];
        model.path = request.URL.path;
        model.host = request.URL.host;
        model.type = DMNetworkTrafficDataTypeRequest;
        model.lineLength = [connection.currentRequest dgm_getLineLength];
        model.headerLength = [connection.currentRequest dgm_getHeadersLengthWithCookie];
        model.bodyLength = [connection.currentRequest dgm_getBodyLength];
        model.emptyLineLength = [self.internalResponse cm_getEmptyLineLength];
        model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength;
        
        NSDictionary *networkTrafficDictionary = [model convertToDictionary];
        [[PrismClient sharedInstance] sendWithType:CMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil];
        return request;
    }
    複製代碼
相關文章
相關標籤/搜索