06.卡顿监控设计实践
目录介绍
- 01.卡顿整体概述
- 1.1 项目背景介绍
- 1.2 卡顿简单介绍
- 1.3 卡顿分类说明
- 1.4 卡顿例子
- 1.5 设计目标
- 02.卡顿技术要点说明
- 2.1 UI卡顿产生原因
- 2.2 卡顿和ANR的关系
- 2.3 什么是FPS流畅度
- 2.4 一帧显示的步骤
- 03.卡顿问题排查思路
- 3.1 卡顿排查总思路
- 04.卡顿监控思路
- 4.1 卡顿监控核心思路
- 4.2 LooperPrinter
- 4.3 WatchDog
- 4.4 FPS帧率检测
- 4.7 卡顿日志记录上传
- 05.卡顿原理探索
- 5.1 Handler机制说一下
- 5.3 UI卡顿检测实现
- 5.4 那些卡顿无法监控
- 5.5 完善监控IdleHandler卡顿
- 5.6 完善监控TouchEvent卡顿
- 5.7 完善监控SyncBarrier泄漏
- 05.方案基础设计
- 5.1 整体架构图
- 5.2 UML设计图
- 5.3 关键流程图
- 5.4 接口设计图
- 5.5 模块间依赖关系
- 06.其他设计说明
- 6.1 性能设计
- 6.2 稳定性设计
- 6.3 灰度设计
- 6.4 降级设计
- 6.5 异常设计
- 07.其他说明介绍
- 7.1 参考链接
01.卡顿整体概述
1.1 项目背景介绍
- App中卡顿让用户使用体验不佳
- 在复杂的项目环境中,由于历史代码庞大,业务复杂,包含各种第三方库,所以在出现了卡顿的时候,很难定位到底是哪里出现了问题。
- 即便知道是哪一个Activity/Fragment,也仍然需要进去里面一行一行看,动辄数千行的类再加上跳来跳去调来调去的,结果就是不了了之随它去了,实在不行了再优化吧。
- 卡顿可能引发未知问题
- 卡顿还可能会引发一些未知的问题,比如卡顿会造成掉帧,会造成抖动,设置还会造成ANR,对用户来说体验特别不好。简单来说,就是APP用起来有点卡……
- 排查卡顿问题非常费劲
- 很多情况下卡顿不是必现的,它们可能与机型、环境、操作等有关,存在偶然性,即使发生了,再去查那如山般的logcat,也不一定能找到卡顿的原因,是我们自己的应用导致的还是其他应用抢占资源导致的?是哪些方法导致的?很难去回朔。
1.2 卡顿简单介绍
- 什么是卡顿
- 界面呈现是指从应用生成帧并将其显示在屏幕上的动作。我们要确保用户能够流畅地与应用互动,我们的应用呈现每帧的时间不应超过16ms,以达到每秒 60 帧的呈现速度。
- 如果我们的应用存在界面呈现缓慢的问题,系统会不得不跳过一些帧,这会导致用户感觉您的应用不流畅。我们将这种情况称为卡顿。
- UI线程执行时间
- 在Android中,UI线程负责执行UI视图的布局、渲染等工作,UI在更新期间,如果UI线程执行时间超过了16ms,则就会产生丢帧的现象,大量的丢帧,就会造成卡顿,影响用户体验。
- Android中规定,每秒可以执行60次屏幕刷新,当我们的APP能够达到60帧/秒时,这种体验是优秀的,当帧率降低到40帧以下,甚至30帧以下,用户就可以感知到卡顿了。
- 时间计算公式
- 公式(1秒刷新60次,那么每次刷新耗时16毫秒):1000毫秒 / 60帧 = 16ms , 关于帧率怎么查看呢?可以通过fps查看。
1.3 卡顿分类说明
- CPU紧张
- 系统CPU资源紧张,分配给APP主线程(UI线程)的CPU时间片减少。
- UI线程中执行了大量的耗时任务,导致了UI线程视图刷新工作的阻塞。
- 频繁GC
- Android虚拟机频繁执行GC操作导致的卡顿。由于GC会占用大量的系统资源,同时GC过程中会产生UI线程停顿,从而产生卡顿。
- 过渡绘制
- 过度绘制产生卡顿。过度绘制会导致GPU执行时间变长,从而产生丢帧现象。
1.4 卡顿例子
- 卡顿其实分为直观的和微观两个方向的,举个例子
- 比如用户点击了登录,预期是得到登录成功/失败的反馈,可现在没有页面刷新,实际的刷新耗时超出了预期,这就是直观的卡顿。
- 比如用户在看股票,正常60秒刷新一次,可到了60秒,触发了刷新但是刷新处理逻辑耗时较长,导致新的数据在下一个60秒之前的第59秒才回来,用户基本无感知,主要说的是内部耗时的问题,这种情况可以粗略的理解为微观卡顿。
1.5 设计目标
- 设计目标要求
- 对主线程操作进行了完全透明的监控,并能输出有效的信息,帮助开发分析、定位到问题所在,迅速优化应用。
- 要求接入方便,外部开发者调用简单,非侵入式,简单的两行就打开监控,不需要到处打点,破坏代码优雅性。
- 精准,输出的信息可以帮助定位到问题所在(精确到行),不需要像Logcat一样,慢慢去找。
- 卡顿优化点分析
- 如果没能在16ms内完成这个过程,就会使屏幕重复显示上一帧的内容,即造成了卡顿。在这16ms内,需要完成视图树的所有测量、布局、绘制渲染及合成。而我们的优化工作主要就是针对这个过程的。
02.卡顿技术要点说明
2.1 UI卡顿产生的原因
- UI卡顿通常产生的原因如下:
- 系统CPU资源紧张,分配给APP主线程(UI线程)的CPU时间片减少。
- UI线程中执行了大量的耗时任务,导致了UI线程视图刷新工作的阻塞。
- Android虚拟机频繁执行GC操作导致的卡顿。由于GC会占用大量的系统资源,同时GC过程中会产生UI线程停顿,从而产生卡顿。
- 过度绘制产生卡顿。过度绘制会导致GPU执行时间变长,从而产生丢帧现象。
- 举一些案例说明
- 比如滑动列表
2.2 卡顿和ANR的关系
- 卡顿与ANR的关系
- 产生卡顿的根本原因是UI线程不能够及时的进行渲染,导致UI的反馈不能按照用户的预期,连续、一致的呈现。
- 产生卡顿的原因多种多样,很难一一列举,而ANR是Google人为规定的概念,产生ANR的原因最多也只有四个。
- 两者相关但不同纬度
- 事实上,长时间的UI卡顿是导致ANR最常见的原因;但另一方面,从原理上来看,两者既不充分也不必要,是两个维度的概念。
- 卡顿监控ANR准确吗
- 一些卡顿监控工具,经常被拿来监控ANR(卡顿阈值设置为5秒),这其实很不严谨。
- 首先,5秒只是发生ANR的其中一种原因(Touch事件5秒未被及时消费)的阈值,而其他原因发生ANR的阈值并不是5秒;
- 另外,就算是主线程卡顿了5秒,如果用户没有输入任何的Touch事件,同样是不会发生ANR的,更何况还有后台ANR等情况。
2.3 什么是FPS流畅度
- 流畅度,是页面在滑动、渲染等过程中的体验。
- Android系统要求每一帧都要在 16ms 内绘制完成,平滑的完成一帧意味着任何特殊的帧需要执行所有的渲染代码(包括 framework 发送给 GPU 和 CPU 绘制到缓冲区的命令)都要在 16ms 内完成,保持流畅的体验。
- 如果没有在期间完成渲染秒就会发生掉帧。掉帧是用户体验中一个非常核心的问题。丢弃了当前帧,并且之后不能够延续之前的帧率,这种不连续的间隔会容易会引起用户的注意,也就是常说的卡顿、不流畅。
- 那么是不是1s只要绘制了60帧是不是就是流畅的呢?
- 也不一定,如果发生抖动的情况,那么肯定会有其中几帧是有问题的。其中肯定会有最大绘制帧,和最小绘制帧的情况,所以平均值,最大值最小值都是我们需要知道的。
2.4 一帧显示的步骤
- 一帧的显示要经历如下步骤:
- cpu计算画多大,画在哪里,画什么,然后gpu渲染计算结果存到buffer,显示器每隔一段时间从buffer取帧。若没有取到帧,只能继续显示上一帧。
- 16ms 内都需要完成什么?
- VSync调度、doframe消息调度、input输入处理、动画、测量/布局、绘制、同步和上传、命令问题、交换缓冲区。也就是我们常用的 GPU 严格模式。
04.卡顿监听思路
4.1 卡顿监控核心思路
- 可以监控卡顿,从而可以对卡顿优化做到极致。我们可以从下面四个方面来监控应用程序卡顿:
- 基于 Looper 的 Printer 分发消息的时间差值来判断是否卡顿。
- 基于 Choreographer 回调函数 postFrameCallback 来监控
- 基于开源框架 BlockCanary 来监控
- 基于开源框架 rabbit-client 来监控
4.2 LooperPrinter
- 卡顿监控不优雅的处理
- 直接创建一个基类放在我们的项目代码中,所有需要Handler的地方都对此进行继承,然后我们在基类中添加日志监控,这样就可以实现我们的目的了吧?
- 不好,这样对项目改造的成本太高了,而且我们也监控不到系统中的消息,也监控不到第三方sdk中的消息执行时间!
- 卡顿监控优雅的处理
- 分析Looper中的loop方法可知,处理消息前后有日志打印,看到有个Printer日志输出管理的类,并且暴露setMessageLogging方法。这个可以自己定义实现。
- 使用LooperPrinter监控
- 替换主线程Looper的Printer,从而监控dispatchMessage的执行时间。甚至,在Android源码中,主线程Looper也会根据执行dispatchMessage的时间来判断是否有卡顿,有则会打印一些日志。
- 在Printer中判断start和end,来获取主线程dispatch该message的开始和结束时间,并判定该时间超过阈值(如2000毫秒)为主线程卡慢发生,并dump出各种信息,提供开发者分析性能瓶颈。
- 卡顿之后收集相关dump信息,主要包含一些基本信息,耗时信息,CPU信息还有堆栈信息等等。可以直接参考LeakCanary即可。
- 优缺点分析
- 优点:不会随机漏报,无需轮询,一劳永逸;缺点:某些类型的卡顿无法被监控到。
4.3 WatchDog
4.3.1 WatchDog卡顿监控
- 如何监控UI卡顿
- 启动一个卡顿检测线程,该线程定期的向UI线程发送一条延迟消息,执行一个标志位加1的操作,如果规定时间内,标志位没有变化,则表示产生了卡顿。如果发生了变化,则代表没有长时间卡顿,我们重新执行延迟消息即可。
- 具体的原理说明
- 具体的原理和实现方法很简单:不断向UI线程发送Message,每隔一段时间检查一次刚刚发送的Message是否被处理,如果没有被处理,则说明这段时间主线程被卡住了。
4.3.2 WatchDog的优缺点
- 优缺点
- 优点:简单,稳定,结果论,可以监控到各种类型的卡顿
- 缺点:轮询不优雅,不环保,有不确定性,随机漏报
- 间隔时间设置
- 这种方法的轮询的时间间隔选择很重要,又让人左右为难,轮询的时间间隔越小,对性能的负面影响就越大,而时间间隔选择的越大,漏报的可能性也就越大。
- 如何理解性能负面影响
- 前者很容易理解,UI线程要不断处理我们发送的Message,必然会影响性能和功耗。
- 如何理解漏报
- 这个在下面会详细分析
4.3.3 如何理解漏报数据
- 时间间隔选择了4秒
- 事实上,之前是想要通过这种方案来监控ANR,当然,这并不严谨。来分析一下
- 举一个例子
- 每隔4秒,向主线程发送一个消息。下面是轮训的过程
- 0秒 ---- 4秒 ---- 8秒 ---- 12秒 ---- 16秒
- 现在有一个5秒的卡顿发生在第2秒,结束在第7秒,这种情况无论是在0-4秒的周期内,还是4-8秒的周期内,都有一段时间是不卡顿的,消息都可以被处理掉,这种情况自然就无法被监控到。
- 计算监控成功率
- 计算公式:p = x/a - 1 ;注意条件(a<= x <= 2a)
- 上面案例计算 : p = 5/4 - 1 = 0.25 ; 如果轮询间隔设置为4秒,发现一个5秒的卡顿的概率仅为25%。
- 修改轮训间隔时间
- 默认轮询间隔为5秒,如果有一个8秒的卡顿(8秒已经很容易产生ANR),被发现的概率也只有8/5-1=60%
- 从这个概率公式还可以发现,对于一个固定的轮询间隔,只有卡顿时间大于两倍的轮询间隔,才能百分之百被监控到。
- 思考把间隔时间缩短
- 每隔2秒,向主线程发送一个消息。下面是轮训的过程
- 0秒 -- 2秒 -- 4秒 -- 6秒 -- 8秒 -- 10秒 -- 12秒
- 现在有一个6秒的卡顿发生在第1秒,结束在第7秒,那么这个在在2-4,和4-6区间可以捕获到。
4.4 FPS帧率检测
- 具体可以看:14.FPS检测设计实践
4.7 卡顿日志记录上传
- 卡顿信息捕获,发生卡顿时需要捕获如下四类信息,以提高定位卡顿问题的效率与精度。
- 1、基础信息:系统版本、机型、进程名、应用版本号、磁盘空间、UID等。
- 2、耗时信息:卡顿开始和结束时间。
- 3、CPU信息:CPU的信息、整体CPU使用率和本进程CPU使用率(可粗略判断是当前应用消耗CPU资源太多导致的卡顿,还是其他原因)等。
- 4、堆栈信息。
- 注意
- 这里的信息建议抽样上报或者可以先将其保存到本地,在合适的时机以及达到一定的量时,再压缩上报到服务器,供开发者分析。
- 具体监控代码实现可以参考BlockCanary开源项目的代码。
05.监控卡顿流程
5.1 Handler机制说一下
- 想要监控线上用户UI线程的卡顿,也就是要把UI线程中的耗时逻辑找出来,然后进行优化开发。那么我们如何如做呢?
- Android中的应用程序是消息驱动的,也就是UI线程执行的所有操作,通常都会经过消息机制来进行传递(也就是Handler通信机制)。
- Handler的handleMessage负责在UI线程中处理UI相关逻辑,如果我们能在handleMessage执行之前和handleMessage执行之后,分别插入一段我们的日志代码,不就可以实现UI任务执行时间的监控了吗?
5.3 UI卡顿检测实现
- 来看Looper的loop方法,loop方法中有一个Printer类型的logging,它会在消息执行之前和消息执行之后,输出一行日志,用于标记消息执行的开始和结束。
- 只要记录开始日志和结束日志的时间差,就可以计算出该任务在UI线程的执行时间了,如果执行时间很长,则必然产生了卡顿。
public static void loop() { for (;;) { Message msg = queue.next(); // might block if (msg == null) { return; } final Printer logging = me.mLogging; //这个是开始 if (logging != null) { logging.println(">>>>> Dispatching to " + msg.target + " " + msg.callback + ": " + msg.what); } //消息处理相关逻辑 msg.target.dispatchMessage(msg); //这个是结束 if (logging != null) { logging.println("<<<<< Finished to " + msg.target + " " + msg.callback); } } }
- 如何监控这个Printer类型的日志呢?
- 发现mLogging这个对象可以通过一个public方法进行设置!这简直太好了!可以通过setMessageLogging方法设置我们自己的Printer对象就可以实现卡顿的监控了!
private Printer mLogging; public void setMessageLogging(@Nullable Printer printer) { mLogging = printer; }
- 为何说可以监测大部分卡顿
- 通过计算执行dispatchMessage方法之后和之前打印字符串的时间的差值,就可以拿到到dispatchMessage方法执行的时间。
- 即整个应用的主线程,只有这一个looper,不管有多少handler,最后都会回到这里。而大部分的主线程的操作最终都会执行到这个dispatchMessage方法中。
- 具体怎么判断卡顿呢?
- 在Printer中判断start和end,来获取主线程dispatch该message的开始和结束时间,并判定该时间超过阈值(如2000毫秒)为主线程卡慢发生,并dump出各种信息,提供开发者分析性能瓶颈。
- 卡顿之后收集相关dump信息【参考LeakCanary】
- 基本信息:安装包标示、机型、api等级、uid、CPU内核数、进程名、内存、版本号等
- 耗时信息:实际耗时、主线程时钟耗时、卡顿开始时间和结束时间
- CPU信息:时间段内CPU是否忙,时间段内的系统CPU/应用CPU占比,I/O占CPU使用率
- 堆栈信息:发生卡慢前的最近堆栈,可以用来帮助定位卡慢发生的地方和重现路径
- 存在的问题有:
- 如果queue.next()卡住了,那么就无法往下执行了。这个时候无法检测到卡顿。
5.4 那些卡顿无法监控
- 看到上面的queue.next(),这里给了注释:might block
- 代码直接跟你说这里是可能会卡住的,这时候再计算dispatchMessage方法的耗时显然就没有意义了。
- 有的同学可能会想,那我改成计算相邻两次dispatchMessage执行之前打印字符串的时间差值不就好了?这样就可以把next方法的耗时也计算在内。
- 不幸的是,主线程空闲时,也会阻塞在MessageQueue的next方法中,我们很难区分究竟是发生了卡顿还是主线程空闲。
- 分析一下MessageQueue的next方法
- 是什么原因会卡在MessageQueue的next方法中呢?下图是next方法简化过后的源码
for (;;) { if (nextPollTimeoutMillis != 0) { Binder.flushPendingCommands(); } nativePollOnce(ptr, nextPollTimeoutMillis); for (int i = 0; i < pendingIdleHandlerCount; i++) { final IdleHandler idler = mPendingIdleHandlers[i]; mPendingIdleHandlers[i] = null; // release the reference to the handler boolean keep = false; try { keep = idler.queueIdle(); } catch (Throwable t) { } if (!keep) { synchronized (this) { mIdleHandlers.remove(idler); } } } //...... }
- 有这么几类无法监控到
- View的TouchEvent中的卡顿这种方案是无法监控的
- IdleHandler的queueIdle()回调方法也是无法被监控的
- SyncBarrier(同步屏障)的泄漏同样无法被监控到
5.4.1 View的TouchEvent卡顿
- 在MessageQueue的next方法中
- 除了主线程空闲时就是阻塞在nativePollOnce之外,非常重要的是,应用的Touch事件也是在这里被处理的。
- 这就意味着,View的TouchEvent中的卡顿这种方案是无法监控的。
- 那么TouchEvent造成卡顿场景有哪些呢?
- 待完善
5.4.2 IdleHandler的queueIdle()
- IdleHandler的queueIdle()回调方法也是无法被监控的
- 这个方法会在主线程空闲的时候被调用。然而实际上,很多开发同学都先入为主的认为这个时候反正主线程空闲,做一些耗时操作也没所谓。
- 其实主线程MessageQueue的queueIdle默认当然也是执行在主线程中,所以这里的耗时操作其实是很容易引起卡顿和ANR的。
- 那么queueIdle()造成卡顿场景有哪些呢?
- 例如App之前就使用IdleHandler在进入主界面后,做一些读写文件的IO操作,就造成了一些卡顿和ANR问题。
5.4.3 SyncBarrier(同步屏障)的泄漏
- SyncBarrier(同步屏障)的泄漏同样无法被监控到
- 当我们每次通过invalidate来刷新UI时,最终都会调用到ViewRootImpl中的scheduleTraversals方法,会向主线程的Looper中post一个SyncBarrier,其目的是为了在刷新UI时,主线程的同步消息都被跳过,此时渲染UI的异步消息就可以得到优先处理。
- 但是注意到这个方法是线程不安全的,如果在非主线程中调用到了这里,就有可能会同时post多个SyncBarrier,但只能remove掉最后一个,从而有一个SyncBarrier就永远无法被remove,就导致了主线程Looper无法处理同步消息(Message默认就是同步消息),导致卡死
- ViewRootImpl源码如下所示
void scheduleTraversals() { if (!mTraversalScheduled) { mTraversalScheduled = true; mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier(); mChoreographer.postCallback( Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null); notifyRendererOfFramePending(); pokeDrawLockIfNeeded(); } } void unscheduleTraversals() { if (mTraversalScheduled) { mTraversalScheduled = false; mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier); mChoreographer.removeCallbacks( Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null); } }
5.5 监控IdleHandler卡顿
- 首先从简单的下手,对于IdleHandler的queueIdle回调方法的监控。
- 我们惊喜的发现MessageQueue中的mIdleHandlers是可以被反射的,这个变量保存了所有将要执行的IdleHandler,我们只需要把ArrayList类型的mIdleHandlers,通过反射,替换为MyArrayList,在我们自定义的MyArrayList中重写add方法,再将我们自定义的MyIdleHandler添加到MyArrayList中,就完成了“偷天换日”。
- 从此之后MessageQueue每次执行queueIdle回调方法,都会执行到我们的MyIdleHandler中的的queueIdle方法,就可以在这里监控queueIdle的执行时间了。
- 代码如下所示
private static void detectIdleHandler() { try { MessageQueue mainQueue = Looper.getMainLooper().getQueue(); Field field = MessageQueue.class.getDeclaredField("mIdleHandlers"); field.setAccessible(true); MyArrayList<MessageQueue.IdleHandler> myIdleHandlerArrayList = new MyArrayList<>(); field.set(mainQueue, myIdleHandlerArrayList); } catch (Throwable t) { t.printStackTrace(); } } static class MyArrayList<T> extends ArrayList { Map<MessageQueue.IdleHandler, MyIdleHandler> map = new HashMap<>(); @Override public boolean add(Object o) { if (o instanceof MessageQueue.IdleHandler) { MyIdleHandler myIdleHandler = new MyIdleHandler((MessageQueue.IdleHandler) o); map.put((MessageQueue.IdleHandler) o, myIdleHandler); return super.add(myIdleHandler); } return super.add(o); } @Override public boolean remove(@Nullable Object o) { if (o instanceof MyIdleHandler) { MessageQueue.IdleHandler idleHandler = ((MyIdleHandler) o).idleHandler; map.remove(idleHandler); return super.remove(o); } else { MyIdleHandler myIdleHandler = map.remove(o); if (myIdleHandler != null) { return super.remove(myIdleHandler); } return super.remove(o); } } }
5.6 监控TouchEvent卡顿
- 那么TouchEvent我们有什么办法监控吗?
- 首先想到的可能是反射View的mListenerInfo,然后进一步替换其中的mTouchListener,但是这需要我们枚举所有需要被监控的View,全部反射替换一遍,这完全是憨憨行为。
- 那有没有更加根本,全局性的方法呢?
- 暂时还没想到
5.7 监控SyncBarrier泄漏
05.参考资料
- 需要列出方案设计过程的文档,包括但不局限于PM需求文档,技术参考文档等。
- 微信卡顿监测方案
- https://mp.weixin.qq.com/s/3dubi2GVW_rVFZZztCpsKg
- 卡顿监控方案
- https://github.com/markzhai/AndroidPerformanceMonitor
- 卡顿监控
- https://github.com/Knight-ZXW/BlockCanaryX
- Android卡顿监测的方方面面
- https://mp.weixin.qq.com/s/-pfWolCWNK326JEkWNUbew
- 【卡顿优化】卡顿问题如何监控?
- https://juejin.cn/post/7222651312073850935