卡顿问题,就是在主线程上无法响应用户交互的问题。如果一个 App 时不时地就给你卡一下,有时还长时间无响应,这时你还愿意继续用它吗?所以说,卡顿问题对 App 的伤害是巨大的
前言
用runloop来监测卡顿其实并不是什么比较前沿的技术,也不算什么新奇的技术,实际上开发者也用的比较少。一来,应为 XCode
的instrument
足够的优秀,几乎所有的监控操作都有对应的工具。二来,大多数项目上都是集成第三方的统计工具,比如Bugly、友盟之类的等等。但是这样也暴露了一些问题,集成第三方会担心自己的APP信息泄露,那怎么办呢?所以这套自己通过runloop的检测也就营运而生。
卡顿可能产生的原因
一般来讲卡顿产生的原因可以大致分为以下几种类型:
1、复杂 UI 、图文混排的绘制量过大;
2、在主线程上做网络同步请求;
4、在主线程做大量的 IO 操作;
4、运算量过大,CPU 持续高占用;死锁和主子线程抢锁
那么问题来了,我们如何来做卡顿的监测呢?只是单纯的检测FPS的波动吗?FPS又是什么呢?维基百科显示FPS,即每秒显示帧数 或者 每秒显示张数 - 影格率测量单位(这里牵扯到CPU和GPU同步的问题,相关只是点就不在陈述了)。也就是说简单地通过监视 FPS 是很难确定是否会出现卡顿,所以FPS是不能作为用来检测卡顿的标准的。那我们应该通过什么来监测卡顿呢?
关于RunLoop
对于iOS开发人员来说,runloop相信大家一定不会陌生,因为他是在日常开发中的一个基础概念,我们都知道,线程的消息事件是依赖于 RunLoop 的,所以从 RunLoop 入手,就可以知道主线程上都调用了哪些方法。我们通过监听 RunLoop 的状态,就能够发现调用方法是否执行时间过长,从而判断出是否会出现卡顿。
当然,如果你要在RunLoop中监测哪些方法的运行时间过长,首先你必须得清楚RunLoop的运行原理,知道了运行原理之后才能知道我们要在RunLoop的哪个环节进行监测。
第一步:通知Observers:即将进入RunLoop
我们在CFRunLoop-1153.18的源码的第2676行中的CFRunLoopRun(void)
中,开启一个do..while
循环来保活
1 | void CFRunLoopRun(void) { /* DOES CALLOUT */ |
那我们的重点就在CFRunLoopRunSpecific
这个方法内部是如何实现的了,我们接下来往下看。CFRunLoopRunSpecific
是runloop
的启动入口
1 | //即将进入runloop |
第二步:通知Observers:即将处理Timers和即将处理Sources和blocks
触发times、source0
1 | __CFRunLoopUnsetIgnoreWakeUps(rl); |
第三步:处理 Source0
到了这一步可能会再次处理一遍blocks
如果有 Source1 是 ready 状态的话,就会跳转到 handle_msg 去处理消息。代码如下:
1 | Boolean sourceHandledThisLoop = __CFRunLoopDoSources0(rl, rlm, stopAfterHandle); |
第四步:通知Observers:开始休眠(等待消息唤醒)
1 | //通知Observers:开始休眠 |
第五步:通知Observers:结束休眠(被某个消息唤醒)
RunLoop 被唤醒后就要开始处理消息了:(这一段代码太长,就不直接贴出来了)
如果是 Timer 时间到的话,就触发 Timer 的回调;
处理 GCD Async To Main Queue;
如果是 source1(MachPor) 事件的话,就处理这个事件。
再次处理Blocks
1 |
|
第六步:根据上一步的操作决定是退出runloop还是继续执行runloop
根据上一步的操作决定是退出runloop还是继续执行runloop
1 | if (sourceHandledThisLoop && stopAfterHandle) { |
RunLoop的六个状态
1 | typedef CF_OPTIONS(CFOptionFlags, CFRunLoopActivity) { |
用一张图概括RunLoop的运行轨迹
思路
通过上面的runloop运行轨迹我们能够知道,RunLoop`处理事件的时间主要出在两个阶段:
kCFRunLoopBeforeSources
和kCFRunLoopBeforeWaiting
之间kCFRunLoopAfterWaiting
之后
试想如果 RunLoop 的线程,进入睡眠前方法的执行时间过长而导致无法进入睡眠,或者线程唤醒后接收消息时间过长而无法进入下一步的话,就可以认为是线程受阻了。如果这个线程是主线程的话,表现出来的就是出现了卡顿。所以,如果我们要利用 RunLoop 原理来监控卡顿的话,就是要关注这三个阶段。
接下来,我们就一起分析一下,如何对 loop 的这两个状态进行监听,以及监控的时间值如何设置才合理。
监控RunLoop状态检测超时
通过RunLoop
的源码我们已经知道了主线程处理事件的时间,那么如何检测应用是否发生了卡顿呢?为了找到合理的处理方案,我们得先在项目中得到runloop的监听状态。
1 | static void runLoopObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void * info) { |
UITableView代理代码:
1 | - (NSInteger)tableView: (UITableView *)tableView numberOfRowsInSection: (NSInteger)section { |
运行之后输出的结果是滚动引发的Sources
事件总是被快速的执行完成,然后进入到kCFRunLoopBeforeWaiting
状态下。假如在滚动过程中发生了卡顿现象,那么RunLoop
必然会保持kCFRunLoopAfterWaiting
或者kCFRunLoopBeforeSources
这两个状态之一。
为了实现卡顿的检测,首先需要注册RunLoop
的监听回调,保存RunLoop
状态;其次,通过创建子线程循环监听主线程RunLoop
的状态来检测是否存在停留卡顿现象: 收到Sources相关的事件时,将超时阙值时间内分割成多个时间片段,重复去获取当前RunLoop的状态。如果多次处在处理事件的状态下,那么可以视作发生了卡顿现象
1 | - (void)startMonitoring { |
标记位检测线程超时
与UI卡顿不同的事,事件处理往往是处在kCFRunLoopBeforeWaiting
的状态下收到了Sources
事件源,最开始笔者尝试同样以多个时间片段查询的方式处理。但是由于主线程的RunLoop
在闲置时基本处于Before Waiting
状态,这就导致了即便没有发生任何卡顿,这种检测方式也总能认定主线程处在卡顿状态。
于是github上查看了下卡顿检测第三方监测卡顿的工具,他们的卡顿监控方案大致思路为:创建一个子线程进行循环检测,每次检测时设置标记位为YES
,然后派发任务到主线程中将标记位设置为NO
。接着子线程沉睡超时阙值时长,判断标志位是否成功设置成NO
。如果没有说明主线程发生了卡顿,无法处理派发任务:
![图片 1](图片 1.png)
获取堆栈
子线程监控发现卡顿后,还需要记录当前出现卡顿的方法堆栈信息,并适时推送到服务端供开发者分析,从而解决卡顿问题。那么,在这个过程中,如何获取卡顿的方法堆栈信息呢?
这里我选择了魔改BSBacktraceLogger
小结
多数开发者对于RunLoop
可能并没有进行实际的应用开发过,或者说即便了解RunLoop
也只是处在理论的认知上。本文仅仅是对采用runloop来进行APP卡顿的一些个人观点,有纰漏还望指出。