1. 程式人生 > IOS開發 >iOS卡頓監測方案總結

iOS卡頓監測方案總結

最近在寫APM相關的東西,所以整理了一下iOS中卡頓監測的那些方案,不瞭解卡頓的原理的可以看這篇文章iOS 保持介面流暢的技巧,寫的很好。

FPS

FPS (Frames Per Second) 是影象領域中的定義,表示每秒渲染幀數,通常用於衡量畫面的流暢度,每秒幀數越多,則表示畫面越流暢,60fps 最佳,一般我們的APP的FPS 只要保持在 50-60之間,使用者體驗都是比較流暢的。

監測FPS也有好幾種,這裡只說最常用的方案,我最早是在YYFPSLabel中看到的。 實現原理實現原理是向主執行緒的RunLoop的新增一個commonModes的CADisplayLink,每次螢幕重新整理的時候都要執行CADisplayLink的方法,所以可以統計1s內螢幕重新整理的次數,也就是FPS了,下面貼上我用Swift實現的程式碼:


class WeakProxy: NSObject {
    
    weak var target: NSObjectProtocol?
    
    init(target: NSObjectProtocol) {
        self.target = target
        super.init()
    }
    
    override func responds(to aSelector: Selector!) -> Bool {
        return (target?.responds(to: aSelector) ?? false) || super.responds(to: aSelector)
    }

