大家好,一年多沒有更新文章了,最大的原因我想是不知道該分享些什么,這次是在一個巧合下發(fā)現(xiàn)網(wǎng)上經(jīng)常被人討論的APP在線上狀態(tài)如何檢測到主線程的卡頓情況,我也稍微了解了一下,前段時間就在一個博主的文章里看到一篇有部分講解這個問題的,據(jù)說美團(tuán)用的也是這種方案,具體不得而知,然后我發(fā)現(xiàn)網(wǎng)上關(guān)于這種問題的實現(xiàn)方案都十分類似,如果屏幕前的你還沒有意識過這個問題,那就請聽我往下分析這個網(wǎng)上常用的檢測方案:
利用runloop的檢測方案
關(guān)于runloop是什么我就不多說了,因為網(wǎng)上有很多關(guān)于這個的文章,最推薦的還是YYKit的作者博客上那篇。
我要拿出來注意的是 runloop 的狀態(tài):
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 即將進(jìn)入Loop
kCFRunLoopBeforeTimers = (1UL << 1), // 即將處理 Timer
kCFRunLoopBeforeSources = (1UL << 2), // 即將處理 Source
kCFRunLoopBeforeWaiting = (1UL << 5), // 即將進(jìn)入休眠
kCFRunLoopAfterWaiting = (1UL << 6), // 剛從休眠中喚醒
kCFRunLoopExit = (1UL << 7), // 即將退出Loop
};
網(wǎng)上熱議的是利用 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 這兩個狀態(tài)之間的耗時進(jìn)行判斷是否有太多事件處理導(dǎo)致出現(xiàn)了卡頓,下面直接上代碼:
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
PingConfig *object = (__bridge PingConfig*)info;
// 記錄狀態(tài)值
object->activity = activity;
// 發(fā)送信號
dispatch_semaphore_t semaphore = object->semaphore;
dispatch_semaphore_signal(semaphore);
}
上面這些是監(jiān)聽runloop的狀態(tài)而寫的回調(diào)函數(shù)
- (void)registerObserver
{
PingConfig *config = [PingConfig new];
// 創(chuàng)建信號
dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
config->semaphore = semaphore;
CFRunLoopObserverContext context = {0,(__bridge void*)config,NULL,NULL};
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
__block uint8_t timeoutCount = 0;
// 在子線程監(jiān)控時長
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
// 假定連續(xù)5次超時50ms認(rèn)為卡頓(當(dāng)然也包含了單次超時250ms)
long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
if (st != 0)
{
// NSLog(@"循環(huán)中--%ld",config->activity);
if (config->activity==kCFRunLoopBeforeSources || config->activity==kCFRunLoopAfterWaiting)
{
if (++timeoutCount < 5){
continue;
}else{
NSLog(@"卡頓了");
}
}
}
timeoutCount = 0;
}
});
}
現(xiàn)在我解讀一下這段代碼:
- PingConfig 只是我隨便寫的一個用來存儲runloop的狀態(tài)和信號量的自定義類,其中的結(jié)構(gòu)如下:
@interface PingConfig : NSObject
{
@public
CFRunLoopActivity activity;
dispatch_semaphore_t semaphore;
}
@end
恩,只有這么多足矣。
- APP啟動時我可以進(jìn)入 registerObserver 方法,其中首先我創(chuàng)建一個記錄信息的類PingConfig實例,然后創(chuàng)建一個信號,并且保存在這個PingConfig實例中(其實只是為了方便拿到)。
- 接下來我創(chuàng)建了一個觀察者監(jiān)測主線程的 runloop,它會在主線程runloop狀態(tài)切換時進(jìn)行回調(diào)。
- 開啟一個子線程,并且在里面進(jìn)行一個 while 循環(huán),在 循環(huán)的開始處 wait 一個信號量,并且設(shè)置超時為 50毫秒,失敗后會返回一個非0數(shù),成功將會返回0,這時候線程會阻塞住等待一個信號的發(fā)出。
- 如果runloop狀態(tài)正常切換,那么就會進(jìn)入回調(diào)函數(shù),在回調(diào)函數(shù)中我們發(fā)出一個信號,并且記錄當(dāng)前狀態(tài)到PingConfig實例中,下面的判斷語句中發(fā)現(xiàn)為0,timeoutCount自動置為0,一切正常。
- 當(dāng)主線程出現(xiàn)卡頓,while循環(huán)中的信號量再次等待,但是回調(diào)函數(shù)沒有觸發(fā),從而導(dǎo)致等待超時,返回一個非0數(shù),進(jìn)入判斷句后,我們再次判斷狀態(tài)是否處于 kCFRunLoopBeforeSources 或 kCFRunLoopAfterWaiting,如果成立,timeoutCount+1。
- 持續(xù)五次runloop不切換狀態(tài),說明runloop正在處理某個棘手的事件無法休息且不更新狀態(tài),這樣while循環(huán)中的信號量超時會一直發(fā)生,超過五次后我們將斷定主線程的卡頓并上傳堆棧信息。
經(jīng)過測試,的確可以檢測到主線程的卡頓現(xiàn)象,不得不佩服大佬們的方案。
但是在一次測試中,發(fā)現(xiàn)當(dāng)主線程卡在界面尚未完全顯示前,這個方案就檢測不出來卡頓了,比如我將下面的代碼放在B控制器中:
dispatch_semaphore_t t = dispatch_semaphore_create(0);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
NSLog(@"----");
dispatch_semaphore_signal(t);
});
dispatch_semaphore_wait(t, DISPATCH_TIME_FOREVER);
上面是一段有問題的代碼,將導(dǎo)致主線程的持續(xù)堵塞,如果我們在這段代碼放在B控制器的ViewDidLoad方法中(ViewWillAppear同樣),這樣運行后,當(dāng)你希望push到B控制器時,項目將在上一個界面完全卡住,并且無法用上面的方案檢測到,而且CPU及內(nèi)存都顯示正常:

