iOS 性能優化 - Runloop監測卡頓分析(2)

前言

我們都知道,線程的消息事件是依賴于 NSRunLoop 的,所以從 NSRunLoop 入手,就可以知道主線程上都調用了哪些方法。我們通過監聽 NSRunLoop 的狀態,就能夠發現調用方法是否執行時間過長,從而判斷出是否會出現卡頓。

Runloop的運行原理。

首先了解一下Runloop的運行原理,如下圖所示:

第一步:
通知Observers: Runloop要開始runloop了。緊接著進入runloop啦

// 通知 observers
if (currentMode->_observerMask & kCFRunLoopEntry ) 
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopEntry);
// 進入 loop
result = __CFRunLoopRun(rl, currentMode, seconds, returnAfterSourceHandled, previousMode);

第二步:
開啟一個do while保活。通知Observers:Runloop會觸發Timer回調、source0回調
、接著執行加入Block。

// 通知 Observers RunLoop 會觸發 Timer 回調
if (currentMode->_observerMask & kCFRunLoopBeforeTimers)
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
// 通知 Observers RunLoop 會觸發 Source0 回調
if (currentMode->_observerMask & kCFRunLoopBeforeSources)
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
// 執行 block
__CFRunLoopDoBlocks(runloop, currentMode);

接下來就是出發Source0回調,如果還有Source1是ready狀態的話,就會跳到hanlde_msg處理消息

if (MACH_PORT_NULL != dispatchPort ) {
    Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
    if (hasMsg) goto handle_msg;
}

第三步:
回調觸發后,通知Observes:Runloop的線程進入休眠狀態

Boolean poll = sourceHandledThisLoop || (0ULL == timeout_context->termTSR);
if (!poll && (currentMode->_observerMask & kCFRunLoopBeforeWaiting)) {
    __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
}

等四步:
進入休眠后,會等待math_port的消息,以再次喚醒,只有在下面四個事件出現時才會被再次喚醒:

  • 基于port的source事件
  • Timer時間到
  • Runloop超時
  • 被調用者喚醒
    等待喚醒的代碼如下:
do {
    __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) {
        // 基于 port 的 Source 事件、調用者喚醒
        if (modeQueuePort != MACH_PORT_NULL && livePort == modeQueuePort) {
            break;
        }
        // Timer 時間到、RunLoop 超時
        if (currentMode->_timerFired) {
            break;
        }
} while (1);

第六步:
Runloop被喚醒后就要開始處理消息了

  • 如果是Timer的時間的話,就處理timer的回調
  • 如果是dispatch的話,就執行Block
  • 如果是Source1時間的話,就處理這個事件

消息執行完之后,就執行到loop里的Block

handle_msg:
// 如果 Timer 時間到,就觸發 Timer 回調
if (msg-is-timer) {
    __CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
} 
// 如果 dispatch 就執行 block
else if (msg_is_dispatch) {
    __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
} 
 
// Source1 事件的話,就處理這個事件
else {
    CFRunLoopSourceRef source1 = __CFRunLoopModeFindSourceForMachPort(runloop, currentMode, livePort);
    sourceHandledThisLoop = __CFRunLoopDoSource1(runloop, currentMode, source1, msg);
    if (sourceHandledThisLoop) {
        mach_msg(reply, MACH_SEND_MSG, reply);
    }
}

第七步:
根據當前的runloop狀態來判斷是否要走下一個loop。當外部強制停止或者loop超時,就不再繼續下一個loop了,否則繼續走下一個loop.

if (sourceHandledThisLoop && stopAfterHandle) {
     // 事件已處理完
    retVal = kCFRunLoopRunHandledSource;
} else if (timeout) {
    // 超時
    retVal = kCFRunLoopRunTimedOut;
} else if (__CFRunLoopIsStopped(runloop)) {
    // 外部調用者強制停止
    retVal = kCFRunLoopRunStopped;
} else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
    // mode 為空,RunLoop 結束
    retVal = kCFRunLoopRunFinished;
}

