iOS應用UI線程卡頓監(jiān)控

iOS設備雖然在硬件和軟件層面一直在優(yōu)化,但還是有不少坑會導致UI線程的卡頓。對于程序員來說,除了增加自身知識儲備和養(yǎng)成良好的編程習慣之外,如果能一套機制能自動預報“卡頓”并檢測出導致該“卡頓”的代碼位置自然更好。本文就可能的實現(xiàn)方案做一些探討和分析

解決方案分析

簡單來說,主線程為了達到接近60fps的繪制效率,不能在UI線程有單個超過(1/60s≈16ms)的計算任務。通過Instrument設置16ms的采樣率可以檢測出大部分這種費時的任務,但有以下缺點:

  1. Instrument profile一次重新編譯,時間較長。
  2. 只能針對特定的操作場景進行檢測,要預先知道卡頓產(chǎn)生的場景。
  3. 每次猜測,更改,再猜測再以此循環(huán),需要重新profile。

我們的目標方案是,檢測能夠自動發(fā)生,并不需要開發(fā)人員做任何預先配置或profile。運行時發(fā)現(xiàn)卡頓能即時通知開發(fā)人員導致卡頓的函數(shù)調(diào)用棧。

基于上述前提,我暫時能想到兩個方案大致可行。

方案一:基于Runloop

主線程絕大部分計算或者繪制任務都是以Runloop為單位發(fā)生。單次Runloop如果時長超過16ms,就會導致UI體驗的卡頓。那如何檢測單次Runloop的耗時呢?

Runloop的生命周期及運行機制雖然不透明,但蘋果提供了一些API去檢測部分行為。我們可以通過如下代碼監(jiān)聽Runloop每次進入的事件:

- (void)setupRunloopObserver
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        CFRunLoopRef runloop = CFRunLoopGetCurrent();

        CFRunLoopObserverRef enterObserver;
        enterObserver = CFRunLoopObserverCreate(CFAllocatorGetDefault(),
                                               kCFRunLoopEntry | kCFRunLoopExit,
                                               true,
                                               -0x7FFFFFFF,
                                               BBRunloopObserverCallBack, NULL);
        CFRunLoopAddObserver(runloop, enterObserver, kCFRunLoopCommonModes);
        CFRelease(enterObserver);
    });
}

static void BBRunloopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    switch (activity) {
        case kCFRunLoopEntry: {
            NSLog(@"enter runloop...");
        }
            break;
        case kCFRunLoopExit: {
            NSLog(@"leave runloop...");
        }
            break;
        default: break;
    }
}

看起來kCFRunLoopExit的時間,減去kCFRunLoopEntry的時間,即為一次Runloop所耗費的時間。這個方案我并沒有繼續(xù)深入思考更多的細節(jié)。因為雖然能找出大于16ms的runloop,但無法定位到具體的函數(shù),只能起到預報的作用,不符合我們的目標方案。

方案二:基于線程

最理想的方案是讓UI線程“主動匯報”當前耗時的任務,聽起來簡單做起來不輕松。

我們可以假設這樣一套機制:每隔16ms讓UI線程來報道一次,如果16ms之后UI線程沒來報道,那就一定是在執(zhí)行某個耗時的任務。這種抽象的描述翻譯成代碼,可以用如下表述:

我們啟動一個worker線程,worker線程每隔一小段時間(delta)ping以下主線程(發(fā)送一個NSNotification),如果主線程此時有空,必然能接收到這個通知,并pong以下(發(fā)送另一個NSNotification),如果worker線程超過delta時間沒有收到pong的回復,那么可以推測UI線程必然在處理其他任務了,此時我們執(zhí)行第二步操作,暫停UI線程,并打印出當前UI線程的函數(shù)調(diào)用棧。

難點在這第二步,如何暫停UI線程,同時獲取到callstack。

iOS的多線程編程一般使用NSOperation或者GCD,這兩者都無法暫停每個正在執(zhí)行的線程。所謂的cancel調(diào)用也只能在目標線程空閑的時候,主動檢測cancelled狀態(tài),然后主動sleep,這顯然非我所欲。

