iOS 性能監(jiān)控(2)——卡頓

原文鏈接

前文探討了 iOS 中進行線上監(jiān)控 CPU缩筛、Memory、FPS 等指標(biāo)的原理以及具體實現(xiàn)方法。本文則繼續(xù)探討如何在 iOS 中進行線上監(jiān)控卡頓的原理及實現(xiàn)炉峰。

卡頓

相關(guān)系統(tǒng)原理

那么為什么會出現(xiàn)卡頓呢当悔?為了解釋這個問題首先需要了解一下屏幕圖像的顯示原理傅瞻。首先從 CRT 顯示器原理說起,如下圖所示盲憎。CRT 的電子槍從上到下逐行掃描嗅骄,掃描完成后顯示器就呈現(xiàn)一幀畫面。然后電子槍回到初始位置進行下一次掃描饼疙。為了同步顯示器的顯示過程和系統(tǒng)的視頻控制器溺森,顯示器會用硬件時鐘產(chǎn)生一系列的定時信號。當(dāng)電子槍換行進行掃描時,顯示器會發(fā)出一個水平同步信號(horizonal synchronization)屏积,簡稱 HSync医窿;而當(dāng)一幀畫面繪制完成后,電子槍回復(fù)到原位炊林,準(zhǔn)備畫下一幀前姥卢,顯示器會發(fā)出一個垂直同步信號(vertical synchronization),簡稱 VSync铛铁。顯示器通常以固定頻率進行刷新隔显,這個刷新率就是 VSync 信號產(chǎn)生的頻率。雖然現(xiàn)在的顯示器基本都是液晶顯示屏了饵逐,但其原理基本一致括眠。

image

下圖所示為常見的 CPU、GPU倍权、顯示器工作方式掷豺。CPU 計算好顯示內(nèi)容(如:視圖的創(chuàng)建、布局計算薄声、圖片解碼当船、文本繪制)提交至 GPU,GPU 渲染完成后將渲染結(jié)果存入幀緩沖區(qū)默辨,視頻控制器會按照 VSync 信號逐幀讀取幀緩沖區(qū)的數(shù)據(jù)德频,經(jīng)過數(shù)據(jù)轉(zhuǎn)換后最終由顯示器進行顯示。

image

最簡單的情況下缩幸,幀緩沖區(qū)只有一個壹置。此時,幀緩沖區(qū)的讀取和刷新都都會有比較大的效率問題表谊。為了解決效率問題钞护,GPU 通常會引入兩個緩沖區(qū),即 雙緩沖機制爆办。事實上难咕,iPhone 使用的就是雙緩沖機制。在這種情況下距辆,GPU 會預(yù)先渲染一幀放入一個緩沖區(qū)中余佃,用于視頻控制器的讀取。當(dāng)下一幀渲染完畢后跨算,GPU 會直接把視頻控制器的指針指向第二個緩沖器咙冗。

image

雙緩沖雖然能解決效率問題,但會引入一個新的問題漂彤。當(dāng)視頻控制器還未讀取完成時,即屏幕內(nèi)容剛顯示一半時,GPU 將新的一幀內(nèi)容提交到幀緩沖區(qū)并把兩個緩沖區(qū)進行交換后挫望,視頻控制器就會把新的一幀數(shù)據(jù)的下半段顯示到屏幕上立润,造成畫面撕裂現(xiàn)象,如下圖:

image

為了解決這個問題媳板,GPU 通常有一個機制叫做垂直同步(簡寫也是 V-Sync)桑腮,當(dāng)開啟垂直同步后,GPU 會等待顯示器的 VSync 信號發(fā)出后蛉幸,才進行新的一幀渲染和緩沖區(qū)更新破讨。這樣能解決畫面撕裂現(xiàn)象,也增加了畫面流暢度奕纫,但需要消費更多的計算資源提陶,也會帶來部分延遲。當(dāng) CPU 和 GPU 計算量比較大時匹层,一旦它們的完成時間錯過了下一次 C-Sync 的到來(通常是 1000/6=16.67ms)隙笆,這樣就會出現(xiàn)顯示屏還是之前幀的內(nèi)容,這就是界面卡頓的原因升筏。

image

FPS 卡頓監(jiān)控方案

FPS 卡頓監(jiān)控方案的原理是 通過一段連續(xù)的 FPS 計算丟幀率來衡量當(dāng)前頁面繪制的質(zhì)量撑柔。

