前言
項(xiàng)目剛起步的過程中,往往時(shí)間緊任務(wù)重,程序員在開發(fā)的時(shí)候,只想著要完成開發(fā)需求,沒有多余的時(shí)間去關(guān)注性能問題。但隨著項(xiàng)目越來越大,功能越來多,卡頓問題越來越嚴(yán)重,用戶體驗(yàn)很不好。解決卡頓的問題,刻不容緩啊,于是整理了檢測(cè)卡頓的一些方法,與大家做個(gè)分享,本文主要包含 fps 和 ping 的方式檢測(cè)。
一、卡頓原因

在顯示器中是固定的頻率,比如iOS中是每秒60幀(60FPS),即每幀16.7ms。從上圖中可以看出,每?jī)蓚€(gè)VSync信號(hào)之間有時(shí)間間隔(16.7ms),在這個(gè)時(shí)間內(nèi),CPU主線程計(jì)算布局,解碼圖片,創(chuàng)建視圖,繪制文本,計(jì)算完成后將內(nèi)容交給GPU,GPU變換,合成,渲染,放入幀緩沖區(qū)。假如16.7ms內(nèi),CPU和GPU沒有來得及生產(chǎn)出一幀緩沖,那么這一幀會(huì)被丟棄,顯示器就會(huì)保持不變,繼續(xù)顯示上一幀內(nèi)容,這就將導(dǎo)致導(dǎo)致畫面卡頓。所以無論CPU,GPU,哪個(gè)消耗時(shí)間過長(zhǎng),都會(huì)導(dǎo)致在16.7ms內(nèi)無法生成一幀緩存
簡(jiǎn)單來說,主線程為了達(dá)到接近60fps的繪制效率,不能在UI線程有單個(gè)超過(1/60s≈16ms)的計(jì)算任務(wù),導(dǎo)致卡頓。
以下操作可能會(huì)引起卡頓:
- 死鎖:主線程拿到鎖 A,需要獲得鎖 B,而同時(shí)某個(gè)子線程拿了鎖 B,需要鎖 A,這樣相互等待就死鎖了。
- 搶鎖:主線程需要訪問 DB,而此時(shí)某個(gè)子線程往 DB 插入大量數(shù)據(jù)。通常搶鎖的體驗(yàn)是偶爾卡一陣子,過會(huì)就恢復(fù)了。
- 主線程大量 IO:主線程為了方便直接寫入大量數(shù)據(jù),會(huì)導(dǎo)致界面卡頓。
- 主線程大量計(jì)算:算法不合理,導(dǎo)致主線程某個(gè)函數(shù)占用大量 CPU。
- 大量的 UI 繪制:復(fù)雜的 UI、圖文混排等,帶來大量的 UI 繪制。
二、可視化FPS展示
FPS是Frames Per Second 的簡(jiǎn)稱縮寫,意思是每秒傳輸幀數(shù),也就是我們常說的“刷新率”(單位為Hz)。FPS是測(cè)量用于保存、顯示動(dòng)態(tài)視頻的信息數(shù)量。每秒鐘幀數(shù)愈多,所顯示的畫面就會(huì)愈流暢,F(xiàn)PS值越低就越卡頓,所以這個(gè)值在一定程度上可以衡量應(yīng)用在圖像繪制渲染處理時(shí)的性能。一般我們的APP的FPS只要保持在 50-60 之間,用戶體驗(yàn)都是比較流暢的。
我們可以通過CADisplayLink來監(jiān)控我們的FPS。CADisplayLink是CoreAnimation提供的另一個(gè)類似于NSTimer的類,它總是在屏幕完成一次更新之前啟動(dòng),它的接口設(shè)計(jì)的和NSTimer很類似,所以它實(shí)際上就是一個(gè)內(nèi)置實(shí)現(xiàn)的替代,但是和timeInterval以秒為單位不同,CADisplayLink有一個(gè)整型的frameInterval屬性,指定了間隔多少幀之后才執(zhí)行。默認(rèn)值是1,意味著每次屏幕更新之前都會(huì)執(zhí)行一次。但是如果動(dòng)畫的代碼執(zhí)行起來超過了六十分之一秒,你可以指定frameInterval為2,就是說動(dòng)畫每隔一幀執(zhí)行一次(一秒鐘30幀)。
@implementation MDFPSLabel {
CADisplayLink *_link;
NSUInteger _count;
NSTimeInterval _lastTime;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
self.textAlignment = NSTextAlignmentCenter;
self.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];
// 創(chuàng)建CADisplayLink,設(shè)置代理和回調(diào)
_link = [CADisplayLink displayLinkWithTarget:[MDWeakProxy proxyWithTarget:self]
selector:@selector(tick:)];
// 并添加到當(dāng)前runloop的NSRunLoopCommonModes
[_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
return self;
}
- (void)dealloc {
[_link invalidate];
}
// 計(jì)算 fps
- (void)tick:(CADisplayLink *)link {
if (_lastTime == 0) { // 當(dāng)前時(shí)間戳
_lastTime = link.timestamp;
return;
}
_count++; // 執(zhí)行次數(shù)
NSTimeInterval delta = link.timestamp - _lastTime;
if (delta < 1) return;
_lastTime = link.timestamp;
float fps = _count / delta; // fps
_count = 0;
// 更新 fps
CGFloat progress = fps / 60.0;
self.text = [NSString stringWithFormat:@"%d",(int)round(fps)];
self.textColor = [UIColor colorWithHue:0.27 * (progress - 0.2)
saturation:1
brightness:0.9
alpha:1];
}
@end
更多FPS介紹及demo下載,請(qǐng)參轉(zhuǎn)閱這篇文章:http://www.itdecent.cn/p/3d3f968c9cf4
三、定位具體位置
1、實(shí)現(xiàn)思路
使用FPS方式只能大概推測(cè)出是哪里的問題,但不能具體定位到具體的位置。最理想的方案是讓UI線程“主動(dòng)匯報(bào)”當(dāng)前耗時(shí)的任務(wù),聽起來簡(jiǎn)單做起來不輕松。
我們可以假設(shè)這樣一套機(jī)制:每隔16ms讓UI線程來報(bào)道一次,如果16ms之后UI線程沒來報(bào)道,那就一定是在執(zhí)行某個(gè)耗時(shí)的任務(wù)。這種抽象的描述翻譯成代碼,可以用如下表述:
我們啟動(dòng)一個(gè)worker線程,worker線程每隔一小段時(shí)間(delta)ping以下主線程(發(fā)送一個(gè)NSNotification),如果主線程此時(shí)有空,必然能接收到這個(gè)通知,并pong以下(發(fā)送另一個(gè)NSNotification),如果worker線程超過delta時(shí)間沒有收到pong的回復(fù),那么可以推測(cè)UI線程必然在處理其他任務(wù)了,此時(shí)我們執(zhí)行第二步操作,暫停UI線程,并打印出當(dāng)前UI線程的函數(shù)調(diào)用棧。

