前言
一个App想要提升用户体验最重要的就是 降低程序崩溃 和 提升程序流畅度。前者在上一篇 崩溃监控 中稍有介绍,而今天要看的就是如何监控程序的卡顿,从而有目的性的优化程序流畅度,提升用户体验。
虽然达到程序60FPS稳定运行是我们的终极目标,但是原文中戴铭老师直接否定了通过 监控FPS 来判断程序是否卡顿的方案,进而提出使用 监控主线程RunLoop的状态 来判断是否卡顿的方法。
RunLoop监控卡顿原理
1 卡顿情况
- 复杂 UI、图文混排的绘制量过大
- 在主线程上做网络同步请求
- 在主线程做大量 IO 操作
- 运算量过大,CPU持续高占用
- 死锁或主子线程间抢锁
2 RunLoop基础概念
简单来说,RunLoop 的工作模式就是,当有事件要处理时保持线程忙,当没有事件要处理时让线程进入休眠。
2.1 相关的类:
CFRunLoopRef
CFRunLoopModeRef
CFRunLoopSourceRef
CFRunLoopTimerRef
CFRunLoopObserverRef
2.2 Mode:
一个RunLoop包含若干个Mode,每个Mode又包含若干个Source/Timer/Observer。
系统默认注册了5个Mode。每次调用RunLoop的主函数时,只能指定其中一个Mode,也就是说RunLoop中的Mode在不断切换。
kCFRunLoopDefaultMode //App的默认Mode,通常主线程是在这个Mode下运行
UITrackingRunLoopMode //界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响
UIInitializationRunLoopMode // 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用
GSEventReceiveRunLoopMode // 接受系统事件的内部 Mode,通常用不到
kCFRunLoopCommonModes //这是一个占位用的Mode,不是一种真正的Mode
2.3 工作过程:
工作过程大致总结为上图的10个步骤:
1 通知Observers,RunLoop要开始进入loop了
2-3 进入loop,开启一个 do while 保活线程。通知Observers,将要处理Timer回调和Source0回调,接着执行block
// 通知 Observers RunLoop 会触发 Timer 回调
if (currentMode->_observerMask & kCFRunLoopBeforeTimers)
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeTimers);
// 通知 Observers RunLoop 会触发 Source0 回调
if (currentMode->_observerMask & kCFRunLoopBeforeSources)
__CFRunLoopDoObservers(runloop, currentMode, kCFRunLoopBeforeSources);
// 执行 block
__CFRunLoopDoBlocks(runloop, currentMode);
4-5 处理Source0回调,如果这里有Source1是ready状态,就会跳转handle_msg去处理消息
if (MACH_PORT_NULL != dispatchPort ) {
Boolean hasMsg = __CFRunLoopServiceMachPort(dispatchPort, &msg)
if (hasMsg) goto handle_msg;
}
6 回调触发后,通知Observers,该线程即将进入休眠
7-8 进入休眠后,如果出现下面四个事件时RunLoop会通知Observers,线程被唤醒了
- 基于 port 的 Source 事件
- Timer 时间到
- RunLoop 超时
- 被调用者唤醒
9 RunLoop 被唤醒后就重新开始处理消息,重复2-3的过程
10 当被外部强制停止或loop超时,就不继续下一个loop了,此时通知Observers,即将退出loop
if (sourceHandledThisLoop && stopAfterHandle) {
// 事件已处理完
retVal = kCFRunLoopRunHandledSource;
} else if (timeout) {
// 超时
retVal = kCFRunLoopRunTimedOut;
} else if (__CFRunLoopIsStopped(runloop)) {
// 外部调用者强制停止
retVal = kCFRunLoopRunStopped;
} else if (__CFRunLoopModeIsEmpty(runloop, currentMode)) {
// mode 为空,RunLoop 结束
retVal = kCFRunLoopRunFinished;
}
2.4 Observer,loop的六个状态
观察者,可以监听RunLoop的状态改变
typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) {
kCFRunLoopEntry = (1UL << 0), // 进入 loop
kCFRunLoopBeforeTimers = (1UL << 1), //即将处理 Timer
kCFRunLoopBeforeSources = (1UL << 2), //即将处理 Sources0
kCFRunLoopBeforeWaiting = (1UL << 5), //即将进入休眠
kCFRunLoopAfterWaiting = (1UL << 6), //刚从休眠中唤醒
kCFRunLoopExit = (1UL << 7), // 退出 loop
kCFRunLoopAllActivities = 0x0FFFFFFFU //所有状态改变
};
3 RunLoop,通过Observer监控卡顿
我们通过RunLoop的工作流程可以知道,如果在 loop进入睡眠前执行方法时间过长(过程2-5) 或者 线程唤醒时接收消息时间过长(过程8)而无法处理下一个事件,我们就可以认为线程受阻而出现了卡顿。
上面两种情况,我们可以通过监听RunLoop的 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 这两个状态所停留的时长来判断。
如何检查卡顿
这里我们从老师分享的源码 截取关键部分 进行分析和学习。
#import "SMLagMonitor.h"
#import "SMCallStack.h"
#import "SMCPUMonitor.h"
@interface SMLagMonitor() {
int timeoutCount;
CFRunLoopObserverRef runLoopObserver;
@public
dispatch_semaphore_t dispatchSemaphore;
CFRunLoopActivity runLoopActivity;
}
@end
@implementation SMLagMonitor
#pragma mark - Interface
+ (instancetype)shareInstance {
static id instance = nil;
static dispatch_once_t dispatchOnce;
dispatch_once(&dispatchOnce, ^{
instance = [[self alloc] init];
});
return instance;
}
- (void)beginMonitor {
//监测卡顿
if (runLoopObserver) {
return;
}
dispatchSemaphore = dispatch_semaphore_create(0); //Dispatch Semaphore保证同步
//创建一个观察者
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
0,
&runLoopObserverCallBack,
&context);
//将观察者添加到主线程runloop的common模式下的观察中
CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);
//创建子线程监控
dispatch_async(dispatch_get_global_queue(0, 0), ^{
//子线程开启一个持续的loop用来进行监控
while (YES) {
long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 3*NSEC_PER_MSEC));
if (semaphoreWait != 0) {
if (!runLoopObserver) {
timeoutCount = 0;
dispatchSemaphore = 0;
runLoopActivity = 0;
return;
}
//两个runloop的状态,BeforeSources和AfterWaiting这两个状态区间时间能够检测到是否卡顿
if (runLoopActivity == kCFRunLoopBeforeSources || runLoopActivity == kCFRunLoopAfterWaiting) {
// 出现异常情况
NSLog(@"monitor trigger");
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
// 异步提交/上传错误的堆栈信息
});
} //end activity
}// end semaphore wait
timeoutCount = 0;
}// end while
});
}
- (void)endMonitor {
if (!runLoopObserver) {
return;
}
CFRunLoopRemoveObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);
CFRelease(runLoopObserver);
runLoopObserver = NULL;
}
#pragma mark - Private
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
SMLagMonitor *lagMonitor = (__bridge SMLagMonitor*)info;
lagMonitor->runLoopActivity = activity;
dispatch_semaphore_t semaphore = lagMonitor->dispatchSemaphore;
dispatch_semaphore_signal(semaphore);
}
@end
思路总结
通过 RunLoop 的 Observer 监控 主线程 中各个状态的变化。如果 kCFRunLoopBeforeSources 和 kCFRunLoopAfterWaiting 这两个状态所停留的时间过长,我们便认定为发生了一次主线程卡顿。
具体做法
1 我们需要创建一个 CFRunLoopObserverContext 观察者,且创建一个 Observer,并监控主线程状态的变化
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context);
//将观察者添加到主线程runloop的common模式下的观察中
CFRunLoopAddObserver(CFRunLoopGetMain(), runLoopObserver, kCFRunLoopCommonModes);
这个Observer会监听 kCFRunLoopAllActivities(所有状态改变),并在状态改变时执行 runLoopObserverCallBack 中的代码。
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info){
SMLagMonitor *lagMonitor = (__bridge SMLagMonitor*)info;
lagMonitor->runLoopActivity = activity;
dispatch_semaphore_t semaphore = lagMonitor->dispatchSemaphore;
dispatch_semaphore_signal(semaphore);
}
这个闭包中执行了4行代码:
1.1 通过 info 属性,拿到当前类
1.2 记录当前 Observers 的状态,并赋值给成员变量 runLoopActivity
1.3 使用信号量 dispatch_semaphore_t 监控 Observers 状态间停留的时长。这里获取当前类声明的 dispatch_semaphore_t 信号量属性
1.4 激活信号量,通过 dispatch_semaphore_signal() 方法使正在等待的信号量继续执行
对应之前创建 dispatch_semaphore_t 对象的的代码是:
dispatchSemaphore = dispatch_semaphore_create(0); //Dispatch Semaphore保证同步
2 创建一个子线程,使用while循环保活,并通过信号量阻塞该线程
long semaphoreWait = dispatch_semaphore_wait(dispatchSemaphore, dispatch_time(DISPATCH_TIME_NOW, 3*NSEC_PER_MSEC));
if (semaphoreWait != 0) {
// Returns zero on success, or non-zero if the timeout occurred.
}
dispatch_semaphore_wait 这个方法会阻塞当前线程一段时间,如果 在阻塞时间内收到激活信号 或者 阻塞时间超时,代码会继续执行,如果超时,该方法的返回值为 非0
对应前面的闭包中的代码,如果各状态切换没有发生阻塞,那么会及时发出信号量的激活信号,此时 dispatch_semaphore_wait 方法的返回值为0,不视为卡顿。反之各状态耗时过长,没有及时发出信号,dispatch_semaphore_wait 方法的返回值为非0,就视为发生卡顿。
3 触发卡顿的时间阈值
我们根据 WatchDog 机制来设置。
- 启动 20s
- 恢复 10s
- 挂起 10s
- 退出 6s
- 后台 3min(iOS7之前每次申请10min,之后改为3min,可以连续申请,最多申请到10min)
总的原则就是,要小于 WatchDog 的限制时间,3s仅做参考值。
4 获取卡顿的方法堆栈信息
监控到卡顿发生后,自然要解决问题,那么如何获取卡顿的堆栈信息呢?
原文中推荐的是直接用 plcrashreporter 能够定位到问题代码的具体位置,而且性能消耗也不大。
具体使用的代码:
// 获取数据
NSData *lagData = [[[PLCrashReporter alloc] initWithConfiguration:[[PLCrashReporterConfig alloc] initWithSignalHandlerType:PLCrashReporterSignalHandlerTypeBSD symbolicationStrategy:PLCrashReporterSymbolicationStrategyAll]] generateLiveReport];
// 转换成 PLCrashReport 对象
PLCrashReport *lagReport = [[PLCrashReport alloc] initWithData:lagData error:NULL];
// 进行字符串格式化处理
NSString *lagReportString = [PLCrashReportTextFormatter stringValueForCrashReport:lagReport withTextFormat:PLCrashReportTextFormatiOS];
// 将字符串上传服务器
NSLog(@"lag happen, detail below: \n %@",lagReportString);
最后
现在,我们可以监控卡顿,并且获取发生卡顿的方法信息了。
这里涉及的知识主要包括了 RunLoop 和 信号量(线程锁知识)。当然也只是皮毛,更多是需要我们自己去实战和应用。
比起事后排查和改进,我们更应该养成良好且正确的代码习惯,通常情况下,设备的性能都足以支撑正确程序的流畅运行。
说回提升用户体验的话题,我觉得更重要的是从产品角度和产品交互出发,卡顿监控只是一项必做的基本功课。好的产品交互才是提升用户体验的重头戏。