Android 性能检测(Gradle Plugin + ASM)

2021/08/05 Android

Android 性能检测(Gradle Plugin + ASM)

介绍

在 Android 开发过程中慢慢发现有时候 App 运行的比较卡。虽然没有达到ANR的地步,但是使用体验已经很不好了。所以一般我们会针对卡顿进行相关的性能调优工作。一般卡顿分析我们有两种方式。第一种方式是通过 Android Studio 自带的 profiler 来分析 CPU 的使用,来定位卡顿的位置。但使用 profiler 消耗很大,会导致应用很卡,无法比较好的分析出来。 第二种方式是在代码方法首尾处记录方法开始,结束的时间,两者取其差计算出方法耗时,如果耗时大于一定的阈值,那么认为方法造成了卡顿,进行打印输出。本次,我们便以第二种方式为思路来记录我们性能检测过程。

思路分析

那么在性能调优之前,我们需要知道是哪里卡住了,运行的比较慢。根据常规思路,我们一般会首先从人为感觉上找到哪个界面比较慢,然后再在相应界面去分析对应的函数,从代码层面上首先分析哪里的代码运行的比较慢,然后在相对应的函数首尾去进行记录方法耗时。这个思路没有问题,但是我们不可能在所有的方法首尾处都手动的添加上记录当前时间的函数,又或者我们手动添加的时候会遗漏某些卡顿的方法。所以需要找个办法可以自动的扫描所有函数方法,并自动地在首尾处添加记录时间函数,算出对应差值进行输出。联想到在学习埋点的时候使用了 Gradle Plugin + ASM的方式自动在点击事件内进行插桩,发现可以使用同样的方式来实现记录方法耗时。

技术基础

使用 Gradle Plugin + ASM,首先我们需要知道 Plugin 是什么,并且是如何工作的,同时还需要对 Transform 做一个简单了解。

Plugin

Plugin 是用户可自定义的Gradle 插件。然后在 build.gradle文件中引入,例如 Android 为引入 Gradle 进行构建而设计的 com.android.application插件,在每个 app module 的 build.gradle中都会进行引入。用户自定义的插件也是通过 apply plugin方式进行引入。

Plugin 插件通过 groovy语言编写,通过实现 org.gradle.api.Plugin<T> 接口实现。打包之后,通过 apply plugin 进行使用。Plugin 可以注册引入 Transform, 而通过 Transform 则可以修改我们的源文件代码。

Transform

Transform 是用来修改 class 文件的一套API。可以理解为 Gradle 的一个 Task,在打包过程中会包含很多个 Transform, 每一个 Transform 的输出会作为下一个 Transform 的输入,如果所示。 Transform流程

并且执行时机是在 .class → .dex 的过程中。在如下打包过程中可以看到,经过 Transform任务之后会生成 .dex 文件,所以 Transform 处理的是 .class 的字节码文件。

Android打包流程

ASM

通过 Transform API, 我们可以处理 .class 字节码文件。但是手动去修改字节码文件难度系数较高,所以需要引入字节码处理库 ASM。 ASM通过访问者模式去编辑修改字节码文件,我们只需要定义好方法签名,就可以将我们想插入的字节码插入到文件中。

实现步骤

首先我们需要先写好要插入的记录方法耗时函数,然后再使用 Plugin 插件将函数插入到我们的项目方法内。所以我们需要分别开发,开发好插桩库和 Plugin 库,即我们可以采用不同的 module 来进行实现。

monitor 插桩库

写插桩库的时候,我们首先需要定义一个bean类来记录方法的信息。例如以下信息:

  • 方法名
  • 方法耗时(ms)
  • 是否主线程

这三个是我们需要输出的信息,具体其他信息可以进行自定义。插桩方法的入参为 String 类型的方法名。在函数开始处插入方法 onMethodStart 记录开始时间。

    public static void onMethodStart(String name) {
        map.put(name, new Entity(name, System.currentTimeMillis(), true, isInMainThread()));
    }

Entity为记录方法的 bean 类, map为记录方法进入时的方法信息哈希表,通过采用方法名为 key, 在方法退出时,通过方法名得到进入时的方法信息 entity,然后计算出时间差得出方法耗时,进行输出。

    public static void onMethodEnd(String name) {
        Entity entity = map.get(name);
        if (entity != null) {
            long nowTime = System.currentTimeMillis();
            long costTime = nowTime - entity.time;
            if (costTime > COST_TIME) {
                Log.d(TAG, " \n【***************************************************\n 方法名(Method Name) : " + name
                        + ", \n 耗时(Cost Time) : " + costTime + "ms"
                        +", \n 是否主线程(Is In MainThread) : " + isInMainThread()
                        + "\n ***************************************************】");
            }
            map.remove(name);
        }
    }