具體實現(xiàn)方式可以通過 iOS 性能監(jiān)控(1)——CPU、Memory您访、FPS 一文中的 FPS 監(jiān)控方法進行 FPS 數(shù)據(jù)采集铅忿,然后處理數(shù)據(jù)。這里不做多余的介紹灵汪。

主線程卡頓監(jiān)控方案

主線程卡頓監(jiān)控方案的原理是 通過子線程監(jiān)控主線程的 RunLoop檀训,判斷兩個狀態(tài)區(qū)域之間的耗時是否達(dá)到一定閾值。因為主線程絕大部分計算或繪制任務(wù)都是以 RunLoop 為單位發(fā)生识虚。單次 RunLoop 如果時長超過 16ms肢扯,就會導(dǎo)致 UI 體驗的卡頓。

美團的移動端性能監(jiān)控方案 Hertz 采用的就是這種方式担锤。

image

首先我們需要了解一下 RunLoop 的原理蔚晨。

RunLoop 定義

RunLoop 是 iOS 事件響應(yīng)與任務(wù)處理最核心的機制。當(dāng)有持續(xù)的異步任務(wù)需求時肛循,我們會創(chuàng)建一個獨立的生命周期可控的線程铭腕。RunLoop 就是控制線程生命周期并接收事件進行處理的機制

RunLoop 機制

主線程(有 RunLoop 的線程)幾乎所有函數(shù)都從以下六個函數(shù)之一的函數(shù)調(diào)起:

  1. CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION
    • CFRunloop is calling out to an abserver callback function
    • 用于向外部報告 RunLoop 當(dāng)前狀態(tài)的改變多糠,框架中很多機制都由 RunLoopObserver 觸發(fā)累舷,如:CAAnimation
  2. CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK
    • CFRunloop is calling out to a block
    • 消息通知、非延遲的 perform夹孔、dispatch 調(diào)用被盈、block 回調(diào)析孽、KVO
  3. CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE
    • CFRunloop is servicing the main dispatch queue
    • 執(zhí)行主隊列上的任務(wù)
  4. CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION
    • CFRunloop is calling out to a timer callback function
    • 基于定時器的延遲的 perfrom,dispatch 調(diào)用
  5. CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION
    • CFRunloop is calling out to a source 0 perform function
    • 處理 App 內(nèi)部事件只怎、App自己負(fù)責(zé)管理(觸發(fā))袜瞬,如:UIEventCFSocket身堡。普通函數(shù)調(diào)用邓尤,系統(tǒng)調(diào)用
  6. CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION
    • CFRunloop is calling out to a source 1 perform function
    • 由 RunLoop 和內(nèi)核管理,Mach port 驅(qū)動贴谎,如:CFMachPort汞扎、CFMessagePort

RunLoop 運行時

如下所示為 CFRunLoop 源碼中的核心方法 CFRunLoopRun 簡化后的主要邏輯。

int32_t __CFRunLoopRun() {
    // 1. 通知 Observers:即將進入 RunLoop
    __CFRunLoopDoObservers(KCFRunLoopEntry);
    
    do {
        // 2. 通知Observers:即將要處理 timer
        __CFRunLoopDoObservers(kCFRunLoopBeforeTimers);
        // 3. 通知Observers:即將要處理 source
        __CFRunLoopDoObservers(kCFRunLoopBeforeSources);
        
        // 處理非延遲的主線程調(diào)用
        __CFRunLoopDoBlocks();
        // 處理 UIEvent 事件
        __CFRunLoopDoSource0();
    
        // GCD dispatch main queue
        CheckIfExistMessagesInMainDispatchQueue();
    
        // 4. 通知 Observers:即將進入休眠等待
        __CFRunLoopDoObservers(kCFRunLoopBeforeWaiting);
        
        // 等待內(nèi)核mach_msg事件
        mach_port_t wakeUpPort = SleepAndWaitForWakingUpPorts();
        
        // mach_msg_trap
        // 休眠中 Zzz...
        // Received mach_msg, wake up
        
        // 5. 通知 Observers:從休眠等待中醒來
        __CFRunLoopDoObservers(kCFRunLoopAfterWaiting);
        
        if (wakeUpPort == timerPort) {
            // 處理因timer的喚醒
            __CFRunLoopDoTimers();
        } else if (wakeUpPort == mainDispatchQueuePort) {
            // 處理異步方法喚醒擅这,如:dispatch_async
            __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__()
        } else {
            // UI 刷新澈魄,動畫顯示
            __CFRunLoopDoSource1();
        }
        
        // 再次確保是否有同步的方法需要調(diào)用
        __CFRunLoopDoBlocks()
    } while(!stop && !timeout);
    
    // 6. 通知 Observers:即將退出runloop
    __CFRunLoopDoObservers(CFRunLoopExit);
}

