
不管是應(yīng)用秒變幻燈片,還是啟動(dòng)過久被殺,基本都是開發(fā)者必經(jīng)的體驗(yàn)。就像沒人希望堵車一樣,卡頓永遠(yuǎn)是不受用戶歡迎的,所以如何發(fā)現(xiàn)卡頓是開發(fā)者需要直面的難題。雖然導(dǎo)致卡頓的原因有很多,但卡頓的表現(xiàn)總是大同小異。如果把卡頓當(dāng)做病癥看待,兩者分別對(duì)應(yīng)所謂的本與標(biāo)。要檢測卡頓,無論是標(biāo)或本都可以下手,但都需要深入的學(xué)習(xí)
instruments與性能
在開發(fā)階段,使用內(nèi)置的性能工具instruments來檢測性能問題是最佳的選擇。與應(yīng)用運(yùn)行性能關(guān)聯(lián)最緊密的兩個(gè)硬件CPU和GPU,前者用于執(zhí)行程序指令,針對(duì)代碼的處理邏輯;后者用于大量計(jì)算,針對(duì)圖像信息的渲染。正常情況下,CPU會(huì)周期性的提交要渲染的圖像信息給GPU處理,保證視圖的更新。一旦其中之一響應(yīng)不過來,就會(huì)表現(xiàn)為卡頓。因此多數(shù)情況下用到的工具是檢測GPU負(fù)載的Core Animation,以及檢測CPU處理效率的Time Profiler

