前言
我們都知道,線程的消息事件是依賴于 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 這個開源的第三方庫來獲取堆棧信息。這種方法的特點是,能夠定位到問題代碼的具體位置,而且性能消耗也不大。所以,也是我推薦的獲取堆棧信息的方法。
搜集到卡頓的方法堆棧信息以后,就是由開發者來分析并解決卡頓問題了。
參考:監控卡頓完整代碼