Android UI卡顿监控

一、背景

应用的使用流畅度,是衡量用户体验的重要标准之一。Android 由于机型配置和系统的不同,项目复杂App场景丰富,代码多人参与迭代历史较久,代码可能会存在很多UI线程耗时的操作,实际测试时候也会偶尔发现某些业务场景发生卡顿的现象,用户也经常反馈和投诉App使用遇到卡顿。因此,我们越来越关注和提升用户体验的流畅度问题。

二、方案

基于这样的痛点,我们希望能使用一套有效的检测机制,能够覆盖各种可能出现的卡顿场景,一旦发生卡顿,能帮助我们更方便地定位耗时卡顿发生的地方,记录下具体的信息和堆栈,直接从代码程度给到开发定位卡顿问题。我们设想的Android卡顿监控系统需要达到几项基本功能:

  • 如何有效地监控到App发生卡顿,同时在发生卡顿时正确记录app的状态,如堆栈信息,CPU占用,内存占用,IO使用情况等等;

  • 统计到的卡顿信息上报到监控平台,需要处理分析分类上报内容,并通过平台Web直观简便地展示,供开发跟进处理。

三、如何从App层面监控卡顿?

我们的思路是,一般主线程过多的UI绘制、大量的IO操作或是大量的计算操作占用CPU,导致App界面卡顿。只要我们能在发生卡顿的时候,捕捉到主线程的堆栈信息和系统的资源使用信息,即可准确分析卡顿发生在什么函数,资源占用情况如何。那么问题就是如何有效检测Android主线程的卡顿发生,目前业界两种主流有效的app监控方式如下:

  • 利用UI线程的Looper打印的日志匹配;

  • 使用Choreographer.FrameCallback。

3.1、利用UI线程的Looper打印的日志匹配判断是否卡顿

Android主线程更新UI。如果界面1秒钟刷新少于60次,即FPS小于60,用户就会产生卡顿感觉。简单来说,Android使用消息机制进行UI更新,UI线程有个Looper,在其loop方法中会不断取出message,调用其绑定的Handler在UI线程执行。如果在handler的dispatchMesaage方法里有耗时操作,就会发生卡顿。

3.1.1、Looper.loop( )的源码

