最近在寫APM相關的東西,因此整理了一下iOS中卡頓監測的那些方案,不瞭解卡頓的原理的能夠看這篇文章iOS 保持界面流暢的技巧,寫的很好。html
FPS (Frames Per Second) 是圖像領域中的定義,表示每秒渲染幀數,一般用於衡量畫面的流暢度,每秒幀數越多,則表示畫面越流暢,60fps 最佳,通常咱們的APP的FPS 只要保持在 50-60之間,用戶體驗都是比較流暢的。ios
監測FPS也有好幾種,這裏只說最經常使用的方案,我最先是在YYFPSLabel中看到的。 實現原理實現原理是向主線程的RunLoop的添加一個commonModes的CADisplayLink,每次屏幕刷新的時候都要執行CADisplayLink的方法,因此能夠統計1s內屏幕刷新的次數,也就是FPS了,下面貼上我用Swift實現的代碼:git
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")
}
}
複製代碼
其實FPS中CADisplayLink的使用也是基於RunLoop,都依賴main RunLoop。咱們來看看github
先來看看簡版的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 兩個狀態區域之間的耗時是否超過某個閥值,來判定主線程的卡頓狀況。swift
這裏作法又有點不一樣,iOS實時卡頓監控是設置連續5次超時50ms認爲卡頓,戴銘在GCDFetchFeed中設置的是連續3次超時80ms認爲卡頓的代碼。如下是iOS實時卡頓監控中提供的代碼:bash
- (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;
}
});
}
複製代碼
可是因爲主線程的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實現的:async
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
}
}
複製代碼
這個是Matrix-iOS 卡頓監控提到的:ide
咱們也認爲 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性能檢測方案彙總(一)