iOS 之如何利用 RunLoop 原理去監(jiān)控卡頓?
1. 前言
卡頓問題,就是在主線程上無法響應(yīng)用戶交互的問題。如果一個 App 時不時地就給你卡一下,有 時還長時間無響應(yīng),這時你還愿意繼續(xù)用它嗎?所以說,卡頓問題對 App 的傷害是巨大的,也是 我們必須要重點解決的一個問題。
2. 卡頓原因
現(xiàn)在,我們先來看一下導(dǎo)致卡頓問題的幾種原因:
- 復(fù)雜 UI 、圖文混排的繪制量過大;
- 在主線程上做網(wǎng)絡(luò)同步請求;
- 在主線程做大量的 IO 操作;
- 運算量過大,CPU 持續(xù)高占用;
- 死鎖和主子線程搶鎖。
那么,我們?nèi)绾伪O(jiān)控到什么時候會出現(xiàn)卡頓呢?是要監(jiān)視FPS嗎?
FPS 是一秒顯示的幀數(shù),也就是一秒內(nèi)畫面變化數(shù)量。當(dāng)FPS達(dá)到60,說明界面很流程,當(dāng)FPS低于24,頁面流暢度不是那么流暢,但是不能說卡主了。
由此可見,簡單地通過監(jiān)視 FPS 是很難確定是否會出現(xiàn)卡頓問題了,所以我就果斷棄了通過監(jiān)視 FPS 來監(jiān)控卡頓的方案。
那么,我們到底應(yīng)該使用什么方案來監(jiān)控卡頓呢?
3. 使用RunLoop來檢控卡頓
對于 iOS 開發(fā)來說,監(jiān)控卡頓就是要去找到主線程上都做了哪些事兒。我們都知道,線程的消息 事件是依賴于 NSRunLoop 的,所以從 NSRunLoop 入手,就可以知道主線程上都調(diào)用了哪些方 法。我們通過監(jiān)聽 NSRunLoop 的狀態(tài),就能夠發(fā)現(xiàn)調(diào)用方法是否執(zhí)行時間過長,從而判斷出是 否會出現(xiàn)卡頓。
所以,我推薦的監(jiān)控卡頓的方案是:通過監(jiān)控 RunLoop 的狀態(tài)來判斷是否會出現(xiàn)卡頓。
3.1 Runloop
RunLoop是iOS開發(fā)中的一個基礎(chǔ)概念,為了幫助你理解并用好這個對象,接下來我會先和你介紹一下它可以做哪些事兒,以及它為什么可以做成這些事兒。
RunLoop 這個對象,在 iOS 里由 CFRunLoop 實現(xiàn)。簡單來說,RunLoop 是用來監(jiān)聽輸入源,進(jìn) 行調(diào)度處理的。這里的輸入源可以是輸入設(shè)備、網(wǎng)絡(luò)、周期性或者延遲時間、異步回調(diào)。
RunLoop 會接收兩種類型的輸入源:
- 一種是來自另一個線程或者來自不同應(yīng)用的異步消息;
- 另一 種是來自預(yù)訂時間或者重復(fù)間隔的同步事件。
RunLoop 的目的是,當(dāng)有事件要去處理時保持線程忙,當(dāng)沒有事件要處理時讓線程進(jìn)入休眠。所 以,了解 RunLoop 原理不光能夠運用到監(jiān)控卡頓上,還可以提高用戶的交互體驗。通過將那些 繁重而不緊急會大量占用 CPU 的任務(wù)(比如圖片加載),放到空閑的 RunLoop 模式里執(zhí)行,這 樣就可以避開在 UITrackingRunLoopMode 這個 RunLoop 模式時是執(zhí)行。
UITrackingRunLoopMode 是用戶進(jìn)行滾動操作時會切換到的 RunLoop 模式,避免在這個 RunLoop 模式執(zhí)行繁重的 CPU 任務(wù),就能避免影響用戶交互操作上體驗。
接下來,我就通過 CFRunLoop 的源碼來跟你分享下 RunLoop 的原理吧。
3.2 RunLoop原理
其內(nèi)部代碼整理如下:
- /// 用DefaultMode啟動
- void CFRunLoopRun(void) {
- CFRunLoopRunSpecific(CFRunLoopGetCurrent(), kCFRunLoopDefaultMode, 1.0e10, false);
- }
- /// 用指定的Mode啟動,允許設(shè)置RunLoop超時時間
- int CFRunLoopRunInMode(CFStringRef modeName, CFTimeInterval seconds, Boolean stopAfterHandle) {
- return CFRunLoopRunSpecific(CFRunLoopGetCurrent(), modeName, seconds, returnAfterSourceHandled);
- }
- /// RunLoop的實現(xiàn)
- int CFRunLoopRunSpecific(runloop, modeName, seconds, stopAfterHandle) {
- /// 首先根據(jù)modeName找到對應(yīng)mode
- CFRunLoopModeRef currentMode = __CFRunLoopFindMode(runloop, modeName, false);
- /// 如果mode里沒有source/timer/observer, 直接返回。
- if (__CFRunLoopModeIsEmpty(currentMode)) return;
- /// 1. 通知 Observers: RunLoop 即將進(jìn)入 loop。
- __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopEntry);
- /// 內(nèi)部函數(shù),進(jìn)入loop
- __CFRunLoopRun(runloop, currentMode, seconds, returnAfterSourceHandled) {
- Boolean sourceHandledThisLoop = NO;
- int retVal = 0;
- do {
- /// 2. 通知 Observers: RunLoop 即將觸發(fā) Timer 回調(diào)。
- __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
- /// 3. 通知 Observers: RunLoop 即將觸發(fā) Source0 (非port) 回調(diào)。
- __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
- /// 執(zhí)行被加入的block
- __CFRunLoopDoBlocks(runloop, currentMode);
- /// 4. RunLoop 觸發(fā) Source0 (非port) 回調(diào)。
- sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle);
- /// 執(zhí)行被加入的block
- __CFRunLoopDoBlocks(runloop, currentMode);
- /// 5. 如果有 Source1 (基于port) 處于 ready 狀態(tài),直接處理這個 Source1 然后跳轉(zhuǎn)去處理消息。
- if (__Source0DidDispatchPortLastTime) {
- Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
- if (hasMsg) goto handle_msg;
- }
- /// 通知 Observers: RunLoop 的線程即將進(jìn)入休眠(sleep)。
- if (!sourceHandledThisLoop) {
- __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting);
- }
- /// 7. 調(diào)用 mach_msg 等待接受 mach_port 的消息。線程將進(jìn)入休眠, 直到被下面某一個事件喚醒。
- /// • 一個基于 port 的Source 的事件。
- /// • 一個 Timer 到時間了
- /// • RunLoop 自身的超時時間到了
- /// • 被其他什么調(diào)用者手動喚醒
- __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) {
- mach_msg(msg, MACH_RCV_MSG, port); // thread wait for receive msg
- }
- /// 8. 通知 Observers: RunLoop 的線程剛剛被喚醒了。
- __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting);
- /// 收到消息,處理消息。
- handle_msg:
- /// 9.1 如果一個 Timer 到時間了,觸發(fā)這個Timer的回調(diào)。
- if (msg_is_timer) {
- __CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time())
- }
- /// 9.2 如果有dispatch到main_queue的block,執(zhí)行block。
- else if (msg_is_dispatch) {
- __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
- }
- /// 9.3 如果一個 Source1 (基于port) 發(fā)出事件了,處理這個事件
- else {
- CFRunLoopSourceRef source1 = __CFRunLoopModeFindSourceForMachPort(runloop, currentMode, livePort);
- sourceHandledThisLoop = __CFRunLoopDoSource1(runloop, currentMode, source1, msg);
- if (sourceHandledThisLoop) {
- mach_msg(reply, MACH_SEND_MSG, reply);
- }
- }
- /// 執(zhí)行加入到Loop的block
- __CFRunLoopDoBlocks(runloop, currentMode);
- if (sourceHandledThisLoop && stopAfterHandle) {
- /// 進(jìn)入loop時參數(shù)說處理完事件就返回。
- retVal = kCFRunLoopRunHandledSource;
- } else if (timeout) {
- /// 超出傳入?yún)?shù)標(biāo)記的超時時間了
- retVal = kCFRunLoopRunTimedOut;
- } else if (__CFRunLoopIsStopped(runloop)) {
- /// 被外部調(diào)用者強制停止了
- retVal = kCFRunLoopRunStopped;
- } else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
- /// source/timer/observer一個都沒有了
- retVal = kCFRunLoopRunFinished;
- }
- /// 如果沒超時,mode里沒空,loop也沒被停止,那繼續(xù)loop。
- } while (retVal == 0);
- }
- /// 10. 通知 Observers: RunLoop 即將退出。
- __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);
- }
可以看到,實際上 RunLoop 就是這樣一個函數(shù),其內(nèi)部是一個 do-while 循環(huán)。當(dāng)你調(diào)用 CFRunLoopRun() 時,線程就會一直停留在這個循環(huán)里;直到超時或被手動停止,該函數(shù)才會返回。
RunLoop內(nèi)部的邏輯圖:
RunLoop內(nèi)部原理.png
4. 如何檢測卡頓
4.1 首先知道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
- kCFRunLoopAllActivities // loop所有狀態(tài)改變
- };
要想監(jiān)聽RunLoop,你就首先需要創(chuàng)建一個 CFRunLoopObserverContext 觀察者,代碼如下:
- - (void)registerObserver {
- CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
- //創(chuàng)建Run loop observer對象
- //第一個參數(shù)用于分配observer對象的內(nèi)存
- //第二個參數(shù)用以設(shè)置observer所要關(guān)注的事件,詳見回調(diào)函數(shù)myRunLoopObserver中注釋
- //第三個參數(shù)用于標(biāo)識該observer是在第一次進(jìn)入run loop時執(zhí)行還是每次進(jìn)入run loop處理時均執(zhí)行
- //第四個參數(shù)用于設(shè)置該observer的優(yōu)先級
- //第五個參數(shù)用于設(shè)置該observer的回調(diào)函數(shù)
- //第六個參數(shù)用于設(shè)置該observer的運行環(huán)境
- CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
- kCFRunLoopAllActivities,
- YES,
- 0,
- &runLoopObserverCallBack,
- &context);
- CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
- }
實時獲取變化的回調(diào)的方法:
- //每當(dāng)runloop狀態(tài)變化的觸發(fā)這個回調(diào)方法
- static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
- MyClass *object = (__bridge MyClass*)info;
- object->activity = activity;
- }
其中UI主要集中在
_CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION(source0)和CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION(source1)之前。
獲取kCFRunLoopBeforeSources到kCFRunLoopBeforeWaiting再到kCFRunLoopAfterWaiting的狀態(tài)就可以知道是否有卡頓的情況。
4.2 檢測卡頓的思路
只需要另外再開啟一個線程,實時計算這兩個狀態(tài)區(qū)域之間的耗時是否到達(dá)某個閥值,便能揪出這些性能殺手。
- 監(jiān)聽runloop狀態(tài)變化回調(diào)方法
- // 就是runloop有一個狀態(tài)改變 就記錄一下
- static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
- BGPerformanceMonitor *monitor = (__bridge BGPerformanceMonitor*)info;
- // 記錄狀態(tài)值
- monitor->activity = activity;
- // 發(fā)送信號
- dispatch_semaphore_t semaphore = monitor->semaphore;
- long st = dispatch_semaphore_signal(semaphore);
- NSLog(@"dispatch_semaphore_signal:st=%ld,time:%@",st,[BGPerformanceMonitor getCurTime]);
- /* Run Loop Observer Activities */
- // typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
- // kCFRunLoopEntry = (1UL << 0), // 進(jìn)入RunLoop循環(huán)(這里其實還沒進(jìn)入)
- // kCFRunLoopBeforeTimers = (1UL << 1), // RunLoop 要處理timer了
- // kCFRunLoopBeforeSources = (1UL << 2), // RunLoop 要處理source了
- // kCFRunLoopBeforeWaiting = (1UL << 5), // RunLoop要休眠了
- // kCFRunLoopAfterWaiting = (1UL << 6), // RunLoop醒了
- // kCFRunLoopExit = (1UL << 7), // RunLoop退出(和kCFRunLoopEntry對應(yīng))
- // kCFRunLoopAllActivities = 0x0FFFFFFFU //RunLoop狀態(tài)變化
- // };
- if (activity == kCFRunLoopEntry) { // 即將進(jìn)入RunLoop
- NSLog(@"runLoopObserverCallBack - %@",@"kCFRunLoopEntry");
- } else if (activity == kCFRunLoopBeforeTimers) { // 即將處理Timer
- NSLog(@"runLoopObserverCallBack - %@",@"kCFRunLoopBeforeTimers");
- } else if (activity == kCFRunLoopBeforeSources) { // 即將處理Source
- NSLog(@"runLoopObserverCallBack - %@",@"kCFRunLoopBeforeSources");
- } else if (activity == kCFRunLoopBeforeWaiting) { //即將進(jìn)入休眠
- NSLog(@"runLoopObserverCallBack - %@",@"kCFRunLoopBeforeWaiting");
- } else if (activity == kCFRunLoopAfterWaiting) { // 剛從休眠中喚醒
- NSLog(@"runLoopObserverCallBack - %@",@"kCFRunLoopAfterWaiting");
- } else if (activity == kCFRunLoopExit) { // 即將退出RunLoop
- NSLog(@"runLoopObserverCallBack - %@",@"kCFRunLoopExit");
- } else if (activity == kCFRunLoopAllActivities) {
- NSLog(@"runLoopObserverCallBack - %@",@"kCFRunLoopAllActivities");
- }
- }
- 開啟runloop監(jiān)聽
- // 開始監(jiān)聽
- - (void)startMonitor {
- if (observer) {
- return;
- }
- // 創(chuàng)建信號
- semaphore = dispatch_semaphore_create(0);
- NSLog(@"dispatch_semaphore_create:%@",[BGPerformanceMonitor getCurTime]);
- // 注冊RunLoop狀態(tài)觀察
- CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
- //創(chuàng)建Run loop observer對象
- //第一個參數(shù)用于分配observer對象的內(nèi)存
- //第二個參數(shù)用以設(shè)置observer所要關(guān)注的事件,詳見回調(diào)函數(shù)myRunLoopObserver中注釋
- //第三個參數(shù)用于標(biāo)識該observer是在第一次進(jìn)入run loop時執(zhí)行還是每次進(jìn)入run loop處理時均執(zhí)行
- //第四個參數(shù)用于設(shè)置該observer的優(yōu)先級
- //第五個參數(shù)用于設(shè)置該observer的回調(diào)函數(shù)
- //第六個參數(shù)用于設(shè)置該observer的運行環(huán)境
- observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
- kCFRunLoopAllActivities,
- YES,
- 0,
- &runLoopObserverCallBack,
- &context);
- CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
- // 在子線程監(jiān)控時長
- dispatch_async(dispatch_get_global_queue(0, 0), ^{
- while (YES) { // 有信號的話 就查詢當(dāng)前runloop的狀態(tài)
- // 假定連續(xù)5次超時50ms認(rèn)為卡頓(當(dāng)然也包含了單次超時250ms)
- // 因為下面 runloop 狀態(tài)改變回調(diào)方法runLoopObserverCallBack中會將信號量遞增 1,所以每次 runloop 狀態(tài)改變后,下面的語句都會執(zhí)行一次
- // dispatch_semaphore_wait:Returns zero on success, or non-zero if the timeout occurred.
- long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
- NSLog(@"dispatch_semaphore_wait:st=%ld,time:%@",st,[self getCurTime]);
- if (st != 0) { // 信號量超時了 - 即 runloop 的狀態(tài)長時間沒有發(fā)生變更,長期處于某一個狀態(tài)下
- if (!observer) {
- timeoutCount = 0;
- semaphore = 0;
- activity = 0;
- return;
- }
- NSLog(@"st = %ld,activity = %lu,timeoutCount = %d,time:%@",st,activity,timeoutCount,[self getCurTime]);
- // kCFRunLoopBeforeSources - 即將處理source kCFRunLoopAfterWaiting - 剛從休眠中喚醒
- // 獲取kCFRunLoopBeforeSources到kCFRunLoopBeforeWaiting再到kCFRunLoopAfterWaiting的狀態(tài)就可以知道是否有卡頓的情況。
- // kCFRunLoopBeforeSources:停留在這個狀態(tài),表示在做很多事情
- if (activity == kCFRunLoopBeforeSources || activity == kCFRunLoopAfterWaiting) { // 發(fā)生卡頓,記錄卡頓次數(shù)
- if (++timeoutCount < 5) {
- continue; // 不足 5 次,直接 continue 當(dāng)次循環(huán),不將timeoutCount置為0
- }
- // 收集Crash信息也可用于實時獲取各線程的調(diào)用堆棧
- PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll];
- PLCrashReporter *crashReporter = [[PLCrashReporter alloc] initWithConfiguration:config];
- NSData *data = [crashReporter generateLiveReport];
- PLCrashReport *reporter = [[PLCrashReport alloc] initWithData:data error:NULL];
- NSString *report = [PLCrashReportTextFormatter stringValueForCrashReport:reporter withTextFormat:PLCrashReportTextFormatiOS];
- NSLog(@"---------卡頓信息\n%@\n--------------",report);
- }
- }
- NSLog(@"dispatch_semaphore_wait timeoutCount = 0,time:%@",[self getCurTime]);
- timeoutCount = 0;
- }
- });
- }
記錄卡頓的函數(shù)調(diào)用
監(jiān)控到了卡頓現(xiàn)場,當(dāng)然下一步便是記錄此時的函數(shù)調(diào)用信息,此處可以使用一個第三方Crash 收集組件 PLCrashReporter,它不僅可以收集 Crash 信息也可用于實時獲取各線程的調(diào)用堆棧,示例如下:
- PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD
- symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll];
- PLCrashReporter *crashReporter = [[PLCrashReporter alloc] initWithConfiguration:config];
- NSData *data = [crashReporter generateLiveReport];
- PLCrashReport *reporter = [[PLCrashReport alloc] initWithData:data error:NULL];
- NSString *report = [PLCrashReportTextFormatter stringValueForCrashReport:reporter
- withTextFormat:PLCrashReportTextFormatiOS];
- NSLog(@"------------\n%@\n------------", report);
當(dāng)檢測到卡頓時,抓取堆棧信息,然后在客戶端做一些過濾處理,便可以上報到服務(wù)器,通過收集一定量的卡頓數(shù)據(jù)后經(jīng)過分析便能準(zhǔn)確定位需要優(yōu)化的邏輯,這個實時卡頓監(jiān)控就大功告成了!
5. 結(jié)尾
通過 Runloop 來檢測卡頓,還是很有必要的。對提高 app 的用戶使用體驗還是很有幫助的。畢竟卡頓是偶顯的不容易復(fù)現(xiàn)。所以檢測卡頓來來抓取堆棧信息,分析并解決卡頓,還是很有必要的。


