这样,插桩的库我们就完成了,接下来要做的就是把这些方法插入到项目中的方法中去。

Plugin 插件库

Plugin 插件库主要是需要将插桩库中的方法插入,具体需要完成以下三个内容。

  • 扫描项目中所有的 目录中的代码以及第三方 Jar输入的代码。
  • 根据配置过滤部分不需要插桩的包和类
  • 在合适地方插入插桩库函数

扫描代码

首先,我们看如何扫描项目中所有的代码。Transform中有重写函数 transform,包含了 transform 的输入,通过该输入参数,我们即可扫描到所有的 class 文件代码。

void _transform(Context context, Collection<TransformInput> inputs, TransformOutputProvider outputProvider, boolean  isIncremental) {
    //......
        if (methodTracerConfig.open) {
            Config traceConfig = initConfig()
            traceConfig.parseTraceConfigFile()

            /**Transform 的 inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历 */
            inputs.each { TransformInput input ->
                /**遍历目录*/
                input.directoryInputs.each { DirectoryInput directoryInput ->
                    traceSrcFiles(directoryInput, outputProvider, traceConfig)
                }

                /**遍历 jar*/
                input.jarInputs.each { JarInput jarInput ->
                    traceJarFiles(jarInput, outputProvider, traceConfig)
                }
            }
        }
    }

通过 traceSrcFilestraceJarFiles 两方法进入处理 inputs,并且将修改后的写入到输出 outputProvider中,具体步骤可看源码。

两种方法处理过程主要是先读取 class 的 File 文件,然后将 File 文件转化成 byte 数组。然后对 byte 数组进行修改,修改完成之后再将 byte 数组写出为 class 的 File 文件。

白名单过滤

有些包和库都是需要过滤的,不需要甚至不能进入插入。例如,Android 官方的库函数就没有必要去进行插桩检测,以及部分第三方库。同时,插桩库函数本身就不能进行插桩,否则会进行递归调用造成 stackOverFlow。

针对这种情况,我们需要进行额外的白名单配置,而这些配置有时候是动态可变的,所以我们单独将配置代码写入为一个 txt 文件,并放在 app module 路径下,在编译的时候只需要去解析该 txt 文件,并且根据文件中内容进行白名单过滤即可。这里列出部分白名单配置文件内容。

#配置需插桩的包,如果为空,则默认所有文件都进行插桩
-tracepackage

#在需插桩的包下设置无需插桩的包
-keeppackage com/peter/monitor
-keeppackage android/support/
-keeppackage androidx/
-keeppackage kotlin/
-keeppackage kotlinx/
-keeppackage com/google/

#在需插桩的包下设置无需插桩的类
-keepclass

#插桩代码所在类
-beatclass com/peter/monitor/MethodTrace

-costtime 500

插入库函数

扫描到了所有代码,并且根据配置文件过滤后,剩下的就是我们需要插桩的所有方法。

我们通过重写 ClassVisitor 类扫描 byte 数组,在扫描的过程中会触发重写函数 visitMethod,此时返回我们自定义的 MethodVisitor。而我们自定义的 MethodVisitor 又需要重写 onMethodEnter()onMethodExit(),这两个函数分别是扫描进入方法时和退出时触发。在这两个函数中,我们就可以将我们的方法插桩进去。

    override fun onMethodEnter() {
        super.onMethodEnter()
        val methodName = generatorMethodName()
        mv.visitLdcInsn(methodName)
        mv.visitMethodInsn(
            INVOKESTATIC,
            traceConfig.mBeatClass,
            "onMethodStart",
            "(Ljava/lang/String;)V",
            false
        )

        if (traceConfig.mIsNeedLogTraceInfo) {
            println("MethodTraceMan-trace-method: ${methodName ?: "未知"}")
        }
    }

    override fun onMethodExit(opcode: Int) {
        mv.visitLdcInsn(generatorMethodName())
        mv.visitLdcInsn(traceConfig.costTime)
        mv.visitMethodInsn(
                INVOKESTATIC,
                "java/lang/Long",
                "valueOf",
                "(J)Ljava/lang/Long;",
                false
        )

        mv.visitMethodInsn(
            INVOKESTATIC,
            traceConfig.mBeatClass,
            "onMethodEnd",
            "(Ljava/lang/String;Ljava/lang/Long;)V",
            false
        )
    }