還剩下pthread一途,pthread系列api當中有個函數(shù)pthread_kill()看起來符合期望。

The pthread_kill() function sends the signal sig to thread, a thread in the same process as the caller. The signal is asynchronously directed to thread. If sig is 0, then no signal is sent, but error checking is still performed.

如果我們從worker線程給UI線程發(fā)送signal,UI線程會被即刻暫停,并進入接收signal的回調(diào),再將callstack打印就接近目標了。

iOS確實允許在主線程注冊一個signal處理函數(shù),類似這樣:

signal(CALLSTACK_SIG, thread_singal_handler);

這里補充下signal相關的知識點。

iOS系統(tǒng)的signal可以被歸為兩類:

第一類內(nèi)核signal,這類signal由操作系統(tǒng)內(nèi)核發(fā)出,比如當我們訪問VM上不屬于自己的內(nèi)存地址時,會觸發(fā)EXC_BAD_ACCESS異常,內(nèi)核檢測到該異常之后會發(fā)出第二類signal:BSD signal,傳遞給應用程序。

第二類BSD signal,這類signal需要被應用程序自己處理。通常當我們的App進程運行時遇到異常,比如NSArray越界訪問。產(chǎn)生異常的線程會向當前進程發(fā)出signal,如果這個signal沒有別處理,我們的app就會crash了。

平常我們調(diào)試的時候很容易遇到第二類signal導致整個程序被中斷的情況,gdb同時會將每個線程的調(diào)用棧呈現(xiàn)出來。

pthread_kill允許我們向目標線程(UI線程)發(fā)送signal,目標線程被暫停,同時進入signal回調(diào),將當前線程的callstack獲取并處理,處理完signal之后UI線程繼續(xù)運行。將callstack打印即可精確定位產(chǎn)生問題的函數(shù)調(diào)用棧。

梳理下流程可以用如下示意圖表示:

image

代碼流程

理清思路之后實現(xiàn)起來就比較簡單了。

在主線程注冊signal handler

signal(CALLSTACK_SIG, thread_singal_handler);

通過NSNotification完成ping pong流程

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(detectPingFromWorkerThread) name:Notification_PMainThreadWatcher_Worker_Ping object:nil];

    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(detectPongFromMainThread) name:Notification_PMainThreadWatcher_Main_Pong object:nil];

如果ping超時,pthread_kill主線程。

pthread_kill(mainThreadID, CALLSTACK_SIG);

主線程被暫停,進入signal回調(diào),通過[NSThread callStackSymbols]獲取主線程當前callstack。

static void thread_singal_handler(int sig)
{
    NSLog(@"main thread catch signal: %d", sig);

    if (sig != CALLSTACK_SIG) {
        return;
    }

    NSArray* callStack = [NSThread callStackSymbols];

    id<PMainThreadWatcherDelegate> del = [PMainThreadWatcher sharedInstance].watchDelegate;
    if (del != nil && [del respondsToSelector:@selector(onMainThreadSlowStackDetected:)])  {
        [del onMainThreadSlowStackDetected:callStack];
    }
    else
    {
        NSLog(@"detect slow call stack on main thread! \n");
        for (NSString* call in callStack) {
            NSLog(@"%@\n", call);
        }
    }

    return;
}

至此基礎流程結束。值得一提的是上述代碼不能調(diào)試,因為調(diào)試時gdb會干擾signal的處理,導致signal handler無法進,但UI線程在遇到卡頓的時候還是能正常被中斷。其中,在debug開發(fā)模式下不被中斷的方案我寫在了之前的一篇文章中,有興趣的可以參考下signal causes debugger to stop app

現(xiàn)階段的實現(xiàn),worker線程每隔1秒會ping一次UI線程,檢測出運行超過16ms的調(diào)用棧。開發(fā)階段可以將1s的間隔調(diào)至更短,可能會對app整體性能造成少許的負擔,但能檢測出更多的卡頓調(diào)用。這部分調(diào)優(yōu)工作需要更多的思考。

轉(zhuǎn)載自http://mrpeak.cn/blog/ui-detect/

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內(nèi)容