0%

卡顿检测 - TraceCanary浅析

TraceCanary原理概述

监控主线程卡顿有两种方案:

  1. 监控主线程Looper的相邻两次的dispatchMessage的耗时;
  2. 通过Choreographer监控相邻两次垂直同步信号的时间差。

当耗时超过阈值,导出当前主线程的堆栈,分析堆栈。

这两种方法的问题在于,导出的堆栈中并不一定有导致卡顿的方法,无法获取到每个函数的调用耗时,判断并不准确。

所以就用ASM对App的每个方法做了字节码插桩,在函数执行的执行前后打点统计函数耗时。为每个插桩的函数分配一个独立的ID,在插桩完成后输出一份ID和方法名的映射表,以供分析。

需要了解的背景知识

  1. Handler机制
  2. Choreographer机制
  3. Binder机制
  4. Activity启动流程
  5. Gradle Plugin
  6. ASM、Visitor模式、JVM字节码指令执行过程

TracePlugin里的init方法中创建的AnrTracer、FrameTracer、EvilMethodTracer、StartupTracer分别是做什么的?

  1. FrameTracer负责帧率检测
  2. AnrTracer负责ANR问题检测
  3. EvilMethodTracer负责检测慢函数
  4. StartupTracer负责应用启动耗时检测

创建Matrix实例的时候会在Matrix的构造函数调用所有Plugin的init()方法。

LooperMonitor是做什么的?

在主线程Looper处理每个消息的前后添加回调方法

回调方法为 LooperDispatchListener 接口的 dispatchStart() 和 dispatchEnd()

原理如下:

给Looper设置自定义的Printer

Printer仅有一个println(String)方法,会在Looper的loop()方法中处理Message的前后各调用一次

对于主线程的Looper来说,相当于是在主线程的方法调用前后插桩,可以统计方法的耗时

通过Looper.setMessageLogging()给Looper设置自定义的LooperPrinter,封装在了LooperMonitor的resetPrinter()方法中,在LooperMonitor的构造函数里会执行

LooperMonitor的构造函数里又向Looper的MessageQueue添加了IdleHandler

IdleHandler接口有一个boolean queueIdle(),该方法会在MessageQueue.next()中执行,next()是从消息队列中取一个消息对象返回给Looper用的,如果消息队列为空,就会调用queueIdle()

返回true表示下一轮处理完消息后还会回调

返回false表示这是单次回调,这次回调后不会再回调了

LooperMonitor里添加IdleHandler是做什么呢?

通过代码查看LooperMonitor中的queueIdle方法,做的是隔60秒以上再执行一下resetPrinter(),目的是防止app中其他地方给主线程的Looper设置了Printer覆盖掉了LooperMonitor里的Printer,这样就没办法执行监控了,所以需要检查并重新设置

为什么要通过IdleHandler来设置呢?

因为queueIdle执行时,主线程没有其他事情做了,消息队列的消息都处理完了,所以在这里执行resetPrinter()不会对主线程造成卡顿

Choreographer中的mCallbackQueues是做什么的?

为什么要分CALLBACK_INPUT、CALLBACK_ANIMATION、CALLBACK_INSETS_ANIMATION、CALLBACK_TRAVERSAL、CALLBACK_COMMIT几种类型?区分类型的好处在哪?

垂直同步信号来临之前,有些任务就已经被安排了,通过mCallbackQueues存储了下一帧要执行的任务,在垂直同步信号来临后,执行doFrame()方法,依次执行预期要执行的任务。

分类的好处:

  1. 不同类型的任务可以集中执行
  2. 可以统计每个类型的任务的耗时,如果不分类就不方便统计了
  3. 可以决定每种类型的任务的执行顺序,便于统一控制

TraceCanary可以在gradle里配置哪些选项?每个选项的作用是什么?

可配置项都在MatrixTraceExtension类里,目前4个成员变量:

1
2
3
4
boolean enable;
String baseMethodMapFile;
String blackListFile;
String customDexTransformName;

官方文档和示例代码中的配置选项 https://github.com/Tencent/matrix#matrix_android_cn

1
2
3
4
5
trace {
enable = true
baseMethodMapFile = "${project.projectDir}/matrixTrace/methodMapping.txt"
blackListFile = "${project.projectDir}/matrixTrace/blackMethodList.txt"
}

每个配置项具体什么意思?

enable

控制是否进行拦截transform任务进行字节码插桩
在MatrixPlugin中控制是否执行MatrixTraceTransform.inject()