在方法进入时,插入我们之前自定义好的方法 onMethodStart,在方法退出时插入 onMethodEnd方法。然后调用API时分别填入方法签名和描述符等信息即可。关于插桩函数的方法签名和描述符信息,可以通过 javap 命令对 .class 文件进行反编译查看。例如,对之前的插桩函数 javap 之后可以看到如下结果:

javap反编译结果

填参数时可以自己通过 javap 看或者安装 ASM Plugin 插件看都可以。但这种方法基本上只能看到方法签名,对如何操作 MethodVisitor 的话可能就不太清楚了。针对这种情况,建议使用 ASM Bytecode Outline 插件,并且查看ASM 写法。比如你要插入一个函数,那么可以先写一个测试函数,然后通过插件看 ASM 写法,这样就可以在 MethodVisitor 中去进行调用对应的 visitLdcInsn, visitMethodInsn 这些API了。如图: AMS插件图

这样,我们就差不多经历了我们卡顿检测库的开发,接下来的问题就是打包和上传Maven库了。

打包及上传

本地打包

在开发 Plugin 插件库的时候,我们经常需要打包 maven 库到本地,然后在本地运行试效果,都OK之后才会上传到 Maven仓库上。本地打包 Maven 的脚本代码在 plugin module 中,使用 uploadArchives 命令。

    repositories.mavenDeployer {
        repository(url: uri('../repo'))

        pom.groupId = 'com.peter'
        pom.artifactId = 'methodtracer'
        pom.version = '1.0.0'
    }

其中定义了库名和位置。同时需要引入库和使用插件等编译代码可具体查看源码。

上传Maven库

完全开发好后,我们需要上传到Maven库。这里选择上传 JitPack库,原因是免费,并且是Github 亲兄弟,只要在 Github 上发布后,就会自动在 JitPack 中进行 CI, 生成maven库。如果采用官方库 Jcenter的话,由于网络原因所以 pass 了。

如何接入 JitPack 则不再叙述。这里,我们直接列出接入代码, 首先在 Project 下的 build.gradle 中配置 maven 地址 以及 classpath 路径。

buildscript {
    ext.kotlin_version = "1.4.32"
    repositories {
        // ...
        maven { url 'https://jitpack.io' }
    }
    dependencies {
        // ...
        classpath "com.github.PeterXiaosa.MethodTracer:plugin:1.0.6"
    }
}

然后在 app module 下的 build.gradle 中配置依赖,接入插件以及配置信息。

dependencies {
    // ...

    implementation 'com.github.PeterXiaosa.MethodTracer:monitor:1.0.6'
    implementation 'com.github.PeterXiaosa.MethodTracer:plugin:1.0.6'
}

apply plugin: "com.peter.methodtracer"

MethodTracer {
    open = true
    logTraceInfo = true
    traceConfigFile = "${project.projectDir}/traceconfig.txt"
}

踩坑总结

在开发 plugin 以及上传 maven 的过程中,即便按照教程做都会碰到一些问题,这里主要总结一些个人碰到的问题。

  • 开发插桩函数的时候,目前使用的是 map 来进行存储的。最初是使用链表存储所有方法信息,然后再输出日志的时候统一去分析处理数据。在 Demo 上跑没问题,但落实到项目中发现会出现两个问题,第一个由于扫描的方法量巨大,导致使用链表存储容易造成OOM,其次是数据量过多解析时会很慢产生ANR。

  • plugin 插件采用了 groovy 和 kotlin 混合开发,由于 gradle 对 kotlin 目前兼容性还不是很好,所以需要在 module 中引入和配置对应的 kotlin 库和版本。以及除了需要建立 groovy/src/main 目录外还需要建立 kotlin/src/main目录和需要在 build.gradle 中配置 sourceSets, 具体可见项目代码。

  • Plugin 插件项目中需配置的 .properties 文件的文件名需要和插件名保持一致。

  • 上传库至 JitPack 时,只需要先上传代码至 github, 然后在根目录加上classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1',然后在 Github 上进行发布,发布之后去 jitPack 官网查 CI状态。如果有问题,根据 CI 日志进行相对应修改即可。

Search

    Table of Contents