導致卡頓問題的幾種原因:
- 復雜 UI 、圖文混排的繪制量過大;
- 在主線程上做網(wǎng)絡同步請求;
- 在主線程做大量的 IO 操作;
- 運算量過大,CPU 持續(xù)高占用;
- 死鎖和主子線程搶鎖。
RunLoop 原理
RunLoop 在 iOS 里由 CFRunLoop 實現(xiàn)。簡單來說,RunLoop 是用來監(jiān)聽輸入源,進行調(diào)度處理的。
這里的輸入源可以是輸入設備、網(wǎng)絡、周期性或者延遲時間、異步回調(diào)。
RunLoop 會接收兩種類型的輸入源:一種是來自另一個線程或者來自不同應用的異步消息;另一種是來自預訂時間或者重復間隔的同步事件。
RunLoop 的目的是,當有事件要去處理時保持線程忙,當沒有事件要處理時讓線程進入休眠。所以,RunLoop 不光能夠運用到監(jiān)控卡頓上,還可以提高用戶的交互體驗。通過將那些繁重而不緊急會大量占用 CPU 的任務(比如圖片加載),放到空閑的 RunLoop 模式里執(zhí)行,就可以避開在 UITrackingRunLoopMode 這個 RunLoop 模式時執(zhí)行
RunLoop 執(zhí)行流程
在RunLoop運行的整個過程中,loop 的狀態(tài)包括 6 個,
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry , // 進入 loop
kCFRunLoopBeforeTimers , // 觸發(fā) Timer 回調(diào)
kCFRunLoopBeforeSources , // 觸發(fā) Source0 回調(diào)
kCFRunLoopBeforeWaiting , // 等待 mach_port 消息
kCFRunLoopAfterWaiting ), // 接收 mach_port 消息
kCFRunLoopExit , // 退出 loop
kCFRunLoopAllActivities // loop 所有狀態(tài)改變
}