public static void loop() {final Looper me = myLooper();if (me == null) { throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");}final MessageQueue queue = me.mQueue;// Make sure the identity of this thread is that of the local process,// and keep track of what that identity token actually is.Binder.clearCallingIdentity();final long ident = Binder.clearCallingIdentity();    for (;;) {Message msg = queue.next(); // might blockif (msg == null) {// No message indicates that the message queue is quitting.return;}// This must be in a local variable, in case a UI event sets the loggerPrinter 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);}        // Make sure that during the course of dispatching the// identity of the thread wasn't corrupted.final long newIdent = Binder.clearCallingIdentity();        if (ident != newIdent) {Log.wtf(TAG, "Thread identity changed from 0x"+ Long.toHexString(ident) + " to 0x"+ Long.toHexString(newIdent) + " while dispatching to "+ msg.target.getClass().getName() + " "+ msg.callback + " what=" + msg.what);}msg.recycleUnchecked();}
}

只要检测第25行代码msg.target.dispatchMessage(msg) 的执行时间,就能检测到部分UI线程是否有耗时的操作。注意到这行执行代码的前后,有两个logging.println函数,如果设置了logging,会分别打印出”>>>>> Dispatching to “和”<<<<< Finished to “这样的日志,这样我们就可以通过两次log的时间差值,来计算dispatchMessage的执行时间,从而设置阈值判断是否发生了卡顿。
在这里插入图片描述

3.1.2、如何设置logging呢?

我们看第19行代码me.mLogging源码

public final class Looper {  private Printer mLogging;  public void setMessageLogging(@Nullable Printer printer) {  mLogging = printer;  }  
}  public interface Printer {  void println(String x);  
}

Looper的mLogging是私有的,并且提供了setMessageLogging(@Nullable Printer printer)方法,所以我们可以自己实现一个Printer,在通过setMessageLogging()方法传入即可,代码如下:

public class BlockDetectByPrinter {    public static void start() {Looper.getMainLooper().setMessageLogging(new Printer() { private static final String START = ">>>>> Dispatching"; private static final String END = "<<<<< Finished";@Overridepublic void println(String x) {if (x.startsWith(START)) {LogMonitor.getInstance().startMonitor();}  if (x.startsWith(END)) {LogMonitor.getInstance().removeMonitor();}}});}
}

设置了logging后,loop方法会回调logging.println打印出每次消息执行的时间日志:”>>>>> Dispatching to “和”<<<<< Finished to “。BlockDetectByPrinter的使用则在Application的onCreate方法中调用 BlockDetectByPrinter.start() 即可。

我们可以简单实现一个LogMonitor来记录卡顿时候主线程的堆栈信息。当匹配到>>>>> Dispatching时,执行startMonitor,会在1000ms(设定的卡顿阈值)后执行任务,这个任务负责在子线程(非UI线程)打印UI线程的堆栈信息。如果消息低于1000ms内执行完成,就可以匹配到<<<<< Finished日志,那么在打印堆栈任务启动前执行removeMonitor取消了这个任务,则认为没有卡顿的发生;如果消息超过1000ms才执行完毕,此时认为发生了卡顿,并打印UI线程的堆栈信息。

3.1.3、LogMonitor如何实现?

public class LogMonitor {    private static LogMonitor sInstance = new LogMonitor();    private HandlerThread mLogThread = new HandlerThread("log");    private Handler mIoHandler;    private static final long TIME_BLOCK = 1000L;    private LogMonitor() {mLogThread.start();mIoHandler = new Handler(mLogThread.getLooper());} private static Runnable mLogRunnable = new Runnable() {        @Overridepublic void run() {StringBuilder sb = new StringBuilder();StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();            for (StackTraceElement s : stackTrace) {sb.append(s.toString() + "\n");}Log.e("TAG", sb.toString());}};public static LogMonitor getInstance() {        return sInstance;}    public boolean isMonitor() {        return mIoHandler.hasCallbacks(mLogRunnable);}    public void startMonitor() {mIoHandler.postDelayed(mLogRunnable, TIME_BLOCK);}    public void removeMonitor() {mIoHandler.removeCallbacks(mLogRunnable);}
}

这里我们使用HandlerThread来构造一个Handler,HandlerThread继承自Thread,实际上就一个Thread,只不过比普通的Thread多了一个Looper,对外提供自己这个Looper对象的getLooper方法,然后创建Handler时将HandlerThread中的looper对象传入。这样我们的mIoHandler对象就是与HandlerThread这个非UI线程绑定的了,它处理耗时操作将不会阻塞UI。如果UI线程阻塞超过1000ms,就会在子线程中执行mLogRunnable,打印出UI线程当前的堆栈信息,如果处理消息没有超过1000ms,则会实时的remove掉这个mLogRunnable任务。

发生卡顿时打印出堆栈信息的大致内容如下,开发可以通过log定位耗时的地方。


优点:用户使用app或者测试过程中都能从app层面来监控卡顿情况,一旦出现卡顿能记录app状态和信息, 只要dispatchMesaage执行耗时过大都会记录下来,不再有前面两种adb方式面临的问题与不足。

缺点:需另开子线程获取堆栈信息,会消耗少量系统资源。

在实际实现中,不同手机不同Android系统甚至是不同的ROM版本,Loop函数不一定都能打印出”>>>>> Dispatching to “和”<<<<< Finished to “这样的日志,导致该方式无法进行。

优化的策略:我们知道Loop函数开始和结束必会执行println打印日志,所以优化版本将卡顿的判断改为,Loop输出第一句log时当作startMonitor,输出下一句log时当作end时刻来解决这个问题。

3.2、利用Choreographer.FrameCallback监控卡顿

Choreographer.FrameCallback官方文档链接(https://developer.android.com/reference/android/view/Choreographer.FrameCallback.html)

我们知道, Android系统每隔16ms发出VSYNC信号,来通知界面进行重绘、渲染,每一次同步的周期为16.6ms,代表一帧的刷新频率。SDK中包含了一个相关类,以及相关回调。理论上来说两次回调的时间周期应该在16ms,如果超过了16ms我们则认为发生了卡顿,利用两次回调间的时间周期来判断是否发生卡顿(这个方案是Android 4.1 API 16以上才支持)。

这个方案的原理主要是通过Choreographer类设置它的FrameCallback函数,当每一帧被渲染时会触发回调FrameCallback, FrameCallback回调void doFrame (long frameTimeNanos)函数。一次界面渲染会回调doFrame方法,如果两次doFrame之间的间隔大于16.6ms说明发生了卡顿。
在这里插入图片描述

public class BlockDetectByChoreographer {public static void start() {Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() { long lastFrameTimeNanos = 0; long currentFrameTimeNanos = 0;@Overridepublic void doFrame(long frameTimeNanos) { if(lastFrameTimeNanos == 0){lastFrameTimeNanos == frameTimeNanos;}currentFrameTimeNanos = frameTimeNanos;long diffMs = TimeUnit.MILLISECONDS.convert(currentFrameTimeNanos-lastFrameTimeNanos, TimeUnit.NANOSECONDS);if (diffMs > 16.6f) {            long droppedCount = (int)diffMs / 16.6;}if (LogMonitor.getInstance().isMonitor()) {LogMonitor.getInstance().removeMonitor();                    } LogMonitor.getInstance().startMonitor();Choreographer.getInstance().postFrameCallback(this);}});}
}

在每一帧被渲染时,记下了渲染的时间用来计算掉帧数,数据可以绘制出流畅度曲线;同时,获取isMonitor()函数判断上一帧LogMonitor已经启动打印堆栈的任务,如果已启动,则移除LogMonitor,此时如果上一帧渲染的时间到现在已经超过了阈值,则已经执行了任务打印堆栈出来了;如果没有超过阈值则及时移除了任务。如果isMonitor返回false,没有LogMonitor回调任务,则开始新的一帧的监控任务。

优点:不仅可用来从app层面来监控卡顿,同时可以实时计算帧率和掉帧数,实时监测App页面的帧率数据,一旦发现帧率过低,可自动保存现场堆栈信息。

缺点:需另开子线程获取堆栈信息,会消耗少量系统资源。

3.3、总结下上述两种方案的对比情况:

Looper.loopChoreographer.FrameCallback
监控是否卡顿
支持静态页面卡顿检测
支持计算帧率X
支持获取App运行信息

实际项目使用中,我们一开始两种监控方式都用上,上报的两种方式收集到的卡顿信息我们分开处理,发现卡顿的监控效果基本相当。同一个卡顿发生时,两种监控方式都能记录下来。 由于Choreographer.FrameCallback的监控方式不仅用来监控卡顿,也方便用来计算实时帧率,因此我们现在只使用Choreographer.FrameCallback来监控app卡顿情况。

四、如何保证捕获卡顿堆栈的准确性?

细心的同学可以发现,我们通过上述两种方案(Looper.loop和Choreographer.FrameCallback)可以判断是当前主线程是否发生了卡顿,进而在计算发现卡顿后的时刻dump下了主线程的堆栈信息。实际上,通过一个子线程,监控主线程的活动情况,计算发现超过阈值后dump下主线程的堆栈,那么生成的堆栈文件只是捕捉了一个时刻的现场快照。打个不太恰当的比方,相当于闭路电视监控只拍下了凶案发生后的惨状,而并没有录下这个案件发生的过程,那么作为警察的你只看到了结局,依然很难判断案情和凶手。在实际的运用中,我们也发现这种方式下获取到的堆栈情况,查看相关的代码和函数,经常已经不是发生卡顿的代码了。
在这里插入图片描述
如图所示,主线程在T1~T2时间段内发生卡顿,上述方案中获取卡顿堆栈的时机已经是T2时刻。实际卡顿可能是这段时间内某个函数的耗时过大导致卡顿,而不一定是T2时刻的问题,如此捕获的卡顿信息就无法如实反应卡顿的现场。

我们看看在这之前微信iOS主线程卡顿监控系统是如何实现的捕获堆栈。微信iOS的方案是起检测线程每1秒检查一次,如果检测到主线程卡顿,就将所有线程的函数调用堆栈dump到内存中。本质上,微信iOS方案的计时起点是固定的,检查次数也是固定的。如果任务1执行花费了较长的时间导致卡顿,但由于监控线程是隔1秒扫一次的,可能到了任务N才发现并dump下来堆栈,并不能抓到关键任务1的堆栈。这样的情况的确是存在的,只不过现上监控量大走人海战术,通过概率分布抓到卡顿点,但依然不是最佳的捕获方案。

因此,摆在我们面前的是如何更加精准地获取卡顿堆栈。为了卡顿堆栈的准确度,我们想要能获取一段时间内的堆栈,而不是一个点的堆栈,如下图:
在这里插入图片描述
我们采用高频采集的方案来获取一段卡顿时间内的多个堆栈,而不再是只有一个点的堆栈。这样的方案的优点是保证了监控的完备性,整个卡顿过程的堆栈都得以采样、收集和落地。

具体做法是在子线程监控的过程中,每一轮log输出或是每一帧开始启动monitor时,我们便已经开启了高频采样收集主线程堆栈的工作了。当下一轮log或者下一帧结束monitor时,我们判断是否发生卡顿(计算耗时是否超过阈值),来决定是否将内存中的这段堆栈集合落地到文件存储。也就是说,每一次卡顿的发生,我们记录了整个卡顿过程的多个高频采样堆栈。由此精确地记录下整个凶案发生的详细过程,供上报后分析处理(后文会阐述如何从一次卡顿中多个堆栈信息中提取出关键堆栈)。

五、海量卡顿堆栈后该如何处理?

卡顿堆栈上报到平台后,需要对上报的文件进行分析,提取和聚类过程,最终展示到卡顿平台。前面我们提到,每一次卡顿发生时,会高频采样到多个堆栈信息描述着这一个卡顿。做个最小的估算,每天上报收集2000个用户卡顿文件,每个卡顿文件dump下了用户遇到的10个卡顿,每个卡顿高频收集到30个堆栈,这就已经产生20001030=60W个堆栈。按照这个量级发展,一个月可产生上千万的堆栈信息,每个堆栈还是几十行的函数调用关系。这么大量的信息对存储,分析,页面展示等均带来相当大的压力。很快就能撑爆存储层,平台无法展示这么大量的数据,开发更是没办法处理这些多的堆栈问题。因而,海量卡顿堆栈成为我们另外一个面对的难题。

在一个卡顿过程中,一般卡顿发生在某个函数的调用上,在这多个堆栈列表中,我们把每个堆栈都做一次hash处理后进行排重分析,有很大的几率会是dump到同一个堆栈hash,如下图:
在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/446897.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

物理 质点运动学

常用公式 重点 1.求轨道方程:消去时间t 2.dr---->位置矢量大小的增量 3.求方向:tanax/y 4.求位置也就是求位移rxiyj即可 习题解析 1.求运动时一定要求出加速度,变速与匀速就是看a 2.求位移时必须看X0是不是为0,如果不为0,求位移与路程时都要减去x0 3. 记住等号两边统一…

使用CLion的时候,对于cmake的使用

问题概述 使用CLion的时候&#xff0c;一个大的项目会有一个总的CMakeLists.txt&#xff0c;这个是控制整个项目的编译环境&#xff0c;但是针对测试的代码会有自己的单独的CMakeLists.txt&#xff0c;这个单独的cmake文件是控制自己的程序所需要的环境即使是编译单独的测试程…

Android NDK之静态/动态注册Native方法

一、简介 关于NDK有两种方法注册&#xff1a;静态注册和动态注册。 静态注册&#xff1a; 就是直接在Java文件里写个native方法 然后再c/c文件中实现这个方法就行了&#xff1b;动态注册&#xff1a; 就是为了不要写很长的方法名&#xff0c;用JNI_OnLoad方法实现预注册&…

概率论 条件概率 全概率 贝叶斯公式

常用知识点 条件概率 1.P(B|A)1表示A发生的情况下B必然发生 A属于B 2.可列可加性 P(BUC|A)P(B|A)P(C|A) 3.P(B|A)的样本空间为A,A与B都发生了 大题解答思路 1.首先设取出一件商品为次品为事件A 2.写B1:甲生产,B2:乙生产 PB1…PB2… P(A|B1)…P(A|B2)… 3.写PAPB1*P(A|B1)……

C语言学习:%d、2d、02d、.2d的区别

%d&#xff1a;为普通的输出。 %2d&#xff1a;按宽度为2输出&#xff0c;右对齐方式输出。若不够两位&#xff0c;左边补空格。 %02d&#xff1a;同样宽度为2&#xff0c;右对齐方式。位数不够&#xff0c;左边补0。 %.2d&#xff1a;从执行效果来看&#xff0c;与%02d一样…

计算机系统基础 数据的表示和存储

数制和编码 1.信息的二进制编码 2.进制转换必须要知道: 1)使用哪一个进制(二,八…) 2)定点数还是浮点数(关于小数点的问题) 3)编码问题----原码,补码,反码,移码 3.进制转换 1)R进制转十进制(按权展开) ----R进制 ----八进制与十六进制 ----R转换为十进制 2)十进制转换为R…