baseMethodMapFile

用于存储ASM插装拦截的每个方法的方法名称和生成的方法id之间的映射关系

blackListFile

指定一个黑名单文件,文件内容格式如下:
[package]
-keepclass aa/bb/cc
-keeppackage com/squareup/
黑名单里的类和包下的类都不会进行字节码插桩
在TraceClassAdapter调用MethodCollector.isNeedTrace()会用到黑名单列表

customDexTransformName

因为android的构建由buildType和flavor组成了各种variant,可能只想给特定variant进行字节码插桩

Matrix Gradle Plugin

MatrixPlugin做了什么?为什么要在project.afterEvaluate后去做?

project.afterEvaluate()方法作用:
该project所有的task都configuration完成后会调用,这样就可以获取到该project的task
This can be used to do things like performing additional configuration once all the definitions in a build script have been applied
https://docs.gradle.org/current/userguide/build_lifecycle.html#sec:project_evaluation

主要在afterEvaluate后执行MatrixTraceTransform.inject()

MatrixTraceTransform.inject()做了什么?

因为需要获取到transformClassesWithDexTask系列的任务,这些任务都是TransformTask的子类,需要通过反射将TransformTask内部的transform给替换为自定义的Transform(即MatrixTraceTransform)实现字节码插桩

最终gradle编译时会调用MatrixTraceTransform的transform()方法,先调用自定义的doTransform(),再调用origTransform.transform()

MatrixTraceTransform.inject()的Configuration的setMethodMapFilePath、setIgnoreMethodMapFilePath、setMappingPath、setTraceClassOut作用是什么?

setMethodMapFilePath()设置的文件,存储内容是:方法id和每个方法的映射表
setIgnoreMethodMapFilePath()设置的文件,存储内容是:不进行插装的方法列表
setMappingPath()设置的混淆的mapping文件路径,用于把ASM拦截到的类和方法还原成原始的类名和方法名
setTraceClassOut()设置的ASM字节码插装后class文件保存路径,用于原本编译流程中的Transform任务作为输入;在CollectJarInputTask、CollectDirectoryInputTask会用到;目录默认位于builds/outputs/traceClassOut

MatrixTraceTransform的doTransform()在做什么?

三步走:
1. 解析proguard的mapping文件,记录保存混淆前后类名、方法名的映射关系
2. 收集项目所有class的方法信息,给每个方法生成一个唯一的id标识,所有信息在内存中保存一份,也输出一份到文件中,以查找方法id和每个方法的映射关系
3. 对项目中所有class的方法进行字节码插桩

MatrixTraceTransform的doTransform()中的ParseMappingTask、MappingCollector在干什么?

对应doTransform()三步走的第一步

读取mapping.txt里的类和方法混淆前后的映射关系,存储到MappingCollector的各个属性中

proguard mapping路径示例
build\outputs\mapping\midongPlay\release

mapping.txt大致内容:

1
2
3
4
5
6
7
8
com.google.common.collect.Synchronized$SynchronizedBiMap -> dc0$e:
java.util.Set valueSet -> f
com.google.common.collect.BiMap inverse -> g
1:1:java.lang.Object delegate():1160:1160 -> c
1:3:java.lang.Object forcePut(java.lang.Object,java.lang.Object):1189:1191 -> forcePut
2:2:java.util.Map delegate():1160:1160 -> c
4:5:com.google.common.collect.BiMap inverse():1200:1201 -> inverse
1:1:java.util.Collection values():1160:1160 -> values

doTransform()第一步的CollectDirectoryInputTask、CollectJarInputTask在做什么?

CollectDirectoryInputTask中通过反射把DirectoryInput的file属性替换为traceClassOut目录的位置
CollectJarInputTask中通过反射把JarInput的file属性替换为traceClassOut目录的位置

DirectoryInput是接口,实现类是ImmutableDirectoryInput,ImmutableDirectoryInput的父类是QualifiedContentImpl,里面有一个file属性。
JarInput是接口,实现类是ImmutableJarInput,ImmutableJarInput的父类是QualifiedContentImpl,里面有一个file属性。

traceClassOut目录的值是什么?里面存放的是什么?有什么作用?

存储的是doTransform第三步用ASM进行字节码插桩后class文件和jar文件。
在doTransform第一步中,CollectDirectoryInputTask、CollectJarInputTask会填充dirInputOutMap、jarInputOutMap两个map,map的key是原始的输入,value是ASM插桩后字节码文件的输出路径
在doTransform第三步中,dirInputOutMap、jarInputOutMap会传递给MethodTracer.trace(),最后分别在innerTraceMethodFromSrc()、innerTraceMethodFromJar()把traceClassOut路径赋值作为ASM的输出路径