RunLoop 在運行時一直在向外部報告當(dāng)前狀態(tài)的更新,其狀態(tài)定義如下:

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)改變
}

從 RunLoop 運行邏輯中蕾哟,不難發(fā)現(xiàn) NSRunLoop 調(diào)用方法主要在于兩個狀態(tài)區(qū)間:

  • kCFRunLoopBeforeSourceskCFRunLoopBeforeWaiting 之間
  • kCFRunLoopAfterWaiting 之后

如果這兩個時間內(nèi)耗時太久而無法進入下一步一忱,可以線程受阻。如果這個線程時主線程谭确,表現(xiàn)出來就是出現(xiàn)了卡頓帘营。

代碼實現(xiàn)

我們可以通過 CFRunLoopObserverRef 實時獲取 NSRunLoop 的狀態(tài)。具體使用方法如下:

首先創(chuàng)建一個 CFRunLoopObserverContext 觀察者 observer逐哈。然后將觀察者 observer 添加到主線程 RunLoop 的 kCFRunLoopCommonModes 模式下進行觀察芬迄。

- (void)registerObserver {
    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                            kCFRunLoopAllActivities,
                                                            YES,
                                                            0,
                                                            &runLoopObserverCallBack,
                                                            &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}

static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    MyClass *object = (__bridge MyClass*)info;
    object->activity = activity;
}

然后,創(chuàng)建一個持續(xù)的子線程專門用來監(jiān)控主線程的 RunLoop 狀態(tài)昂秃。為了讓計算更精確禀梳,需要讓子線程更及時的獲知主線程 RunLoop 狀態(tài)變化,dispatch_semaphore_t 是一個不錯的選擇肠骆。另外算途,卡頓需要覆蓋多次連續(xù)短時間卡頓和單次長時間卡頓兩種情景,所以判定條件也需要做適當(dāng)優(yōu)化蚀腿。優(yōu)化后的代碼實現(xiàn)如下所示:

- (void)registerObserver {
    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                            kCFRunLoopAllActivities,
                                                            YES,
                                                            0,
                                                            &runLoopObserverCallBack,
                                                            &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
    
    // 創(chuàng)建信號
    semaphore = dispatch_semaphore_create(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) {
                if (activity == kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting) {
                    if (++timeoutCount < 5)
                        continue;
                    
                    NSLog(@"好像有點兒卡哦");
                }
            }
            timeoutCount = 0;
        }
    });
}

static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    MyClass *object = (__bridge MyClass*)info;
    
    // 記錄狀態(tài)值
    object->activity = activity;
    
    // 發(fā)送信號
    dispatch_semaphore_t semaphore = moniotr->semaphore;
    dispatch_semaphore_signal(semaphore);
}

檢測到卡頓時應(yīng)該立刻獲取卡頓的方法堆棧信息嘴瓤,并推送至服務(wù)端共開發(fā)者分析,從而解決卡頓問題莉钙。

獲取堆棧信息的一種方法是:直接調(diào)用系統(tǒng)函數(shù)廓脆。這種方法的優(yōu)點是 性能消耗小。缺點是 它只能夠獲取簡單的信息磁玉,無法配合 dSYM 來獲取具體是哪行代碼出了問題停忿,而且能夠獲取的信息類型也有限

直接調(diào)用系統(tǒng)函數(shù)的主要思路是:用 signal 進行錯誤信息獲取蚊伞。具體代碼如下:

static int s_fatal_signals[] = {
    SIGABRT,
    SIGBUS,
    SIGFPE,
    SIGILL,
    SIGSEGV,
    SIGTRAP,
    SIGTERM,
    SIGKILL,
};

static int s_fatal_signal_num = sizeof(s_fatal_signals) / sizeof(s_fatal_signals[0]);

void UncaughtExceptionHandler(NSException *exception) {
    NSArray *exceptionArray = [exception callStackSymbols];     // 得到當(dāng)前調(diào)用棧信息
    NSString *exceptionReason = [exception reason];             // 非常重要席赂,就是崩潰的原因
    NSString *exceptionName = [exception name];                 // 異常類型
}

void SignalHandler(int code) {
    NSLog(@"signal handler = %d",code);
}

void InitCrashReport() {
    // 系統(tǒng)錯誤信號捕獲
    for (int i = 0; i < s_fatal_signal_num; ++i) {
        signal(s_fatal_signals[i], SignalHandler);
    }
    
    //oc 未捕獲異常的捕獲
    NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);
}

