BlockCanary解析与如何监控方法耗时

BlockCanary简介

BlockCanary 是一个用于 Android 应用的性能监控工具,主要用于检测应用中的 UI 卡顿 问题(也就是 ANR,Application Not Responding)。BlockCanary 可以帮助开发者追踪和记录 UI 线程阻塞的情况,从而分析应用在某些操作下可能存在的性能瓶颈。

主要功能

  1. UI 卡顿检测

    • 通过在主线程(UI 线程)上监控并记录方法的执行时间,BlockCanary 可以检测到线程阻塞和长时间运行的操作,从而识别出 UI 卡顿的问题。
  2. 线程分析

    • 它能够检测线程中的阻塞操作,例如主线程由于执行某些耗时操作(如网络请求、数据库查询等)导致的卡顿,并帮助开发者定位问题发生的具体方法。
  3. 数据可视化

    • BlockCanary 会提供一份详细的报告,包含卡顿的堆栈信息、时间等,并且通过图表展示,帮助开发者分析和优化应用。
  4. 轻量级

    • 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) {
        
    }
});
相关推荐
日记成书14 分钟前
【HTML 基础教程】HTML 表格
前端·html
木木黄木木18 分钟前
HTML5贪吃蛇游戏开发经验分享
前端·html·html5
无名之逆24 分钟前
hyperlane:Rust HTTP 服务器开发的不二之选
服务器·开发语言·前端·后端·安全·http·rust
李鸿耀28 分钟前
前端包管理工具演进史:从 npm 到 pnpm 的技术革新
前端·面试
麓殇⊙30 分钟前
前端基础知识汇总
前端
MariaH33 分钟前
邂逅jQuery库
前端
Jenlybein38 分钟前
学完 Vue3 记不牢?快来看这篇精炼Vue3笔记复习一下 [ Route 篇 ]
前端·vue.js
页面魔术41 分钟前
[译]专访尤雨溪: 2025年有什么计划?
前端·vue.js·vite
zhangxiao43 分钟前
Vite项目打包生成dist.zip方法
前端
Version1 小时前
深入理解JavaScript 中的 this
前端