物理 常见力与牛顿三定律

常用知识点 动量 dmvdmvdvm p-mv- f-dp-/dtma- 开普勒第三定律 r1^3__k只与恒星质量有关 T^2 总结 1.电梯匀速就相当于在地面,加速或减速就会有一个a 2.当合外力为0时,物体保持静止或匀速直线运动 3.力是改变物体运动状态的原因 4.重力在地球两极最大,赤道最小,随纬度…

Java命令:jmap — 打印指定进程的共享对象内存映射或堆内存细节

文章目录一、前言二、命令介绍三、使用实例1、jmap -heap [pid]2、jmap -histo[:live] [pid]3、jmap -histo[:live] [pid] |grep "[关键字1]\|[关键字2]"4、jmap -dump:live,formatb,filea.log [pid]四、总结一、前言 jdk安装后会自带一些小工具&#xff0c;jmap命令…

概率论 一维随机变量

随机变量 离散型随机变量:有限个或无限可列个 连续型随机变量 分布函数F(X) 范围是[a,b) 包含能取到a以及a之前的值的概率相加 分布律(概率分布) 1.所有概率相加为1 2.WX-1,计算出每一个对应的W,然后如果有相同的W就合并其概率,最后一一对应P(x)即可 概率密度函数(密度) …

JAVA牛客专项练习2020.12.31

