前言
項(xiàng)目剛起步的過程中猾瘸,往往時(shí)間緊任務(wù)重,程序員在開發(fā)的時(shí)候丢习,只想著要完成開發(fā)需求牵触,沒有多余的時(shí)間去關(guān)注性能問題。但隨著項(xiàng)目越來越大咐低,功能越來多揽思,卡頓問題越來越嚴(yán)重,用戶體驗(yàn)很不好见擦。解決卡頓的問題钉汗,刻不容緩啊,于是整理了檢測卡頓的一些方法鲤屡,與大家做個(gè)分享损痰,本文主要包含 fps
和 ping
的方式檢測。
一酒来、卡頓原因
在顯示器中是固定的頻率,比如iOS中是每秒60幀(60FPS)堰汉,即每幀16.7ms辽社。從上圖中可以看出,每兩個(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í)間過長,都會(huì)導(dǎo)致在16.7ms內(nèi)無法生成一幀緩存
簡單來說徘铝,主線程為了達(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 的簡稱縮寫梁钾,意思是每秒傳輸幀數(shù)绳泉,也就是我們常說的“刷新率”(單位為Hz)。FPS是測量用于保存姆泻、顯示動(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.reibang.com/p/3d3f968c9cf4
三、定位具體位置
1横腿、實(shí)現(xiàn)思路
使用FPS方式只能大概推測出是哪里的問題颓屑,但不能具體定位到具體的位置。最理想的方案是讓UI線程“主動(dòng)匯報(bào)”當(dāng)前耗時(shí)的任務(wù)耿焊,聽起來簡單做起來不輕松揪惦。
我們可以假設(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ù),那么可以推測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í)長定時(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í)長鱼炒,并做相應(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(@"檢測主線程上的耗時(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)擊查看下載叠赐。