慢方法检测插件

262 阅读6分钟

前言

一般在大型项目开发后期,会对其进行性能优化,包括内存优化、卡顿优化、启动优化等。针对卡顿分析或者启动优化,一般有两种比较基本的方式。

  • 第一种是通过 Android Studio 自带的 Profiler 工具追踪每个方法的耗时。但是用过 Profile 的小伙伴都知道,使用起来性能损耗非常大,应用会非常卡,影响性能分析。
  • 第二种是在方法的入口和出口处添加时间戳,两个时间戳相减就是该方法的耗时,如果方法耗时超过设定的阈值,即认为该方法可能是造成卡顿的原因,将该方法相关信息输出到 logcat 中。

接下来,我们按照第二种方式来实现一个方法耗时统计的插件。

思路

在优化时,我们并不知道是哪些方法执行时间过长造成的卡顿,所以需要对项目中所有方法的首、尾添加时间戳。但是一个普通的项目动辄上万个方法,不可能对每一个方法手动添加,而且可能也需要对项目引入的外部 sdk 添加时间戳。所以我们可以利用 AOP 编程的思想,基于 Android Gradle Plugin + Transform + AMS 通过字节码插桩的方式来实现对所有方法的代码注入。

实现

关于 Android Gradle Plugin、ASM 框架等基础知识,网络上有很多优秀的文章,笔者这里就不过多的介绍了。

要实现这个功能,整体可以分为两个部分,plguin module 通过 ASM 在方法的首、尾插桩,trace module 用来实现方法耗时的统计逻辑,两者相辅相成,而后者的逻辑决定了前者的插桩代码。

trace module

对于 trace module 的实现,我们首先来看一个最容易想到的版本。

				/**
         * 方法入口
         */
        @JvmStatic
        fun onMethodEnter() {
            startTime = SystemClock.elapsedRealtime()
        }


        /**
         * 方法出口
         */
        @JvmStatic
        fun onMethodExit(className: String,methodName:String) {
            methodConst(classname,methodName, (SystemClock.elapsedRealtime() - startTime))
        }

在方法的入口记录一个时间戳,在方法出口计算时间差,同时记录方法相关的信息。

但是上述的逻辑在某些场景下会存在一些问题。

方法不闭合

  • 问题描述
public class Const {
    public static void main(String[] args) {
        a();
    }
  
    public static void a() {
        System.out.println("a enter");
        try {
            b();
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println("a exit");
    }

    public static void b() {
        System.out.println("b enter");
        // Mock Throw RunTimeException
        int i = 2 / 0;
        System.out.println("b exit");
    }
}

//运行结果
fun a enter
fun b enter
fun a exit

如上,因为 b 方法抛出异常没有闭合,会将 a 方法的 exit timestamp 减 b 方法的 enter timestamp 当作 a 方法的耗时。

  • 解决方案

    增加方法入参,利用方法参数生成唯一 key。同时模拟虚拟机栈数据结构,在出栈时计算时间戳。

    override fun onMethodEnter(
            classNameFullName: String,
            methodName: String,
            args: String,
            returnType: String
        ) {
            val key = classNameFullName + methodName + args + returnType
            // 入栈
            ...
        }
    
       override fun onMethodExit(
            classNameFullName: String,
            methodName: String,
            args: String,
            returnType: String
        ) {
         val key = classNameFullName + methodName + args + returnType
         // 出栈,计算时间戳
         ...
       }
    

多线程

  • 问题描述

    我们会在方法的耗时信息中记录调用线程,当多个线程同时访问一个方法时,如果线程进入和退出方法的顺序不一致会导致计算错误,这个大家应该都能明白。

  • 解决方案

