質量監控-卡頓檢測

騎著jm的hi發表於2018-09-30

原文連結

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

instruments與效能

在開發階段,使用內建的效能工具instruments來檢測效能問題是最佳的選擇。與應用執行效能關聯最緊密的兩個硬體CPUGPU,前者用於執行程式指令,針對程式碼的處理邏輯;後者用於大量計算,針對影像資訊的渲染。正常情況下,CPU會週期性的提交要渲染的影像資訊給GPU處理,保證檢視的更新。一旦其中之一響應不過來,就會表現為卡頓。因此多數情況下用到的工具是檢測GPU負載的Core Animation,以及檢測CPU處理效率的Time Profiler

質量監控-卡頓檢測

由於CPU提交影像資訊是在主執行緒執行的,會影響到CPU效能的誘因包括以下:

  1. 發生在主執行緒的I/O任務
  2. 過多的執行緒搶佔CPU資源
  3. 溫度過高導致的CPU降頻

而影響GPU的因素較為客觀,難以針對做程式碼上的優化,包括:

  1. 視訊記憶體頻率
  2. 渲染演算法
  3. 大計算量

本文旨在介紹如何去檢測卡頓,而非如何解決卡頓,因此如果對上面列出的誘因有興趣的讀者可以自行閱讀相關文章書籍

卡頓檢測

檢測的方案根據執行緒是否相關分為兩大類:

  • 執行耗時任務會導致CPU短時間無法響應其他任務,檢測任務耗時來判斷是否可能導致卡頓
  • 由於卡頓直接表現為操作無響應,介面動畫遲緩,檢測主執行緒是否能響應任務來判斷是否卡頓

與主執行緒相關的檢測方案包括:

  1. fps
  2. ping
  3. runloop

與主執行緒不相關的檢測包括:

  1. stack backtrace
  2. 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.67msfps自然達不到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
卡頓反饋 中高
採集精度 中高 中低
效能損耗 中低 中高
實現成本 中低 中低 中高

關注我的公眾號獲取更新資訊

相關文章