教你打造一套移動端 APM 監控系統

fantasticbaby發表於2020-06-28
APM 是 Application Performance Monitoring 的縮寫,監視和管理軟體應用程式的效能和可用性。應用效能管理對一個應用的持續穩定執行至關重要。所以這篇文章就從一個 iOS App 的效能管理的緯度談談如何精確監控以及資料如何上報等技術點

App 的效能問題是影響使用者體驗的重要因素之一。效能問題主要包含:Crash、網路請求錯誤或者超時、UI 響應速度慢、主執行緒卡頓、CPU 和記憶體使用率高、耗電量大等等。大多數的問題原因在於開發者錯誤地使用了執行緒鎖、系統函式、程式設計規範問題、資料結構等等。解決問題的關鍵在於儘早的發現和定位問題。

本篇文章著重總結了 APM 的原因以及如何收集資料。APM 資料收集後結合資料上報機制,按照一定策略上傳資料到服務端。服務端消費這些資訊併產出報告。請結合姊妹篇, 總結了如何打造一款靈活可配置、功能強大的資料上報元件。

一、卡頓監控

卡頓問題,就是在主執行緒上無法響應使用者互動的問題。影響著使用者的直接體驗,所以針對 App 的卡頓監控是 APM 裡面重要的一環。

FPS(frame per second)每秒鐘的幀重新整理次數,iPhone 手機以 60 為最佳,iPad 某些型號是 120,也是作為卡頓監控的一項參考引數,為什麼說是參考引數?因為它不準確。先說說怎麼獲取到 FPS。CADisplayLink 是一個系統定時器,會以幀重新整理頻率一樣的速率來重新整理檢視。 [CADisplayLink displayLinkWithTarget:self selector:@selector(###:)]。至於為什麼不准我們來看看下面的示例程式碼

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

程式碼所示,CADisplayLink 物件是被新增到指定的 RunLoop 的某個 Mode 下。所以還是 CPU 層面的操作,卡頓的體驗是整個影像渲染的結果:CPU + GPU。請繼續往下看

1. 螢幕繪製原理

老式 CRT 顯示器原理

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

顯示器和 CPU、GPU 關係

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

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

目前來看,雙緩衝區提高了效率,但是帶來了新的問題:當視訊控制器還未讀取完成時,即螢幕內容顯示了部分,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();
}

二、 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 還是需要解析成程式碼來渲染頁面,多了一步。

三、 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]; 
            }
        }
    }
}

四、 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.hkern_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 顯示一張圖片

    carbon.png

    可以看出使用 ImageIO 比使用 UIImage 直接縮放佔用記憶體更低。

  2. 合理使用 autoreleasepool

    我們知道 autoreleasepool 物件是在 RunLoop 結束時才釋放。在 ARC 下,我們如果在不斷申請記憶體,比如各種迴圈,那麼我們就需要手動新增 autoreleasepool,避免短時間內記憶體猛漲發生 OOM。

    對比實驗

    carbon (1).png

    實驗1消耗記憶體 739.6M,實驗2消耗記憶體 587M。

  3. UIGraphicsBeginImageContext 和 UIGraphicsEndImageContext 必須成雙出現,不然會造成 context 洩漏。另外 XCode 的 Analyze 也能掃出這類問題。
  4. 不管是開啟網頁,還是執行 js,都應該使用 WKWebView。UIWebView 會佔用大量記憶體,從而導致 App 發生 OOM 的機率增加,而 WKWebView 是一個多程式元件,Network Loading 以及 UI Rendering 在其它程式中執行,比 UIWebView 佔用更低的記憶體開銷。
  5. 在做 SDK 或者 App,如果場景是快取相關,儘量使用 NSCache 而不是 NSMutableDictionary。它是系統提供的專門處理快取的類,NSCache 分配的記憶體是 Purgeable Memory,可以由系統自動釋放。NSCache 與 NSPureableData 的結合使用可以讓系統根據情況回收記憶體,也可以在記憶體清理時移除物件。

    其他的開發習慣就不一一描述了,良好的開發習慣和程式碼意識是需要平時注意修煉的。

五、 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 的工具類

    carbon (3).png

  • 建立一個繼承自 NSProxy 抽象類的類,實現相應方法。

    carbon (5).png

  • 建立一個物件,實現 NSURLConnection、NSURLSession、NSIuputStream 代理方法

    carbon (6).png

  • 給 NSURLConnection 新增 Category,專門設定 hook 代理物件、hook NSURLConnection 物件方法

    carbon (7).png

這樣下來就是可以監控到網路資訊了,然後將資料交給資料上報 SDK,按照下發的資料上報策略去上報資料。

2.3.2 方法二

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

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

  • Method swizzling 原理

    carbon (12).png

    method swizzling

    method swizzling 改進版如下

    carbon (13).png

  • isa swizzling

    carbon (14).png

    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

carbon (15).png

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

本著成本的原因,由於現在大多數的專案的網路能力都是通過 AFNetworking 完成的,所以本文的網路監控可以快速完成。

AFNetworking 在發起網路的時候會有相應的通知。AFNetworkingTaskDidResumeNotificationAFNetworkingTaskDidCompleteNotification。通過監聽通知攜帶的引數獲取網路情況資訊。

carbon (16).png
在 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