    • 最容易想到的解决方法是加锁,但是会影响性能,不推荐。
    • 利用当前线程的哈希值和方法入参一起生成唯一 key,不够优雅。
    • 使用 threadLocal,为每个线程创建一个数据栈的副本,这是比较好的一种方式。

OK,上面的问题解决完之后,我们来看一下完整的代码。

companion object {
    private const val LINE =
        "==================================================================================================="

    private const val TAG = "methodTrace"

    /**
     * 最大调用嵌套层级。方法调用栈深度不能超过此数值
     */
    private const val MAX_LEVEL = 40

    // 用来解决多线程同时访问同一个方法
    @JvmStatic
    val threadLocal: ThreadLocal<Deque<Addition>> = object : ThreadLocal<Deque<Addition>>() {
        override fun initialValue(): Deque<Addition> {
            return ArrayDeque()
        }
    }
}

/**
 * @param enterTime 方法入口时间戳
 * @param name 唯一 key
 */
class Addition(val enterTime: Long, val name: String)

override fun onMethodEnter(
    any: Any,
    classNameFullName: String,
    methodName: String,
    args: String,
    returnType: String
) {
    if (!checkMathStart()) {
        return
    }
    val deque = threadLocal.get()!!
    if (deque.size > MAX_LEVEL) {
        return
    }
    val enterTime = nowTime()
    val key = classNameFullName + methodName + args + returnType
    deque.addLast(Addition(enterTime, key))
}

private fun nowTime(): Long {
    return SystemClock.elapsedRealtime()
}

override fun onMethodExit(
    any: Any,
    classNameFullName: String,
    methodName: String,
    args: String,
    returnType: String
) {
    if (!checkMatchExit()) {
        return
    }
    val deque = threadLocal.get() ?: return
    val exitTime = nowTime()
    val key = classNameFullName + methodName + args + returnType
    // 从栈顶遍历,找到匹配的 key
    var addition: Addition? = null
    while (deque.size > 0) {
        addition = deque.pollLast()
        if (addition.name == key) {
            break
        }
    }
    if (addition == null) {
        return
    }
    val enterTime = addition.enterTime
    if (enterTime <= 0) {
        return
    }
    val const = exitTime - enterTime
    ...打印日志

plugin module

有了 trace module 的处理逻辑,插桩要生成的代码就比较简单了,有一定 ASM 基础的同学应该很容易就能想到如何实现,笔者这里就不多写了,我们直接看代码吧,源码链接在文章的底部。

 override fun onMethodEnter() {
        if (static) {
            push("null")
        } else {
            mv.visitVarInsn(ALOAD, 0)
        }
        push(className)
        push(methodName)
        push(args())
        push(returns())
        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            METHOD_TRACE_CLASS_NAME.replace(DOT, SEPARATOR),
            METHOD_TRACE_ENTER_NAME,
            METHOD_TRACE_ENTER_DESCRIPTOR,
            false
        )
    }

    override fun onMethodExit(opcode: Int) {
        if (static) {
            push("null")
        } else {
            mv.visitIntInsn(ALOAD, 0)
        }
        push(className)
        push(methodName)
        push(args())
        push(returns())
        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            METHOD_TRACE_CLASS_NAME.replace(DOT, SEPARATOR),
            METHOD_TRACE_EXIT_NAME,
            METHOD_TRACE_EXIT_DESCRIPTOR,
            false
        )
    }
		
    private fun args(): String {
        val arg = argumentTypes ?: return "()"
        val builder = StringBuilder()
        builder.append("(")
        for ((i, value) in arg.withIndex()) {
            builder.append(value.className)
            if (i != arg.size - 1) {
                builder.append(COMMA)
            }
        }
        builder.append(")")
        return builder.toString()
    }

    private fun returns(): String {
        val `return` = returnType ?: return "[]"
        return "[${`return`.className}]"
    }

小 TIPS

在这里还想在这里和大家分享一个开发 Android Gradle Pugin 的小 tips,就是如何将 gradle 文件中的插件配置项数据注入到 app 项目中。

以该插件为例子。使用时,在项目的 build.gradle 中引入插件,同时会设置一些插件配置项,比如耗时的阈值,是否只检测主线程等。