为什么要替换DirectoryInput的file属性?这个file属性有什么用?

DirectoryInput和JarInput代表的是当前Transform任务的输入,替换输入的file属性,相当于改变了任务的输入。

为什么要改变Transform任务的输入?是为了达成什么目的?

因为MatrixTraceTransform.inject()中拦截了所有的TransformTask,把TransformTask的transform属性都替换为了MatrixTraceTransform,同时将原来的Transform对象也传给了MatrixTraceTransform。
在MatrixTraceTransform的transform()方法里,会先执行doTransform(),然后再执行原始的Transform对象的transform(),原始的Transform对象就可以用经过ASM字节码插桩后的class作为输入了

MethodCollector在做什么?

收集项目代码、依赖jar包中所有方法的信息
方法信息包括名称、描述符、访问限定符、所属类名、唯一标识的id
其中方法id是通过一个全局变量自增生成,每扫描到一个方法,方法id变量就自增加1

具体过程:

收集DirectoryInput指定的目录下的所有class文件路径,针对每个class文件执行CollectSrcTask。
针对所有JarInput指定的jar路径,执行CollectJarTask。

CollectSrcTask中使用ASM处理每一个class文件,处理过程交给了TraceClassAdapter类。
CollectJarTask会用ZipFile遍历每一个Entry,检查是class就使用ASM处理class文件,处理过程交给了TraceClassAdapter类。

ASM相关代码调用如下:

1
2
3
4
5
InputStream is = new FileInputStream(classFile);
ClassReader classReader = new ClassReader(is);
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassVisitor visitor = new TraceClassAdapter(Opcodes.ASM5, classWriter);
classReader.accept(visitor, 0);

以上处理完成class后没有获取classWriter的结果进行输出,这里只是读取所有的方法,为每个方法生成一个methodId

读取完所有的方法后,接着执行saveIgnoreCollectedMethod和saveCollectedMethod

saveIgnoreCollectedMethod在做什么?

把先前CollectSrcTask、CollectJarTask中用ASM收集到的要忽略的、不进行插桩的方法输出保存到configuration.ignoreMethodMapFilePath指定的路径的文件中。
默认位置是在proguard的mapping.txt同目录下,文件名为ignoreMethodMapping.txt

saveCollectedMethod在做什么?

把先前CollectSrcTask、CollectJarTask中用ASM收集到的要进行插桩的方法输出保存到configuration.methodMapFilePath指定的路径的文件中。
默认位置是在proguard的mapping.txt同目录下,文件名为methodMapping.txt

MethodTracer在做什么?

给项目中所有类(包括项目目录代码、第三方jar包)进行字节码插装。
插桩后的字节码输出保存到Configuration中traceClassOut指定的目录下,以供原始的Transform进行处理

如何向没有实现onWindowFocusChanged(boolean hasFocus)方法的Activity实例中插入该方法?

具体在MethodTracer.TraceClassAdapter类中实现
TraceClassAdapter继承的是ClassVisitor

在TraceClassAdapter的visitMethod(int access, String name, String desc, String signature, String[] exceptions) 方法中,通过检查方法名name和方法的描述符desc判断是否实现了一个类onWindowFocusChanged()方法,如果一个类实现了onWindowFocusChanged(),那么ASM扫描字节码就一定会走到visitMethod()

当一个类所有的属性和方法都被扫描处理完成后,会调用TraceClassAdapter的visitEnd(),在其中判断如果当前类是Activity子类并且未实现onWindowFocusChanged(),就会调用insertWindowFocusChangeMethod()插入该方法

UIThreadMonitor

TracePlugin的start()方法里出现的UIThreadMonitor是做什么的?

  1. 对Handler的消息处理和Choreographer的INPUT、ANIMATION、TRAVERSAL几个动作做耗时计算
  2. 提供Handler消息处理前后的回调、Choreographer的doFrame执行的回调给LooperObserver的dispatchBegin、dispatchEnd、doFrame方法,详细的耗时传递在各方法的参数中

所有的Tracer都实现了LooperObserver接口,Tracer的子类有AnrTracer、FrameTracer、EvilMethodTracer、StartupTracer

原理如下:

