戴铭(iOS开发课)读书笔记:13章节-卡顿监控

原文链接:如何利用 RunLoop 原理去监控卡顿?


前言

一个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。

戴铭(iOS开发课)读书笔记:13章节-卡顿监控_第1张图片

系统默认注册了5个Mode。每次调用RunLoop的主函数时,只能指定其中一个Mode,也就是说RunLoop中的Mode在不断切换。

kCFRunLoopDefaultMode //App的默认Mode,通常主线程是在这个Mode下运行
UITrackingRunLoopMode //界面跟踪 Mode,用于 ScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响
UIInitializationRunLoopMode // 在刚启动 App 时第进入的第一个 Mode,启动完成后就不再使用
GSEventReceiveRunLoopMode // 接受系统事件的内部 Mode,通常用不到
kCFRunLoopCommonModes //这是一个占位用的Mode,不是一种真正的Mode
2.3 工作过程:
戴铭(iOS开发课)读书笔记:13章节-卡顿监控_第2张图片

工作过程大致总结为上图的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的 kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting 这两个状态所停留的时长来判断。

如何检查卡顿

这里我们从老师分享的源码 截取关键部分 进行分析和学习。

#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 监控 主线程 中各个状态的变化。如果 kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting 这两个状态所停留的时间过长,我们便认定为发生了一次主线程卡顿。

具体做法

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 和 信号量(线程锁知识)。当然也只是皮毛,更多是需要我们自己去实战和应用。
比起事后排查和改进,我们更应该养成良好且正确的代码习惯,通常情况下,设备的性能都足以支撑正确程序的流畅运行。
说回提升用户体验的话题,我觉得更重要的是从产品角度和产品交互出发,卡顿监控只是一项必做的基本功课。好的产品交互才是提升用户体验的重头戏。

你可能感兴趣的:(戴铭(iOS开发课)读书笔记:13章节-卡顿监控)