apply plugin: 'method-trace-plugin'
trace_config {
    // 是否只检测主线程
    checkOnlyMainThread = true
    //保证 info < warn < error,
    // 如果阈值未设置,不会输出对应的 log 等级的日志
    errorThreshold = 50
    warnThreshold = 30
    infoThreshold = 10
}

这些数据只能在插件中使用,如何提供给上层 app 使用呢?还是通过 ASM 代码注入的方式来实现。

  • 首先,我们先在 app 中定义 MethodTraceConfig.kt 文件,提供一个静态的空方法 applyConfig 及和插件的配置项对应的属性。
var onlyCheckMainThread1: Boolean = true

var errorConstThreshold1: Int = Integer.MAX_VALUE

var warnConstThreshold1 = Integer.MAX_VALUE

var infoConstThreshold1 = Integer.MAX_VALUE

/**
 * 插件中通过 ASM 调用,应用插件的配置
 */
fun applyConfig() {

}
  • 接着,就可以在插件项目中进行代码注入。在 applyConfig 的方法出口, 将插件配置项中的数据通过字节码指令设置给 MethodTraceConfigKt.class(kotlin 文件对应的类名称为文件名称+kt ) 中对应的属性。

    override fun onMethodExit(opcode: Int) {
            super.onMethodExit(opcode)
            val onlyCheckMain: Boolean = GlobalConfig.pluginConfig.checkOnlyMainThread
            val info: Int = GlobalConfig.pluginConfig.infoThreshold
            val warn: Int = GlobalConfig.pluginConfig.warnThreshold
            val error: Int = GlobalConfig.pluginConfig.errorThreshold
      			// MethodTraceConfigKt.class
            val owner = APPLY_CONFIG_CLASS_NAME.replace(DOT, SEPARATOR)
            // push onlyCheckMain
            push(onlyCheckMain)
            mv.visitVarInsn(Opcodes.ISTORE, 0)
            // push info threshold
            push(info)
            mv.visitVarInsn(Opcodes.ISTORE, 1)
            // push warn threshold
            push(warn)
            mv.visitVarInsn(Opcodes.ISTORE, 2)
            // push error threshold
            push(error)
            mv.visitVarInsn(Opcodes.ISTORE, 3)
            // put field onlyCheckMainThread1
            mv.visitVarInsn(Opcodes.ILOAD, 0)
            mv.visitFieldInsn(Opcodes.PUTSTATIC, owner, APPLY_CONFIG_FIELD_ONLY_CHECK_MAIN, "Z")
            // put field infoConstThreshold1
            mv.visitVarInsn(Opcodes.ILOAD, 1)
            mv.visitFieldInsn(Opcodes.PUTSTATIC, owner, APPLY_CONFIG_FIELD_INFO_THRESHOLD, "I")
            // put field warnConstThreshold1
            mv.visitVarInsn(Opcodes.ILOAD, 2)
            mv.visitFieldInsn(Opcodes.PUTSTATIC, owner, APPLY_CONFIG_FIELD_WARN_THRESHOLD, "I")
            // put field errorConstThreshold1
            mv.visitVarInsn(Opcodes.ILOAD, 3)
            mv.visitFieldInsn(Opcodes.PUTSTATIC, owner, APPLY_CONFIG_FIELD_ERROR_THRESHOLD, "I")
        }
    

    最终,编译完成之后,MethodTraceConfigKt.classapplyConfig 方法中包含以下的代码

    /**
     * 插件中通过 ASM 调用,应用插件的配置
     */
    fun applyConfig() {
    	val i0 = true
    	val i1 = 50
    	val i2 = 30	
    	val i3 = 10
    	onlyCheckMainThread1 = i0
    	errorConstThreshold1 = i1
     	warnConstThreshold1 = i2 
    	infoConstThreshold1 = i3  
    }
    
  • 最后,在上层 app 中需要使用的地方进行调用即可。

    class NeedUse{
    	static {
        MethodTraceConfigKt.applyConfig();
      }
    }
    