TracePlugin.start()会依次调用UIThreadMonitor的init()和onStart()

Plugin的start()都需要手动调用的,TracePlugin.start()会在自定义的Application的onCreate()中调用

UIThreadMonitor.init()中主要做了两件事:

  1. 反射获取Choreographer的mCallbackQueues,以及其中的INPUT、ANIMATION、TRAVERSAL对应CallbackQueue的addCallbackLocked方法的引用
  2. 向LooperMonitor注册了一个LooperDispatchListener,将dispatchStart()和dispatchEnd()分发给UIThreadMonitor的dispatchBegin()和dispatchEnd()

UIThreadMonitor.onStart()中主要是执行addFrameCallback(CALLBACK_INPUT,this,true),方法内部通过反射相当于执行了Choreographer.mCallbackQueues[CALLBACK_ANIMATION].addCallbackLocked(),传递的callback是UIThreadMonitor自身,UIThreadMonitor实现了Runnable接口

为什么要给CALLBACK_INPUT加回调呢?为什么不直接调用Choreographer.postFrameCallback(FrameCallback)来添加回调?

首先Choreographer.postFrameCallback(FrameCallback)内部是调用了mCallbackQueues[CALLBACK_ANIMATION].addCallbackLocked()

其次得看CALLBACK_INPUT的callback action执行了什么,也就是UIThreadMonitor.run()里做了什么?

其实就是对INPUT、ANIMATION、TRAVERSAL分别进行了耗时统计,并把耗时存放在queueCost数组中,这些耗时统计的是一帧内的耗时

CALLBACK_TRAVERSAL的回调耗时应该在哪进行结束统计?

doFrameEnd()中对TRAVERSAL回调做了耗时结束的统(doQueueEnd(CALLBACK_TRAVERSAL)),doFrameEnd()又是在dispatchEnd()中调用的,dispatchEnd()在Looper处理完一个消息后会被调用

为什么dispatchEnd()可以算作是一帧的结束?即为什么dispatchEnd()可以调用doFrameEnd()?

因为Choreographer.doFrame()也是用过Handler发送异步消息调用的,这一点可以在FrameDisplayEventReceiver的onVsync的最后几行代码确认。
而LooperMonitor对主线程Looper的消息处理的前后进行了拦截,在调用Choreographer.doFrame()之前,会先调用LooperMonitor.LooperDispatchListener的dispatchStart(),UIThreadMonitor.init()里向LooperMonitor注册了一个LooperDispatchListener,将LooperDispatchListener的dispatchStart()和dispatchEnd()回调分发给UIThreadMonitor的dispatchBegin()和dispatchEnd()。
也就是说在UIThreadMonitor.onStart()和run()中对Choreographer addFrameCallback() 的3个Runnable,都会在UIThreadMonitor的dispatchBegin()调用后和dispatchEnd()调用前这个时间段内调用,这个意思就是几个FrameCallack执行的时候,当前Looper处理的消息一定是Choreographer.doFrame()的消息,因为几个FrameCallack就是在Choreographer.doFrame()中执行的。
第一个FrameCallback中(即UIThreadMonitor的run方法)调用了doFrameBegin(),里面给isVsyncFrame设置了true,等到调用dispatchEnd()的时候,一定是这一帧结束了,所以可以调用doFrameEnd。

dispatchBegin()和dispatchEnd()中System.nanoTime()和SystemClock.currentThreadTimeMillis()有什么区别?

System.nanoTime()
单位:纳秒
android系统开机到当前的时间
系统设置修改时钟,不影响该时间。
重启android系统后该值会重置为0。

1秒 = 1000毫秒; 1毫秒=1000微秒; 1微秒=1000纳秒
纳秒他是毫秒的百万分之一。远比毫秒的颗粒度要低。

SystemClock.currentThreadTimeMillis()
当前线程处于running状态经过的时间,线程失去CPU时间片后不会计时

参考:

AppActiveMatrixDelegate

为什么AppActiveMatrixDelegate是一个枚举,枚举项只有一个INSTANCE?

通过枚举实现线程安全的单例模式,这样代码量最少,同时也解决了反序列化破坏单例唯一性的问题

参考:为什么我墙裂建议大家使用枚举来实现单例

init()做了什么?

init()是在Matrix对象的构造函数中调用的

  1. 创建了一个默认的HandlerThread,获取其Looper用来创建一个Handler,以在子线程执行任务
  2. 调用Application.registerComponentCallbacks()注册一个监听器
  3. 调用Application.registerActivityLifecycleCallbacks()注册一个监听器

