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, range: NSRange(location: 0, 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, currentMode, kCFRunLoopBeforeTimers); // 3.RunLoop 即將觸發 Source0 (非port) 回調。 __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources); // 4.RunLoop 觸發 Source0 (非port) 回調。 sourceHandledThisLoop = __CFRunLoopDoSources0(runloop, currentMode, stopAfterHandle) // 5.執行被加入的block __CFRunLoopDoBlocks(runloop, currentMode); // 6.RunLoop 的線程即將進入休眠(sleep)。 __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeWaiting); // 7.調用 mach_msg 等待接受 mach_port 的消息。線程將進入休眠, 直到被下面某一個事件喚醒。 __CFRunLoopServiceMachPort(waitSet, &msg, sizeof(msg_buffer), &livePort) // 進入休眠 // 8.RunLoop 的線程剛剛被喚醒瞭。 __CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopAfterWaiting // 9.如果一個 Timer 到時間瞭,觸發這個Timer的回調 __CFRunLoopDoTimers(runloop, currentMode, mach_absolute_time()) // 10.如果有dispatch到main_queue的block,執行bloc __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__(msg); // 11.如果一個 Source1 (基於port) 發出事件瞭,處理這個事件 __CFRunLoopDoSource1(runloop, currentMode, source1, msg); // 12.RunLoop 即將退出 __CFRunLoopDoObservers(rl, currentMode, kCFRunLoopExit);
我們可以看到RunLoop調用方法主要集中在kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting之間,有人可能會問kCFRunLoopAfterWaiting之後也有一些方法調用,為什麼不監測呢,我的理解,大部分導致卡頓的的方法是在kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting之間,比如source0主要是處理App內部事件,App自己負責管理(出發),如UIEvent(Touch事件等,GS發起到RunLoop運行再到事件回調到UI)、CFSocketRef。開辟一個子線程,然後實時計算 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 兩個狀態區域之間的耗時是否超過某個閥值,來斷定主線程的卡頓情況。
這裡做法又有點不同,iOS實時卡頓監控3 是設置連續5次超時50ms認為卡頓,戴銘在 GCDFetchFeed
4 中設置的是連續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, 0, &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,如果沒有說明主線程發生瞭卡頓。ANREye5中就是使用子線程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獲取任意線程調用棧7寫的挺詳細的,同時開源瞭代碼RCBacktrace,另一種方式是通過Signal獲取任意線程調用棧,實現原理我在通過Signal handling(信號處理)獲取任意線程調用棧寫瞭,代碼在backtrace-swift,但這種方式在調試時比較麻煩,建議用第一種方式。
以上就是IOS中判斷卡頓的方案總結的詳細內容,更多關於IOS卡頓檢測的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- None Found