不管是應用秒變幻燈片,還是啟動過久被殺,基本都是開發者必經的體驗。就像沒人希望堵車一樣,卡頓永遠是不受用戶歡迎的,所以如何發現卡頓是開發者需要直面的難題。雖然導致卡頓的原因有很多,但卡頓的表現總是大同小異。如果把卡頓當做病癥看待,兩者分別對應所謂的本與標。要檢測卡頓,無論是標或本都可以下手,但都需要深入的學習
instruments與性能
在開發階段,使用內置的性能工具instruments
來檢測性能問題是最佳的選擇。與應用運行性能關聯最緊密的兩個硬件CPU
和GPU
,前者用于執行程序指令,針對代碼的處理邏輯;后者用于大量計算,針對圖像信息的渲染。正常情況下,CPU
會周期性的提交要渲染的圖像信息給GPU
處理,保證視圖的更新。一旦其中之一響應不過來,就會表現為卡頓。因此多數情況下用到的工具是檢測GPU
負載的Core Animation
,以及檢測CPU
處理效率的Time Profiler
由于CPU
提交圖像信息是在主線程執行的,會影響到CPU
性能的誘因包括以下:
- 發生在主線程的
I/O
任務 - 過多的線程搶占
CPU
資源 - 溫度過高導致的
CPU
降頻
而影響GPU
的因素較為客觀,難以針對做代碼上的優化,包括:
- 顯存頻率
- 渲染算法
- 大計算量
本文旨在介紹如何去檢測卡頓,而非如何解決卡頓,因此如果對上面列出的誘因有興趣的讀者可以自行閱讀相關文章書籍
卡頓檢測
檢測的方案根據線程是否相關分為兩大類:
- 執行耗時任務會導致
CPU
短時間無法響應其他任務,檢測任務耗時來判斷是否可能導致卡頓 - 由于卡頓直接表現為操作無響應,界面動畫遲緩,檢測主線程是否能響應任務來判斷是否卡頓
與主線程相關的檢測方案包括:
fps
ping
runloop
與主線程不相關的檢測包括:
stack backtrace
msgSend observe
衡量指標
不同方案的檢測原理和實現機制都不同,為了更好的選擇所需的方案,需要建立一套衡量指標來對方案進行對比,個人總結的衡量指標包括四項:
-
卡頓反饋
卡頓發生時,檢測方案是否能及時、直觀的反饋出本次卡頓
-
采集精度
卡頓發生時,檢測方案能否采集到充足的信息來做定位追溯
-
性能損耗
維持檢測所需的
CPU
占用、內存使用是否會引入額外的問題 -
實現成本
檢測方案是否易于實現,代碼的維護成本與穩定性等
fps
通常情況下,屏幕會保持60hz/s
的刷新速度,每次刷新時會發出一個屏幕刷新信號,CADisplayLink
允許我們注冊一個與刷新信號同步的回調處理。可以通過屏幕刷新機制來展示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();
}
}
指標 | |
---|---|
卡頓反饋 | 卡頓發生時,fps 會有明顯下滑。但轉場動畫等特殊場景也存在下滑情況。高 |
采集精度 | 回調總是需要cpu 空閑才能處理,無法及時采集調用棧信息。低 |
性能損耗 | 監聽屏幕刷新會頻繁喚醒runloop ,閑置狀態下有一定的損耗。中低 |
實現成本 | 單純的采用CADisplayLink 實現。低 |
結論 | 更適用于開發階段,線上可作為輔助手段 |
ping
ping
是一種常用的網絡測試工具,用來測試數據包是否能到達ip
地址。在卡頓發生的時候,主線程會出現短時間內無響應
這一表現,基于ping
的思路從子線程嘗試通信主線程來獲取主線程的卡頓延時:
@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
指標 | |
---|---|
卡頓反饋 | 主線程出現堵塞直到空閑期間都無法回包,但在ping 之間的卡頓存在漏查情況。中高 |
采集精度 | 子線程在ping 前能獲取主線程準確的調用棧信息。中高 |
性能損耗 | 需要常駐線程和采集調用棧。中 |
實現成本 | 需要維護一個常駐線程,以及對象的內存控制。中低 |
結論 | 監控能力、性能損耗和ping 頻率都成正比,監控效果強 |
runloop
作為和主線程相關的最后一個方案,基于runloop
的檢測和fps
的方案非常相似,都需要依賴于主線程的runloop
。由于runloop
會調起同步屏幕刷新的callback
,如果loop
的間隔大于16.67ms
,fps
自然達不到60hz
。而在一個loop
當中存在多個階段,可以監控每一個階段停留了多長時間:
- (void)startRunLoopMonitoring {
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(CFAllocatorGetDefault(), kCFRunLoopAllActivities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
if (CFAbsoluteTimeGetCurrent() - _lastActivityTime >= _threshold) {
......
_lastActivityTime = CFAbsoluteTimeGetCurrent();
}
});
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
指標 | |
---|---|
卡頓反饋 |
runloop 的不同階段把時間分片,如果某個時間片太長,基本認定發生了卡頓。此外應用閑置狀態常駐beforeWaiting 階段,此階段存在誤報可能。中 |
采集精度 | 和fps 類似的,依附于主線程callback 的方案缺少準確采集調用棧的時機,但優于fps 檢測方案。中低 |
性能損耗 | 此方案不會頻繁喚醒runloop ,相較于fps 性能更佳。低 |
實現成本 | 需要注冊runloop observer 。中低 |
結論 | 綜合性能優于fps ,但反饋表現不足,只適合作為輔助工具使用 |
stack backtrace
代碼質量不夠好的方法可能會在一段時間內持續占用CPU
的資源,換句話說在一段時間內,調用棧總是停留在執行某個地址指令的狀態。由于函數調用會發生入棧行為,如果比對兩次調用棧的符號信息,前者是后者的符號子集時,可以認為出現了卡頓惡鬼
:
@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
指標 | |
---|---|
卡頓反饋 | 由于符號地址的唯一性,調用棧比對的準確性高。但需要排除閑置狀態下的調用棧信息。高 |
采集精度 | 直接通過調用棧符號信息比對可以準確的獲取調用棧信息。高 |
性能損耗 | 需要頻繁獲取調用棧,需要考慮延后符號化的時機減少損耗。中高 |
實現成本 | 需要維護常駐線程和調用棧追溯算法。中高 |
結論 | 準確率很高的工具,適用面廣 |
msgSend observe
OC
方法的調用最終轉換成msgSend
的調用執行,通過在函數前后插入自定義的函數調用,維護一個函數棧結構可以獲取每一個OC
方法的調用耗時,以此進行性能分析與優化:
#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");
}
指標 | |
---|---|
卡頓反饋 | 高 |
采集精度 | 高 |
性能損耗 | 攔截后調用頻次非常高,啟動階段可達10w 次以上調用。高 |
實現成本 | 需要維護方法棧和優化攔截算法。高 |
結論 | 準確率很高的工具,但不適用于Swift 代碼 |
總結
fps | ping | runloop | stack backtrace | msgSend observe | |
---|---|---|---|---|---|
卡頓反饋 | 高 | 中高 | 中 | 高 | 高 |
采集精度 | 低 | 中高 | 中低 | 高 | 高 |
性能損耗 | 中低 | 中 | 低 | 中高 | 高 |
實現成本 | 低 | 中低 | 中低 | 中高 | 高 |