具體原因我想了一下,由于runloop在處理完source0或者source1后,比如界面的跳轉(zhuǎn)也是執(zhí)行了方法,具體有沒有用到source0這不重要,但是后面會緊接著進(jìn)入準(zhǔn)備睡眠(kCFRunLoopBeforeWaiting)的狀態(tài),然而此時線程的阻塞導(dǎo)致runloop的狀態(tài)也被卡住無法切換,這樣也就導(dǎo)致在那段檢測代碼中無法進(jìn)入條件,從而檢測不出來。
但是話說回來,APP在靜止?fàn)顟B(tài)(保持休眠)和剛剛那種卡死狀態(tài)都會使runloop維持在 kCFRunLoopBeforeWaiting狀態(tài),這樣我們就無法在那段代碼中增加判斷來修復(fù),因為無法知道到底是真的靜止沒有操作還是被阻塞住,我也沒找到線程的阻塞狀態(tài)屬性,如果你發(fā)現(xiàn)這個屬性,那么就可以使用那個屬性來判斷。但是我也得說下在沒找到那個屬性時我的檢測方案:
我的檢測方案
先上代碼:
dispatch_queue_t serialQueue = dispatch_queue_create("serial", DISPATCH_QUEUE_SERIAL);
self.timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, serialQueue);
dispatch_source_set_timer(self.timer, DISPATCH_TIME_NOW, 0.25 * NSEC_PER_SEC, 0);
__block int8_t chokeCount = 0;
dispatch_semaphore_t t2 = dispatch_semaphore_create(0);
dispatch_source_set_event_handler(self.timer, ^{
if (config->activity == kCFRunLoopBeforeWaiting) {
static BOOL ex = YES;
if (ex == NO) {
chokeCount ++;
if (chokeCount > 40) {
NSLog(@"差不多卡死了");
dispatch_suspend(self.timer);
return ;
}
NSLog(@"卡頓了");
return ;
}
dispatch_async(dispatch_get_main_queue(), ^{
ex = YES;
dispatch_semaphore_signal(t2);
});
BOOL su = dispatch_semaphore_wait(t2, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
if (su != 0) {
ex = NO;
};
}
});
dispatch_resume(self.timer);
解釋一下我的方案:
- 開啟一個異步隊列,并且創(chuàng)建一個定時器,時間我設(shè)置的是0.25秒,具體時間隨你自己,這個時間是用來檢測卡死的持續(xù)時間。
- 在定時器外面我也同樣創(chuàng)建了一個用來同步的信號量,這個不解釋了,不會的就去看一下信號量的使用方式。進(jìn)入定時器的回調(diào)后,我設(shè)置了一個靜態(tài)變量來記錄主隊列是否執(zhí)行完成。
- 我們判斷當(dāng)前runloop的狀態(tài)是否為kCFRunLoopBeforeWaiting,所以這個方案是用來彌補(bǔ)前面那個方案,如果主線程此時沒有阻塞住,我們在這里向main Queue拋一個block,看它是否能夠成功執(zhí)行,如果成功執(zhí)行,說明主線程沒有阻塞住,如果已經(jīng)被阻塞住,那我拋過去的block是肯定不會被執(zhí)行的。
- 下面的代碼就是一些輔助操作,當(dāng)信號量超過50毫秒,拋給主線程的block沒有執(zhí)行,那么說明此時就有一些阻塞了,返回一個非0數(shù),并設(shè)置 ex為NO,從而在下一次定時器回調(diào)到來時進(jìn)行上報。
我寫的這段解決方案中的示例代碼只是用來演示,具體是原理可以大家盡情在此基礎(chǔ)上優(yōu)化,目前在我的項目中可以正常檢測到之前那種阻塞造成的APP卡死現(xiàn)象,如果你發(fā)現(xiàn)有更好的檢測方案,希望能告訴我,謝謝!