BlockCanary简介
BlockCanary 是一个用于 Android 应用的性能监控工具,主要用于检测应用中的 UI 卡顿 问题(也就是 ANR,Application Not Responding)。BlockCanary 可以帮助开发者追踪和记录 UI 线程阻塞的情况,从而分析应用在某些操作下可能存在的性能瓶颈。
主要功能
-
UI 卡顿检测:
- 通过在主线程(UI 线程)上监控并记录方法的执行时间,BlockCanary 可以检测到线程阻塞和长时间运行的操作,从而识别出 UI 卡顿的问题。
-
线程分析:
- 它能够检测线程中的阻塞操作,例如主线程由于执行某些耗时操作(如网络请求、数据库查询等)导致的卡顿,并帮助开发者定位问题发生的具体方法。
-
数据可视化:
- BlockCanary 会提供一份详细的报告,包含卡顿的堆栈信息、时间等,并且通过图表展示,帮助开发者分析和优化应用。
-
轻量级:
- BlockCanary 是一个轻量级的工具,开发者只需要在项目中集成相应的依赖库即可,它的运行时开销很小,不会对应用的性能造成过大影响。
核心特点
- 基于 Android 的 UI 阻塞检测:BlockCanary 通过监控线程的执行时间来检测 UI 线程阻塞的情况,特别是卡顿超过一定时间的事件。
- 简便的集成方式 :只需要在应用的
build.gradle
中添加依赖,并进行少量的配置,即可启用 BlockCanary。 - 详细的性能报告:BlockCanary 提供详细的性能报告,帮助开发者快速找到并解决卡顿和性能瓶颈问题。
- 兼容性:支持 Android 4.0 及以上的版本。
使用场景
- UI 卡顿定位:在用户体验中,卡顿是最常见的性能问题之一。BlockCanary 可以帮助开发者定位主线程阻塞,提升应用的流畅度。
- 性能优化:通过分析 BlockCanary 生成的报告,开发者可以识别出高开销的操作,并优化代码。
- ANR 问题检测:BlockCanary 可以帮助开发者发现导致 ANR 的潜在问题,确保应用的响应性。
BlockCanary使用方式
在 build.gradle
文件中添加依赖:
kotlin
dependencies {
implementation 'com.github.squareup.blockcanary:blockcanary-android:1.5.1'
}
在 Application
类中初始化 BlockCanary:
java
BlockCanary.install(this, new BlockCanaryContext()).start();
源码流程分析
js
public static BlockCanary install(Context context, BlockCanaryContext blockCanaryContext) {
BlockCanaryContext.init(context, blockCanaryContext);
// 开启弹通知栏以及点击打开Activity的操作
setEnabled(context, DisplayActivity.class, BlockCanaryContext.get().displayNotification());
// 使用单例模式返回一个BlockCanary对象
return get();
}
public void start() {
if (!mMonitorStarted) {
mMonitorStarted = true;
Looper.getMainLooper().setMessageLogging(mBlockCanaryCore.monitor);
}
}
上述自定义的printer对象就是在Looper的下边两处代码中使用的:
第一处:
第二处: 这两处中间存在以下的代码:
js
msg.target.dispatchMessage(msg);
主线程的所有工作都会交给dispatchMessage
来处理,一次主线程的消息处理一定发生在dispatchMessage
中间,如果两次 dispatchMessage
的消息相差太久,就说明发生了卡顿状态,如何知道这个时间是否很长呢?其实就是利用 dispatchMessage
的开始时间与结束时间的差值就能做到了,也就是上边的两个打印log的地方。
简化之后的代码:
伪代码效果:
那么现在的关键代码就是打印的东西是什么了,进入LooperPrinter:
java
@Override
public void println(String x) { // 这里的x包含message的target以及message的what
if (mStopWhenDebugging && Debug.isDebuggerConnected()) {
return;
}
// 区分是否是dispatchMessage之前还是之后
if (!mPrintingStarted) {
mStartTimestamp = System.currentTimeMillis();
mStartThreadTimestamp = SystemClock.currentThreadTimeMillis();
mPrintingStarted = true;
// 打印
startDump();
} else {
final long endTime = System.currentTimeMillis();
mPrintingStarted = false;
if (isBlock(endTime)) { // 得到dispatchMessage的耗时,确认是否达到卡顿标准
// 将结束时间抛出去。
notifyBlockEvent(endTime);
}
// 打印
stopDump();
}
}
由于println 的打印方法能拿到 target 也就是 Handler,因此,联想到ActivityThread 又有 mH 的变量,也是Handler,我们就可以通过判断是否是 mH 变量来确定是否是Activity的生命周期调用了。
下边看看打印的逻辑:
java
private void startDump() {
if (null != BlockCanaryInternals.getInstance().stackSampler) {
BlockCanaryInternals.getInstance().stackSampler.start();
}
// 下边的代码高版本已经无法使用
if (null != BlockCanaryInternals.getInstance().cpuSampler) {
BlockCanaryInternals.getInstance().cpuSampler.start();
}
}
public void start() {
if (mShouldSample.get()) {
return;
}
mShouldSample.set(true);
// 不断地向子线程发送消息,在超时时间之前完成了消息的处理的话,mRunnable就会被remove掉,不会触发打印卡顿堆栈的逻辑。
HandlerThreadFactory.getTimerThreadHandler().removeCallbacks(mRunnable);
HandlerThreadFactory.getTimerThreadHandler().postDelayed(mRunnable,
BlockCanaryInternals.getInstance().getSampleDelay());
}
内部的实现原理也很简单:不断地向子线程发送消息,在超时时间之前完成了消息的处理的话,mRunnable就会被remove掉,不会触发打印卡顿堆栈的逻辑。否则就会触发打印的逻辑:
java
private Runnable mRunnable = new Runnable() {
@Override
public void run() {
doSample();
if (mShouldSample.get()) {
HandlerThreadFactory.getTimerThreadHandler()
.postDelayed(mRunnable, mSampleInterval);
}
}
};
@Override
protected void doSample() {
StringBuilder stringBuilder = new StringBuilder();
// 利用字符串拼接,打印出堆栈的信息。
for (StackTraceElement stackTraceElement : mCurrentThread.getStackTrace()) {
stringBuilder
.append(stackTraceElement.toString())
.append(BlockInfo.SEPARATOR);
}
synchronized (sStackMap) {
if (sStackMap.size() == mMaxEntryCount && mMaxEntryCount > 0) {
sStackMap.remove(sStackMap.keySet().iterator().next());
}
sStackMap.put(System.currentTimeMillis(), stringBuilder.toString());
}
}
获取线程的堆栈是需要暂停线程的运行的。因此,即便是在子线程触发的打印,主线程也会被暂停的,尽管打印的拼接流程是在子线程,但是我们获取的是主线程的调用栈。
从代码上看,其实就是把线程的堆栈打印出来了。
明白BlockCanary的核心原理之后,要写出它打印逻辑也很容易了:
kotlin
Looper.getMainLooper().setMessageLogging(new Printer() {
boolean start = true;
long startTime = 0;
@Override
public void println(String x) {
if (start) {
start = false;
startTime = System.currentTimeMillis();
} else {
start = true;
Log.e("Monitor", "dispatchMessage 耗时: "
+ (System.currentTimeMillis() - startTime) + " ms");
}
}
});
在打印对象的时候,由于下边的代码存在大量临时对象的拼接,因此在列表中快速滑动的时候会创建大量的StringBuilder的临时对象,这可能会对手机性能造成影响:
kotlin
logging.println(">>>>> Dispatching to " + msg.target + " "
+ msg.callback + ": " + msg.what);
BlockCanary还有个问题,就是因为我们是在子线程通过定时任务来获取主线程的堆栈的,在一个消息中,可能有多个方法执行,由于我们的阈值默认是800ms 如果有以下的代码触发,最终BlockCanary打印的却是方法B导致的耗时,这是因为在800ms内A刚好执行完,此时B进入了执行队列,当B进入执行时,还未执行完成的时候,刚好达到了阈值,因此会把B的堆栈信息打印出来了。
那么出现这种情况应该怎么办呢?最好是能够获取到每一个消息中的每一个方法的执行耗时。这样便能精准定位问题了。这就需要使用到Trace:
不过上边的方式也有缺陷,只能线下使用,而且只有添加了trace的方法记录耗时,通过干预构建流程,来实现自动插入代码的方式是可取的。AspectJ 用起来很方便,但是会产生很多额外的代码,增加了方法数,如果考虑性能问题或者是包体积问题,ASM是一个更好地选择。Kotlin与Groovy都是利用ASM来生成字节码,夸张点说,你甚至可以利用ASM来实现一门可以运行在JVM上的语言。
利用ASM来实现对部分方法添加 Trace
那么如何利用ASM来实现对部分方法添加 Trace 呢?出于自动化考虑,最好的方式是利用打包过程中的transform Task 来做这个事情,因此这就涉及到自定义Plugin了,在自定义的Plugin里边注册Transform(如何完成自定义的Transform请参考文档:juejin.cn/post/747256...):
java
class TracePlugin : Plugin<Project> {
override fun apply(project: Project) {
project.extensions
.getByType(AppExtension::class.java)
.registerTransform(TraceTransform())
}
}
registerTransform 会将上边的Transform添加到一个集合之中:
java
fun registerTransform(transform: Transform, vararg dependencies: Any) {
_transforms.add(transform)
_transformDependencies.add(listOf(dependencies))
}
存进去的Transform会在TaskManager里边的下边代码中取出来:
完整的Transform代码:
java
import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import org.apache.commons.io.FileUtils
import org.apache.commons.io.IOUtils
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter
import java.io.*
import java.util.jar.JarEntry
import java.util.jar.JarFile
import java.util.jar.JarOutputStream
import java.util.zip.ZipEntry
class TraceTransform : Transform() {
override fun getName(): String = "traceTransform"
// 只处理 CLASS 类型的内容
override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> =
TransformManager.CONTENT_CLASS
// 设置 Transform 作用域,这里示例同时处理当前项目与其依赖
override fun getScopes(): MutableSet<in QualifiedContent.Scope> =
mutableSetOf(
QualifiedContent.Scope.PROJECT,
QualifiedContent.Scope.SUB_PROJECTS,
QualifiedContent.Scope.EXTERNAL_LIBRARIES
)
// 是否支持增量编译,示例中设置为 false
override fun isIncremental(): Boolean = false
@Throws(TransformException::class, InterruptedException::class, IOException::class)
override fun transform(transformInvocation: TransformInvocation) {
super.transform(transformInvocation)
val outputProvider = transformInvocation.outputProvider
if (!transformInvocation.isIncremental) {
// 非增量编译时,清空之前的输出
outputProvider.deleteAll()
}
// 遍历所有输入(可能包含多个 DirectoryInput 与 JarInput)
transformInvocation.inputs.forEach { transformInput ->
// 处理目录中的 .class 文件
transformInput.directoryInputs.forEach { directoryInput ->
traceDirectoryFiles(directoryInput, outputProvider)
}
// 处理 jar 包中的 .class 文件
transformInput.jarInputs.forEach { jarInput ->
traceJarFiles(jarInput, outputProvider)
}
}
}
/**
* 遍历并处理文件夹中的所有 .class 文件,使用 ASM 进行插桩
*/
private fun traceDirectoryFiles(directoryInput: DirectoryInput, outputProvider: TransformOutputProvider) {
// 先遍历目录中的所有 .class 文件,使用 ASM 插桩
directoryInput.file.walkTopDown().forEach { file ->
if (file.isFile && file.extension == "class") {
FileInputStream(file).use { fis ->
val classReader = ClassReader(fis)
val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
// 这里替换为你自定义的 ASM Visitor,例如 ClassTraceVisitor
val visitor = ClassTraceVisitor(api = org.objectweb.asm.Opcodes.ASM9, classWriter)
classReader.accept(visitor, ClassReader.EXPAND_FRAMES)
// 将修改后的字节码写回原文件
file.writeBytes(classWriter.toByteArray())
}
}
}
// 然后把目录复制到目标输出位置
val dest = outputProvider.getContentLocation(
directoryInput.name,
directoryInput.contentTypes,
directoryInput.scopes,
Format.DIRECTORY
)
FileUtils.copyDirectory(directoryInput.file, dest)
}
/**
* 遍历并处理 jar 包中的所有 .class 文件,使用 ASM 进行插桩
*/
private fun traceJarFiles(jarInput: JarInput, outputProvider: TransformOutputProvider) {
// 确定输出位置(依旧是 .jar 格式)
val dest = outputProvider.getContentLocation(
jarInput.name,
jarInput.contentTypes,
jarInput.scopes,
Format.JAR
)
// 判断是否是 .jar 文件
if (jarInput.file.extension == "jar") {
val jarFile = JarFile(jarInput.file)
// 创建临时文件用于输出修改后的字节码
val tmpFile = File(jarInput.file.parent, "classes_trace_temp_${jarInput.file.nameWithoutExtension}.jar")
val jarOutputStream = JarOutputStream(BufferedOutputStream(FileOutputStream(tmpFile)))
val enumeration = jarFile.entries()
while (enumeration.hasMoreElements()) {
val jarEntry = enumeration.nextElement()
val zipEntry = ZipEntry(jarEntry.name)
jarOutputStream.putNextEntry(zipEntry)
jarFile.getInputStream(jarEntry).use { inputStream ->
if (!jarEntry.isDirectory && jarEntry.name.endsWith(".class")) {
// 如果是 .class 文件,则进行 ASM 插桩
val classReader = ClassReader(IOUtils.toByteArray(inputStream))
val classWriter = ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
// 这里替换为你自定义的 ASM Visitor,例如 ClassTraceVisitor
val visitor = ClassTraceVisitor(api = org.objectweb.asm.Opcodes.ASM9, classWriter)
classReader.accept(visitor, ClassReader.EXPAND_FRAMES)
jarOutputStream.write(classWriter.toByteArray())
} else {
// 非 .class 文件(如资源文件),原样写入
jarOutputStream.write(IOUtils.toByteArray(inputStream))
}
}
jarOutputStream.closeEntry()
}
jarOutputStream.finish()
jarOutputStream.close()
jarFile.close()
// 将临时文件复制到最终输出
FileUtils.copyFile(tmpFile, dest)
tmpFile.delete()
} else {
// 如果不是 .jar 文件,直接复制到目标位置
FileUtils.copyFile(jarInput.file, dest)
}
}
}
Transform的中我们需要再transform的代码中完成对源码的修改,其中的directoryInput表示项目中的代码,而jarInput表示三方库中的代码:
处理方式其实就是递归遍历里边的文件夹,然后对里边的class文件进行处理,处理完成后,要把处理完成后的文件交给下一个transform,此时就需要transfortInvocation的 outputProvider变量,通过它调用 getContentLocation 得到一个输出的文件夹。因为我们处理的是文件夹,因此最后一个填写的是文件夹。最后需要把处理后的文件全部拷贝到输出文件夹就可以了。
处理三方的jar有些区别,如何遍历jar文件里边的class文件有所不同,这个jar文件里边可能有文件夹或者Manifest等清单文件,所以需要判断下,Jar包也不能像项目里边的class文件一样,直接把增强后的class写入文件里边,我们需要写一个新的jar文件来接受的,中间的增强字节码的操作也是一样的:
下边我们着重看文件的处理流程,也就是下边红框中的代码:
comput_maxs表示的是会自动计算局部变量表和操作数大小,ClassTraceVisitor是我们自己创建的类,其中的visit方法会在通过流来访问类的时候被调用,file.writeBytes 会把我们已经完成了插桩的代码全部写入文件中在里边。 看起来比较简单,其实就是创建了几个对象,然后把修改内容写入文件就行了。我们把类名存起来。因为我们需要在方法的前后调用代码一遍来计算方法的执行耗时:
这里使用的是访问者模式,的确,解析文件的时候,一个文件有很多组成部分,对于每个部分操作也不一样。这个时候就可以引入访问者模式了。如果我们想通过类名来过滤一些不需要插桩的类,可以在下边的方法中,直接return父类的的方法就行了
我们所有的针对方法的修改都是在 MethodTraceVisitor 完成的。下边来看看我们是如何做到插入代码的:
java
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Opcodes
import org.objectweb.asm.commons.AdviceAdapter
class MethodTraceVisitor(
api: Int,
methodVisitor: MethodVisitor,
access: Int,
name: String,
descriptor: String
) : AdviceAdapter(api, methodVisitor, access, name, descriptor) {
// 用于记录当前类名
lateinit var className: String
override fun onMethodEnter() {
super.onMethodEnter()
// 在方法进入时,调用 Trace.beginSection 开始追踪
mv.visitLdcInsn("$className/$name")
mv.visitMethodInsn(
Opcodes.INVOKESTATIC,
"android/os/Trace",
"beginSection",
"(Ljava/lang/String;)V",
false
)
}
override fun onMethodExit(opcode: Int) {
super.onMethodExit(opcode)
// 在方法退出时,调用 Trace.endSection 结束追踪
mv.visitMethodInsn(
Opcodes.INVOKESTATIC,
"android/os/Trace",
"endSection",
"()V",
false
)
}
}
首先进入方法的时候我们要进入beginSection也就是Trace.beginSection("AodView")
这里边有字符串参数,所以我们先要把字符串加入操作数栈。也就是mv.visitLdcInsn($className/${this.name})
, 然后使用mv.visitMethodInsn()
来往字节码中添加方法的调用。结束的时候因为不需要方法名,直接调用endSection就可以了。通过上边的操作,针对下边的源码,我们就可以得到下边的结果:
kotlin
protected void onCreate(@Nullable Bundle savedInstanceState) {
Trace.beginSection("MainActivity.onCreate");
// donSomething
super.onCreate(savedInstanceState);
Trace.endSection();
}
编译后的字节码,这也是我们可以填入ASM中的逻辑:
js
.method public onCreate()V
.registers 2
# 将字符串常量 "com/example/blockcanary/app/onCreate" 加载到寄存器 v0 中
const-string v0, "com/example/blockcanary/app/onCreate"
# 调用 Trace.beginSection,开始记录名为 "com/example/blockcanary/app/onCreate" 的追踪区段
invoke-static {v0}, Landroid/os/Trace;->beginSection(Ljava/lang/String;)V
# 调用父类 (Application) 的 onCreate() 方法
invoke-super {p0}, Landroid/app/Application;->onCreate()V
# 调用 Trace.endSection,结束追踪区段
invoke-static {}, Landroid/os/Trace;->endSection()V
# 方法结束,无返回值
return-void
.end method
直接写字节码指令确实比较麻烦,不过还好ASM插件,我们只需要把源码按照Java或者是kotlin写好,然后通过ASM插件获取字节码代码,我们再把这些填入到自己写的Visitor里边就可以了。
腾讯的TraceCanary
不过上述的方式并不完美,我们甚至在R文件里边的构造都插入了Trace代码:
java
.method private constructor <init>()V
.registers 2
invoke-direct {p0}, Ljava/lang/Object;-><init>()V
const-string v0, "com/example/blockcanary/R/<init>"
invoke-static {v0}, Landroid/os/Trace;->beginSection(Ljava/lang/String;)V
invoke-static {}, Landroid/os/Trace;->endSection()V
return-void
.end method
为此,微信团队提出了TraceCanary对上述方案有很好的改进,他们在整个过程中考虑了混淆,抽象类,接口,他们在处理的过程中,把混淆后的方法名转化成原始方法名,然后保存起来。,它也是利用了ASM,不过,它并没有直接利用Systrace来进行插入代码,它是在进入的时候插入的事 AppMethodBeat 里边的 i 方法自己记录了进入事件,退出的时候插入的 o 方法,这里边会记录退出的时间,这两个方法都只是会运行在主线程上。
js
/**
* Merges trace information into a single long value.
*
* @param methodId The ID of the method being traced.
* @param index The current index or sequence number in the trace buffer.
* @param isIn Indicates whether this trace event marks method entry (true) or exit (false).
*/
private static void mergeData(int methodId, int index, boolean isIn) {
// 如果当前方法 ID 为调度方法(示例:HEAT_ID_DISPATCH),则计算当前时间差
if (methodId == AppMethodBeat.HEAT_ID_DISPATCH) {
sCurrentDiffTime = SystemClock.uptimeMillis() - sDiffTime;
}
// 用于标记进入方法的最高位(第 63 位)
long trueId = 0L;
if (isIn) {
// 如果是进入方法,则将最高位设置为 1
trueId = 1L << 63;
}
// 将 methodId 和当前时间差合并为一个 long 值:
// - methodId 左移 43 位
// - sCurrentDiffTime 使用 0x7FFFFFFFFF(即 43 位掩码)进行混合
long mergedValue = ((long) methodId << 43) | (sCurrentDiffTime & 0x7FFFFFFFFFL);
// 将最高位标记(trueId)与合并后的值相或,存入全局缓存
sBuffer[sBufferIndex] = trueId | mergedValue;
// 检查是否需要进行其他处理(如缓冲区溢出检测)
checkPileup(index);
// 更新最后一次操作的索引
sLastIndex = index;
}
mergeData 有一个设计亮点,它将进入还是退出,哪一个方法,方法触发时间,这三个合并为一个long类型的值,存储到数组中。利用long类型是基本类型,避免了频繁创建对象的操作。
相对于BlockCanary,腾讯开源的TraceCanary利用插桩的方式,在每个方法的进入和退出左上插桩标记,这样当有耗时操作的时候,这里边有记录了所有耗时方法的数组,这个时候可以非常准确的知道某个方法的耗时是多少了。
额外补充
获取两帧之间的耗时可以使用下边的方法:
java
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
}
});