通知 observers:RunLoop 要開始進入 loop 了。緊接著就進入 loop
開啟一個 do while 來保活線程。通知 Observers:RunLoop 會觸發(fā) Timer 回調(diào)、Source0 回調(diào),接著執(zhí)行加入的 block。
觸發(fā) Source0 回調(diào),如果有 Source1 是 ready 狀態(tài)的話,通知 Observers:結束休眠 , 跳轉到 handle_msg 去處理消息。回調(diào)觸發(fā)后,通知 Observers:RunLoop 的線程將進入休眠(sleep)狀態(tài)。
進入休眠后,會等待 mach_port 的消息,以再次喚醒。只有在下面四個事件出現(xiàn)時才會被再次喚醒:
基于 port 的 Source 事件;
Timer 時間到;
RunLoop 超時;
被調(diào)用者喚醒。喚醒時通知 Observer:RunLoop 的線程剛剛被喚醒了。
RunLoop 被喚醒后就要開始處理消息了:
如果是 Timer 時間到的話,就觸發(fā) Timer 的回調(diào);
如果是 dispatch 的話,就執(zhí)行 block;
如果是 source1 事件的話,就處理這個事件。
消息執(zhí)行完后,就執(zhí)行加到 loop 里的 block。根據(jù)當前 RunLoop 的狀態(tài)來判斷是否需要走下一個 loop。當被外部強制停止或 loop 超時時,就不繼續(xù)下一個 loop 了,否則繼續(xù)下一個 loop 。
如果 RunLoop 的線程,進入睡眠前方法的執(zhí)行時間過長而導致無法進入睡眠,或者線程喚醒后接收消息時間過長而無法進入下一步的話,就可以認為是線程受阻了。如果這個線程是主線程的話,表現(xiàn)出來的就是出現(xiàn)了卡頓。
要利用 RunLoop 原理來監(jiān)控卡頓的話,要關注兩個階段。分別是 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting ,就是要觸發(fā) Source0 回調(diào)和接收 mach_port 消息兩個狀態(tài)。
如何檢查卡頓
創(chuàng)建 CFRunLoopObserverContext 觀察者
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context);
// 添加觀察者
CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);
觀察RunLoop 的 common 模式
將創(chuàng)建好的觀察者 runLoopObserver 添加到主線程 RunLoop 的 common 模式下觀察。
然后,創(chuàng)建一個持續(xù)的子線程專門用來監(jiān)控主線程的 RunLoop 狀態(tài)。
一旦發(fā)現(xiàn)進入睡眠前的 kCFRunLoopBeforeSources 狀態(tài),或者喚醒后的狀態(tài) kCFRunLoopAfterWaiting,在設置的時間閾值內(nèi)一直沒有變化,即可判定為卡頓。接下來,我們就可以 dump 出堆棧的信息,從而進一步分析出具體是哪個方法的執(zhí)行時間過長。
dispatchSemaphore = dispatch_semaphore_create(0); //Dispatch Semaphore保證同步
//創(chuàng)建子線程監(jiān)控
dispatch_async(dispatch_get_global_queue(0, 0), ^{
//子線程開啟一個持續(xù)的loop用來進行監(jiān)控
while (YES) {
long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 3*NSEC_PER_MSEC));
if (semaphoreWait != 0) {
if (!runLoopObserver) {
timeoutCount = 0;
dispatchSemaphore = 0;
runLoopActivity = 0;
return;
}
//兩個runloop的狀態(tài),BeforeSources和AfterWaiting這兩個狀態(tài)區(qū)間時間能夠檢測到是否卡頓
if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
//出現(xiàn)三次出結果
// if (++timeoutCount < 3) {
// continue;
// }
NSLog(@"monitor trigger");
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
// [SMCallStack callStackWithType:SMCallStackTypeAll];
});
} //end activity
}// end semaphore wait
timeoutCount = 0;
}// end while
});
收到beforesource通知發(fā)出后開始處理事件,處理完之后狀態(tài)會改變成beforewaiting。
收到afterwaiting也會開始處理事件,處理完之后變成beforetimer。
也就是說如果長時間停留在beforesource和afterwaiting狀態(tài),那么就發(fā)生了卡頓。
代碼中觸發(fā)卡頓的時間閾值 ,設置成了 3 秒。這個 3 秒的閾值合理?我們可以根據(jù) WatchDog 機制來設置。WatchDog 在不同狀態(tài)下設置的不同時間,如下所示:
- 啟動(Launch):20s;
- 恢復(Resume):10s;
- 掛起(Suspend):10s;
- 退出(Quit):6s;
- 后臺(Background):3min(在 iOS 7 之前,每次申請 10min; 之后改為每次申請 3min,可連續(xù)申請,最多申請到 10min)。
如何獲取卡頓的方法堆棧信息
子線程監(jiān)控發(fā)現(xiàn)卡頓后,還需要記錄當前出現(xiàn)卡頓的方法堆棧信息,并適時推送到服務端供開發(fā)者分析,從而解決卡頓問題。
直接調(diào)用系統(tǒng)函數(shù)獲取堆棧
這種方法的優(yōu)點在于,性能消耗小。但是,它只能夠獲取簡單的信息,也沒有辦法配合 dSYM 來獲取具體是哪行代碼出了問題,而且能夠獲取的信息類型也有限。
但因為性能比較好,所以適用于觀察大盤統(tǒng)計卡頓情況,而不是想要找到卡頓原因的場景。
#include <libkern/OSAtomic.h>
#include <execinfo.h>
//獲取函數(shù)堆棧信息
+ (NSArray *)backtrace {
void* callstack[128];
int frames = backtrace(callstack, 128);//用于獲取當前線程的函數(shù)調(diào)用堆棧,返回實際獲取的指針個數(shù)
char **strs = backtrace_symbols(callstack, frames);//從backtrace函數(shù)獲取的信息轉化為一個字符串數(shù)組
int i;
NSMutableArray *backtrace = [NSMutableArray arrayWithCapacity:frames];
for (i = 0;
i < backtrace.count;
i++) {
[backtrace addObject:[NSString stringWithUTF8String:strs[i]]];
}
free(strs);
return backtrace;
}
三方庫
直接用 PLCrashReporter這個開源的第三方庫來獲取堆棧信息。這種方法的特點是,能夠定位到問題代碼的具體位置,而且性能消耗也不大。
// 獲取數(shù)據(jù)
NSData *lagData = [[[PLCrashReporter alloc]
initWithConfiguration:[[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll]] generateLiveReport];
// 轉換成 PLCrashReport 對象
PLCrashReport *lagReport = [[PLCrashReport alloc] initWithData:lagData error:NULL];
// 進行字符串格式化處理
NSString *lagReportString = [PLCrashReportTextFormatter stringValueForCrashReport:lagReport withTextFormat:PLCrashReportTextFormatiOS];
//將字符串上傳服務器
NSLog(@"lag happen, detail below: \n %@",lagReportString);