如果 RunLoop 的線程,進入睡眠前方法的執行時間過長而導致無法進入睡眠,或者線程喚醒后接收消息時間過長而無法進入下一步的話,就可以認為是線程受阻了。如果這個線程是主線程的話,表現出來的就是出現了卡頓。

所以,如果我們要利用 RunLoop 原理來監控卡頓的話,就是要關注這兩個階段。RunLoop 在進入睡眠之前和喚醒后的兩個 loop 狀態定義的值,分別是 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting ,也就是要觸發 Source0 回調和接收 mach_port 消息兩個狀態。

檢查卡頓

要想監聽 RunLoop,你就首先需要創建一個 CFRunLoopObserverContext 觀察者,代碼如下:

CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context);

將創建好的觀察者 runLoopObserver 添加到主線程 RunLoop 的 common 模式下觀察。然后,創建一個持續的子線程專門用來監控主線程的 RunLoop 狀態

一旦發現進入睡眠前的 kCFRunLoopBeforeSources 狀態,或者喚醒后的狀態 kCFRunLoopAfterWaiting,在設置的時間閾值內一直沒有變化,即可判定為卡頓。

開啟一個子線程監控的代碼如下:

// 創建子線程監控
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    // 子線程開啟一個持續的 loop 用來進行監控
    while (YES) {
        long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC));
        if (semaphoreWait != 0) {
            if (!runLoopObserver) {
                timeoutCount = 0;
                dispatchSemaphore = 0;
                runLoopActivity = 0;
                return;
            }
            //BeforeSources 和 AfterWaiting 這兩個狀態能夠檢測到是否卡頓
            if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
                // 將堆棧信息上報服務器的代碼放到這里
            } //end activity
        }// end semaphore wait
        timeoutCount = 0;
    }// end while
});

我們把這個閾值設置成了 3 秒。那么,這個 3 秒的閾值是從何而來呢?這樣設置合理嗎?

其實,觸發卡頓的時間閾值,我們可以根據 WatchDog 機制來設置。WatchDog 在不同狀態下設置的不同時間,如下所示:

  • 啟動(Launch):20s;
  • 恢復(Resume):10s;
  • 掛起(Suspend):10s;
  • 退出(Quit):6s;
  • 后臺(Background):3min(在 iOS 7 之前,每次申請 10min; 之后改為每次申請 3min,可連續申請,最多申請到 10min)。
    通過 WatchDog 設置的時間,我認為可以把啟動的閾值設置為 10 秒,其他狀態則都默認設置為 3 秒。總的原則就是,要小于 WatchDog 的限制時間。當然了,這個閾值也不用小得太多,原則就是要優先解決用戶感知最明顯的體驗問題。

獲取卡頓的方法堆棧信息

子線程監控發現卡頓后,還需要記錄當前出現卡頓的方法堆棧信息,并適時推送到服務端供開發者分析,從而解決卡頓問題。那么,在這個過程中,如何獲取卡頓的方法堆棧信息呢?

獲取堆棧信息的一種方法是直接調用系統函數。這種方法的優點在于,性能消耗小。但是,它只能夠獲取簡單的信息,也沒有辦法配合 dSYM 來獲取具體是哪行代碼出了問題,而且能夠獲取的信息類型也有限。這種方法,因為性能比較好,所以適用于觀察大盤統計卡頓情況,而不是想要找到卡頓原因的場景。

第一種:直接調用系統函數方法的主要思路是:用 signal 進行錯誤信息的獲取。

第二種:直接用 PLCrashReporter 這個開源的第三方庫來獲取堆棧信息。這種方法的特點是,能夠定位到問題代碼的具體位置,而且性能消耗也不大。所以,也是我推薦的獲取堆棧信息的方法。

搜集到卡頓的方法堆棧信息以后,就是由開發者來分析并解決卡頓問題了。

參考:監控卡頓完整代碼

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容