1.使用迭代器的remove方法&#xff0c;可以边遍历边删除元素 2.线程 启动线程 new thread&#xff08;&#xff09;.start&#xff08;&#xff09; new thread&#xff08;new runnable&#xff08;&#xff09;&#xff09;.start&#xff08;&#xff09; 普通方法&#xf…

安卓牛客专项练习2020.12.31

1.窗口dialog或半透明 2.Pracelable性能比serializable高

安卓系统体系架构

1.大体:共有四层&#xff0c;系统应用层&#xff0c;JAVA API层&#xff0c;安卓系统运行层&#xff0c;Linux内核层 具体: 系统应用层&#xff08;System Apps&#xff09; Java API 框架层&#xff08;Java API Framework&#xff09; Android系统运行层&#xff08;包括Andr…

Java命令:jstack — 获取线程dump信息

目录一、命令介绍二、使用实例实例一&#xff1a;jstack查看输出实例二&#xff1a;jstack统计线程数实例三&#xff1a;jstack检测死锁实例四&#xff1a;jstack检测CPU高一、命令介绍 Usage:jstack [-l] <pid>(to connect to running process) //连接活动线程jstack …

Java多线程死锁例子

目录一、产生死锁的原因二、如何避免死锁一、产生死锁的原因 发生死锁的情况&#xff1a; 多个线程需要同时占用多个共享资源而发生需要互相死循环等待的情况&#xff0c;就是&#xff0c;两个线程互相等待着对象释放锁&#xff0c;一直这样僵持下去&#xff0c;所以导致了死锁…