调用Application.registerActivityLifecycleCallbacks()注册一个监听器 是为了做什么?

onActivityStarted()调用时
1. 记录当前可见的Activity的类名赋值给visibleScene
2. 执行onDispatchForeground(String visibleScene)通知向AppActiveMatrixDelegate注册的IAppForeground监听器应用进入前台了

onActivityStopped()调用时
1. 通过getTopActivityName() == null 判断当前进程没有activity在运行了
2. 执行onDispatchBackground(String visibleScene)通知向AppActiveMatrixDelegate注册的IAppForeground监听器应用进入后台了

调用Application.registerComponentCallbacks()注册一个监听器 是为了做什么?

为了在ComponentCallbacks的onTrimMemory()监听到TRIM_MEMORY_UI_HIDDEN事件,作为应用进入后台的另外一个触发时机

ComponentCallbacks.onTrimMemory()是做什么的?TRIM_MEMORY_UI_HIDDEN是什么意思?

onTrimMemory是Android在4.0之后加入的一个回调,任何实现了ComponentCallbacks2接口的类都可以重写实现这个回调方法

onTrimMemory的主要作用就是指导应用程序在不同的情况下进行自身的内存释放,以避免被系统直接杀掉,提高应用程序的用户体验.

系统提供onTrimMemory()回调的类有:Application/Activity/Fragement/Service/ContentProvider
这些类都实现了ComponentCallbacks2接口

Android系统会根据不同等级的内存使用情况,调用这个函数,并传入对应的等级:

TRIM_MEMORY_UI_HIDDEN
表示应用程序的所有UI界面被隐藏了,只有当我们程序中的所有UI组件全部不可见的时候才会触发。
即用户点击了Home键或者Back键导致应用的UI界面不可见.这时候应该释放一些资源.

需要注意的是,onTrimMemory的TRIM_MEMORY_UI_HIDDEN 等级是在onStop方法之前调用的.

参考:

为什么要监听TRIM_MEMORY_UI_HIDDEN?不监听会有什么问题?不是已经在onActivityStopped()中判断过了吗?

因为getTopActivityName() 是通过反射获取ActivityThread中的mActivities,如果系统升级或在定制的系统上,反射有可能失败,这样getTopActivityName()就会失效,需要通过另外一个机制来做备案。

创建的Handler是在做什么任务需要在子线程执行?

在子线程中通知向AppActiveMatrixDelegate注册的IAppForeground监听器应用进入前台或后台了。

为什么要在子线程执行监听器的回调?

因为执行listener的回调前需要对监听器列表listeners进行synchronized同步锁定,避免通知时listeners被其他线程修改,在主线程加锁会拖慢主线程运行速度。

getTopActivityName()为什么要通过反射获取ActivityThread.mActivities,遍历获取第一个没有pause的ActivityClientRecord里的activity?直接在回调里记录最顶层的activity不行吗?

优点:获取android framework代码里的状态是最准确的,不会有偏差
缺点:反射会比一般的方法调用要耗时

这里对精确度的要求应该更高。

AppMethodBeat的i()、o()应该在何处调用?

在UIThreadMonitor的dispatchBegin()和dispatchEnd()中调用。

这样监听了所有主线程的Looper处理消息时所有方法的调用。

AnrTracer

AnrTracer是如何检测ANR的?

在Looper处理一个消息之前,延迟执行一个任务,如果 5s 内没有取消这个任务,则认为 ANR 发生;

这时会在任务中主动取出当前记录的 buffer 数据进行独立分析上报,对这种 ANR 事件进行单独监控及定位。

具体实现:

在dispatchBegin()中创建了一个AnrHandleTask实例,通过anrHandler.postDelayed()延迟执行。

其中anrHandler的Looper是MatrixHandlerThread.getDefaultHandlerThread()返回的HandlerThread的Looper,也就是说anrHandler里的所有任务和消息都会在子线程执行,不会导致主线程卡顿。

dispatchBegin()中anrHandler.postDelayed()为什么延迟时间要设置为Constants.DEFAULT_ANR-(System.nanoTime()-token)/Constants.TIME_MILLIS_TO_NANO?而不是直接用Constants.DEFAULT_ANR作为延迟时间?

其中

DEFAULT_ANR = 5* 1000

TIME_MILLIS_TO_NANO = 1000000

默认认为主线程Looper的Message处理超过5秒即为卡顿