插件配置说明

apply plugin: 'method-trace-plugin'
trace_config {
    printlnLog = true
    injectScope = 3
    pkgList = ['com.zhangyue.ireader.methodtrace']
    checkOnlyMainThread = false
    errorThreshold = 50
    warnThreshold = 30
    infoThreshold = 20
    printCallStack = true
    whiteList = ['androidx.',
                 "android.support.",
                 "kotlin.",
                 "kotlinx.",
                 "com.google."]
    customHandle = "com.zhangyue.ireader.methodtrace.MyMethodTraceHandle"
}

printlnLog

是否打印插件内部日志信息

injectScope

取值范围1到3

  • 1 -> 只处理 DirectoryInput
  • 2 -> 只处理 JarInput
  • 3 -> 全都处理

pkgList

想要执行耗时监测的包名,会对包名下的所有 class 进行插桩,直接匹配到某个 class 全限定名称也可以。 如果不设置或者设置为 [] ,则执行 injectScope 对应的范围内全插桩

checkOnlyMainThread

是否只对主线程方法进行耗时监测,如果是,将只打印主线程日志信息

infoThreshold,warnThreshold,errorThreshold

设置了三级 log 的阈值,要保证 0 < info < warn < error,至少需要设置一个级别的阈值

printCallStack

输出日志的模式,是否是调用栈模式。有两种输出模式,详见下面的效果图

whiteList

白名单,匹配的类不执行插桩。

customHandle

可选,自定义监测处理接口的全限定名称,需要实现[IMethodTraceHandle]接口,将方法入口、出口逻辑托管给用户,将不再打印方法耗时信息

/**
 * @author season
 * 对方法入口、出口的逻辑自定义处理
 *
 * 需要添加 [IgnoreMethodTrace] 注解,防止被插桩
 */
@Keep
@IgnoreMethodTrace
class MyMethodTraceHandle : IMethodTraceHandle {
    override fun onMethodEnter(
        any: Any,
        className: String,
        methodName: String,
        args: String,
        returnType: String
    ) {
        Log.e("method_trace_handle", "-------> onMethodEnter")
    }

    override fun onMethodExit(
        any: Any,
        className: String,
        methodName: String,
        args: String,
        returnType: String
    ) {
        Log.e("method_trace_handle", "-------> onMethodExit")
    }
}

注解

  • HookMethodTrace:执行插桩的注解,作用于方法或者类型上。即使某个方法所在的类或者某个类和插件设置的 pkgList 不匹配,如果有此注解,也会执行插桩逻辑

    /**
     * 执行插桩的注解
     * 小于 [IgnoreMethodTrace] 的优先级
     */
    @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
    @Retention(AnnotationRetention.BINARY)
    annotation class HookMethodTrace
    
  • IgnoreMethodTrace:忽略插桩的注解,作用于类型上,优先级大于 HookMethodTrace

    /**
     * 忽略插桩的注解
     * 大于 [HookMethodTrace] 的优先级
     */
    @Target(AnnotationTarget.CLASS)
    @Retention(AnnotationRetention.BINARY)
    annotation class IgnoreMethodTrace
    

总结

最终的效果如下

  • 编译完成之后,在 module/build 文件夹下生成 methodTrace.json 文件,记录插桩信息,方便溯源。

trace_method_list.png

  • 项目运行之后,会在 logcat 中打印出方法相关的信息。
    • 普通模式 error_log.png
    • 堆栈模式 WX20230219-005557@2x.png

因为 methodTrace 会对大量方法进行插桩并且进行耗时分析,对 app 性能有一定的影响,所以建议只在开发和测试环境使用,不宜带到线上。

源码

该项目已经在 github 上开源,欢迎大家多多围观。项目地址:耗时检测插件