例子如下:
carbon (17).png

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

    carbon (9).png

    carbon (10).png

  3. Status Line 部分

    NSURLResponse 沒有 Status Line 等屬性或者介面,HTTP Version 資訊也沒有,所以要想獲取 Status Line 想辦法轉換到 CFNetwork 層試試看。發現有私有 API 可以實現。

    思路:將 NSURLResponse 通過 _CFURLResponse 轉換為 CFTypeRef,然後再將 CFTypeRef 轉換為 CFHTTPMessageRef,再通過 CFHTTPMessageCopyResponseStatusLine 獲取 CFHTTPMessageRef 的 Status Line 資訊。

    將讀取 Status Line 的功能新增一個 NSURLResponse 的分類。

    carbon (11).png

  4. 將獲取到的 Status Line 轉換為 NSData,再計算大小
    carbon.png
  5. Header 部分

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

    注意:key: value key 後是有空格的,curl 或者 chrome Network 皮膚可以檢視印證下。

    carbon.png

  6. 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

    carbon (1).png

  • stopLoading 方法中拿到 allHeaderFields 字典,獲取 Content-Encoding key 的值,如果是 gzip,則在 stopLoading 中將 NSData 處理為 gzip 壓縮後的資料,再計算大小。(gzip 相關功能可以使用這個工具

    需要額外計算一個空白行的長度
    carbon (2).png

2.5.3.2 Resquest 部分
  1. 先利用網路監控方案將 NSURLProtocol 管理 App 的各種網路請求
  2. 在各個方法內部記錄各項所需引數(NSURLProtocol 不能分析請求握手、揮手等資料大小和時間消耗,不過對於正常情況的介面流量分析足夠了,最底層需要 Socket 層)

    carbon (3).png

    carbon (4).png

  3. Status Line 部分

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

    為 NSURLRequest 新增一個專門獲取 Status Line 的分類。

    carbon (5).png

  4. Header 部分

    一個 HTTP 請求會先構建判斷是否存在快取,然後進行 DNS 域名解析以獲取請求域名的伺服器 IP 地址。如果請求協議是 HTTPS,那麼還需要建立 TLS 連線。接下來就是利用 IP 地址和伺服器建立 TCP 連線。連線建立之後,瀏覽器端會構建請求行、請求頭等資訊,並把和該域名相關的 Cookie 等資料附加到請求頭中,然後向伺服器傳送構建的請求資訊。

    所以一個網路監控不考慮 cookie ?,借用王多魚的一句話「那不完犢子了嗎」。

    看過一些文章說 NSURLRequest 不能完整獲取到請求頭資訊。其實問題不大, 幾個資訊獲取不完全也沒辦法。衡量監控方案本身就是看介面在不同版本或者某些情況下資料消耗是否異常,WebView 資源請求是否過大,類似於控制變數法的思想。

    所以獲取到 NSURLRequest 的 allHeaderFields 後,加上 cookie 資訊,計算完整的 Header 大小

    carbon (6).png

  5. Body 部分

    NSURLConnection 的 HTTPBody 有可能獲取不到,問題類似於 WebView 上 ajax 等情況。所以可以通過 HTTPBodyStream 讀取 stream 來計算 body 大小.

    carbon (7).png

  6. - (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response 方法中將資料上報會在 打造功能強大、靈活可配置的資料上報元件

    carbon (8).png

六、 電量消耗

移動裝置上電量一直是比較敏感的問題,如果使用者在某款 App 的時候發現耗電量嚴重、手機發熱嚴重,那麼使用者很大可能會馬上解除安裝這款 App。所以需要在開發階段關心耗電量問題。

一般來說遇到耗電量較大,我們立馬會想到是不是使用了定位、是不是使用了頻繁網路請求、是不是不斷迴圈做某件事情?

開發階段基本沒啥問題,我們可以結合 Instrucments 裡的 Energy Log 工具來定位問題。但是線上問題就需要程式碼去監控耗電量,可以作為 APM 的能力之一。

1. 如何獲取電量

在 iOS 中,IOKit 是一個私有框架,用來獲取硬體和裝置的詳細資訊,也是硬體和核心服務通訊的底層框架。所以我們可以通過 IOKit 來獲取硬體資訊,從而獲取到電量資訊。步驟如下:

  • 首先在蘋果開放原始碼 opensource 中找到 IOPowerSources.hIOPSKeys.h。在 Xcode 的 Package Contents 裡面找到 IOKit.framework。 路徑為 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/IOKit.framework
  • 然後將 IOPowerSources.h、IOPSKeys.h、IOKit.framework 匯入專案工程
  • 設定 UIDevice 的 batteryMonitoringEnabled 為 true
  • 獲取到的耗電量精確度為 1%

2. 定位問題

通常我們通過 Instrucments 裡的 Energy Log 解決了很多問題後,App 上線了,線上的耗電量解決就需要使用 APM 來解決了。耗電地方可能是二方庫、三方庫,也可能是某個同事的程式碼。

思路是:在檢測到耗電後,先找到有問題的執行緒,然後堆疊 dump,還原案發現場。

在上面部分我們知道了執行緒資訊的結構, thread_basic_info 中有個記錄 CPU 使用率百分比的欄位 cpu_usage。所以我們可以通過遍歷當前執行緒,判斷哪個執行緒的 CPU 使用率較高,從而找出有問題的執行緒。然後再 dump 堆疊,從而定位到發生耗電量的程式碼。詳細請看 3.2 部分。
carbon (9).png

3. 開發階段針對電量消耗我們能做什麼

CPU 密集運算是耗電量主要原因。所以我們對 CPU 的使用需要精打細算。儘量避免讓 CPU 做無用功。對於大量資料的複雜運算,可以藉助伺服器的能力、GPU 的能力。如果方案設計必須是在 CPU 上完成資料的運算,則可以利用 GCD 技術,使用 dispatch_block_create_with_qos_class(<#dispatch_block_flags_t flags#>, dispatch_qos_class_t qos_class, <#int relative_priority#>, <#^(void)block#>)() 並指定 佇列的 qos 為 QOS_CLASS_UTILITY。將任務提交到這個佇列的 block 中,在 QOS_CLASS_UTILITY 模式下,系統針對大量資料的計算,做了電量優化

除了 CPU 大量運算,I/O 操作也是耗電主要原因。業界常見方案都是將「碎片化的資料寫入磁碟儲存」這個操作延後,先在記憶體中聚合嗎,然後再進行磁碟儲存。碎片化資料先聚合,在記憶體中進行儲存的機制,iOS 提供 NSCache 這個物件。

NSCache 是執行緒安全的,NSCache 會在達到達預設的快取空間的條件時清理快取,此時會觸發 - (**void**)cache:(NSCache *)cache willEvictObject:(**id**)obj; 方法回撥,在該方法內部對資料進行 I/O 操作,達到將聚合的資料 I/O 延後的目的。I/O 次數少了,對電量的消耗也就減少了。

NSCache 的使用可以檢視 SDWebImage 這個圖片載入框架。在圖片讀取快取處理時,沒直接讀取硬碟檔案(I/O),而是使用系統的 NSCache。

carbon (10).png

可以看到主要邏輯是先從磁碟中讀取圖片,如果配置允許開啟記憶體快取,則將圖片儲存到 NSCache 中,使用的時候也是從 NSCache 中讀取圖片。NSCache 的 totalCostLimit、countLimit 屬性,

- (void)setObject:(ObjectType)obj forKey:(KeyType)key cost:(NSUInteger)g; 方法用來設定快取條件。所以我們寫磁碟、記憶體的檔案操作時可以借鑑該策略,以優化耗電量。

七、 Crash 監控

1. 異常相關知識回顧

1.1 Mach 層對異常的處理

Mach 在訊息傳遞基礎上實現了一套獨特的異常處理方法。Mach 異常處理在設計時考慮到:

  • 帶有一致的語義的單一異常處理設施:Mach 只提供一個異常處理機制用於處理所有型別的異常(包括使用者定義的異常、平臺無關的異常以及平臺特定的異常)。根據異常型別進行分組,具體的平臺可以定義具體的子型別。
  • 清晰和簡潔:異常處理的介面依賴於 Mach 已有的具有良好定義的訊息和埠架構,因此非常優雅(不會影響效率)。這就允許偵錯程式和外部處理程式的擴充-甚至在理論上還支援擴充基於網路的異常處理。

在 Mach 中,異常是通過核心中的基礎設施-訊息傳遞機制處理的。一個異常並不比一條訊息複雜多少,異常由出錯的執行緒或者任務(通過 msg_send()) 丟擲,然後由一個處理程式通過 msg_recv())捕捉。處理程式可以處理異常,也可以清楚異常(將異常標記為已完成並繼續),還可以決定終止執行緒。

Mach 的異常處理模型和其他的異常處理模型不同,其他模型的異常處理程式執行在出錯的執行緒上下文中,而 Mach 的異常處理程式在不同的上下文中執行異常處理程式,出錯的執行緒向預先指定好的異常埠傳送訊息,然後等待應答。每一個任務都可以註冊一個異常處理埠,這個異常處理埠會對該任務中的所有執行緒生效。此外,每個執行緒都可以通過 thread_set_exception_ports(<#thread_act_t thread#>, <#exception_mask_t exception_mask#>, <#mach_port_t new_port#>, <#exception_behavior_t behavior#>, <#thread_state_flavor_t new_flavor#>) 註冊自己的異常處理埠。通常情況下,任務和執行緒的異常埠都是 NULL,也就是異常不會被處理,而一旦建立異常埠,這些埠就像系統中的其他埠一樣,可以轉交給其他任務或者其他主機。(有了埠,就可以使用 UDP 協議,通過網路能力讓其他的主機上應用程式處理異常)。

發生異常時,首先嚐試將異常拋給執行緒的異常埠,然後嘗試拋給任務的異常埠,最後再拋給主機的異常埠(即主機註冊的預設埠)。如果沒有一個埠返回 KERN_SUCCESS,那麼整個任務將被終止。也就是 Mach 不提供異常處理邏輯,只提供傳遞異常通知的框架。

異常首先是由處理器陷阱引發的。為了處理陷阱,每一個現代的核心都會安插陷阱處理程式。這些底層函式是由核心的彙編部分安插的。

1.2 BSD 層對異常的處理

BSD 層是使用者態主要使用的 XUN 介面,這一層展示了一個符合 POSIX 標準的介面。開發者可以使用 UNIX 系統的一切功能,但不需要了解 Mach 層的細節實現。

Mach 已經通過異常機制提供了底層的陷進處理,而 BSD 則在異常機制之上構建了訊號處理機制。硬體產生的訊號被 Mach 層捕捉,然後轉換為對應的 UNIX 訊號,為了維護一個統一的機制,作業系統和使用者產生的訊號首先被轉換為 Mach 異常,然後再轉換為訊號。

Mach 異常都在 host 層被 ux_exception 轉換為相應的 unix 訊號,並通過 threadsignal 將訊號投遞到出錯的執行緒。

Mach 異常處理以及轉換為 Unix 訊號的流程

2. Crash 收集方式

iOS 系統自帶的 Apples`s Crash Reporter 在設定中記錄 Crash 日誌,我們先觀察下 Crash 日誌

Incident Identifier: 7FA6736D-09E8-47A1-95EC-76C4522BDE1A
CrashReporter Key:   4e2d36419259f14413c3229e8b7235bcc74847f3
Hardware Model:      iPhone7,1
Process:         CMMonitorExample [3608]
Path:            /var/containers/Bundle/Application/9518A4F4-59B7-44E9-BDDA-9FBEE8CA18E5/CMMonitorExample.app/CMMonitorExample
Identifier:      com.Wacai.CMMonitorExample
Version:         1.0 (1)
Code Type:       ARM-64
Parent Process:  ? [1]

Date/Time:       2017-01-03 11:43:03.000 +0800
OS Version:      iOS 10.2 (14C92)
Report Version:  104

Exception Type:  EXC_CRASH (SIGABRT)
Exception Codes: 0x00000000 at 0x0000000000000000
Crashed Thread:  0

Application Specific Information:
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSSingleObjectArrayI objectForKey:]: unrecognized selector sent to instance 0x174015060'

Thread 0 Crashed:
0   CoreFoundation                  0x0000000188f291b8 0x188df9000 + 1245624 (<redacted> + 124)
1   libobjc.A.dylib                 0x000000018796055c 0x187958000 + 34140 (objc_exception_throw + 56)
2   CoreFoundation                  0x0000000188f30268 0x188df9000 + 1274472 (<redacted> + 140)
3   CoreFoundation                  0x0000000188f2d270 0x188df9000 + 1262192 (<redacted> + 916)
4   CoreFoundation                  0x0000000188e2680c 0x188df9000 + 186380 (_CF_forwarding_prep_0 + 92)
5   CMMonitorExample                0x000000010004c618 0x100044000 + 34328 (-[MakeCrashHandler throwUncaughtNSException] + 80)

會發現,Crash 日誌中 Exception Type 項由2部分組成:Mach 異常 + Unix 訊號。

所以 Exception Type: EXC_CRASH (SIGABRT) 表示:Mach 層發生了 EXC_CRASH 異常,在 host 層被轉換為 SIGABRT 訊號投遞到出錯的執行緒。

問題: 捕獲 Mach 層異常、註冊 Unix 訊號處理都可以捕獲 Crash,這兩種方式如何選擇?

答: 優選 Mach 層異常攔截。根據上面 1.2 中的描述我們知道 Mach 層異常處理時機更早些,假如 Mach 層異常處理程式讓程式退出,這樣 Unix 訊號永遠不會發生了。

業界關於崩潰日誌的收集開源專案很多,著名的有: KSCrash、plcrashreporter,提供一條龍服務的 Bugly、友盟等。我們一般使用開源專案在此基礎上開發成符合公司內部需求的 bug 收集工具。一番對比後選擇 KSCrash。為什麼選擇 KSCrash 不在本文重點。

KSCrash 功能齊全,可以捕獲如下型別的 Crash

  • Mach kernel exceptions
  • Fatal signals
  • C++ exceptions
  • Objective-C exceptions
  • Main thread deadlock (experimental)
  • Custom crashes (e.g. from scripting languages)

所以分析 iOS 端的 Crash 收集方案也就是分析 KSCrash 的 Crash 監控實現原理。

2.1. Mach 層異常處理

大體思路是:先建立一個異常處理埠,為該埠申請許可權,再設定異常埠、新建一個核心執行緒,在該執行緒內迴圈等待異常。但是為了防止自己註冊的 Mach 層異常處理搶佔了其他 SDK、或者業務線開發者設定的邏輯,我們需要在最開始儲存其他的異常處理埠,等邏輯執行完後將異常處理交給其他的埠內的邏輯處理。收集到 Crash 資訊後組裝資料,寫入 json 檔案。

流程圖如下:

KSCrash流程圖

對於 Mach 異常捕獲,可以註冊一個異常埠,該埠負責對當前任務的所有執行緒進行監聽。

下面來看看關鍵程式碼:

註冊 Mach 層異常監聽程式碼

static bool installExceptionHandler()
{
    KSLOG_DEBUG("Installing mach exception handler.");

    bool attributes_created = false;
    pthread_attr_t attr;

    kern_return_t kr;
    int error;
    // 拿到當前程式
    const task_t thisTask = mach_task_self();
    exception_mask_t mask = EXC_MASK_BAD_ACCESS |
    EXC_MASK_BAD_INSTRUCTION |
    EXC_MASK_ARITHMETIC |
    EXC_MASK_SOFTWARE |
    EXC_MASK_BREAKPOINT;

    KSLOG_DEBUG("Backing up original exception ports.");
    // 獲取該 Task 上的註冊好的異常埠
    kr = task_get_exception_ports(thisTask,
                                  mask,
                                  g_previousExceptionPorts.masks,
                                  &g_previousExceptionPorts.count,
                                  g_previousExceptionPorts.ports,
                                  g_previousExceptionPorts.behaviors,
                                  g_previousExceptionPorts.flavors);
    // 獲取失敗走 failed 邏輯
    if(kr != KERN_SUCCESS)
    {
        KSLOG_ERROR("task_get_exception_ports: %s", mach_error_string(kr));
        goto failed;
    }
    // KSCrash 的異常為空則走執行邏輯
    if(g_exceptionPort == MACH_PORT_NULL)
    {
        KSLOG_DEBUG("Allocating new port with receive rights.");
        // 申請異常處理埠
        kr = mach_port_allocate(thisTask,
                                MACH_PORT_RIGHT_RECEIVE,
                                &g_exceptionPort);
        if(kr != KERN_SUCCESS)
        {
            KSLOG_ERROR("mach_port_allocate: %s", mach_error_string(kr));
            goto failed;
        }

        KSLOG_DEBUG("Adding send rights to port.");
        // 為異常處理埠申請許可權:MACH_MSG_TYPE_MAKE_SEND
        kr = mach_port_insert_right(thisTask,
                                    g_exceptionPort,
                                    g_exceptionPort,
                                    MACH_MSG_TYPE_MAKE_SEND);
        if(kr != KERN_SUCCESS)
        {
            KSLOG_ERROR("mach_port_insert_right: %s", mach_error_string(kr));
            goto failed;
        }
    }

    KSLOG_DEBUG("Installing port as exception handler.");
    // 為該 Task 設定異常處理埠
    kr = task_set_exception_ports(thisTask,
                                  mask,
                                  g_exceptionPort,
                                  EXCEPTION_DEFAULT,
                                  THREAD_STATE_NONE);
    if(kr != KERN_SUCCESS)
    {
        KSLOG_ERROR("task_set_exception_ports: %s", mach_error_string(kr));
        goto failed;
    }

    KSLOG_DEBUG("Creating secondary exception thread (suspended).");
    pthread_attr_init(&attr);
    attributes_created = true;
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    // 設定監控執行緒
    error = pthread_create(&g_secondaryPThread,
                           &attr,
                           &handleExceptions,
                           kThreadSecondary);
    if(error != 0)
    {
        KSLOG_ERROR("pthread_create_suspended_np: %s", strerror(error));
        goto failed;
    }
    // 轉換為 Mach 核心執行緒
    g_secondaryMachThread = pthread_mach_thread_np(g_secondaryPThread);
    ksmc_addReservedThread(g_secondaryMachThread);

    KSLOG_DEBUG("Creating primary exception thread.");
    error = pthread_create(&g_primaryPThread,
                           &attr,
                           &handleExceptions,
                           kThreadPrimary);
    if(error != 0)
    {
        KSLOG_ERROR("pthread_create: %s", strerror(error));
        goto failed;
    }
    pthread_attr_destroy(&attr);
    g_primaryMachThread = pthread_mach_thread_np(g_primaryPThread);
    ksmc_addReservedThread(g_primaryMachThread);
    
    KSLOG_DEBUG("Mach exception handler installed.");
    return true;


failed:
    KSLOG_DEBUG("Failed to install mach exception handler.");
    if(attributes_created)
    {
        pthread_attr_destroy(&attr);
    }
    // 還原之前的異常註冊埠,將控制權還原
    uninstallExceptionHandler();
    return false;
}

處理異常的邏輯、組裝崩潰資訊

/** Our exception handler thread routine.
 * Wait for an exception message, uninstall our exception port, record the
 * exception information, and write a report.
 */
static void* handleExceptions(void* const userData)
{
    MachExceptionMessage exceptionMessage = {{0}};
    MachReplyMessage replyMessage = {{0}};
    char* eventID = g_primaryEventID;

    const char* threadName = (const char*) userData;
    pthread_setname_np(threadName);
    if(threadName == kThreadSecondary)
    {
        KSLOG_DEBUG("This is the secondary thread. Suspending.");
        thread_suspend((thread_t)ksthread_self());
        eventID = g_secondaryEventID;
    }
    // 迴圈讀取註冊好的異常埠資訊
    for(;;)
    {
        KSLOG_DEBUG("Waiting for mach exception");

        // Wait for a message.
        kern_return_t kr = mach_msg(&exceptionMessage.header,
                                    MACH_RCV_MSG,
                                    0,
                                    sizeof(exceptionMessage),
                                    g_exceptionPort,
                                    MACH_MSG_TIMEOUT_NONE,
                                    MACH_PORT_NULL);
        // 獲取到資訊後則代表發生了 Mach 層異常,跳出 for 迴圈,組裝資料
        if(kr == KERN_SUCCESS)
        {
            break;
        }

        // Loop and try again on failure.
        KSLOG_ERROR("mach_msg: %s", mach_error_string(kr));
    }

    KSLOG_DEBUG("Trapped mach exception code 0x%x, subcode 0x%x",
                exceptionMessage.code[0], exceptionMessage.code[1]);
    if(g_isEnabled)
    {
        // 掛起所有執行緒
        ksmc_suspendEnvironment();
        g_isHandlingCrash = true;
        // 通知發生了異常
        kscm_notifyFatalExceptionCaptured(true);

        KSLOG_DEBUG("Exception handler is installed. Continuing exception handling.");


        // Switch to the secondary thread if necessary, or uninstall the handler
        // to avoid a death loop.
        if(ksthread_self() == g_primaryMachThread)
        {
            KSLOG_DEBUG("This is the primary exception thread. Activating secondary thread.");
// TODO: This was put here to avoid a freeze. Does secondary thread ever fire?
            restoreExceptionPorts();
            if(thread_resume(g_secondaryMachThread) != KERN_SUCCESS)
            {
                KSLOG_DEBUG("Could not activate secondary thread. Restoring original exception ports.");
            }
        }
        else
        {
            KSLOG_DEBUG("This is the secondary exception thread. Restoring original exception ports.");
//            restoreExceptionPorts();
        }

        // Fill out crash information
        // 組裝異常所需要的方案現場資訊
        KSLOG_DEBUG("Fetching machine state.");
        KSMC_NEW_CONTEXT(machineContext);
        KSCrash_MonitorContext* crashContext = &g_monitorContext;
        crashContext->offendingMachineContext = machineContext;
        kssc_initCursor(&g_stackCursor, NULL, NULL);
        if(ksmc_getContextForThread(exceptionMessage.thread.name, machineContext, true))
        {
            kssc_initWithMachineContext(&g_stackCursor, 100, machineContext);
            KSLOG_TRACE("Fault address 0x%x, instruction address 0x%x", kscpu_faultAddress(machineContext), kscpu_instructionAddress(machineContext));
            if(exceptionMessage.exception == EXC_BAD_ACCESS)
            {
                crashContext->faultAddress = kscpu_faultAddress(machineContext);
            }
            else
            {
                crashContext->faultAddress = kscpu_instructionAddress(machineContext);
            }
        }

        KSLOG_DEBUG("Filling out context.");
        crashContext->crashType = KSCrashMonitorTypeMachException;
        crashContext->eventID = eventID;
        crashContext->registersAreValid = true;
        crashContext->mach.type = exceptionMessage.exception;
        crashContext->mach.code = exceptionMessage.code[0];
        crashContext->mach.subcode = exceptionMessage.code[1];
        if(crashContext->mach.code == KERN_PROTECTION_FAILURE && crashContext->isStackOverflow)
        {
            // A stack overflow should return KERN_INVALID_ADDRESS, but
            // when a stack blasts through the guard pages at the top of the stack,
            // it generates KERN_PROTECTION_FAILURE. Correct for this.
            crashContext->mach.code = KERN_INVALID_ADDRESS;
        }
        crashContext->signal.signum = signalForMachException(crashContext->mach.type, crashContext->mach.code);
        crashContext->stackCursor = &g_stackCursor;

        kscm_handleException(crashContext);

        KSLOG_DEBUG("Crash handling complete. Restoring original handlers.");
        g_isHandlingCrash = false;
        ksmc_resumeEnvironment();
    }

    KSLOG_DEBUG("Replying to mach exception message.");
    // Send a reply saying "I didn't handle this exception".
    replyMessage.header = exceptionMessage.header;
    replyMessage.NDR = exceptionMessage.NDR;
    replyMessage.returnCode = KERN_FAILURE;

    mach_msg(&replyMessage.header,
             MACH_SEND_MSG,
             sizeof(replyMessage),
             0,
             MACH_PORT_NULL,
             MACH_MSG_TIMEOUT_NONE,
             MACH_PORT_NULL);

    return NULL;
}

還原異常處理埠,轉移控制權

/** Restore the original mach exception ports.
 */
static void restoreExceptionPorts(void)
{
    KSLOG_DEBUG("Restoring original exception ports.");
    if(g_previousExceptionPorts.count == 0)
    {
        KSLOG_DEBUG("Original exception ports were already restored.");
        return;
    }

    const task_t thisTask = mach_task_self();
    kern_return_t kr;

    // Reinstall old exception ports.
    // for 迴圈去除儲存好的在 KSCrash 之前註冊好的異常埠,將每個埠註冊回去
    for(mach_msg_type_number_t i = 0; i < g_previousExceptionPorts.count; i++)
    {
        KSLOG_TRACE("Restoring port index %d", i);
        kr = task_set_exception_ports(thisTask,
                                      g_previousExceptionPorts.masks[i],
                                      g_previousExceptionPorts.ports[i],
                                      g_previousExceptionPorts.behaviors[i],
                                      g_previousExceptionPorts.flavors[i]);
        if(kr != KERN_SUCCESS)
        {
            KSLOG_ERROR("task_set_exception_ports: %s",
                        mach_error_string(kr));
        }
    }
    KSLOG_DEBUG("Exception ports restored.");
    g_previousExceptionPorts.count = 0;
}

2.2. Signal 異常處理

對於 Mach 異常,作業系統會將其轉換為對應的 Unix 訊號,所以開發者可以通過註冊 signanHandler 的方式來處理。

KSCrash 在這裡的處理邏輯如下圖:

signal 處理步驟

看一下關鍵程式碼:

設定訊號處理函式

static bool installSignalHandler()
{
    KSLOG_DEBUG("Installing signal handler.");

#if KSCRASH_HAS_SIGNAL_STACK
    // 在堆上分配一塊記憶體,
    if(g_signalStack.ss_size == 0)
    {
        KSLOG_DEBUG("Allocating signal stack area.");
        g_signalStack.ss_size = SIGSTKSZ;
        g_signalStack.ss_sp = malloc(g_signalStack.ss_size);
    }
    // 訊號處理函式的棧挪到堆中,而不和程式共用一塊棧區
    // sigaltstack() 函式,該函式的第 1 個引數 sigstack 是一個 stack_t 結構的指標,該結構儲存了一個“可替換訊號棧” 的位置及屬性資訊。第 2 個引數 old_sigstack 也是一個 stack_t 型別指標,它用來返回上一次建立的“可替換訊號棧”的資訊(如果有的話)
    KSLOG_DEBUG("Setting signal stack area.");
    // sigaltstack 第一個引數為建立的新的可替換訊號棧,第二個引數可以設定為NULL,如果不為NULL的話,將會將舊的可替換訊號棧的資訊儲存在裡面。函式成功返回0,失敗返回-1.
    if(sigaltstack(&g_signalStack, NULL) != 0)
    {
        KSLOG_ERROR("signalstack: %s", strerror(errno));
        goto failed;
    }
#endif

    const int* fatalSignals = kssignal_fatalSignals();
    int fatalSignalsCount = kssignal_numFatalSignals();

    if(g_previousSignalHandlers == NULL)
    {
        KSLOG_DEBUG("Allocating memory to store previous signal handlers.");
        g_previousSignalHandlers = malloc(sizeof(*g_previousSignalHandlers)
                                          * (unsigned)fatalSignalsCount);
    }

    // 設定訊號處理函式 sigaction 的第二個引數,型別為 sigaction 結構體
    struct sigaction action = {{0}};
    // sa_flags 成員設立 SA_ONSTACK 標誌,該標誌告訴核心訊號處理函式的棧幀就在“可替換訊號棧”上建立。
    action.sa_flags = SA_SIGINFO | SA_ONSTACK;
#if KSCRASH_HOST_APPLE && defined(__LP64__)
    action.sa_flags |= SA_64REGSET;
#endif
    sigemptyset(&action.sa_mask);
    action.sa_sigaction = &handleSignal;

    // 遍歷需要處理的訊號陣列
    for(int i = 0; i < fatalSignalsCount; i++)
    {
        // 將每個訊號的處理函式繫結到上面宣告的 action 去,另外用 g_previousSignalHandlers 儲存當前訊號的處理函式
        KSLOG_DEBUG("Assigning handler for signal %d", fatalSignals[i]);
        if(sigaction(fatalSignals[i], &action, &g_previousSignalHandlers[i]) != 0)
        {
            char sigNameBuff[30];
            const char* sigName = kssignal_signalName(fatalSignals[i]);
            if(sigName == NULL)
            {
                snprintf(sigNameBuff, sizeof(sigNameBuff), "%d", fatalSignals[i]);
                sigName = sigNameBuff;
            }
            KSLOG_ERROR("sigaction (%s): %s", sigName, strerror(errno));
            // Try to reverse the damage
            for(i--;i >= 0; i--)
            {
                sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL);
            }
            goto failed;
        }
    }
    KSLOG_DEBUG("Signal handlers installed.");
    return true;

failed:
    KSLOG_DEBUG("Failed to install signal handlers.");
    return false;
}

訊號處理時記錄執行緒等上下文資訊

static void handleSignal(int sigNum, siginfo_t* signalInfo, void* userContext)
{
    KSLOG_DEBUG("Trapped signal %d", sigNum);
    if(g_isEnabled)
    {
        ksmc_suspendEnvironment();
        kscm_notifyFatalExceptionCaptured(false);
        
        KSLOG_DEBUG("Filling out context.");
        KSMC_NEW_CONTEXT(machineContext);
        ksmc_getContextForSignal(userContext, machineContext);
        kssc_initWithMachineContext(&g_stackCursor, 100, machineContext);
        // 記錄訊號處理時的上下文資訊
        KSCrash_MonitorContext* crashContext = &g_monitorContext;
        memset(crashContext, 0, sizeof(*crashContext));
        crashContext->crashType = KSCrashMonitorTypeSignal;
        crashContext->eventID = g_eventID;
        crashContext->offendingMachineContext = machineContext;
        crashContext->registersAreValid = true;
        crashContext->faultAddress = (uintptr_t)signalInfo->si_addr;
        crashContext->signal.userContext = userContext;
        crashContext->signal.signum = signalInfo->si_signo;
        crashContext->signal.sigcode = signalInfo->si_code;
        crashContext->stackCursor = &g_stackCursor;

        kscm_handleException(crashContext);
        ksmc_resumeEnvironment();
    }

    KSLOG_DEBUG("Re-raising signal for regular handlers to catch.");
    // This is technically not allowed, but it works in OSX and iOS.
    raise(sigNum);
}

KSCrash 訊號處理後還原之前的訊號處理許可權

static void uninstallSignalHandler(void)
{
    KSLOG_DEBUG("Uninstalling signal handlers.");

    const int* fatalSignals = kssignal_fatalSignals();
    int fatalSignalsCount = kssignal_numFatalSignals();
    // 遍歷需要處理訊號陣列,將之前的訊號處理函式還原
    for(int i = 0; i < fatalSignalsCount; i++)
    {
        KSLOG_DEBUG("Restoring original handler for signal %d", fatalSignals[i]);
        sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL);
    }
    
    KSLOG_DEBUG("Signal handlers uninstalled.");
}

說明:

  1. 先從堆上分配一塊記憶體區域,被稱為“可替換訊號棧”,目的是將訊號處理函式的棧幹掉,用堆上的記憶體區域代替,而不和程式共用一塊棧區。

    為什麼這麼做?一個程式可能有 n 個執行緒,每個執行緒都有自己的任務,假如某個執行緒執行出錯,這樣就會導致整個程式的崩潰。所以為了訊號處理函式正常執行,需要為訊號處理函式設定單獨的執行空間。另一種情況是遞迴函式將系統預設的棧空間用盡了,但是訊號處理函式使用的棧是它實現在堆中分配的空間,而不是系統預設的棧,所以它仍舊可以正常工作。

  2. int sigaltstack(const stack_t * __restrict, stack_t * __restrict) 函式的二個引數都是 stack_t 結構的指標,儲存了可替換訊號棧的資訊(棧的起始地址、棧的長度、狀態)。第1個引數該結構儲存了一個“可替換訊號棧” 的位置及屬性資訊。第 2 個引數用來返回上一次建立的“可替換訊號棧”的資訊(如果有的話)。

    _STRUCT_SIGALTSTACK
    {
        void            *ss_sp;         /* signal stack base */
        __darwin_size_t ss_size;        /* signal stack length */
        int             ss_flags;       /* SA_DISABLE and/or SA_ONSTACK */
    };
    typedef _STRUCT_SIGALTSTACK     stack_t; /* [???] signal stack */

    新建立的可替換訊號棧,ss_flags 必須設定為 0。系統定義了 SIGSTKSZ 常量,可滿足絕大多可替換訊號棧的需求。

    carbon (12).png

    sigaltstack 系統呼叫通知核心“可替換訊號棧”已經建立。

    ss_flagsSS_ONSTACK 時,表示程式當前正在“可替換訊號棧”中執行,如果此時試圖去建立一個新的“可替換訊號棧”,那麼會遇到 EPERM (禁止該動作) 的錯誤;為 SS_DISABLE 說明當前沒有已建立的“可替換訊號棧”,禁止建立“可替換訊號棧”。

  3. int sigaction(int, const struct sigaction * __restrict, struct sigaction * __restrict);

    第一個函式表示需要處理的訊號值,但不能是 SIGKILLSIGSTOP ,這兩個訊號的處理函式不允許使用者重寫,因為它們給超級使用者提供了終止程式的方法( SIGKILL and SIGSTOP cannot be caught, blocked, or ignored);

    第二個和第三個引數是一個 sigaction 結構體。如果第二個引數不為空則代表將其指向訊號處理函式,第三個引數不為空,則將之前的訊號處理函式儲存到該指標中。如果第二個引數為空,第三個引數不為空,則可以獲取當前的訊號處理函式。

    carbon (13).png

    sigaction 函式的 sa_flags 引數需要設定 SA_ONSTACK 標誌,告訴核心訊號處理函式的棧幀就在“可替換訊號棧”上建立。

2.3. C++ 異常處理

c++ 異常處理的實現是依靠了標準庫的 std::set_terminate(CPPExceptionTerminate) 函式。

iOS 工程中某些功能的實現可能使用了C、C++等。假如丟擲 C++ 異常,如果該異常可以被轉換為 NSException,則走 OC 異常捕獲機制,如果不能轉換,則繼續走 C++ 異常流程,也就是 default_terminate_handler。這個 C++ 異常的預設 terminate 函式內部呼叫 abort_message 函式,最後觸發了一個 abort 呼叫,系統產生一個 SIGABRT 訊號。

在系統丟擲 C++ 異常後,加一層 try...catch... 來判斷該異常是否可以轉換為 NSException,再重新丟擲的C++異常。此時異常的現場堆疊已經消失,所以上層通過捕獲 SIGABRT 訊號是無法還原發生異常時的場景,即異常堆疊缺失。

為什麼?try...catch... 語句內部會呼叫 __cxa_rethrow() 丟擲異常,__cxa_rethrow() 內部又會呼叫 unwindunwind 可以簡單理解為函式呼叫的逆呼叫,主要用來清理函式呼叫過程中每個函式生成的區域性變數,一直到最外層的 catch 語句所在的函式,並把控制移交給 catch 語句,這就是C++異常的堆疊消失原因。

static void setEnabled(bool isEnabled)
{
    if(isEnabled != g_isEnabled)
    {
        g_isEnabled = isEnabled;
        if(isEnabled)
        {
            initialize();

            ksid_generate(g_eventID);
            g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate);
        }
        else
        {
            std::set_terminate(g_originalTerminateHandler);
        }
        g_captureNextStackTrace = isEnabled;
    }
}

static void initialize()
{
    static bool isInitialized = false;
    if(!isInitialized)
    {
        isInitialized = true;
        kssc_initCursor(&g_stackCursor, NULL, NULL);
    }
}

void kssc_initCursor(KSStackCursor *cursor,
                     void (*resetCursor)(KSStackCursor*),
                     bool (*advanceCursor)(KSStackCursor*))
{
    cursor->symbolicate = kssymbolicator_symbolicate;
    cursor->advanceCursor = advanceCursor != NULL ? advanceCursor : g_advanceCursor;
    cursor->resetCursor = resetCursor != NULL ? resetCursor : kssc_resetCursor;
    cursor->resetCursor(cursor);
}

carbon (11).png

2.4. Objective-C 異常處理

對於 OC 層面的 NSException 異常處理較為容易,可以通過註冊 NSUncaughtExceptionHandler 來捕獲異常資訊,通過 NSException 引數來做 Crash 資訊的收集,交給資料上報元件。

static void setEnabled(bool isEnabled)
{
    if(isEnabled != g_isEnabled)
    {
        g_isEnabled = isEnabled;
        if(isEnabled)
        {
            KSLOG_DEBUG(@"Backing up original handler.");
            // 記錄之前的 OC 異常處理函式
            g_previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
            
            KSLOG_DEBUG(@"Setting new handler.");
            // 設定新的 OC 異常處理函式
            NSSetUncaughtExceptionHandler(&handleException);
            KSCrash.sharedInstance.uncaughtExceptionHandler = &handleException;
        }
        else
        {
            KSLOG_DEBUG(@"Restoring original handler.");
            NSSetUncaughtExceptionHandler(g_previousUncaughtExceptionHandler);
        }
    }
}

2.5. 主執行緒死鎖

主執行緒死鎖的檢測和 ANR 的檢測有些類似

  • 建立一個執行緒,線上程執行方法中用 do...while... 迴圈處理邏輯,加了 autorelease 避免記憶體過高
  • 有一個 awaitingResponse 屬性和 watchdogPulse 方法。watchdogPulse 主要邏輯為設定 awaitingResponse 為 YES,切換到主執行緒中,設定 awaitingResponse 為 NO,

carbon (15).png

  • 執行緒的執行方法裡面不斷迴圈,等待設定的 g_watchdogInterval 後判斷 awaitingResponse 的屬性值是不是初始狀態的值,否則判斷為死鎖

    carbon (16).png

2.6 Crash 的生成與儲存

2.6.1 Crash 日誌的生成邏輯

上面的部分講過了 iOS 應用開發中的各種 crash 監控邏輯,接下來就應該分析下 crash 捕獲後如何將 crash 資訊記錄下來,也就是儲存到應用沙盒中。

拿主執行緒死鎖這種 crash 舉例子,看看 KSCrash 是如何記錄 crash 資訊的。

// KSCrashMonitor_Deadlock.m
- (void) handleDeadlock
{
    ksmc_suspendEnvironment();
    kscm_notifyFatalExceptionCaptured(false);

    KSMC_NEW_CONTEXT(machineContext);
    ksmc_getContextForThread(g_mainQueueThread, machineContext, false);
    KSStackCursor stackCursor;
    kssc_initWithMachineContext(&stackCursor, 100, machineContext);
    char eventID[37];
    ksid_generate(eventID);

    KSLOG_DEBUG(@"Filling out context.");
    KSCrash_MonitorContext* crashContext = &g_monitorContext;
    memset(crashContext, 0, sizeof(*crashContext));
    crashContext->crashType = KSCrashMonitorTypeMainThreadDeadlock;
    crashContext->eventID = eventID;
    crashContext->registersAreValid = false;
    crashContext->offendingMachineContext = machineContext;
    crashContext->stackCursor = &stackCursor;
    
    kscm_handleException(crashContext);
    ksmc_resumeEnvironment();

    KSLOG_DEBUG(@"Calling abort()");
    abort();
}

其他幾個 crash 也是一樣,異常資訊經過包裝交給 kscm_handleException() 函式處理。可以看到這個函式被其他幾種 crash 捕獲後所呼叫。

caller


/** Start general exception processing.
 *
 * @oaram context Contextual information about the exception.
 */
void kscm_handleException(struct KSCrash_MonitorContext* context)
{
    context->requiresAsyncSafety = g_requiresAsyncSafety;
    if(g_crashedDuringExceptionHandling)
    {
        context->crashedDuringCrashHandling = true;
    }
    for(int i = 0; i < g_monitorsCount; i++)
    {
        Monitor* monitor = &g_monitors[i];
        // 判斷當前的 crash 監控是開啟狀態
        if(isMonitorEnabled(monitor))
        {
            // 針對每種 crash 型別做一些額外的補充資訊
            addContextualInfoToEvent(monitor, context);
        }
    }
    // 真正處理 crash 資訊,儲存 json 格式的 crash 資訊
    g_onExceptionEvent(context);

    
    if(g_handlingFatalException && !g_crashedDuringExceptionHandling)
    {
        KSLOG_DEBUG("Exception is fatal. Restoring original handlers.");
        kscm_setActiveMonitors(KSCrashMonitorTypeNone);
    }
}

g_onExceptionEvent 是一個 block,宣告為 static void (*g_onExceptionEvent)(struct KSCrash_MonitorContext* monitorContext);KSCrashMonitor.c 中被賦值

void kscm_setEventCallback(void (*onEvent)(struct KSCrash_MonitorContext* monitorContext))
{
    g_onExceptionEvent = onEvent;
}

kscm_setEventCallback() 函式在 KSCrashC.c 檔案中被呼叫

KSCrashMonitorType kscrash_install(const char* appName, const char* const installPath)
{
    KSLOG_DEBUG("Installing crash reporter.");

    if(g_installed)
    {
        KSLOG_DEBUG("Crash reporter already installed.");
        return g_monitoring;
    }
    g_installed = 1;

    char path[KSFU_MAX_PATH_LENGTH];
    snprintf(path, sizeof(path), "%s/Reports", installPath);
    ksfu_makePath(path);
    kscrs_initialize(appName, path);

    snprintf(path, sizeof(path), "%s/Data", installPath);
    ksfu_makePath(path);
    snprintf(path, sizeof(path), "%s/Data/CrashState.json", installPath);
    kscrashstate_initialize(path);

    snprintf(g_consoleLogPath, sizeof(g_consoleLogPath), "%s/Data/ConsoleLog.txt", installPath);
    if(g_shouldPrintPreviousLog)
    {
        printPreviousLog(g_consoleLogPath);
    }
    kslog_setLogFilename(g_consoleLogPath, true);
    
    ksccd_init(60);
    // 設定 crash 發生時的 callback 函式
    kscm_setEventCallback(onCrash);
    KSCrashMonitorType monitors = kscrash_setMonitoring(g_monitoring);

    KSLOG_DEBUG("Installation complete.");
    return monitors;
}

/** Called when a crash occurs.
 *
 * This function gets passed as a callback to a crash handler.
 */
static void onCrash(struct KSCrash_MonitorContext* monitorContext)
{
    KSLOG_DEBUG("Updating application state to note crash.");
    kscrashstate_notifyAppCrash();
    monitorContext->consoleLogPath = g_shouldAddConsoleLogToReport ? g_consoleLogPath : NULL;

    // 正在處理 crash 的時候,發生了再次 crash
    if(monitorContext->crashedDuringCrashHandling)
    {
        kscrashreport_writeRecrashReport(monitorContext, g_lastCrashReportFilePath);
    }
    else
    {
        // 1. 先根據當前時間建立新的 crash 的檔案路徑
        char crashReportFilePath[KSFU_MAX_PATH_LENGTH];
        kscrs_getNextCrashReportPath(crashReportFilePath);
        // 2. 將新生成的檔案路徑儲存到 g_lastCrashReportFilePath
        strncpy(g_lastCrashReportFilePath, crashReportFilePath, sizeof(g_lastCrashReportFilePath));
        // 3. 將新生成的檔案路徑傳入函式進行 crash 寫入
        kscrashreport_writeStandardReport(monitorContext, crashReportFilePath);
    }
}

接下來的函式就是具體的日誌寫入檔案的實現。2個函式做的事情相似,都是格式化為 json 形式並寫入檔案。區別在於 crash 寫入時如果再次發生 crash, 則走簡易版的寫入邏輯 kscrashreport_writeRecrashReport(),否則走標準的寫入邏輯 kscrashreport_writeStandardReport()

bool ksfu_openBufferedWriter(KSBufferedWriter* writer, const char* const path, char* writeBuffer, int writeBufferLength)
{
    writer->buffer = writeBuffer;
    writer->bufferLength = writeBufferLength;
    writer->position = 0;
    /*
     open() 的第二個引數描述的是檔案操作的許可權
     #define O_RDONLY        0x0000         open for reading only
     #define O_WRONLY        0x0001         open for writing only
     #define O_RDWR          0x0002         open for reading and writing
     #define O_ACCMODE       0x0003         mask for above mode
     
     #define O_CREAT         0x0200         create if nonexistant
     #define O_TRUNC         0x0400         truncate to zero length
     #define O_EXCL          0x0800         error if already exists
     
     0755:即使用者具有讀/寫/執行許可權,組使用者和其它使用者具有讀寫許可權;
     0644:即使用者具有讀寫許可權,組使用者和其它使用者具有隻讀許可權;
     成功則返回檔案描述符,若出現則返回 -1
     */
    writer->fd = open(path, O_RDWR | O_CREAT | O_EXCL, 0644);
    if(writer->fd < 0)
    {
        KSLOG_ERROR("Could not open crash report file %s: %s", path, strerror(errno));
        return false;
    }
    return true;
}
/**
 * Write a standard crash report to a file.
 *
 *  @param monitorContext Contextual information about the crash and environment.
 *                      The caller must fill this out before passing it in.
 *
 *  @param path The file to write to.
 */
void kscrashreport_writeStandardReport(const struct KSCrash_MonitorContext* const monitorContext,
                                       const char* path)
{
        KSLOG_INFO("Writing crash report to %s", path);
    char writeBuffer[1024];
    KSBufferedWriter bufferedWriter;

    if(!ksfu_openBufferedWriter(&bufferedWriter, path, writeBuffer, sizeof(writeBuffer)))
    {
        return;
    }

    ksccd_freeze();
    
    KSJSONEncodeContext jsonContext;
    jsonContext.userData = &bufferedWriter;
    KSCrashReportWriter concreteWriter;
    KSCrashReportWriter* writer = &concreteWriter;
    prepareReportWriter(writer, &jsonContext);

    ksjson_beginEncode(getJsonContext(writer), true, addJSONData, &bufferedWriter);

    writer->beginObject(writer, KSCrashField_Report);
    {
        writeReportInfo(writer,
                        KSCrashField_Report,
                        KSCrashReportType_Standard,
                        monitorContext->eventID,
                        monitorContext->System.processName);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writeBinaryImages(writer, KSCrashField_BinaryImages);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writeProcessState(writer, KSCrashField_ProcessState, monitorContext);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writeSystemInfo(writer, KSCrashField_System, monitorContext);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writer->beginObject(writer, KSCrashField_Crash);
        {
            writeError(writer, KSCrashField_Error, monitorContext);
            ksfu_flushBufferedWriter(&bufferedWriter);
            writeAllThreads(writer,
                            KSCrashField_Threads,
                            monitorContext,
                            g_introspectionRules.enabled);
            ksfu_flushBufferedWriter(&bufferedWriter);
        }
        writer->endContainer(writer);

        if(g_userInfoJSON != NULL)
        {
            addJSONElement(writer, KSCrashField_User, g_userInfoJSON, false);
            ksfu_flushBufferedWriter(&bufferedWriter);
        }
        else
        {
            writer->beginObject(writer, KSCrashField_User);
        }
        if(g_userSectionWriteCallback != NULL)
        {
            ksfu_flushBufferedWriter(&bufferedWriter);
            g_userSectionWriteCallback(writer);
        }
        writer->endContainer(writer);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writeDebugInfo(writer, KSCrashField_Debug, monitorContext);
    }
    writer->endContainer(writer);
    
    ksjson_endEncode(getJsonContext(writer));
    ksfu_closeBufferedWriter(&bufferedWriter);
    ksccd_unfreeze();
}

/** Write a minimal crash report to a file.
 *
 * @param monitorContext Contextual information about the crash and environment.
 *                       The caller must fill this out before passing it in.
 *
 * @param path The file to write to.
 */
void kscrashreport_writeRecrashReport(const struct KSCrash_MonitorContext* const monitorContext,
                                      const char* path)
{
  char writeBuffer[1024];
    KSBufferedWriter bufferedWriter;
    static char tempPath[KSFU_MAX_PATH_LENGTH];
    // 將傳遞過來的上份 crash report 檔名路徑(/var/mobile/Containers/Data/Application/******/Library/Caches/KSCrash/Test/Reports/Test-report-******.json)修改為去掉 .json ,加上 .old 成為新的檔案路徑 /var/mobile/Containers/Data/Application/******/Library/Caches/KSCrash/Test/Reports/Test-report-******.old

    strncpy(tempPath, path, sizeof(tempPath) - 10);
    strncpy(tempPath + strlen(tempPath) - 5, ".old", 5);
    KSLOG_INFO("Writing recrash report to %s", path);

    if(rename(path, tempPath) < 0)
    {
        KSLOG_ERROR("Could not rename %s to %s: %s", path, tempPath, strerror(errno));
    }
    // 根據傳入路徑來開啟記憶體寫入需要的檔案
    if(!ksfu_openBufferedWriter(&bufferedWriter, path, writeBuffer, sizeof(writeBuffer)))
    {
        return;
    }

    ksccd_freeze();
    // json 解析的 c 程式碼
    KSJSONEncodeContext jsonContext;
    jsonContext.userData = &bufferedWriter;
    KSCrashReportWriter concreteWriter;
    KSCrashReportWriter* writer = &concreteWriter;
    prepareReportWriter(writer, &jsonContext);

    ksjson_beginEncode(getJsonContext(writer), true, addJSONData, &bufferedWriter);

    writer->beginObject(writer, KSCrashField_Report);
    {
        writeRecrash(writer, KSCrashField_RecrashReport, tempPath);
        ksfu_flushBufferedWriter(&bufferedWriter);
        if(remove(tempPath) < 0)
        {
            KSLOG_ERROR("Could not remove %s: %s", tempPath, strerror(errno));
        }
        writeReportInfo(writer,
                        KSCrashField_Report,
                        KSCrashReportType_Minimal,
                        monitorContext->eventID,
                        monitorContext->System.processName);
        ksfu_flushBufferedWriter(&bufferedWriter);

        writer->beginObject(writer, KSCrashField_Crash);
        {
            writeError(writer, KSCrashField_Error, monitorContext);
            ksfu_flushBufferedWriter(&bufferedWriter);
            int threadIndex = ksmc_indexOfThread(monitorContext->offendingMachineContext,
                                                 ksmc_getThreadFromContext(monitorContext->offendingMachineContext));
            writeThread(writer,
                        KSCrashField_CrashedThread,
                        monitorContext,
                        monitorContext->offendingMachineContext,
                        threadIndex,
                        false);
            ksfu_flushBufferedWriter(&bufferedWriter);
        }
        writer->endContainer(writer);
    }
    writer->endContainer(writer);

    ksjson_endEncode(getJsonContext(writer));
    ksfu_closeBufferedWriter(&bufferedWriter);
    ksccd_unfreeze();
}
2.6.2 Crash 日誌的讀取邏輯

當前 App 在 Crash 之後,KSCrash 將資料儲存到 App 沙盒目錄下,App 下次啟動後我們讀取儲存的 crash 檔案,然後處理資料並上傳。

App 啟動後函式呼叫:

[KSCrashInstallation sendAllReportsWithCompletion:] -> [KSCrash sendAllReportsWithCompletion:] -> [KSCrash allReports] -> [KSCrash reportWithIntID:] ->[KSCrash loadCrashReportJSONWithID:] -> kscrs_readReport

sendAllReportsWithCompletion 裡讀取沙盒裡的Crash 資料。

// 先通過讀取資料夾,遍歷資料夾內的檔案數量來判斷 crash 報告的個數
static int getReportCount()
{
    int count = 0;
    DIR* dir = opendir(g_reportsPath);
    if(dir == NULL)
    {
        KSLOG_ERROR("Could not open directory %s", g_reportsPath);
        goto done;
    }
    struct dirent* ent;
    while((ent = readdir(dir)) != NULL)
    {
        if(getReportIDFromFilename(ent->d_name) > 0)
        {
            count++;
        }
    }

done:
    if(dir != NULL)
    {
        closedir(dir);
    }
    return count;
}

// 通過 crash 檔案個數、資料夾資訊去遍歷,一次獲取到檔名(檔名的最後一部分就是 reportID),拿到 reportID 再去讀取 crash 報告內的檔案內容,寫入陣列
- (NSArray*) allReports
{
    int reportCount = kscrash_getReportCount();
    int64_t reportIDs[reportCount];
    reportCount = kscrash_getReportIDs(reportIDs, reportCount);
    NSMutableArray* reports = [NSMutableArray arrayWithCapacity:(NSUInteger)reportCount];
    for(int i = 0; i < reportCount; i++)
    {
        NSDictionary* report = [self reportWithIntID:reportIDs[i]];
        if(report != nil)
        {
            [reports addObject:report];
        }
    }
    
    return reports;
}

//  根據 reportID 找到 crash 資訊
- (NSDictionary*) reportWithIntID:(int64_t) reportID
{
    NSData* jsonData = [self loadCrashReportJSONWithID:reportID];
    if(jsonData == nil)
    {
        return nil;
    }

    NSError* error = nil;
    NSMutableDictionary* crashReport = [KSJSONCodec decode:jsonData
                                                   options:KSJSONDecodeOptionIgnoreNullInArray |
                                                           KSJSONDecodeOptionIgnoreNullInObject |
                                                           KSJSONDecodeOptionKeepPartialObject
                                                     error:&error];
    if(error != nil)
    {
        KSLOG_ERROR(@"Encountered error loading crash report %" PRIx64 ": %@", reportID, error);
    }
    if(crashReport == nil)
    {
        KSLOG_ERROR(@"Could not load crash report");
        return nil;
    }
    [self doctorReport:crashReport];

    return crashReport;
}

//  reportID 讀取 crash 內容並轉換為 NSData 型別
- (NSData*) loadCrashReportJSONWithID:(int64_t) reportID
{
    char* report = kscrash_readReport(reportID);
    if(report != NULL)
    {
        return [NSData dataWithBytesNoCopy:report length:strlen(report) freeWhenDone:YES];
    }
    return nil;
}

// reportID 讀取 crash 資料到 char 型別
char* kscrash_readReport(int64_t reportID)
{
    if(reportID <= 0)
    {
        KSLOG_ERROR("Report ID was %" PRIx64, reportID);
        return NULL;
    }

    char* rawReport = kscrs_readReport(reportID);
    if(rawReport == NULL)
    {
        KSLOG_ERROR("Failed to load report ID %" PRIx64, reportID);
        return NULL;
    }

    char* fixedReport = kscrf_fixupCrashReport(rawReport);
    if(fixedReport == NULL)
    {
        KSLOG_ERROR("Failed to fixup report ID %" PRIx64, reportID);
    }

    free(rawReport);
    return fixedReport;
}

// 多執行緒加鎖,通過 reportID 執行 c 函式 getCrashReportPathByID,將路徑設定到 path 上。然後執行 ksfu_readEntireFile 讀取 crash 資訊到 result
char* kscrs_readReport(int64_t reportID)
{
    pthread_mutex_lock(&g_mutex);
    char path[KSCRS_MAX_PATH_LENGTH];
    getCrashReportPathByID(reportID, path);
    char* result;
    ksfu_readEntireFile(path, &result, NULL, 2000000);
    pthread_mutex_unlock(&g_mutex);
    return result;
}

int kscrash_getReportIDs(int64_t* reportIDs, int count)
{
    return kscrs_getReportIDs(reportIDs, count);
}

int kscrs_getReportIDs(int64_t* reportIDs, int count)
{
    pthread_mutex_lock(&g_mutex);
    count = getReportIDs(reportIDs, count);
    pthread_mutex_unlock(&g_mutex);
    return count;
}
// 迴圈讀取資料夾內容,根據 ent->d_name 呼叫 getReportIDFromFilename 函式,來獲取 reportID,迴圈內部填充陣列
static int getReportIDs(int64_t* reportIDs, int count)
{
    int index = 0;
    DIR* dir = opendir(g_reportsPath);
    if(dir == NULL)
    {
        KSLOG_ERROR("Could not open directory %s", g_reportsPath);
        goto done;
    }

    struct dirent* ent;
    while((ent = readdir(dir)) != NULL && index < count)
    {
        int64_t reportID = getReportIDFromFilename(ent->d_name);
        if(reportID > 0)
        {
            reportIDs[index++] = reportID;
        }
    }

    qsort(reportIDs, (unsigned)count, sizeof(reportIDs[0]), compareInt64);

done:
    if(dir != NULL)
    {
        closedir(dir);
    }
    return index;
}

// sprintf(引數1, 格式2) 函式將格式2的值返回到引數1上,然後執行 sscanf(引數1, 引數2, 引數3),函式將字串引數1的內容,按照引數2的格式,寫入到引數3上。crash 檔案命名為 "App名稱-report-reportID.json"
static int64_t getReportIDFromFilename(const char* filename)
{
    char scanFormat[100];
    sprintf(scanFormat, "%s-report-%%" PRIx64 ".json", g_appName);
    
    int64_t reportID = 0;
    sscanf(filename, scanFormat, &reportID);
    return reportID;
}

KSCrash 儲存 Crash 資料位置

2.7 前端 js 相關的 Crash 的監控

2.7.1 JavascriptCore 異常監控

這部分簡單粗暴,直接通過 JSContext 物件的 exceptionHandler 屬性來監控,比如下面的程式碼

jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) {
    // 處理 jscore 相關的異常資訊    
};
2.7.2 h5 頁面異常監控

當 h5 頁面內的 Javascript 執行異常時會 window 物件會觸發 ErrorEvent 介面的 error 事件,並執行 window.onerror()

window.onerror = function (msg, url, lineNumber, columnNumber, error) {
   // 處理異常資訊
};

h5 異常監控

2.7.3 React Native 異常監控

小實驗:下圖是寫了一個 RN Demo 工程,在 Debug Text 控制元件上加了事件監聽程式碼,內部人為觸發 crash

<Text style={styles.sectionTitle} onPress={()=>{1+qw;}}>Debug</Text>

對比組1:

條件: iOS 專案 debug 模式。在 RN 端增加了異常處理的程式碼。

模擬器點選 command + d 調出皮膚,選擇 Debug,開啟 Chrome 瀏覽器, Mac 下快捷鍵 Command + Option + J 開啟除錯皮膚,就可以像除錯 React 一樣除錯 RN 程式碼了。

React Native Crash Monitor

檢視到 crash stack 後點選可以跳轉到 sourceMap 的地方。

Tips:RN 專案打 Release 包

  • 在專案根目錄下建立資料夾( release_iOS),作為資源的輸出資料夾
  • 在終端切換到工程目錄,然後執行下面的程式碼

    react-native bundle --entry-file index.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.ios.map;
  • 將 release_iOS 資料夾內的 .jsbundleassets 資料夾內容拖入到 iOS 工程中即可

對比組2:

條件:iOS 專案 release 模式。在 RN 端不增加異常處理程式碼

操作:執行 iOS 工程,點選按鈕模擬 crash

現象:iOS 專案奔潰。截圖以及日誌如下

RN crash

2020-06-22 22:26:03.318 [info][tid:main][RCTRootView.m:294] Running application todos ({
    initialProps =     {
    };
    rootTag = 1;
})
2020-06-22 22:26:03.490 [info][tid:com.facebook.react.JavaScript] Running "todos" with {"rootTag":1,"initialProps":{}}
2020-06-22 22:27:38.673 [error][tid:com.facebook.react.JavaScript] ReferenceError: Can't find variable: qw
2020-06-22 22:27:38.675 [fatal][tid:com.facebook.react.ExceptionsManagerQueue] Unhandled JS Exception: ReferenceError: Can't find variable: qw
2020-06-22 22:27:38.691300+0800 todos[16790:314161] *** Terminating app due to uncaught exception 'RCTFatalException: Unhandled JS Exception: ReferenceError: Can't find variable: qw', reason: 'Unhandled JS Exception: ReferenceError: Can't find variable: qw, stack:
onPress@397:1821
<unknown>@203:3896
_performSideEffectsForTransition@210:9689
_performSideEffectsForTransition@(null):(null)
_receiveSignal@210:8425
_receiveSignal@(null):(null)
touchableHandleResponderRelease@210:5671
touchableHandleResponderRelease@(null):(null)
onResponderRelease@203:3006
b@97:1125
S@97:1268
w@97:1322
R@97:1617
M@97:2401
forEach@(null):(null)
U@97:2201
<unknown>@97:13818
Pe@97:90199
Re@97:13478
Ie@97:13664
receiveTouches@97:14448
value@27:3544
<unknown>@27:840
value@27:2798
value@27:812
value@(null):(null)
'
*** First throw call stack:
(
    0   CoreFoundation                      0x00007fff23e3cf0e __exceptionPreprocess + 350
    1   libobjc.A.dylib                     0x00007fff50ba89b2 objc_exception_throw + 48
    2   todos                               0x00000001017b0510 RCTFormatError + 0
    3   todos                               0x000000010182d8ca -[RCTExceptionsManager reportFatal:stack:exceptionId:suppressRedBox:] + 503
    4   todos                               0x000000010182e34e -[RCTExceptionsManager reportException:] + 1658
    5   CoreFoundation                      0x00007fff23e43e8c __invoking___ + 140
    6   CoreFoundation                      0x00007fff23e41071 -[NSInvocation invoke] + 321
    7   CoreFoundation                      0x00007fff23e41344 -[NSInvocation invokeWithTarget:] + 68
    8   todos                               0x00000001017e07fa -[RCTModuleMethod invokeWithBridge:module:arguments:] + 578
    9   todos                               0x00000001017e2a84 _ZN8facebook5reactL11invokeInnerEP9RCTBridgeP13RCTModuleDatajRKN5folly7dynamicE + 246
    10  todos                               0x00000001017e280c ___ZN8facebook5react15RCTNativeModule6invokeEjON5folly7dynamicEi_block_invoke + 78
    11  libdispatch.dylib                   0x00000001025b5f11 _dispatch_call_block_and_release + 12
    12  libdispatch.dylib                   0x00000001025b6e8e _dispatch_client_callout + 8
    13  libdispatch.dylib                   0x00000001025bd6fd _dispatch_lane_serial_drain + 788
    14  libdispatch.dylib                   0x00000001025be28f _dispatch_lane_invoke + 422
    15  libdispatch.dylib                   0x00000001025c9b65 _dispatch_workloop_worker_thread + 719
    16  libsystem_pthread.dylib             0x00007fff51c08a3d _pthread_wqthread + 290
    17  libsystem_pthread.dylib             0x00007fff51c07b77 start_wqthread + 15
)
libc++abi.dylib: terminating with uncaught exception of type NSException
(lldb) 

Tips:如何在 RN release 模式下除錯(看到 js 側的 console 資訊)

  • AppDelegate.m 中引入 #import <React/RCTLog.h>
  • - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions 中加入 RCTSetLogThreshold(RCTLogLevelTrace);

對比組3:

條件:iOS 專案 release 模式。在 RN 端增加異常處理程式碼。

global.ErrorUtils.setGlobalHandler((e) => {
  console.log(e);
  let message = { name: e.name,
                message: e.message,
                stack: e.stack
  };
  axios.get('http://192.168.1.100:8888/test.php', {
      params: { 'message': JSON.stringify(message) }
  }).then(function (response) {
          console.log(response)
  }).catch(function (error) {
  console.log(error)
  });
}, true)

操作:執行 iOS 工程,點選按鈕模擬 crash。

現象:iOS 專案不奔潰。日誌資訊如下,對比 bundle 包中的 js。

RN release log

結論:

在 RN 專案中,如果發生了 crash 則會在 Native 側有相應體現。如果 RN 側寫了 crash 捕獲的程式碼,則 Native 側不會奔潰。如果 RN 側的 crash 沒有捕獲,則 Native 直接奔潰。

RN 專案寫了 crash 監控,監控後將堆疊資訊列印出來發現對應的 js 資訊是經過 webpack 處理的,crash 分析難度很大。所以我們針對 RN 的 crash 需要在 RN 側寫監控程式碼,監控後需要上報,此外針對監控後的資訊需要寫專門的 crash 資訊還原給你,也就是 sourceMap 解析。

2.7.3.1 js 邏輯錯誤

寫過 RN 的人都知道在 DEBUG 模式下 js 程式碼有問題則會產生紅屏,在 RELEASE 模式下則會白屏或者閃退,為了體驗和質量把控需要做異常監控。

在看 RN 原始碼時候發現了 ErrorUtils,看程式碼可以設定處理錯誤資訊。

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @format
 * @flow strict
 * @polyfill
 */

let _inGuard = 0;

type ErrorHandler = (error: mixed, isFatal: boolean) => void;
type Fn<Args, Return> = (...Args) => Return;

/**
 * This is the error handler that is called when we encounter an exception
 * when loading a module. This will report any errors encountered before
 * ExceptionsManager is configured.
 */
let _globalHandler: ErrorHandler = function onError(
  e: mixed,
  isFatal: boolean,
) {
  throw e;
};

/**
 * The particular require runtime that we are using looks for a global
 * `ErrorUtils` object and if it exists, then it requires modules with the
 * error handler specified via ErrorUtils.setGlobalHandler by calling the
 * require function with applyWithGuard. Since the require module is loaded
 * before any of the modules, this ErrorUtils must be defined (and the handler
 * set) globally before requiring anything.
 */
const ErrorUtils = {
  setGlobalHandler(fun: ErrorHandler): void {
    _globalHandler = fun;
  },
  getGlobalHandler(): ErrorHandler {
    return _globalHandler;
  },
  reportError(error: mixed): void {
    _globalHandler && _globalHandler(error, false);
  },
  reportFatalError(error: mixed): void {
    // NOTE: This has an untyped call site in Metro.
    _globalHandler && _globalHandler(error, true);
  },
  applyWithGuard<TArgs: $ReadOnlyArray<mixed>, TOut>(
    fun: Fn<TArgs, TOut>,
    context?: ?mixed,
    args?: ?TArgs,
    // Unused, but some code synced from www sets it to null.
    unused_onError?: null,
    // Some callers pass a name here, which we ignore.
    unused_name?: ?string,
  ): ?TOut {
    try {
      _inGuard++;
      // $FlowFixMe: TODO T48204745 (1) apply(context, null) is fine. (2) array -> rest array should work
      return fun.apply(context, args);
    } catch (e) {
      ErrorUtils.reportError(e);
    } finally {
      _inGuard--;
    }
    return null;
  },
  applyWithGuardIfNeeded<TArgs: $ReadOnlyArray<mixed>, TOut>(
    fun: Fn<TArgs, TOut>,
    context?: ?mixed,
    args?: ?TArgs,
  ): ?TOut {
    if (ErrorUtils.inGuard()) {
      // $FlowFixMe: TODO T48204745 (1) apply(context, null) is fine. (2) array -> rest array should work
      return fun.apply(context, args);
    } else {
      ErrorUtils.applyWithGuard(fun, context, args);
    }
    return null;
  },
  inGuard(): boolean {
    return !!_inGuard;
  },
  guard<TArgs: $ReadOnlyArray<mixed>, TOut>(
    fun: Fn<TArgs, TOut>,
    name?: ?string,
    context?: ?mixed,
  ): ?(...TArgs) => ?TOut {
    // TODO: (moti) T48204753 Make sure this warning is never hit and remove it - types
    // should be sufficient.
    if (typeof fun !== 'function') {
      console.warn('A function must be passed to ErrorUtils.guard, got ', fun);
      return null;
    }
    const guardName = name ?? fun.name ?? '<generated guard>';
    function guarded(...args: TArgs): ?TOut {
      return ErrorUtils.applyWithGuard(
        fun,
        context ?? this,
        args,
        null,
        guardName,
      );
    }

    return guarded;
  },
};

global.ErrorUtils = ErrorUtils;

export type ErrorUtilsT = typeof ErrorUtils;

所以 RN 的異常可以使用 global.ErrorUtils 來設定錯誤處理。舉個例子

global.ErrorUtils.setGlobalHandler(e => {
   // e.name e.message e.stack
}, true);
2.7.3.2 元件問題

其實對於 RN 的 crash 處理還有個需要注意的就是 React Error Boundaries詳細資料

過去,元件內的 JavaScript 錯誤會導致 React 的內部狀態被破壞,並且在下一次渲染時 產生 可能無法追蹤的 錯誤。這些錯誤基本上是由較早的其他程式碼(非 React 元件程式碼)錯誤引起的,但 React 並沒有提供一種在元件中優雅處理這些錯誤的方式,也無法從錯誤中恢復。

部分 UI 的 JavaScript 錯誤不應該導致整個應用崩潰,為了解決這個問題,React 16 引入了一個新的概念 —— 錯誤邊界。

錯誤邊界是一種 React 元件,這種元件可以捕獲並列印發生在其子元件樹任何位置的 JavaScript 錯誤,並且,它會渲染出備用 UI,而不是渲染那些崩潰了的子元件樹。錯誤邊界在渲染期間、生命週期方法和整個元件樹的建構函式中捕獲錯誤。

它能捕獲子元件生命週期函式中的異常,包括建構函式(constructor)和 render 函式

而不能捕獲以下異常:

  • Event handlers(事件處理函式)
  • Asynchronous code(非同步程式碼,如setTimeout、promise等)
  • Server side rendering(服務端渲染)
  • Errors thrown in the error boundary itself (rather than its children)(異常邊界元件本身丟擲的異常)

所以可以通過異常邊界元件捕獲元件生命週期內的所有異常然後渲染兜底元件 ,防止 App crash,提高使用者體驗。也可引導使用者反饋問題,方便問題的排查和修復

至此 RN 的 crash 分為2種,分別是 js 邏輯錯誤、元件 js 錯誤,都已經被監控處理了。接下來就看看如何從工程化層面解決這些問題

2.7.4 RN Crash 還原

SourceMap 檔案對於前端日誌的解析至關重要,SourceMap 檔案中各個引數和如何計算的步驟都在裡面有寫,可以檢視這篇文章

有了 SourceMap 檔案,藉助於 mozilla source-map 專案,可以很好的還原 RN 的 crash 日誌。

我寫了個 NodeJS 指令碼,程式碼如下

var fs = require('fs');
var sourceMap = require('source-map');
var arguments = process.argv.splice(2);

function parseJSError(aLine, aColumn) {
    fs.readFile('./index.ios.map', 'utf8', function (err, data) {
        const whatever =  sourceMap.SourceMapConsumer.with(data, null, consumer => {
            // 讀取 crash 日誌的行號、列號
            let parseData = consumer.originalPositionFor({
                line: parseInt(aLine),
                column: parseInt(aColumn)
            });
            // 輸出到控制檯
            console.log(parseData);
            // 輸出到檔案中
            fs.writeFileSync('./parsed.txt', JSON.stringify(parseData) + '\n', 'utf8', function(err) {  
                if(err) {  
                    console.log(err);
                }
            });
        });
    });
}

var line = arguments[0];
var column = arguments[1];
parseJSError(line, column);

接下來做個實驗,還是上述的 todos 專案。

  1. 在 Text 的點選事件上模擬 crash

    <Text style={styles.sectionTitle} onPress={()=>{1+qw;}}>Debug</Text>
  2. 將 RN 專案打 bundle 包、產出 sourceMap 檔案。執行命令,

    react-native bundle --entry-file index.js --platform android --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.android.map;

    因為高頻使用,所以給 iterm2 增加 alias 別名設定,修改 .zshrc 檔案

    alias RNRelease='react-native bundle --entry-file index.js --platform ios --dev false --bundle-output release_ios/main.jsbundle --assets-dest release_iOS --sourcemap-output release_ios/index.ios.map;' # RN 打 Release 包
  3. 將 js bundle 和圖片資源拷貝到 Xcode 工程中
  4. 點選模擬 crash,將日誌下面的行號和列號拷貝,在 Node 專案下,執行下面命令

    node index.js 397 1822
  5. 拿指令碼解析好的行號、列號、檔案資訊去和原始碼檔案比較,結果很正確。

RN Log analysis

2.7.5 SourceMap 解析系統設計

目的:通過平臺可以將 RN 專案線上 crash 可以還原到具體的檔案、程式碼行數、程式碼列數。可以看到具體的程式碼,可以看到 RN stack trace、提供原始檔下載功能。

  1. 打包系統下管理的伺服器:

    • 生產環境下打包才生成 source map 檔案
    • 儲存打包前的所有檔案(install)
  2. 開發產品側 RN 分析介面。點選收集到的 RN crash,在詳情頁可以看到具體的檔案、程式碼行數、程式碼列數。可以看到具體的程式碼,可以看到 RN stack trace、Native stack trace。(具體技術實現上面講過了)
  3. 由於 souece map 檔案較大,RN 解析過長雖然不久,但是是對計算資源的消耗,所以需要設計高效讀取方式
  4. SourceMap 在 iOS、Android 模式下不一樣,所以 SoureceMap 儲存需要區分 os。

3. KSCrash 的使用包裝

然後再封裝自己的 Crash 處理邏輯。比如要做的事情就是:

  • 繼承自 KSCrashInstallation 這個抽象類,設定初始化工作(抽象類比如 NSURLProtocol 必須繼承後使用),實現抽象類中的 sink 方法。

    carbon (17).png

    carbon (18).png

  • sink 方法內部的 CMCrashReporterSink 類,遵循了 KSCrashReportFilter 協議,宣告瞭公有方法 defaultCrashReportFilterSetAppleFmt

    carbon (19).png

    其中 defaultCrashReportFilterSetAppleFmt 方法內部返回了一個 KSCrashReportFilterPipeline 類方法 filterWithFilters 的結果。

    CMCrashReportFilterAppleFmt 是一個繼承自 KSCrashReportFilterAppleFmt 的類,遵循了 KSCrashReportFilter 協議。協議方法允許開發者處理 Crash 的資料格式。

    carbon (20).png

    carbon (21).png
    carbon (22).png

  • APM 能力中為 Crash 模組設定一個啟動器。啟動器內部設定 KSCrash 的初始化工作,以及觸發 Crash 時候監控所需資料的組裝。比如:SESSION_ID、App 啟動時間、App 名稱、崩潰時間、App 版本號、當前頁面資訊等基礎資訊。

    carbon (23).png
    carbon (24).png

    installKSCrash 方法中呼叫了 [[CMCrashInstallation sharedInstance] sendAllReportsWithCompletion: nil],內部實現如下

    carbon (25).png

    方法內部將 KSCrashInstallationsink 賦值給 KSCrash 物件。 內部還是呼叫了 KSCrashsendAllReportsWithCompletion 方法,實現如下

    carbon (26).png

    該方法內部呼叫了物件方法 sendReports: onCompletion:,如下所示

    carbon (27).png

    方法內部的 [self.sink filterReports: onCompletion: ] 實現其實就是 CMCrashInstallation 中設定的 sink getter 方法,內部返回了 CMCrashReporterSink 物件的 defaultCrashReportFilterSetAppleFmt 方法的返回值。內部實現如下

    carbon (28).png

    可以看到這個函式內部設定了多個 filters,其中一個就是 self,也就是 CMCrashReporterSink 物件,所以上面的 [self.sink filterReports: onCompletion:] ,也就是呼叫 CMCrashReporterSink 內的資料處理方法。完了之後通過 kscrash_callCompletion(onCompletion, reports, YES, nil); 告訴 KSCrash 本地儲存的 Crash 日誌已經處理完畢,可以刪除了。
    carbon (29).png

    至此,概括下 KSCrash 做的事情,提供各種 crash 的監控能力,在 crash 後將程式資訊、基本資訊、異常資訊、執行緒資訊等用 c 高效轉換為 json 寫入檔案,App 下次啟動後讀取本地的 crash 資料夾中的 crash 日誌,讓開發者可以自定義 key、value 然後去上報日誌到 APM 系統,然後刪除本地 crash 資料夾中的日誌。

4. 符號化

應用 crash 之後,系統會生成一份崩潰日誌,儲存在設定中,應用的執行狀態、呼叫堆疊、所處執行緒等資訊會記錄在日誌中。但是這些日誌是地址,並不可讀,所以需要進行符號化還原。

4.1 .dSYM 檔案

.dSYM (debugging symbol)檔案是儲存十六進位制函式地址對映資訊的中轉檔案,除錯資訊(symbols)都包含在該檔案中。Xcode 工程每次編譯執行都會生成新的 .dSYM 檔案。預設情況下 debug 模式時不生成 .dSYM ,可以在 Build Settings -> Build Options -> Debug Information Format 後將值 DWARF 修改為 DWARF with dSYM File,這樣再次編譯執行就可以生成 .dSYM 檔案。

所以每次 App 打包的時候都需要儲存每個版本的 .dSYM 檔案。

.dSYM 檔案中包含 DWARF 資訊,開啟檔案的包內容 Test.app.dSYM/Contents/Resources/DWARF/Test 儲存的就是 DWARF 檔案。

.dSYM 檔案是從 Mach-O 檔案中抽取除錯資訊而得到的檔案目錄,釋出的時候為了安全,會把除錯資訊儲存在單獨的檔案,.dSYM 其實是一個檔案目錄,結構如下:

.dSYM檔案結構

4.2 DWARF 檔案

DWARF is a debugging file format used by many compilers and debuggers to support source level debugging. It addresses the requirements of a number of procedural languages, such as C, C++, and Fortran, and is designed to be extensible to other languages. DWARF is architecture independent and applicable to any processor or operating system. It is widely used on Unix, Linux and other operating systems, as well as in stand-alone environments.

DWARF 是一種除錯檔案格式,它被許多編譯器和偵錯程式所廣泛使用以支援原始碼級別的除錯。它滿足許多過程語言(C、C++、Fortran)的需求,它被設計為支援擴充到其他語言。DWARF 是架構獨立的,適用於其他任何的處理器和作業系統。被廣泛使用在 Unix、Linux 和其他的作業系統上,以及獨立環境上。

DWARF 全稱是 Debugging With Arbitrary Record Formats,是一種使用屬性化記錄格式的除錯檔案。

DWARF 是可執行程式與原始碼關係的一個緊湊表示。

大多數現代程式語言都是塊結構:每個實體(一個類、一個函式)被包含在另一個實體中。一個 c 程式,每個檔案可能包含多個資料定義、多個變數、多個函式,所以 DWARF 遵循這個模型,也是塊結構。DWARF 裡基本的描述項是除錯資訊項 DIE(Debugging Information Entry)。一個 DIE 有一個標籤,表示這個 DIE 描述了什麼以及一個填入了細節並進一步描述該項的屬性列表(類比 html、xml 結構)。一個 DIE(除了最頂層的)被一個父 DIE 包含,可能存在兄弟 DIE 或者子 DIE,屬性可能包含各種值:常量(比如一個函式名),變數(比如一個函式的起始地址),或對另一個DIE的引用(比如一個函式的返回值型別)。

DWARF 檔案中的資料如下:

資料列 資訊說明
.debug_loc 在 DW_AT_location 屬性中使用的位置列表
.debug_macinfo 巨集資訊
.debug_pubnames 全域性物件和函式的查詢表
.debug_pubtypes 全域性型別的查詢表
.debug_ranges 在 DW_AT_ranges 屬性中使用的地址範圍
.debug_str 在 .debug_info 中使用的字串表
.debug_types 型別描述

常用的標記與屬性如下:

資料列 資訊說明
DW_TAG_class_type 表示類名稱和型別資訊
DW_TAG_structure_type 表示結構名稱和型別資訊
DW_TAG_union_type 表示聯合名稱和型別資訊
DW_TAG_enumeration_type 表示列舉名稱和型別資訊
DW_TAG_typedef 表示 typedef 的名稱和型別資訊
DW_TAG_array_type 表示陣列名稱和型別資訊
DW_TAG_subrange_type 表示陣列的大小資訊
DW_TAG_inheritance 表示繼承的類名稱和型別資訊
DW_TAG_member 表示類的成員
DW_TAG_subprogram 表示函式的名稱資訊
DW_TAG_formal_parameter 表示函式的引數資訊
DW_TAG_name 表示名稱字串
DW_TAG_type 表示型別資訊
DW_TAG_artifical 在建立時由編譯程式設定
DW_TAG_sibling 表示兄弟位置資訊
DW_TAG_data_memver_location 表示位置資訊
DW_TAG_virtuality 在虛擬時設定

簡單看一個 DWARF 的例子:將測試工程的 .dSYM 資料夾下的 DWARF 檔案用下面命令解析

dwarfdump -F --debug-info Test.app.dSYM/Contents/Resources/DWARF/Test > debug-info.txt

開啟如下

Test.app.dSYM/Contents/Resources/DWARF/Test:    file format Mach-O arm64

.debug_info contents:
0x00000000: Compile Unit: length = 0x0000004f version = 0x0004 abbr_offset = 0x0000 addr_size = 0x08 (next unit at 0x00000053)

0x0000000b: DW_TAG_compile_unit
              DW_AT_producer [DW_FORM_strp]    ("Apple clang version 11.0.3 (clang-1103.0.32.62)")
              DW_AT_language [DW_FORM_data2]    (DW_LANG_ObjC)
              DW_AT_name [DW_FORM_strp]    ("_Builtin_stddef_max_align_t")
              DW_AT_stmt_list [DW_FORM_sec_offset]    (0x00000000)
              DW_AT_comp_dir [DW_FORM_strp]    ("/Users/lbp/Desktop/Test")
              DW_AT_APPLE_major_runtime_vers [DW_FORM_data1]    (0x02)
              DW_AT_GNU_dwo_id [DW_FORM_data8]    (0x392b5344d415340c)

0x00000027:   DW_TAG_module
                DW_AT_name [DW_FORM_strp]    ("_Builtin_stddef_max_align_t")
                DW_AT_LLVM_config_macros [DW_FORM_strp]    ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"")
                DW_AT_LLVM_include_path [DW_FORM_strp]    ("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include")
                DW_AT_LLVM_isysroot [DW_FORM_strp]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk")

0x00000038:     DW_TAG_typedef
                  DW_AT_type [DW_FORM_ref4]    (0x0000004b "long double")
                  DW_AT_name [DW_FORM_strp]    ("max_align_t")
                  DW_AT_decl_file [DW_FORM_data1]    ("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include/__stddef_max_align_t.h")
                  DW_AT_decl_line [DW_FORM_data1]    (16)

0x00000043:     DW_TAG_imported_declaration
                  DW_AT_decl_file [DW_FORM_data1]    ("/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include/__stddef_max_align_t.h")
                  DW_AT_decl_line [DW_FORM_data1]    (27)
                  DW_AT_import [DW_FORM_ref_addr]    (0x0000000000000027)

0x0000004a:     NULL

0x0000004b:   DW_TAG_base_type
                DW_AT_name [DW_FORM_strp]    ("long double")
                DW_AT_encoding [DW_FORM_data1]    (DW_ATE_float)
                DW_AT_byte_size [DW_FORM_data1]    (0x08)

0x00000052:   NULL
0x00000053: Compile Unit: length = 0x000183dc version = 0x0004 abbr_offset = 0x0000 addr_size = 0x08 (next unit at 0x00018433)

0x0000005e: DW_TAG_compile_unit
              DW_AT_producer [DW_FORM_strp]    ("Apple clang version 11.0.3 (clang-1103.0.32.62)")
              DW_AT_language [DW_FORM_data2]    (DW_LANG_ObjC)
              DW_AT_name [DW_FORM_strp]    ("Darwin")
              DW_AT_stmt_list [DW_FORM_sec_offset]    (0x000000a7)
              DW_AT_comp_dir [DW_FORM_strp]    ("/Users/lbp/Desktop/Test")
              DW_AT_APPLE_major_runtime_vers [DW_FORM_data1]    (0x02)
              DW_AT_GNU_dwo_id [DW_FORM_data8]    (0xa4a1d339379e18a5)

0x0000007a:   DW_TAG_module
                DW_AT_name [DW_FORM_strp]    ("Darwin")
                DW_AT_LLVM_config_macros [DW_FORM_strp]    ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"")
                DW_AT_LLVM_include_path [DW_FORM_strp]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include")
                DW_AT_LLVM_isysroot [DW_FORM_strp]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk")

0x0000008b:     DW_TAG_module
                  DW_AT_name [DW_FORM_strp]    ("C")
                  DW_AT_LLVM_config_macros [DW_FORM_strp]    ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"")
                  DW_AT_LLVM_include_path [DW_FORM_strp]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include")
                  DW_AT_LLVM_isysroot [DW_FORM_strp]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk")

0x0000009c:       DW_TAG_module
                    DW_AT_name [DW_FORM_strp]    ("fenv")
                    DW_AT_LLVM_config_macros [DW_FORM_strp]    ("\"-DDEBUG=1\" \"-DOBJC_OLD_DISPATCH_PROTOTYPES=1\"")
                    DW_AT_LLVM_include_path [DW_FORM_strp]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include")
                    DW_AT_LLVM_isysroot [DW_FORM_strp]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk")

0x000000ad:         DW_TAG_enumeration_type
                      DW_AT_type [DW_FORM_ref4]    (0x00017276 "unsigned int")
                      DW_AT_byte_size [DW_FORM_data1]    (0x04)
                      DW_AT_decl_file [DW_FORM_data1]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/fenv.h")
                      DW_AT_decl_line [DW_FORM_data1]    (154)

0x000000b5:           DW_TAG_enumerator
                        DW_AT_name [DW_FORM_strp]    ("__fpcr_trap_invalid")
                        DW_AT_const_value [DW_FORM_udata]    (256)

0x000000bc:           DW_TAG_enumerator
                        DW_AT_name [DW_FORM_strp]    ("__fpcr_trap_divbyzero")
                        DW_AT_const_value [DW_FORM_udata]    (512)

0x000000c3:           DW_TAG_enumerator
                        DW_AT_name [DW_FORM_strp]    ("__fpcr_trap_overflow")
                        DW_AT_const_value [DW_FORM_udata]    (1024)

0x000000ca:           DW_TAG_enumerator
                        DW_AT_name [DW_FORM_strp]    ("__fpcr_trap_underflow")
// ......
0x000466ee:   DW_TAG_subprogram
                DW_AT_name [DW_FORM_strp]    ("CFBridgingRetain")
                DW_AT_decl_file [DW_FORM_data1]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObject.h")
                DW_AT_decl_line [DW_FORM_data1]    (105)
                DW_AT_prototyped [DW_FORM_flag_present]    (true)
                DW_AT_type [DW_FORM_ref_addr]    (0x0000000000019155 "CFTypeRef")
                DW_AT_inline [DW_FORM_data1]    (DW_INL_inlined)

0x000466fa:     DW_TAG_formal_parameter
                  DW_AT_name [DW_FORM_strp]    ("X")
                  DW_AT_decl_file [DW_FORM_data1]    ("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/System/Library/Frameworks/Foundation.framework/Headers/NSObject.h")
                  DW_AT_decl_line [DW_FORM_data1]    (105)
                  DW_AT_type [DW_FORM_ref4]    (0x00046706 "id")

0x00046705:     NULL

0x00046706:   DW_TAG_typedef
                DW_AT_type [DW_FORM_ref4]    (0x00046711 "objc_object*")
                DW_AT_name [DW_FORM_strp]    ("id")
                DW_AT_decl_file [DW_FORM_data1]    ("/Users/lbp/Desktop/Test/Test/NetworkAPM/NSURLResponse+cm_FetchStatusLineFromCFNetwork.m")
                DW_AT_decl_line [DW_FORM_data1]    (44)

0x00046711:   DW_TAG_pointer_type
                DW_AT_type [DW_FORM_ref4]    (0x00046716 "objc_object")

0x00046716:   DW_TAG_structure_type
                DW_AT_name [DW_FORM_strp]    ("objc_object")
                DW_AT_byte_size [DW_FORM_data1]    (0x00)

0x0004671c:     DW_TAG_member
                  DW_AT_name [DW_FORM_strp]    ("isa")
                  DW_AT_type [DW_FORM_ref4]    (0x00046727 "objc_class*")
                  DW_AT_data_member_location [DW_FORM_data1]    (0x00)
// ......

這裡就不貼上全部內容了(太長了)。可以看到 DIE 包含了函式開始地址、結束地址、函式名、檔名、所在行數,對於給定的地址,找到函式開始地址、結束地址之間包含該抵制的 DIE,則可以還原函式名和檔名資訊。

debug_line 可以還原檔案行數等資訊

dwarfdump -F --debug-line Test.app.dSYM/Contents/Resources/DWARF/Test > debug-inline.txt

貼部分資訊

Test.app.dSYM/Contents/Resources/DWARF/Test:    file format Mach-O arm64

.debug_line contents:
debug_line[0x00000000]
Line table prologue:
    total_length: 0x000000a3
         version: 4
 prologue_length: 0x0000009a
 min_inst_length: 1
max_ops_per_inst: 1
 default_is_stmt: 1
       line_base: -5
      line_range: 14
     opcode_base: 13
standard_opcode_lengths[DW_LNS_copy] = 0
standard_opcode_lengths[DW_LNS_advance_pc] = 1
standard_opcode_lengths[DW_LNS_advance_line] = 1
standard_opcode_lengths[DW_LNS_set_file] = 1
standard_opcode_lengths[DW_LNS_set_column] = 1
standard_opcode_lengths[DW_LNS_negate_stmt] = 0
standard_opcode_lengths[DW_LNS_set_basic_block] = 0
standard_opcode_lengths[DW_LNS_const_add_pc] = 0
standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1
standard_opcode_lengths[DW_LNS_set_prologue_end] = 0
standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0
standard_opcode_lengths[DW_LNS_set_isa] = 1
include_directories[  1] = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include"
file_names[  1]:
           name: "__stddef_max_align_t.h"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000

Address            Line   Column File   ISA Discriminator Flags
------------------ ------ ------ ------ --- ------------- -------------
0x0000000000000000      1      0      1   0             0  is_stmt end_sequence
debug_line[0x000000a7]
Line table prologue:
    total_length: 0x0000230a
         version: 4
 prologue_length: 0x00002301
 min_inst_length: 1
max_ops_per_inst: 1
 default_is_stmt: 1
       line_base: -5
      line_range: 14
     opcode_base: 13
standard_opcode_lengths[DW_LNS_copy] = 0
standard_opcode_lengths[DW_LNS_advance_pc] = 1
standard_opcode_lengths[DW_LNS_advance_line] = 1
standard_opcode_lengths[DW_LNS_set_file] = 1
standard_opcode_lengths[DW_LNS_set_column] = 1
standard_opcode_lengths[DW_LNS_negate_stmt] = 0
standard_opcode_lengths[DW_LNS_set_basic_block] = 0
standard_opcode_lengths[DW_LNS_const_add_pc] = 0
standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1
standard_opcode_lengths[DW_LNS_set_prologue_end] = 0
standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0
standard_opcode_lengths[DW_LNS_set_isa] = 1
include_directories[  1] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include"
include_directories[  2] = "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang/11.0.3/include"
include_directories[  3] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys"
include_directories[  4] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach"
include_directories[  5] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/libkern"
include_directories[  6] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/architecture"
include_directories[  7] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys/_types"
include_directories[  8] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/_types"
include_directories[  9] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/arm"
include_directories[ 10] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/sys/_pthread"
include_directories[ 11] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach/arm"
include_directories[ 12] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/libkern/arm"
include_directories[ 13] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/uuid"
include_directories[ 14] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/netinet"
include_directories[ 15] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/netinet6"
include_directories[ 16] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/net"
include_directories[ 17] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/pthread"
include_directories[ 18] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach_debug"
include_directories[ 19] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/os"
include_directories[ 20] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/malloc"
include_directories[ 21] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/bsm"
include_directories[ 22] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/machine"
include_directories[ 23] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/mach/machine"
include_directories[ 24] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/secure"
include_directories[ 25] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/xlocale"
include_directories[ 26] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/arpa"
file_names[  1]:
           name: "fenv.h"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000
file_names[  2]:
           name: "stdatomic.h"
      dir_index: 2
       mod_time: 0x00000000
         length: 0x00000000
file_names[  3]:
           name: "wait.h"
      dir_index: 3
       mod_time: 0x00000000
         length: 0x00000000
// ......
Address            Line   Column File   ISA Discriminator Flags
------------------ ------ ------ ------ --- ------------- -------------
0x000000010000b588     14      0      2   0             0  is_stmt
0x000000010000b5b4     16      5      2   0             0  is_stmt prologue_end
0x000000010000b5d0     17     11      2   0             0  is_stmt
0x000000010000b5d4      0      0      2   0             0 
0x000000010000b5d8     17      5      2   0             0 
0x000000010000b5dc     17     11      2   0             0 
0x000000010000b5e8     18      1      2   0             0  is_stmt
0x000000010000b608     20      0      2   0             0  is_stmt
0x000000010000b61c     22      5      2   0             0  is_stmt prologue_end
0x000000010000b628     23      5      2   0             0  is_stmt
0x000000010000b644     24      1      2   0             0  is_stmt
0x000000010000b650     15      0      1   0             0  is_stmt
0x000000010000b65c     15     41      1   0             0  is_stmt prologue_end
0x000000010000b66c     11      0      2   0             0  is_stmt
0x000000010000b680     11     17      2   0             0  is_stmt prologue_end
0x000000010000b6a4     11     17      2   0             0  is_stmt end_sequence
debug_line[0x0000def9]
Line table prologue:
    total_length: 0x0000015a
         version: 4
 prologue_length: 0x000000eb
 min_inst_length: 1
max_ops_per_inst: 1
 default_is_stmt: 1
       line_base: -5
      line_range: 14
     opcode_base: 13
standard_opcode_lengths[DW_LNS_copy] = 0
standard_opcode_lengths[DW_LNS_advance_pc] = 1
standard_opcode_lengths[DW_LNS_advance_line] = 1
standard_opcode_lengths[DW_LNS_set_file] = 1
standard_opcode_lengths[DW_LNS_set_column] = 1
standard_opcode_lengths[DW_LNS_negate_stmt] = 0
standard_opcode_lengths[DW_LNS_set_basic_block] = 0
standard_opcode_lengths[DW_LNS_const_add_pc] = 0
standard_opcode_lengths[DW_LNS_fixed_advance_pc] = 1
standard_opcode_lengths[DW_LNS_set_prologue_end] = 0
standard_opcode_lengths[DW_LNS_set_epilogue_begin] = 0
standard_opcode_lengths[DW_LNS_set_isa] = 1
include_directories[  1] = "Test"
include_directories[  2] = "Test/NetworkAPM"
include_directories[  3] = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.5.sdk/usr/include/objc"
file_names[  1]:
           name: "AppDelegate.h"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000
file_names[  2]:
           name: "JMWebResourceURLProtocol.h"
      dir_index: 2
       mod_time: 0x00000000
         length: 0x00000000
file_names[  3]:
           name: "AppDelegate.m"
      dir_index: 1
       mod_time: 0x00000000
         length: 0x00000000
file_names[  4]:
           name: "objc.h"
      dir_index: 3
       mod_time: 0x00000000
         length: 0x00000000
// ......

可以看到 debug_line 裡包含了每個程式碼地址對應的行數。上面貼了 AppDelegate 的部分。

4.3 symbols

在連結中,我們將函式和變數統稱為符合(Symbol),函式名或變數名就是符號名(Symbol Name),我們可以將符號看成是連結中的粘合劑,整個連結過程正是基於符號才能正確完成的。

上述文字來自《程式設計師的自我修養》。所以符號就是函式、變數、類的統稱。

按照型別劃分,符號可以分為三類:

  • 全域性符號:目標檔案外可見的符號,可以被其他目標檔案所引用,或者需要其他目標檔案定義
  • 區域性符號:只在目標檔案內可見的符號,指只在目標檔案內可見的函式和變數
  • 除錯符號:包括行號資訊的除錯符號資訊,行號資訊記錄了函式和變數對應的檔案和檔案行號。

符號表(Symbol Table):是記憶體地址與函式名、檔名、行號的對映表。每個定義的符號都有一個對應的值得,叫做符號值(Symbol Value),對於變數和函式來說,符號值就是地址,符號表組成如下

<起始地址> <結束地址> <函式> [<檔名:行號>]

4.4 如何獲取地址?

image 載入的時候會進行相對基地址進行重定位,並且每次載入的基地址都不一樣,函式棧 frame 的地址是重定位後的絕對地址,我們要的是重定位前的相對地址。

Binary Images

拿測試工程的 crash 日誌舉例子,開啟貼部分 Binary Images 內容

// ...
Binary Images:
0x102fe0000 - 0x102ff3fff Test arm64  <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test
0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64  <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib
0x103204000 - 0x103267fff dyld arm64  <6f1c86b640a3352a8529bca213946dd5> /usr/lib/dyld
0x189a78000 - 0x189a8efff libsystem_trace.dylib arm64  <b7477df8f6ab3b2b9275ad23c6cc0b75> /usr/lib/system/libsystem_trace.dylib
// ...

可以看到 Crash 日誌的 Binary Images 包含每個 Image 的載入開始地址、結束地址、image 名稱、arm 架構、uuid、image 路徑。

crash 日誌中的資訊

Last Exception Backtrace:
// ...
5   Test                              0x102fe592c -[ViewController testMonitorCrash] + 22828 (ViewController.mm:58)
Binary Images:
0x102fe0000 - 0x102ff3fff Test arm64  <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test

所以 frame 5 的相對地址為 0x102fe592c - 0x102fe0000 。再使用 命令可以還原符號資訊。

使用 atos 來解析,0x102fe0000 為 image 載入的開始地址,0x102fe592c 為 frame 需要還原的地址。

atos -o Test.app.dSYM/Contents/Resources/DWARF/Test-arch arm64 -l 0x102fe0000 0x102fe592c

4.5 UUID

  • crash 檔案的 UUID

    grep --after-context=2 "Binary Images:" *.crash
    Test  5-28-20, 7-47 PM.crash:Binary Images:
    Test  5-28-20, 7-47 PM.crash-0x102fe0000 - 0x102ff3fff Test arm64  <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test
    Test  5-28-20, 7-47 PM.crash-0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64  <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib
    --
    Test.crash:Binary Images:
    Test.crash-0x102fe0000 - 0x102ff3fff Test arm64  <37eaa57df2523d95969e47a9a1d69ce5> /var/containers/Bundle/Application/643F0DFE-A710-4136-A278-A89D780B7208/Test.app/Test
    Test.crash-0x1030e0000 - 0x1030ebfff libobjc-trampolines.dylib arm64  <181f3aa866d93165ac54344385ac6e1d> /usr/lib/libobjc-trampolines.dylib

    Test App 的 UUID 為 37eaa57df2523d95969e47a9a1d69ce5.

  • .dSYM 檔案的 UUID

    dwarfdump --uuid Test.app.dSYM

    結果為

    UUID: 37EAA57D-F252-3D95-969E-47A9A1D69CE5 (arm64) Test.app.dSYM/Contents/Resources/DWARF/Test
  • app 的 UUID

    dwarfdump --uuid Test.app/Test

    結果為

    UUID: 37EAA57D-F252-3D95-969E-47A9A1D69CE5 (arm64) Test.app/Test

4.6 符號化(解析 Crash 日誌)

上述篇幅分析瞭如何捕獲各種型別的 crash,App 在使用者手中我們通過技術手段可以獲取 crash 案發現場資訊並結合一定的機制去上報,但是這種堆疊是十六進位制的地址,無法定位問題,所以需要做符號化處理。

上面也說明了.dSYM 檔案 的作用,通過符號地址結合 dSYM 檔案來還原檔名、所在行、函式名,這個過程叫符號化。但是 .dSYM 檔案必須和 crash log 檔案的 bundle id、version 嚴格對應。

獲取 Crash 日誌可以通過 Xcode -> Window -> Devices and Simulators 選擇對應裝置,找到 Crash 日誌檔案,根據時間和 App 名稱定位。

app 和 .dSYM 檔案可以通過打包的產物得到,路徑為 ~/Library/Developer/Xcode/Archives

解析方法一般有2種:

  • 使用 symbolicatecrash

    symbolicatecrash 是 Xcode 自帶的 crash 日誌分析工具,先確定所在路徑,在終端執行下面的命令

    find /Applications/Xcode.app -name symbolicatecrash -type f

    會返回幾個路徑,找到 iPhoneSimulator.platform 所在那一行

    /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Library/PrivateFrameworks/DVTFoundation.framework/symbolicatecrash

    將 symbolicatecrash 拷貝到指定資料夾下(儲存了 app、dSYM、crash 檔案的資料夾)

    執行命令

    ./symbolicatecrash Test.crash Test.dSYM > Test.crash

    第一次做這事兒應該會報錯 Error: "DEVELOPER_DIR" is not defined at ./symbolicatecrash line 69.,解決方案:在終端執行下面命令

    export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
  • 使用 atos

    區別於 symbolicatecrash,atos 較為靈活,只要 .crash.dSYM 或者 .crash.app 檔案對應即可。

    用法如下,-l 最後跟得是符號地址

    xcrun atos -o Test.app.dSYM/Contents/Resources/DWARF/Test -arch armv7 -l 0x1023c592c

    也可以解析 .app 檔案(不存在 .dSYM 檔案),其中xxx為段地址,xx為偏移地址

    atos -arch architecture -o binary -l xxx xx

因為我們的 App 可能有很多,每個 App 在使用者手中可能是不同的版本,所以在 APM 攔截之後需要符號化的時候需要將 crash 檔案和 .dSYM 檔案一一對應,才能正確符號化,對應的原則就是 UUID 一致。

4.7 系統庫符號化解析

我們每次真機連線 Xcode 執行程式,會提示等待,其實系統為了堆疊解析,都會把當前版本的系統符號庫自動匯入到 /Users/你自己的使用者名稱/Library/Developer/Xcode/iOS DeviceSupport 目錄下安裝了一大堆系統庫的符號化檔案。你可以訪問下面目錄看看

/Users/你自己的使用者名稱/Library/Developer/Xcode/iOS DeviceSupport/

系統符號化檔案

5. 服務端處理

5.1 ELK 日誌系統

業界設計日誌監控系統一般會採用基於 ELK 技術。ELK 是 Elasticsearch、Logstash、Kibana 三個開源框架縮寫。Elasticsearch 是一個分散式、通過 Restful 方式進行互動的近實時搜尋的平臺框架。Logstash 是一箇中央資料流引擎,用於從不同目標(檔案/資料儲存/MQ)收集不同格式的資料,經過過濾後支援輸出到不同目的地(檔案/MQ/Redis/ElasticsSearch/Kafka)。Kibana 可以將 Elasticserarch 的資料通過友好的頁面展示出來,提供視覺化分析功能。所以 ELK 可以搭建一個高效、企業級的日誌分析系統。

早期單體應用時代,幾乎應用的所有功能都在一臺機器上執行,出了問題,運維人員開啟終端輸入命令直接檢視系統日誌,進而定位問題、解決問題。隨著系統的功能越來越複雜,使用者體量越來越大,單體應用幾乎很難滿足需求,所以技術架構迭代了,通過水平擴充來支援龐大的使用者量,將單體應用進行拆分為多個應用,每個應用採用叢集方式部署,負載均衡控制排程,假如某個子模組發生問題,去找這臺伺服器上終端找日誌分析嗎?顯然臺落後,所以日誌管理平臺便應運而生。通過 Logstash 去收集分析每臺伺服器的日誌檔案,然後按照定義的正則模版過濾後傳輸到 Kafka 或 Redis,然後由另一個 Logstash 從 Kafka 或 Redis 上讀取日誌儲存到 ES 中建立索引,最後通過 Kibana 進行視覺化分析。此外可以將收集到的資料進行資料分析,做更進一步的維護和決策。

ELK架構圖

上圖展示了一個 ELK 的日誌架構圖。簡單說明下:

  • Logstash 和 ES 之前存在一個 Kafka 層,因為 Logstash 是架設在資料資源伺服器上,將收集到的資料進行實時過濾,過濾需要消耗時間和記憶體,所以存在 Kafka,起到了資料緩衝儲存作用,因為 Kafka 具備非常出色的讀寫效能。
  • 再一步就是 Logstash 從 Kafka 裡面進行讀取資料,將資料過濾、處理,將結果傳輸到 ES
  • 這個設計不但效能好、耦合低,還具備可擴充性。比如可以從 n 個不同的 Logstash 上讀取傳輸到 n 個 Kafka 上,再由 n 個 Logstash 過濾處理。日誌來源可以是 m 個,比如 App 日誌、Tomcat 日誌、Nginx 日誌等等

下圖貼一個 Elasticsearch 社群分享的一個 “Elastic APM 動手實戰”主題的內容截圖。

Elasticsearch & APM

5.2 服務側

Crash log 統一入庫 Kibana 時是沒有符號化的,所以需要符號化處理,以方便定位問題、crash 產生報表和後續處理。

crash log 處理流程

所以整個流程就是:客戶端 APM SDK 收集 crash log -> Kafka 儲存 -> Mac 機執行定時任務符號化 -> 資料回傳 Kafka -> 產品側(顯示端)對資料進行分類、報表、報警等操作。

因為公司的產品線有多條,相應的 App 有多個,使用者使用的 App 版本也各不相同,所以 crash 日誌分析必須要有正確的 .dSYM 檔案,那麼多 App 的不同版本,自動化就變得非常重要了。

自動化有2種手段,規模小一點的公司或者圖省事,可以在 Xcode中 新增 runScript 指令碼程式碼來自動在 release 模式下上傳dSYM)。

因為我們公司有自己的一套體系,wax-cli,可以同時管理 iOS SDK、iOS App、Android SDK、Android App、Node、React、React Native 工程專案的初始化、依賴管理、構建(持續整合、Unit Test、Lint、統跳檢測)、測試、打包、部署、動態能力(熱更新、統跳路由下發)等能力於一身。可以基於各個階段做能力的插入,所以可以在呼叫打包後在打包機上傳 .dSYM 檔案到七牛雲端儲存(規則可以是以 AppName + Version 為 key,value 為 .dSYM 檔案)。

現在很多架構設計都是微服務,至於為什麼選微服務,不在本文範疇。所以 crash 日誌的符號化被設計為一個微服務。架構圖如下

crash 符號化流程圖

說明:

  • Symbolication Service 作為整個監控系統 Prism 的一個組成部分,是專注於 crash report 符號化的微服務。
  • 接收來自 mass 的包含預處理過的 crash report 和 dsym index 的請求,從七牛拉取對應的 dsym,對 crash report 做符號化解析,計算 hash,並將 hash 響應給 mass。
  • 接收來自 Prism 管理系統的包含原始 crash report 和 dsym index 的請求,從七牛拉取對應的 dsym,對crash report 做符號化解析,並將符號化的 crash report 響應給 Prism 管理系統。
  • Mass 是一個通用的資料處理(流式/批式)和任務排程框架
  • candle 是一個打包系統,上面說的 wax-cli 有個能力就是打包,其實就是呼叫的 candle 系統的打包構建能力。會根據專案的特點,選擇合適的打包機(打包平臺是維護了多個打包任務,不同任務根據特點被派發到不同的打包機上,任務詳情頁可以看到依賴的下載、編譯、執行過程等,打包好的產物包括二進位制包、下載二維碼等等)

符號化流程圖

其中符號化服務是大前端背景下大前端團隊的產物,所以是 NodeJS 實現的。iOS 的符號化機器是 雙核的 Mac mini,這就需要做實驗測評到底需要開啟幾個 worker 程式做符號化服務。結果是雙程式處理 crash log,比單程式效率高近一倍,而四程式比雙程式效率提升不明顯,符合雙核 mac mini 的特點。所以開啟兩個 worker 程式做符號化處理。

下圖是完整設計圖

符號化技術設計圖

簡單說明下,符號化流程是一個主從模式,一臺 master 機,多個 slave 機,master 機讀取 .dSYM 和 crash 結果的 cache。mass 排程符號化服務(內部2個 symbolocate worker)同時從七牛雲上獲取 .dSYM 檔案。

系統架構圖如下

符號化服務架構圖

八、 APM 小結

  1. 通常來說各個端的監控能力是不太一致的,技術實現細節也不統一。所以在技術方案評審的時候需要將監控能力對齊統一。每個能力在各個端的資料欄位必須對齊(欄位個數、名稱、資料型別和精度),因為 APM 本身是一個閉環,監控了之後需符號化解析、資料整理,進行產品化開發、最後需要監控大盤展示等
  2. 一些 crash 或者 ANR 等根據等級需要郵件、簡訊、企業內容通訊工具告知干係人,之後快速釋出版本、hot fix 等。
  3. 監控的各個能力需要做成可配置,靈活開啟關閉。
  4. 監控資料需要做記憶體到檔案的寫入處理,需要注意策略。監控資料需要儲存資料庫,資料庫大小、設計規則等。存入資料庫後如何上報,上報機制等會在另一篇文章講:打造一個通用、可配置的資料上報 SDK
  5. 儘量在技術評審後,將各端的技術實現寫進文件中,同步給相關人員。比如 ANR 的實現

    carbon (30).png

  6. 整個 APM 的架構圖如下

    APM Structure

    說明:

    • 埋點 SDK,通過 sessionId 來關聯日誌資料
    • wax 上面介紹過了,是一種多端專案管理模式,每個 wax 專案都具有基礎資訊
  7. APM 技術方案本身是隨著技術手段、分析需求不斷調整升級的。上圖的幾個結構示意圖是早期幾個版本的,目前使用的是在此基礎上進行了升級和結構調整,提幾個關鍵詞:Hermes、Flink SQL、InfluxDB。

參考資料

相關文章