token是在UIThreadMonitor.dispatchBegin()通过System.nanoTime()赋值的,意思是主线程Looper开始处理Message前获取的一个时间戳

UIThreadMonitor.dispatchBegin()一直到anrHandler.postDelayed()是有时间消耗的,虽然代码上没有多少代码,但为了准确性,要减去这个时间消耗,这个消耗的时长就是System.nanoTime()-token,单位是纳秒,除以Constants.TIME_MILLIS_TO_NANO把单位转换为毫秒

AnrHandleTask里做了什么?

取出AppMethodBeat当前记录的 buffer 数据进行分析上报

buffer里存放的都是long,存储了方法的id、进入方法的时间、退出方法的时间

数据格式参见:

https://github.com/Tencent/matrix/wiki/Matrix-Android-TraceCanary

编译期已经对全局的函数进行插桩,在运行期间每个函数的执行前后都会调用 MethodBeat.i/o 的方法,如果是在主线程中执行,则在函数的执行前后获取当前距离 MethodBeat 模块初始化的时间 offset(为了压缩数据,存进一个long类型变量中),并将当前执行的是 MethodBeat i或者o、mehtod id 及时间 offset,存放到一个 long 类型变量中,记录到一个预先初始化好的数组 long[] 中 index 的位置(预先分配记录数据的 buffer 长度为 100w,内存占用约 7.6M)。数据存储如下图:

怎么知道发生卡顿前的时间点是在哪?从哪条记录开始取?

dispatchBegin()的时候,也就是主线程Looper处理Message前,通过AppMethodBeat.maskIndex()创建了一个IndexRecord传递给了AnrHandleTask。

AppMethodBeat.maskIndex()创建IndexRecord时,记录下了当前是从哪个方法开始执行的,即记录了buffer数组的索引。

在AnrHandleTask.run()中执行AppMethodBeat.getInstance().copyData(beginRecord)时,copyData()内部会再创建一个IndexRecord并取得此时buffer的索引作为结束索引。

copyData()就是在AppMethodBeat的sBuffer数组中取这两个索引之间的数据出来上报。

上报时怎么通过method id找到对应调的是哪个类的哪个方法?

通过代理编译期间的任务 transformClassesWithDexTask,将全局 class 文件作为输入,利用 ASM 工具,高效地对所有 class 文件进行扫描及插桩。

为了方便及高效记录函数执行过程,我们为每个插桩的函数分配一个独立 ID,在插桩过程中,记录插桩的函数签名及分配的 ID,在插桩完成后输出一份 mapping,作为数据上报后的解析支持。

AppMethodBeat.maskIndex()是做什么的?IndexRecord有什么作用?传给AnrHandleTask做什么?

AppMethodBeat中的sIndexRecordHead是一个链表,按照IndexRecord.index从小到大从左到右排列。

AppMethodBeat.maskIndex(String source)里新建了一个IndexRecord结点,index值为sIndex-1,按index顺序插入链表。

EvilMethodTracer

EvilMethodTracer做了什么?

TraceConfig中配置的方法耗时阈值(默认是700毫秒)来判断某个message dispatch时间是否过长,超过阈值则把进程信息,CPU使用率,调用栈(包含具体方法耗时),这帧input、animation、traversal耗时等信息上报

AnalyseTask做了什么?

三步走:

  1. 获取要上报的方法调用信息,并做适当的裁剪,去除对分析没有帮助的冗余信息,减少上报体积
  2. 生成唯一标识方法调用栈的key,方便后台做聚合处理
  3. 上报信息

如何获取方法调用信息?如何进行裁剪?

通过TraceDataUtils的structuredDataToStack()、trimStack()对long[] data做修剪,提取出方法的id、耗时、调用深度,并只保留最多30个方法的信息,会忽略调用深度过深的方法。

如何生成方法调用栈的key?

生成key是调用如下的方法

TraceDatautils.getTreeKey(List<MethodItem> stack, long stackCost)

stack是由TraceDataUtils的structuredDataToStack()、trimStack()得来的。

stackCost是TraceDataUtils.stackToString(List<MethodItem> stack)返回得来的。

stackToString()就是寻找stack里耗时最大的方法的耗时,这个方法是哪个方法呢,就是调用深度为0的方法。

具体这个有什么用得看后面的getTreeKey()里面是怎么处理的。

getTreeKey()做了什么?

第1步:

从stack中过滤得到所有耗时大于stackCost * 0.3耗时的MethodItem,得到一个LinkedListsortList