2、具體實(shí)現(xiàn)
- 設(shè)置定時(shí)器:工作線程定時(shí)給主線程發(fā)送 ping 消息
/// 開始監(jiān)聽
- (void)startWatch {
// 設(shè)置定時(shí)器:定時(shí)給主線程發(fā)送信息
uint64_t interval = PMainThreadWatcher_Watch_Interval * NSEC_PER_SEC;
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
self.pingTimer = createGCDTimer(interval,
interval / 10000,
queue,
^{
[self pingMainThread];
});
}
/// 給主線程發(fā)信息
- (void)pingMainThread {
// 設(shè)置回應(yīng)時(shí)長(zhǎng)定時(shí)器
uint64_t interval = PMainThreadWatcher_Warning_Level * NSEC_PER_SEC;
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
self.pongTimer = createGCDTimer(interval,
interval / 10000,
queue,
^{
[self onPongTimeout];
});
// 給主線程發(fā)送通知消息
dispatch_async(dispatch_get_main_queue(), ^{
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center postNotificationName:Notification_PMainThreadWatcher_Worker_Ping
object:nil];
});
}
2)主線程收到 ping 消息,并返回 pong 消息
/// 收到從工作線程發(fā)送的Ping通知
- (void)detectPingFromWorkerThread {
// 回應(yīng)工作線程的通知:發(fā)送 pong 通知
NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
[center postNotificationName:Notification_PMainThreadWatcher_Main_Pong
object:nil];
}
3)判斷回應(yīng)時(shí)長(zhǎng),并做相應(yīng)處理
/// 回應(yīng)超時(shí)
- (void)onPongTimeout {
[self cancelPongTimer];
// 暫停主線程,打印堆棧信息
printMainThreadCallStack();
}
/// 收到從主線程返回的Pong通知
- (void)detectPongFromMainThread {
[self cancelPongTimer];
}
/// 取消回應(yīng)時(shí)常定時(shí)器
- (void)cancelPongTimer {
if (self.pongTimer) {
dispatch_source_cancel(_pongTimer);
_pongTimer = nil;
}
}
- 如果超時(shí)則殺掉進(jìn)程
int pthread_kill(pthread_t, int);
殺掉進(jìn)程這里使用 pthread_kill(), 該函數(shù)的API介紹如下
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.
別被名字嚇到,pthread_kill 可不是kill,而是向線程發(fā)送signal,大部分signal的默認(rèn)動(dòng)作是終止進(jìn)程的運(yùn)行。向指定ID的線程發(fā)送sig信號(hào),如果線程代碼內(nèi)不做處理,則按照信號(hào)默認(rèn)的行為影響整個(gè)進(jìn)程,也就是說,如果你給一個(gè)線程發(fā)送了SIGQUIT,但線程卻沒有實(shí)現(xiàn)signal處理函數(shù),則整個(gè)進(jìn)程退出。
static void printMainThreadCallStack() {
NSLog(@"發(fā)送信號(hào): %d 到主線程", CALLSTACK_SIG);
// pthread_kill主線程
pthread_kill(mainThreadID, CALLSTACK_SIG);
}
5)監(jiān)聽信號(hào),打印堆棧信息
iOS允許在主線程注冊(cè)一個(gè)signal處理函數(shù),當(dāng)調(diào)用pthread_kill函數(shù)時(shí)能收到該信號(hào),這時(shí)候就可以在signal回調(diào)方法中打印堆棧信息了。
/// singal回調(diào)方法
static void thread_singal_handler(int sig) {
NSLog(@"主線程捕獲信號(hào): %d", sig);
if (sig != CALLSTACK_SIG) {
return;
}
NSArray *callStack = [NSThread callStackSymbols];
// 代理回調(diào)或打印堆棧信息
id<PMainThreadWatcherDelegate> del = [PMainThreadWatcher sharedInstance].watchDelegate;
if (del != nil && [del respondsToSelector:@selector(onMainThreadSlowStackDetected:)]) {
[del onMainThreadSlowStackDetected:callStack];
} else {
NSLog(@"檢測(cè)主線程上的耗時(shí)調(diào)用堆棧 \n");
for (NSString *call in callStack) {
NSLog(@"%@\n", call);
}
}
return;
}
/// 注冊(cè)signal函數(shù)
static void install_signal_handler() {
// 主線程注冊(cè)一個(gè)signal處理函數(shù)
signal(CALLSTACK_SIG, thread_singal_handler);
}
注意
signal方法不能調(diào)試,因?yàn)閄code Debug模式運(yùn)行App時(shí),App進(jìn)程signal被LLDB Debugger調(diào)試器捕獲,導(dǎo)致signal handler無法進(jìn),但UI線程在遇到卡頓的時(shí)候還是能正常被中斷。
更多signal函數(shù)用法及解釋,請(qǐng)轉(zhuǎn)閱這篇文章:
本章節(jié)根據(jù)該文改編:http://mrpeak.cn/blog/ui-detect/ 。原文有對(duì)應(yīng)的 Demo ,可點(diǎn)擊查看下載。