int main(int argc, char * argv[]) {
    @autoreleasepool {
        InitCrashReport();
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

獲取堆棧信息的另一種方法是:直接使用 PLCrashReporter 第三方開源庫吮铭。這種方法的優(yōu)點是 能夠定位到問題代碼的具體位置,而且性能消耗也不大氧枣。具體代碼如下:

PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD     
                                                                   symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll];
PLCrashReporter *reporter = [[PLCrashReporter alloc] initWithConfiguration:config];

// 獲取數(shù)據(jù)
NSData *lagData = [reporter generateLiveReport];

// 轉(zhuǎn)換成 PLCrashReport 對象
PLCrashReport *lagReport = [[PLCrashReport alloc] initWithData:lagData error:NULL];

// 進行字符串格式化處理
NSString *lagReportString = [PLCrashReportTextFormatter stringValueForCrashReport:lagReport withTextFormat:PLCrashReportTextFormatiOS];

// 將字符串上傳服務(wù)器
NSLog(@"lag happen, detail below: \n %@",lagReportString);

參考

  1. 計算機那些事(8)——圖形圖像渲染原理
  2. Run Loop 原理詳解
  3. 微信讀書 iOS 性能優(yōu)化總結(jié)
  4. iOS 實時卡頓監(jiān)控
  5. CFRunLoop.c
  6. RunLoop刨根問底
  7. RunLoop 原理和核心機制
  8. iOS應(yīng)用UI線程卡頓監(jiān)控
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末沐兵,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子便监,更是在濱河造成了極大的恐慌,老刑警劉巖碳想,帶你破解...
    沈念sama閱讀 212,222評論 6 493
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件烧董,死亡現(xiàn)場離奇詭異,居然都是意外死亡胧奔,警方通過查閱死者的電腦和手機逊移,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,455評論 3 385
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來龙填,“玉大人胳泉,你說我怎么就攤上這事⊙乙牛” “怎么了扇商?”我有些...
    開封第一講書人閱讀 157,720評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長宿礁。 經(jīng)常有香客問我案铺,道長,這世上最難降的妖魔是什么梆靖? 我笑而不...
    開封第一講書人閱讀 56,568評論 1 284
  • 正文 為了忘掉前任控汉,我火速辦了婚禮,結(jié)果婚禮上返吻,老公的妹妹穿的比我還像新娘姑子。我一直安慰自己,他們只是感情好测僵,可當(dāng)我...
    茶點故事閱讀 65,696評論 6 386
  • 文/花漫 我一把揭開白布街佑。 她就那樣靜靜地躺著,像睡著了一般恨课。 火紅的嫁衣襯著肌膚如雪舆乔。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,879評論 1 290
  • 那天剂公,我揣著相機與錄音希俩,去河邊找鬼。 笑死纲辽,一個胖子當(dāng)著我的面吹牛颜武,可吹牛的內(nèi)容都是我干的璃搜。 我是一名探鬼主播,決...
    沈念sama閱讀 39,028評論 3 409
  • 文/蒼蘭香墨 我猛地睜開眼鳞上,長吁一口氣:“原來是場噩夢啊……” “哼这吻!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起篙议,我...
    開封第一講書人閱讀 37,773評論 0 268
  • 序言:老撾萬榮一對情侶失蹤唾糯,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后鬼贱,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體移怯,經(jīng)...
    沈念sama閱讀 44,220評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,550評論 2 327
  • 正文 我和宋清朗相戀三年这难,在試婚紗的時候發(fā)現(xiàn)自己被綠了舟误。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,697評論 1 341
  • 序言:一個原本活蹦亂跳的男人離奇死亡姻乓,死狀恐怖嵌溢,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情蹋岩,我是刑警寧澤赖草,帶...
    沈念sama閱讀 34,360評論 4 332
  • 正文 年R本政府宣布,位于F島的核電站星澳,受9級特大地震影響疚顷,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜禁偎,卻給世界環(huán)境...
    茶點故事閱讀 40,002評論 3 315
  • 文/蒙蒙 一腿堤、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧如暖,春花似錦笆檀、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,782評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至枷遂,卻和暖如春樱衷,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背酒唉。 一陣腳步聲響...
    開封第一講書人閱讀 32,010評論 1 266
  • 我被黑心中介騙來泰國打工矩桂, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人痪伦。 一個月前我還...
    沈念sama閱讀 46,433評論 2 360
  • 正文 我出身青樓侄榴,卻偏偏與公主長得像雹锣,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子癞蚕,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,587評論 2 350