第2步:

排序sortList

排序比较的基准是按照 方法的调用深度 * 方法耗时 从大到小的排列

第3步:

按序遍历sortList,取出sortList中所有元素的methodId拼接为字符串作为key

如何上报信息?

三步走:

  1. 通过Matrix.with().getPluginByClass(TracePlugin.class)获取TracePlugin实例
  2. 生成一个Issue对象,把上报信息都塞进去
  3. 调用TracePlugin.onDetectIssue(issue)

打印的logcat日志是什么样子?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
>>>>>>>>>>>>>>>>>>>>> maybe happens Jankiness!(870ms) <<<<<<<<<<<<<<<<<<<<<
|* [Status]
|* Scene: com.xiaomi.hm.health.activity.MainTabActivity
|* Foreground: true
|* Priority: 10 Nice: -10
|* is64BitRuntime: true
|* CPU: 78.97%
|* [doFrame]
|* inputCost:animationCost:traversalCost
|* 216667:745313:2104688
|* StackKey: 171|
|* TraceStack:
|* [id count cost]
|* 1048574 1 871
|* .138415 1 79
|* ..169 1 53
|* ...11645 1 53
|* ....108755 1 53
|* .....205696 1 53
|* ......205676 1 53
|* .......97487 1 53
|* ........97795 1 53
|* .........97808 1 53
|* ..........103756 1 53
|* ...........103813 1 53
|* ............106527 1 53
|* .............106533 1 53
|* .171 1 637
|* ..255 1 109
|* ...225 1 51
|* ...11058 1 52
|* ....11060 1 52
|* ..117374 1 51
|* ...117379 1 51
|* ....117385 7 46
|* .....117417 1 46
|* ......117424 1 46
|* .......307 1 46
|* ........324 1 46
|* ..25641 1 74
|* ...95509 1 74
|* ..208 1 52
|* .274 1 57

StartupTracer

StartupTracer统计了什么?

统计应用冷启动、热启动、首屏耗时、单个activity启动等阶段耗时

各耗时项是如何规定起止时间的?

StartupTracer类注释:

启动和首屏出现耗时的起点都是应用中的第一个方法开始执行的时间,通过字节码插桩已经可以拿到了。

application创建结束的时间计算方法是:只要activity开始launch或者service开始create或者broadcastReceiver开始创建就算application创建结束。

单个activity从启动到展示UI的耗时是从launch activity到onWindowFocusChanged这段时间,onWindowFocusChanged(true)表示activity已经获取到焦点,可以和用户进行交互。

Application创建的起止时间分别从哪里算起?在哪里进行拦截?

开始时间点:从app的第一个方法开始执行

具体表现为:

在ActivityThreadHacker.hackSysHandlerCallback()保存了应用启动时间,并保存了当前调用到的方法的index:

sApplicationCreateBeginTime=SystemClock.uptimeMillis();

sApplicationCreateBeginMethodIndex=AppMethodBeat.getInstance().maskIndex(“ApplicationCreateBeginMethodIndex”);

hackSysHandlerCallback()被调用的时机:

AppMethodBeat.i() -> AppMethodBeat.realExecute() -> ActivityThreadHacker.hackSysHandlerCallback()

其中AppMethodBeat.realExecute()会有状态控制只会被执行一次

结束时间点:只要有activity开始launch、或者service开始create、或者BroadcastReceiver开始创建

原理是拦截ActivityThread的Handler消息处理过程,启动Activity、Service、BroadcastReceiver都会经过这里

具体实现:

ActivityThreadHacker.hackSysHandlerCallback()中通过反射获取ActivityThread的sCurrentActivityThread静态变量,获取当前进程的ActivityThread实例,获取ActivityThread的mH成员变量,给这个Handler设置自定义的Handler.Callback类HackCallback;

HackCallback中拦截Activity的启动、Service的create、BroadcastReceiver的创建

其中Activity的启动流程Android 9及其以上发生了变化,需要单独处理,这里需要熟悉Activity的启动流程

热启动的起止时间分别从哪里算起?在哪里进行拦截?

第一个Activity的onCreate()调用算作热启动开始

第一个Activity的onWindowFocusChanged()算作热启动结束

单个activity启动的起止时间分别从哪里算起?在哪里进行拦截?

单个activity从启动到展示UI的耗时是从launch activity到onWindowFocusChanged这段时间,onWindowFocusChanged(true)表示activity已经获取到焦点,可以和用户进行交互。

