不管是應用秒變幻燈片,還是啟動過久被殺,基本都是開發者必經的體驗。就像沒人希望堵車一樣,卡頓永遠是不受使用者歡迎的,所以如何發現卡頓是開發者需要直面的難題。雖然導致卡頓的原因有很多,但卡頓的表現總是大同小異。如果把卡頓當做病症看待,兩者分別對應所謂的本與標。要檢測卡頓,無論是標或本都可以下手,但都需要深入的學習
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 | |
---|---|---|---|---|---|
卡頓反饋 | 高 | 中高 | 中 | 高 | 高 |
採集精度 | 低 | 中高 | 中低 | 高 | 高 |
效能損耗 | 中低 | 中 | 低 | 中高 | 高 |
實現成本 | 低 | 中低 | 中低 | 中高 | 高 |