    override func forwardingTarget(for
aSelector: Selector!) -> Any? { return target } } class FPSLabel: UILabel { var link:CADisplayLink! //記錄方法執行次數 var count: Int = 0 //記錄上次方法執行的時間,通過link.timestamp - _lastTime計算時間間隔 var lastTime: TimeInterval = 0 var _font: UIFont! var _subFont: UIFont! fileprivate let
defaultSize = CGSize(width: 55,height: 20) override init(frame: CGRect) { super.init(frame: frame) if frame.size.width == 0 && frame.size.height == 0 { self.frame.size = defaultSize } self.layer.cornerRadius = 5 self.clipsToBounds = true self.textAlignment = NSTextAlignment.center self.isUserInteractionEnabled = false self.backgroundColor = UIColor.white.withAlphaComponent(0.7) _font = UIFont(name: "Menlo",size: 14) if _font != nil { _subFont = UIFont(name: "Menlo",size: 4) }else{ _font = UIFont(name: "Courier",size: 14) _subFont = UIFont(name: "Courier",size: 4) } link = CADisplayLink(target: WeakProxy.init(target: self),selector: #selector(FPSLabel.tick(link:))) link.add(to: RunLoop.main,forMode: .commonModes) } //CADisplayLink 重新整理執行的方法 @objc func tick(link: CADisplayLink) { guard lastTime != 0 else { lastTime = link.timestamp return } count += 1 let timePassed = link.timestamp - lastTime //時間大於等於1秒計算一次,也就是FPSLabel重新整理的間隔,不希望太頻繁重新整理 guard timePassed >= 1 else { return } lastTime = link.timestamp let fps = Double(count) / timePassed count = 0 let progress = fps / 60.0 let color = UIColor(hue: CGFloat(0.27 * (progress - 0.2)),saturation: 1,brightness: 0.9,alpha: 1) let text = NSMutableAttributedString(string: "\(Int(round(fps))) FPS") text.addAttribute(NSAttributedStringKey.foregroundColor,value: color,range: NSRange(location: 0,length: text.length - 3)) text.addAttribute(NSAttributedStringKey.foregroundColor,value: UIColor.white,range: NSRange(location: text.length - 3,length: 3)) text.addAttribute(NSAttributedStringKey.font,value: _font,length: text.length)) text.addAttribute(NSAttributedStringKey.font,value: _subFont,range: NSRange(location: text.length - 4,length: 1)) self.attributedText = text } // 把displaylin從Runloop modes中移除 deinit { link.invalidate() } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } 複製程式碼

RunLoop

其實FPS中CADisplayLink的使用也是基於RunLoop,都依賴main RunLoop。我們來看看

先來看看簡版的RunLoop的程式碼

// 1.進入loop
__CFRunLoopRun(runloop,currentMode,seconds,returnAfterSourceHandled)

// 2.RunLoop 即將觸發 Timer 回撥。
__CFRunLoopDoObservers(runloop,kCFRunLoopBeforeTimers);
// 3.RunLoop 即將觸發 Source0 (非port) 回撥。
__CFRunLoopDoObservers(runloop,kCFRunLoopBeforeSources);
// 4.RunLoop 觸發 Source0 (非port) 回撥。
sourceHandledThisLoop = __CFRunLoopDoSources0(runloop,stopAfterHandle)
// 5.執行被加入的block
__CFRunLoopDoBlocks(runloop,currentMode);

// 6.RunLoop 的執行緒即將進入休眠(sleep)。
__CFRunLoopDoObservers(runloop,kCFRunLoopBeforeWaiting);

// 7.呼叫 mach_msg 等待接受 mach_port 的訊息。執行緒將進入休眠,直到被下面某一個事件喚醒。
__CFRunLoopServiceMachPort(waitSet,&msg,sizeof(msg_buffer),&livePort)



// 進入休眠


// 8.RunLoop 的執行緒剛剛被喚醒了。
__CFRunLoopDoObservers(runloop,kCFRunLoopAfterWaiting

// 9.如果一個 Timer 到時間了,觸發這個Timer的回撥
__CFRunLoopDoTimers(runloop,mach_absolute_time())

// 10.如果有dispatch到main_queue的block,執行bloc
 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg);
 
 // 11.如果一個 Source1 (基於port) 發出事件了,處理這個事件
__CFRunLoopDoSource1(runloop,source1,msg);

// 12.RunLoop 即將退出
__CFRunLoopDoObservers(rl,kCFRunLoopExit);

複製程式碼

我們可以看到RunLoop呼叫方法主要集中在kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting之間,有人可能會問kCFRunLoopAfterWaiting之後也有一些方法呼叫,為什麼不監測呢,我的理解,大部分導致卡頓的的方法是在kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting之間,比如source0主要是處理App內部事件,App自己負責管理(出發),如UIEvent(Touch事件等,GS發起到RunLoop執行再到事件回撥到UI)、CFSocketRef。開闢一個子執行緒,然後實時計算 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 兩個狀態區域之間的耗時是否超過某個閥值,來斷定主執行緒的卡頓情況。

這裡做法又有點不同,iOS實時卡頓監控是設定連續5次超時50ms認為卡頓,戴銘在GCDFetchFeed中設定的是連續3次超時80ms認為卡頓的程式碼。以下是iOS實時卡頓監控中提供的程式碼:


- (void)start
{
    if (observer)
        return;
    
    // 訊號
    semaphore = dispatch_semaphore_create(0);
    
    // 註冊RunLoop狀態觀察
    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
    observer = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,&runLoopObserverCallBack,&context);
    CFRunLoopAddObserver(CFRunLoopGetMain(),observer,kCFRunLoopCommonModes);
    
    // 在子執行緒監控時長
    dispatch_async(dispatch_get_global_queue(0,0),^{
        while (YES)
        {
            long st = dispatch_semaphore_wait(semaphore,dispatch_time(DISPATCH_TIME_NOW,50*NSEC_PER_MSEC));
            if (st != 0)
            {
                if (!observer)
                {
                    timeoutCount = 0;
                    semaphore = 0;
                    activity = 0;
                    return;
                }
                
                if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting)
                {
                    if (++timeoutCount < 5)
                        continue;
                    
                    PLCrashReporterConfig *config = [[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD
                                                                                       symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll];
                    PLCrashReporter *crashReporter = [[PLCrashReporter alloc] initWithConfiguration:config];
                    
                    NSData *data = [crashReporter generateLiveReport];
                    PLCrashReport *reporter = [[PLCrashReport alloc] initWithData:data error:NULL];
                    NSString *report = [PLCrashReportTextFormatter stringValueForCrashReport:reporter
                                                                              withTextFormat:PLCrashReportTextFormatiOS];
                    
                    NSLog(@"------------\n%@\n------------",report);
                }
            }
            timeoutCount = 0;
        }
    });
}

複製程式碼

子執行緒Ping

但是由於主執行緒的RunLoop在閒置時基本處於Before Waiting狀態,這就導致了即便沒有發生任何卡頓,這種檢測方式也總能認定主執行緒處在卡頓狀態。這套卡頓監控方案大致思路為:建立一個子執行緒通過訊號量去ping主執行緒,因為ping的時候主執行緒肯定是在kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting之間。每次檢測時設定標記位為YES,然後派發任務到主執行緒中將標記位設定為NO。接著子執行緒沉睡超時闕值時長,判斷標誌位是否成功設定成NO,如果沒有說明主執行緒發生了卡頓。ANREye中就是使用子執行緒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

複製程式碼

以下是我用Swift實現的:

public class CatonMonitor {
    
    enum Constants {
        static let timeOutInterval: TimeInterval = 0.05
        static let queueTitle = "com.roy.PerformanceMonitor.CatonMonitor"
    }
    
    private var queue: DispatchQueue = DispatchQueue(label: Constants.queueTitle)
    private var isMonitoring = false
    private var semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
    
    public init() {}
    
    public func start() {
        guard !isMonitoring else { return }
        
        isMonitoring = true
        queue.async {
            while self.isMonitoring {
                
                var timeout = true
                
                DispatchQueue.main.async {
                    timeout = false
                    self.semaphore.signal()
                }
                
                Thread.sleep(forTimeInterval: Constants.timeOutInterval)
                
                if timeout {
                    let symbols = RCBacktrace.callstack(.main)
                    for symbol in symbols {
                        print(symbol.description)
                    }
                }
                self.semaphore.wait()
            }
        }
    }
    
    public func stop() {
        guard isMonitoring else { return }
        
        isMonitoring = false
    }
}


複製程式碼

CPU超過了80%

這個是Matrix-iOS 卡頓監控提到的:

我們也認為 CPU 過高也可能導致應用出現卡頓,所以在子執行緒檢查主執行緒狀態的同時,如果檢測到 CPU 佔用過高,會捕獲當前的執行緒快照儲存到檔案中。目前微信應用中認為,單核 CPU 的佔用超過了 80%,此時的 CPU 佔用就過高了。

這種方式一般不能單獨拿來作為卡頓監測,但可以像微信Matrix一樣配合其他方式一起工作。

戴銘在GCDFetchFeed中如果CPU 的佔用超過了 80%也捕獲函式呼叫棧,以下是程式碼:

#define CPUMONITORRATE 80

+ (void)updateCPU {
    thread_act_array_t threads;
    mach_msg_type_number_t threadCount = 0;
    const task_t thisTask = mach_task_self();
    kern_return_t kr = task_threads(thisTask,&threads,&threadCount);
    if (kr != KERN_SUCCESS) {
        return;
    }
    for (int i = 0; i < threadCount; i++) {
        thread_info_data_t threadInfo;
        thread_basic_info_t threadBaseInfo;
        mach_msg_type_number_t threadInfoCount = THREAD_INFO_MAX;
        if (thread_info((thread_act_t)threads[i],THREAD_BASIC_INFO,(thread_info_t)threadInfo,&threadInfoCount) == KERN_SUCCESS) {
            threadBaseInfo = (thread_basic_info_t)threadInfo;
            if (!(threadBaseInfo->flags & TH_FLAGS_IDLE)) {
                integer_t cpuUsage = threadBaseInfo->cpu_usage / 10;
                if (cpuUsage > CPUMONITORRATE) {
                    //cup 消耗大於設定值時列印和記錄堆疊
                    NSString *reStr = smStackOfThread(threads[i]);
                    SMCallStackModel *model = [[SMCallStackModel alloc] init];
                    model.stackStr = reStr;
                    //記錄資料庫中
                    [[[SMLagDB shareInstance] increaseWithStackModel:model] subscribeNext:^(id x) {}];
//                    NSLog(@"CPU useage overload thread stack:\n%@",reStr);
                }
            }
        }
    }
}


複製程式碼

卡頓方法的棧資訊

當我們得到卡頓的時間點,就要立即拿到卡頓的堆疊,有兩種方式一種是遍歷棧幀,實現原理我在iOS獲取任意執行緒呼叫棧寫的挺詳細的,同時開源了程式碼RCBacktrace,另一種方式是通過Signal獲取任意執行緒呼叫棧,實現原理我在通過Signal handling(訊號處理)獲取任意執行緒呼叫棧寫了,程式碼在backtrace-swift,但這種方式在除錯時比較麻煩,建議用第一種方式。

參考文章

質量監控-卡頓檢測
Matrix-iOS 卡頓監控
13 | 如何利用 RunLoop 原理去監控卡頓?
iOS實時卡頓監控
iOS開發--APP效能檢測方案彙總(一)