前言
一般在大型项目开发后期,会对其进行性能优化,包括内存优化、卡顿优化、启动优化等。针对卡顿分析或者启动优化,一般有两种比较基本的方式。
- 第一种是通过 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.class 中 applyConfig 方法中包含以下的代码
/** * 插件中通过 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 文件,记录插桩信息,方便溯源。
- 项目运行之后,会在 logcat 中打印出方法相关的信息。
- 普通模式
- 堆栈模式
因为 methodTrace 会对大量方法进行插桩并且进行耗时分析,对 app 性能有一定的影响,所以建议只在开发和测试环境使用,不宜带到线上。
源码
该项目已经在 github 上开源,欢迎大家多多围观。项目地址:耗时检测插件