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) {
        
    }
});
相关推荐
摸鱼的春哥7 小时前
春哥的Agent通关秘籍07:5分钟实现文件归类助手【实战】
前端·javascript·后端
念念不忘 必有回响8 小时前
viepress:vue组件展示和源码功能
前端·javascript·vue.js
C澒8 小时前
多场景多角色前端架构方案:基于页面协议化与模块标准化的通用能力沉淀
前端·架构·系统架构·前端框架
崔庆才丨静觅8 小时前
稳定好用的 ADSL 拨号代理,就这家了!
前端
江湖有缘8 小时前
Docker部署music-tag-web音乐标签编辑器
前端·docker·编辑器
恋猫de小郭9 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅15 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606116 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了16 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅16 小时前
实用免费的 Short URL 短链接 API 对接说明
前端