TraceDataUtils

structuredDataToStack()做了什么?

structuredDataToStack()方法参数是:

long[] buffer, LinkedList result, boolean isStrict, long endTime

buffer数组中每个long,存储了三个信息:

  1. 是进入一个方法,还是退出一个方法
  2. 方法id
  3. 进入或退出的时间

structuredDataToStack()做的主要是将buffer的方法记录转换为一个方法调用栈,存储在栈中,元素类型为MethodItem

MethodItem相比buffer多存储了一个方法调用深度depth,并计算存储了方法调用耗时

如果在a方法中连续调用b方法,会将b方法的调用信息合并为1个

输入是long[] buffer,格式如:

输出是LinkedList result

相当于把buffer中相邻的I和O替换为了MethodItem,这很像LeetCode上利用栈进行括号匹配的问题,具体算法如下:

先碰到同一个方法的I和O就会先push()到栈中,也就是先push栈深度大的方法,栈用LinkedList实现,push方法实际就是addFirst(),result中越靠后的MethodItem代表的是方法栈深度越大的方法,同一深度的方法调用顺序在result中是反过来的。

例如上图的方法id为4、5、6、7的方法,在result中的位置是,4、7、6、5

然后调用了stackToTree(result,root),把栈转为树,同时把同一深度的方法调用顺序变为原来正确的顺序。

再调用treeToStack(root,result),把树转换为栈,得到的方法调用顺序为正序的LinkedList<MethodItem>,result中越后面的MethodItem表示的调用深度越深的方法。

例如上图的方法id为4、5、6、7的方法,在result中的最终位置是,4、5、6、7

trimStack()做了什么?

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static void trimStack(List<MethodItem> stack, int targetCount, IStructuredDataFilter filter) {
if (0 > targetCount) {
stack.clear();
return;
}

int filterCount = 1;
int curStackSize = stack.size();
while (curStackSize > targetCount) {
ListIterator<MethodItem> iterator = stack.listIterator(stack.size());
while (iterator.hasPrevious()) {
MethodItem item = iterator.previous();
if (filter.isFilter(item.durTime, filterCount)) {
iterator.remove();
curStackSize--;
if (curStackSize <= targetCount) {
return;
}
}
}
curStackSize = stack.size();
filterCount++;
if (filter.getFilterMaxCount() < filterCount) {
break;
}
}
int size = stack.size();
if (size > targetCount) {
filter.fallback(stack, size);
}
}

方法的输入和输出是什么?

输入输出都是List<MethodItem> stack

输入内容就是structuredDataToStack()的输出

例如下图的方法id为4、5、6、7的方法,在stack中的位置是4、5、6、7

targetCount是什么?

targetCount指定的是期望的stack的上限大小,默认为Constants.TARGET_EVIL_METHOD_STACK,即30

也就是说接下来要一直裁剪stack,一直到stack中不要保存超过targetCount个的方法信息(MethodItem)

为什么要逆向遍历stack?

因为stack的越后面,是调用深度越深的方法,深度越深的方法,耗时非常小的几率是很大的,第一步要先排除耗时特别小的方法,这些并不是卡顿的主要原因,删除这些方法信息可以减少数据上传量。

filter.isFilter()在做什么?

先通过filter.isFilter()过滤掉 执行耗时小于 5ms 的单个函数调用,因为这些函数对卡顿来说不是主要原因,删除这些方法信息可以减少数据上传量。

isFilter()的实现为 during < filterCount * 5

filterCount是干什么的?

就是从整个stack过滤短耗时方法的次数,第一次过滤掉小于5毫秒耗时的方法,第二次过滤掉小于25毫秒耗时的方法,第三次是35毫秒,以此类推,直至达到filter.getFilterMaxCount()

过滤了一定次数后stack保存的方法信息数量还是大于targetCount时应该怎么办?

调用filter.fallback(),代码实现如下:

1
2
3
4
5
6
7
public void fallback(List<MethodItem> stack, int size) {
Iterator iterator = stack.listIterator(Math.min(size, Constants.TARGET_EVIL_METHOD_STACK));
while (iterator.hasNext()) {
iterator.next();
iterator.remove();
}
}

只会保留stack中前30个MethodItem

为什么可以舍弃stack后面的MethodItem呢?

因为后面的都是调用深度更深的方法,前面的是调用深度更小的方法,其实已经囊括了调用深度更深的方法的耗时信息,只不过粒度更粗一些,但是应该足够定位到大多数的问题。