戴铭(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。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容