由于CPU提交圖像信息是在主線程執(zhí)行的,會(huì)影響到CPU性能的誘因包括以下:
- 發(fā)生在主線程的
I/O任務(wù) - 過多的線程搶占
CPU資源 - 溫度過高導(dǎo)致的
CPU降頻
而影響GPU的因素較為客觀,難以針對(duì)做代碼上的優(yōu)化,包括:
- 顯存頻率
- 渲染算法
- 大計(jì)算量
本文旨在介紹如何去檢測卡頓,而非如何解決卡頓,因此如果對(duì)上面列出的誘因有興趣的讀者可以自行閱讀相關(guān)文章書籍
卡頓檢測
檢測的方案根據(jù)線程是否相關(guān)分為兩大類:
- 執(zhí)行耗時(shí)任務(wù)會(huì)導(dǎo)致
CPU短時(shí)間無法響應(yīng)其他任務(wù),檢測任務(wù)耗時(shí)來判斷是否可能導(dǎo)致卡頓 - 由于卡頓直接表現(xiàn)為操作無響應(yīng),界面動(dòng)畫遲緩,檢測主線程是否能響應(yīng)任務(wù)來判斷是否卡頓
與主線程相關(guān)的檢測方案包括:
fpspingrunloop
與主線程不相關(guān)的檢測包括:
stack backtracemsgSend observe
衡量指標(biāo)
不同方案的檢測原理和實(shí)現(xiàn)機(jī)制都不同,為了更好的選擇所需的方案,需要建立一套衡量指標(biāo)來對(duì)方案進(jìn)行對(duì)比,個(gè)人總結(jié)的衡量指標(biāo)包括四項(xiàng):
-
卡頓反饋
卡頓發(fā)生時(shí),檢測方案是否能及時(shí)、直觀的反饋出本次卡頓
-
采集精度
卡頓發(fā)生時(shí),檢測方案能否采集到充足的信息來做定位追溯
-
性能損耗
維持檢測所需的
CPU占用、內(nèi)存使用是否會(huì)引入額外的問題 -
實(shí)現(xiàn)成本
檢測方案是否易于實(shí)現(xiàn),代碼的維護(hù)成本與穩(wěn)定性等
fps
通常情況下,屏幕會(huì)保持60hz/s的刷新速度,每次刷新時(shí)會(huì)發(fā)出一個(gè)屏幕刷新信號(hào),CADisplayLink允許我們注冊(cè)一個(gè)與刷新信號(hào)同步的回調(diào)處理??梢酝ㄟ^屏幕刷新機(jī)制來展示fps值:
- (void)startFpsMonitoring {
WeakProxy *proxy = [WeakProxy proxyWithClient: self];
self.fpsDisplay = [CADisplayLink displayLinkWithTarget: proxy selector: @selector(displayFps:)];
[self.fpsDisplay addToRunLoop: [NSRunLoop mainRunLoop] forMode: NSRunLoopCommonModes];
}
- (void)displayFps: (CADisplayLink *)fpsDisplay {
_count++;
CFAbsoluteTime threshold = CFAbsoluteTimeGetCurrent() - _lastUpadateTime;
if (threshold >= 1.0) {
[FPSDisplayer updateFps: (_count / threshold)];
_lastUpadateTime = CFAbsoluteTimeGetCurrent();
}
}
| 指標(biāo) | |
|---|---|
| 卡頓反饋 | 卡頓發(fā)生時(shí),fps會(huì)有明顯下滑。但轉(zhuǎn)場動(dòng)畫等特殊場景也存在下滑情況。高 |
| 采集精度 | 回調(diào)總是需要cpu空閑才能處理,無法及時(shí)采集調(diào)用棧信息。低 |
| 性能損耗 | 監(jiān)聽屏幕刷新會(huì)頻繁喚醒runloop,閑置狀態(tài)下有一定的損耗。中低 |
| 實(shí)現(xiàn)成本 | 單純的采用CADisplayLink實(shí)現(xiàn)。低 |
| 結(jié)論 | 更適用于開發(fā)階段,線上可作為輔助手段 |
ping
ping是一種常用的網(wǎng)絡(luò)測試工具,用來測試數(shù)據(jù)包是否能到達(dá)ip地址。在卡頓發(fā)生的時(shí)候,主線程會(huì)出現(xiàn)短時(shí)間內(nèi)無響應(yīng)這一表現(xiàn),基于ping的思路從子線程嘗試通信主線程來獲取主線程的卡頓延時(shí):
@interface PingThread : NSThread
......
@end
@implementation PingThread
- (void)main {
[self pingMainThread];
}
- (void)pingMainThread {
while (!self.cancelled) {
@autoreleasepool {
dispatch_async(dispatch_get_main_queue(), ^{
[_lock unlock];
});
CFAbsoluteTime pingTime = CFAbsoluteTimeGetCurrent();
NSArray *callSymbols = [StackBacktrace backtraceMainThread];
[_lock lock];
if (CFAbsoluteTimeGetCurrent() - pingTime >= _threshold) {
......
}
[NSThread sleepForTimeInterval: _interval];
}
}
}
@end
| 指標(biāo) | |
|---|---|
| 卡頓反饋 | 主線程出現(xiàn)堵塞直到空閑期間都無法回包,但在ping之間的卡頓存在漏查情況。中高 |
| 采集精度 | 子線程在ping前能獲取主線程準(zhǔn)確的調(diào)用棧信息。中高 |
| 性能損耗 | 需要常駐線程和采集調(diào)用棧。中 |
| 實(shí)現(xiàn)成本 | 需要維護(hù)一個(gè)常駐線程,以及對(duì)象的內(nèi)存控制。中低 |
| 結(jié)論 | 監(jiān)控能力、性能損耗和ping頻率都成正比,監(jiān)控效果強(qiáng) |
runloop
作為和主線程相關(guān)的最后一個(gè)方案,基于runloop的檢測和fps的方案非常相似,都需要依賴于主線程的runloop。由于runloop會(huì)調(diào)起同步屏幕刷新的callback,如果loop的間隔大于16.67ms,fps自然達(dá)不到60hz。而在一個(gè)loop當(dāng)中存在多個(gè)階段,可以監(jiān)控每一個(gè)階段停留了多長時(shí)間:
- (void)startRunLoopMonitoring {
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
if (CFAbsoluteTimeGetCurrent() - _lastActivityTime >= _threshold) {
......
_lastActivityTime = CFAbsoluteTimeGetCurrent();
}
});
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
| 指標(biāo) | |
|---|---|
| 卡頓反饋 |
runloop的不同階段把時(shí)間分片,如果某個(gè)時(shí)間片太長,基本認(rèn)定發(fā)生了卡頓。此外應(yīng)用閑置狀態(tài)常駐beforeWaiting階段,此階段存在誤報(bào)可能。中 |
| 采集精度 | 和fps類似的,依附于主線程callback的方案缺少準(zhǔn)確采集調(diào)用棧的時(shí)機(jī),但優(yōu)于fps檢測方案。中低 |
| 性能損耗 | 此方案不會(huì)頻繁喚醒runloop,相較于fps性能更佳。低 |
| 實(shí)現(xiàn)成本 | 需要注冊(cè)runloop observer。中低 |
| 結(jié)論 | 綜合性能優(yōu)于fps,但反饋表現(xiàn)不足,只適合作為輔助工具使用 |
stack backtrace
代碼質(zhì)量不夠好的方法可能會(huì)在一段時(shí)間內(nèi)持續(xù)占用CPU的資源,換句話說在一段時(shí)間內(nèi),調(diào)用??偸峭A粼趫?zhí)行某個(gè)地址指令的狀態(tài)。由于函數(shù)調(diào)用會(huì)發(fā)生入棧行為,如果比對(duì)兩次調(diào)用棧的符號(hào)信息,前者是后者的符號(hào)子集時(shí),可以認(rèn)為出現(xiàn)了卡頓惡鬼:
@interface StackBacktrace : NSThread
......
@end
@implementation StackBacktrace
- (void)main {
[self backtraceStack];
}
- (void)backtraceStack {
while (!self.cancelled) {
@autoreleasepool {
NSSet *curSymbols = [NSSet setWithArray: [StackBacktrace backtraceMainThread]];
if ([_saveSymbols isSubsetOfSet: curSymbols]) {
......
}
_saveSymbols = curSymbols;
[NSThread sleepForTimeInterval: _interval];
}
}
}
@end
| 指標(biāo) | |
|---|---|
| 卡頓反饋 | 由于符號(hào)地址的唯一性,調(diào)用棧比對(duì)的準(zhǔn)確性高。但需要排除閑置狀態(tài)下的調(diào)用棧信息。高 |
| 采集精度 | 直接通過調(diào)用棧符號(hào)信息比對(duì)可以準(zhǔn)確的獲取調(diào)用棧信息。高 |
| 性能損耗 | 需要頻繁獲取調(diào)用棧,需要考慮延后符號(hào)化的時(shí)機(jī)減少損耗。中高 |
| 實(shí)現(xiàn)成本 | 需要維護(hù)常駐線程和調(diào)用棧追溯算法。中高 |
| 結(jié)論 | 準(zhǔn)確率很高的工具,適用面廣 |
msgSend observe
OC方法的調(diào)用最終轉(zhuǎn)換成msgSend的調(diào)用執(zhí)行,通過在函數(shù)前后插入自定義的函數(shù)調(diào)用,維護(hù)一個(gè)函數(shù)棧結(jié)構(gòu)可以獲取每一個(gè)OC方法的調(diào)用耗時(shí),以此進(jìn)行性能分析與優(yōu)化:
#define save() \
__asm volatile ( \
"stp x8, x9, [sp, #-16]!\n" \
"stp x6, x7, [sp, #-16]!\n" \
"stp x4, x5, [sp, #-16]!\n" \
"stp x2, x3, [sp, #-16]!\n" \
"stp x0, x1, [sp, #-16]!\n");
#define resume() \
__asm volatile ( \
"ldp x0, x1, [sp], #16\n" \
"ldp x2, x3, [sp], #16\n" \
"ldp x4, x5, [sp], #16\n" \
"ldp x6, x7, [sp], #16\n" \
"ldp x8, x9, [sp], #16\n" );
#define call(b, value) \
__asm volatile ("stp x8, x9, [sp, #-16]!\n"); \
__asm volatile ("mov x12, %0\n" :: "r"(value)); \
__asm volatile ("ldp x8, x9, [sp], #16\n"); \
__asm volatile (#b " x12\n");
__attribute__((__naked__)) static void hook_Objc_msgSend() {
save()
__asm volatile ("mov x2, lr\n");
__asm volatile ("mov x3, x4\n");
call(blr, &push_msgSend)
resume()
call(blr, orig_objc_msgSend)
save()
call(blr, &pop_msgSend)
__asm volatile ("mov lr, x0\n");
resume()
__asm volatile ("ret\n");
}
| 指標(biāo) | |
|---|---|
| 卡頓反饋 | 高 |
| 采集精度 | 高 |
| 性能損耗 | 攔截后調(diào)用頻次非常高,啟動(dòng)階段可達(dá)10w次以上調(diào)用。高 |
| 實(shí)現(xiàn)成本 | 需要維護(hù)方法棧和優(yōu)化攔截算法。高 |
| 結(jié)論 | 準(zhǔn)確率很高的工具,但不適用于Swift代碼 |
總結(jié)
| fps | ping | runloop | stack backtrace | msgSend observe | |
|---|---|---|---|---|---|
| 卡頓反饋 | 高 | 中高 | 中 | 高 | 高 |
| 采集精度 | 低 | 中高 | 中低 | 高 | 高 |
| 性能損耗 | 中低 | 中 | 低 | 中高 | 高 |
| 實(shí)現(xiàn)成本 | 低 | 中低 | 中低 | 中高 | 高 |