前文探討了 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)在的顯示器基本都是液晶顯示屏了饵逐,但其原理基本一致括眠。
下圖所示為常見的 CPU、GPU倍权、顯示器工作方式掷豺。CPU 計算好顯示內(nèi)容(如:視圖的創(chuàng)建、布局計算薄声、圖片解碼当船、文本繪制)提交至 GPU,GPU 渲染完成后將渲染結(jié)果存入幀緩沖區(qū)默辨,視頻控制器會按照 VSync 信號逐幀讀取幀緩沖區(qū)的數(shù)據(jù)德频,經(jīng)過數(shù)據(jù)轉(zhuǎn)換后最終由顯示器進行顯示。
最簡單的情況下缩幸,幀緩沖區(qū)只有一個壹置。此時,幀緩沖區(qū)的讀取和刷新都都會有比較大的效率問題表谊。為了解決效率問題钞护,GPU 通常會引入兩個緩沖區(qū),即 雙緩沖機制爆办。事實上难咕,iPhone 使用的就是雙緩沖機制。在這種情況下距辆,GPU 會預(yù)先渲染一幀放入一個緩沖區(qū)中余佃,用于視頻控制器的讀取。當(dāng)下一幀渲染完畢后跨算,GPU 會直接把視頻控制器的指針指向第二個緩沖器咙冗。
雙緩沖雖然能解決效率問題,但會引入一個新的問題漂彤。當(dāng)視頻控制器還未讀取完成時,即屏幕內(nèi)容剛顯示一半時,GPU 將新的一幀內(nèi)容提交到幀緩沖區(qū)并把兩個緩沖區(qū)進行交換后挫望,視頻控制器就會把新的一幀數(shù)據(jù)的下半段顯示到屏幕上立润,造成畫面撕裂現(xiàn)象,如下圖:
為了解決這個問題媳板,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)容,這就是界面卡頓的原因升筏。
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 采用的就是這種方式担锤。
首先我們需要了解一下 RunLoop 的原理蔚晨。
RunLoop 定義
RunLoop 是 iOS 事件響應(yīng)與任務(wù)處理最核心的機制。當(dāng)有持續(xù)的異步任務(wù)需求時肛循,我們會創(chuàng)建一個獨立的生命周期可控的線程铭腕。RunLoop 就是控制線程生命周期并接收事件進行處理的機制。
RunLoop 機制
主線程(有 RunLoop 的線程)幾乎所有函數(shù)都從以下六個函數(shù)之一的函數(shù)調(diào)起:
-
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
-
CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK
- CFRunloop is calling out to a block
- 消息通知、非延遲的 perform夹孔、dispatch 調(diào)用被盈、block 回調(diào)析孽、KVO
-
CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE
- CFRunloop is servicing the main dispatch queue
- 執(zhí)行主隊列上的任務(wù)
-
CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION
- CFRunloop is calling out to a timer callback function
- 基于定時器的延遲的 perfrom,dispatch 調(diào)用
-
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ā))袜瞬,如:
UIEvent
、CFSocket
身堡。普通函數(shù)調(diào)用邓尤,系統(tǒng)調(diào)用
-
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ū)間:
-
kCFRunLoopBeforeSources
和kCFRunLoopBeforeWaiting
之間 -
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);