安卓四大组件简介

安卓四大组件 Activity活动&#xff0c;Service服务&#xff0c;BroadcastRecevicer广播接受器&#xff0c;Content Provider内容提供者 Activity活动 所有程序的流程都运行在activity中 Service服务 只能后台运行&#xff0c;没有界面的长生命周期的代码 BroadcastRece…

WebLogic域的创建与发布

目录一、前言二、准备三、创建域步骤第一步&#xff1a;直接【回车】第二步&#xff1a;直接【回车】第三步&#xff1a;直接【回车】第四步&#xff1a;输入域名称后【回车】第五步&#xff1a;直接【回车】第六步&#xff1a;直接【回车】&#xff08;此步骤是提示域的存放目…

WebLogic启动失败:java.lang.AssertionError: Could not obtain the localhost address.

目录一、错误信息二、解决方案第一步&#xff1a;查看本机计算机名称第二步&#xff1a;编辑hosts文件一、错误信息 linux下启动WebLogic报如下错误&#xff1a; 二、解决方案 此错误多半是hosts文件不对导致的。 解决步骤如下&#xff1a; 第一步&#xff1a;查看本机计…

Windows查找JDK的路径

第一步&#xff1a;确定是否安装JDK 在控制台输入&#xff1a; java -version输出结果&#xff1a; 此时说明你电脑安装了JDK。 第二步&#xff1a;查找路径 然后在控制台输入&#xff1a; java -verbose输出结果&#xff1a; 从最后的jre目录可以找到相应的jdk目录。 …

安卓布局中xml文件属性和ID简介

编写xml属性 加载xml资源 当编译应用时&#xff0c;系统会将每个xml文件编译为view资源 属性 xml属性&#xff1a;特有属性&#xff0c;共有属性&#xff0c;其他属性&#xff08;布局参数&#xff09; ID ——在结构树中对view对象唯一标识 编译应用后&#xff0c;系统以…

Java监控工具VisualVM

目录一、简介二、内存分析1、Heap堆三、CPU分析四、线程分析一、简介 VisualVM 是一款免费的&#xff0c;集成了多个JDK命令行工具的可视化工具&#xff0c;它能为您提供强大的分析能力&#xff0c;对Java应用程序做 性能分析和调优 。这些功能包括 生成和分析海量数据、跟踪内…