App流畅度优化:利用字节码插桩实现一个快速排查高耗时方法的工具

14,606 阅读17分钟

    我们产线的主流程页面中有几个比较复杂的页面在版本迭代中流畅度频繁出现反复,经常由于开发的不注意导致变卡,主要是对流畅度缺少必要的监控和可持续的优化手段,这个系列是对上半年实践App流畅度监控、优化过程中的一点总结,希望可以给需要的同学一点小参考。

当然App内存上的优化,尽量减少内存抖动也能显著提升流畅度,内存优化方面可以参考之前的文章:实践App内存优化:如何有序地做内存分析与优化

整个系列将主要包括以下几部分:

  1. 卡顿与View的绘制过程解析

    这部分内容比较多,主要是从源码层面解析一下整个过程,也是我们后面做流畅度监控与优化的基础

  2. Debug阶段如何对实时帧率进行监控和显示

    根据上面的原理,设计一个显示实时帧率的工具,可以有效的在开发阶段发现问题

  3. 如何实现流畅度自动化测试

    实现一个流畅度UI自动化测试,在上线前跑一下UI自动化并生成流畅度报表邮件给相关人员

  4. 线上的用户流畅度的监控方案

    实时反映真实用户的流畅度体验,线上庞大的数据可以敏感的反应出版本迭代间流畅度的变化

  5. 实现一个方便排查高耗时方法的工具

    利用自定义gradle plugin+ASM插桩实现快速而准确的找出耗时的方法,进行针对性的优化

  6. 分享提升app流畅度的一些经验

    分享一些成本小收益高的提升流畅度的方案


   

工欲善其事必先利其器,今天首先分享一下在优化页面流畅度过程中自己实现的一个方便快速排查高耗时方法的工具:MethodTraceMan,毕竟保持主流程流畅,避免在主流程执行高耗时方法永远是优化卡顿最直接的手段,只要我们能快速方便的排查到高耗时的方法,就可以做针对性优化。

实现一个方便排查高耗时方法的工具

    平常我们用来排查Android卡顿的比较熟悉的工具有TraceViewsystrace等,一般分为两种模式:instrumentsample。但是这些工具不管是哪种模式都有各自不足的地方,比如instruement模式,可以获得所有函数的调用过程,信息比较丰富,但是会带来极大的性能开销,导致统计的耗时与实际不符;而sample模式是通过采样的方式进行分析的,所以信息丰富度上就大打折扣,像systrace就属于sample型的,它只能监控一些系统调用的耗时情况。

    除了上面说的工具,著名的JackWharton也实现了一个可以打印出出方法耗时的工具hugo,它是基于注解触发的,在一个方法上加上特定注解即可打印出该方法的耗时等信息,但是如果我们想排查高耗时方法,显然在所有方法上一个一个加注解太费劲了。  

那么我们在做卡顿优化的过程中需要一个什么样的工具呢?

  • 可以方便地统计所有方法的耗时
  • 对性能影响微小,能准确统计出方法的精确耗时
  • 支持耗时筛选、线程筛选、方法名搜索等功能,能快速发现主线程高耗时方法

    要实现这样一个工具,首先想到的就是通过插桩技术来实现,在编译过程中对所有的方法进行插桩,在方法进入和方法结束的地方进行打点,就可以在对性能影响很小的方式下统计到每个方法的耗时。统计到每个方法的耗时数据后,我们再实现一个UI界面来展示这些数据,并实现耗时筛选、线程筛选、方法名搜索等功能,这样我们就可以快速的找到主线程高耗时的方法进行针对性的优化了。

1. 效果预览

我们先来看下最终实现的效果预览:

输出所有的方法耗时,高耗时方法以红色预警,同时支持对耗时筛选,线程筛选,方法名搜索等,比如想筛出主线程耗时大于50ms的方法,就可以很方便的找出。  

详细的集成以及使用文档详见:MethodTraceMan 效果预览

2. 技术选型

    插桩技术其实充斥在我们平常开发中的方方面面,可以帮助我们实现很多繁琐复杂的功能,还可以帮助我们提高功能的稳定性,比如ButterKnife、Protocol Buffers等都会在编译时期生成代码,当然插桩技术也分很多种,比如ButterKnife是利用APT在编译的开始阶段对java文件进行操作,而像AscpectJ、ASM等则是在java文件编译为字节码文件后,对字节码进行操作,当然还有一些可以在字节码文件被编译为dex文件后对dex进行操作的框架。 由于我们的需求是在编译期对所有的方法的进入和结束的地方插桩进行耗时统计,所以最终的技术选型锁定在对字节码文件的操作。那么我们来对比一下AspectJ和ASM两种字节码插桩的框架:

一. AspectJ

    AspectJ是老牌的字节码处理框架了,其优点就是使用简单上手容易,不需要了解字节码相关知识也可以在项目中集成使用,只要指定简单的规则就可以完成对代码的插桩,比如我们现在要实现对所有方法的进入和退出时进行插桩,十分简单,如下:

@Before("execution(* **(..))")
public void beforeMethod(JoinPoint joinPoint) {
    //TODO 耗时统计
}

@After("execution(* **(..))")
public void afterMethod() {
    //TODO 耗时统计
}

当然相对于优点来说,AspectJ的缺点是,由于其基于规则,所以其切入点相对固定,对于字节码文件的操作自由度以及开发的掌控度就大打折扣。还有就是我们要实现的是对所有方法进行插桩,所以代码注入后的性能也是我们需要关注的一个重要的点,我们希望只插入我们想插入的代码,而AspectJ会额外生成一些包装代码,对性能以及包大小有一定影响。

二. ASM

    ASM是一个十分强大的字节码处理框架,基本上可以实现任何对字节码的操作,也就是自由度和开发的掌控度很高,但是其相对来说比AspectJ上手难度要高,需要对Java字节码有一定了解,不过ASM为我们提供了访问者模式来访问字节码文件,这种模式下可以比较简单的做一些字节码操作,实现一些功能。同时ASM可以精确的只注入我们想要注入的代码,不会额外生成一些包装代码,所以性能上影响比较微小。

上面说了很多,对于java字节码,这里做一些简单的介绍:

java字节码

我们都知道在java文件的通过javac编译后会生成十六进制的class文件,比如我们先编写一个简单的Test.java文件:

public class Test {
    private int m = 1;

    public int add() {
        int j = 2;
        int k = m + j;
        return k;
    }
}

然后我们通过 javac Test.java -g来编译为Test.class,用文本编辑器打开如下: test.class

可以看到是一堆十六进制数,但是其实这一堆十六进制数是按严格的结构拼接在一起的,按顺序分别是:魔数(cafe babe)、java版本号、常量池、访问权限标志、当前类索引、父类索引、接口索引、字段表、方法表、附加属性等十个部分,这些部分以十六进制的形式表达出来并紧凑的拼接在一起,就是上面看到的class字节码文件。

当然上面的十六进制文件显然不具备可阅读性,所以我们可以通过 javap -verbose Test来反编译,有兴趣的可以自己试一试,就可以看到上面说的十个部分,由于我们做字节码插桩一般和方法表关联比较大,所以我们下面着重看一下方法表,下面是反编译后的add()方法:

add方法

可以看到包括三部分:

  1. Code: 这里部分就是方法里的JVM指令操作码,也是最重要的一部分,因为我们方法里的逻辑实际上就是一条一条的指令操作码来完成的。这里可以看到我们的add方法是通过9条指令操作码完成的。当然插桩重点操作的也是这一块,只要能修改指令,也就能操控任何代码了。
  2. LineNumberTable: 这个是表示行号表。是我们的java源码与指令行的行号对应。比如我们上面的add方法java源码里总共有三行,也就是上图中的line10、line11、line12,这三行对应的JVM指令行数。有了这样的对应关系后,就可以实现比如Debug调试的功能,指令执行的时候,我们就可以定位到该指令对应的源码所在的位置。
  3. LocalVariableTable:本地变量表,主要包括This和方法里的局部变量。从上图可以看到add方法里有this、j、k三个局部变量。

由于JVM指令集是基于栈的,上面我们已经了解到了add方法的逻辑编译为class文件后变成了9个指令操作码,下面我们简单看看这些指令操作码是如何配合操作数栈+本地变量表+常量池来执行add方法的逻辑的:

指令操作

按顺序执行9条指令操作码:

  • 0:把数字2入栈
  • 1:将2赋值给本地变量表中的j
  • 2、3:获取常量池中的m入栈
  • 6:将本地变量表中的j入栈
  • 7、8:将m和j相加,然后赋值给本地变量表中的k
  • 9、10:将本地变量表中的k入栈,并return

好的,关于java字节码的暂时就简单介绍这些,主要是让我们基本了解字节码文件的结构,以及编译后代码时如何运行的。而ASM可以通过操作指令码来生成字节码或者插桩,当你可以利用ASM来接触到字节码,并且可以利用ASM的api来操控字节码时,就有很大的自由度来进行各种字节码的生成、修改、操作等等,也就能产生很强大的功能。

三、Gradle plugin + Transform

    上面对于插桩框架的选择,我们通过对比最终选择了ASM,但是ASM只负责操作字节码,我们还需要通过自定义gradle plugin的形式来干预编译过程,在编译过程中获取到所有的class文件和jar包,然后遍历他们,利用ASM来修改字节码,达到插桩的目的。

    那么干预编译的过程,我们的第一个念头可能就是,对class转为dex的任务进行hook,在class转为dex之前拿到所有的class文件,然后利用ASM对这些字节码文件进行插桩,然后再把处理过的字节码文件作为transformClassesWithDex任务的输入即可。这种方案的好处是易于控制,我们明确的知道操作的字节码文件是最终的字节码,因为我们是在transformClassesWithDex任务的前一刻拿到字节码文件的。缺点就是,如果项目开启了混淆,那么在transformClassesWithDex任务的前一刻拿到的字节码文件显然是经过了混淆了的,所以利用ASM操作字节码的时候还需要mapping文件进行配合才能找到正确的插桩点,这一点比较麻烦。

    幸亏gradle还为我们提供了另一种干预编译转换过程的方法:Transform.其实我们稍微翻一下gradle编译过程的源码,就会发现一些我们熟知的功能都是通过Transform来实现的。还有一点,就是关于混淆的问题,上面我们说了如果通过hook transformClassesWithDex任务的方式来实现插桩,开启混淆的情况下会出现问题,那么利用Transform的方式会不会有混淆的问题呢?下面我们从gradle源码上面找一下答案:

我们从com.android.build.gradle.internal.TaskManager类里的createCompileTask()方法看起,显然这是一个创建编译任务的方法:

protected void createCompileTask(@NonNull VariantScope variantScope) {
        //创建一个将java文件编译为class文件的任务
        JavaCompile javacTask = createJavacTask(variantScope);
        addJavacClassesStream(variantScope);
        setJavaCompilerTask(javacTask, variantScope);
        
        //创建一些在编译为class文件后执行的额外任务,比如一些Transform等
        createPostCompilationTasks(variantScope);
    }

接下来我们看看createPostCompilationTasks()方法,这个方法比较长,下面只保留重要的几个代码:

public void createPostCompilationTasks(@NonNull final VariantScope variantScope) {
       、、、、、、
    TransformManager transformManager = variantScope.getTransformManager();
      、、、、、
     // ----- External Transforms 这个就是我们自定义注册进来的Transform-----
     // apply all the external transforms.
        List<Transform> customTransforms = extension.getTransforms();
        List<List<Object>> customTransformsDependencies = extension.getTransformsDependencies();
        、、、、、、
        、、、、、、
        // ----- Minify next  这个就是混淆代码的Transform-----
        CodeShrinker shrinker = maybeCreateJavaCodeShrinkerTransform(variantScope);
        、、、、、、
        、、、、、、
    }

    其实这个方法里有很多其他Transform,这里都省略了,我们重点只看我们自定义注册的Transform和混淆代码的Transform,从上面的代码上我们自定义的Transform是在混淆Transform之前添加进TransformManager,所以执行的时候我们自定义的Transform也会在混淆之前执行的,也就是说我们利用自定义Transform的方式对代码进行插桩是不受混淆影响的

所以我们最终确定的方案就是 Gradle plugin + Transform +ASM 的技术方案。下面我们正式说说利用该技术方案进行具体实现。

3. 具体实现

这里具体实现只挑重点实现步骤讲,详细的可以看具体源码,文章结尾提供了项目的github地址。

一、自定义gradle plugin

关于如何创建一个自定义gradle plugin的项目,这边就不细说了,可以网上搜索,或者直接看MethodTraceMan项目的源码也行,自定义gradle plgin继承自Plugin类,入口是apply方法,我们的apply方法里很简单,就是创建一个自定义扩展配置,然后就是注册一下我们自定义的Transform:

@Override
    void apply(Project project) {

        println '*****************MethodTraceMan Plugin apply*********************'
        project.extensions.create("traceMan", TraceManConfig)

        def android = project.extensions.getByType(AppExtension)
        android.registerTransform(new TraceManTransform(project))
    }

二、自定义Transform实现

这里我们创建了一个名叫traceMan的扩展,这样我们可以再使用这个plugin的时候进行一些配置,比如配置插桩的范围,配置是否开启插桩等,这样我们就可以根据自己的需要来配置。

接下来我们看一下TraceManTransform的实现:

public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        println '[MethodTraceMan]: transform()'
        def traceManConfig = project.traceMan
        String output = traceManConfig.output
        if (output == null || output.isEmpty()) {
            traceManConfig.output = project.getBuildDir().getAbsolutePath() + File.separator + "traceman_output"
        }

        if (traceManConfig.open) {
            //读取配置
            Config traceConfig = initConfig()
            traceConfig.parseTraceConfigFile()


            Collection<TransformInput> inputs = transformInvocation.inputs
            TransformOutputProvider outputProvider = transformInvocation.outputProvider
            if (outputProvider != null) {
                outputProvider.deleteAll()
            }

            //遍历,分为class文件变量和jar包的遍历
            inputs.each { TransformInput input ->
                input.directoryInputs.each { DirectoryInput directoryInput ->
                    traceSrcFiles(directoryInput, outputProvider, traceConfig)
                }

                input.jarInputs.each { JarInput jarInput ->
                    traceJarFiles(jarInput, outputProvider, traceConfig)
                }
            }
        }
    }

三、利用ASM进行插桩

接下来看看遍历class文件后如何利用ASM的访问者模式进行插桩:

static void traceSrcFiles(DirectoryInput directoryInput, TransformOutputProvider outputProvider, Config traceConfig) {
        if (directoryInput.file.isDirectory()) {
            directoryInput.file.eachFileRecurse { File file ->
                def name = file.name
                //根据配置的插桩范围决定要对某个class文件进行处理
                if (traceConfig.isNeedTraceClass(name)) {
                    //利用ASM的api对class文件进行访问
                    ClassReader classReader = new ClassReader(file.bytes)
                    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                    ClassVisitor cv = new TraceClassVisitor(Opcodes.ASM5, classWriter, traceConfig)
                    classReader.accept(cv, EXPAND_FRAMES)
                    byte[] code = classWriter.toByteArray()
                    FileOutputStream fos = new FileOutputStream(
                            file.parentFile.absolutePath + File.separator + name)
                    fos.write(code)
                    fos.close()
                }
            }
        }

        //处理完输出给下一任务作为输入
        def dest = outputProvider.getContentLocation(directoryInput.name,
                directoryInput.contentTypes, directoryInput.scopes,
                Format.DIRECTORY)
        FileUtils.copyDirectory(directoryInput.file, dest)
    }

可以看到,最终是TraceClassVisitor类里对class文件进行处理的,我们看一下TraceClassVisitor

class TraceClassVisitor(api: Int, cv: ClassVisitor?, var traceConfig: Config) : ClassVisitor(api, cv) {

    private var className: String? = null
    private var isABSClass = false
    private var isBeatClass = false
    private var isConfigTraceClass = false

    override fun visit(
        version: Int,
        access: Int,
        name: String?,
        signature: String?,
        superName: String?,
        interfaces: Array<out String>?
    ) {
        super.visit(version, access, name, signature, superName, interfaces)

        this.className = name
        //抽象方法或者接口
        if (access and Opcodes.ACC_ABSTRACT > 0 || access and Opcodes.ACC_INTERFACE > 0) {
            this.isABSClass = true
        }

        //插桩代码所属类
        val resultClassName = name?.replace(".", "/")
        if (resultClassName == traceConfig.mBeatClass) {
            this.isBeatClass = true
        }

        //是否是配置的需要插桩的类
        name?.let { className ->
            isConfigTraceClass = traceConfig.isConfigTraceClass(className)
        }
    }

    override fun visitMethod(
        access: Int,
        name: String?,
        desc: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val isConstructor = MethodFilter.isConstructor(name)
        //抽象方法、构造方法、不是插桩范围内的方法,则不进行插桩
        return if (isABSClass || isBeatClass || !isConfigTraceClass || isConstructor) {
            super.visitMethod(access, name, desc, signature, exceptions)
        } else {
            //TraceMethodVisitor中对方法进行插桩
            val mv = cv.visitMethod(access, name, desc, signature, exceptions)
            TraceMethodVisitor(api, mv, access, name, desc, className, traceConfig)
        }
    }
}

再来看看TraceMethodVisitor:

override fun onMethodEnter() {
        super.onMethodEnter()
        //利用ASM在方法进入的时候 通过插入指令调用耗时统计的方法:start()
        mv.visitLdcInsn(generatorMethodName())
        mv.visitMethodInsn(INVOKESTATIC, traceConfig.mBeatClass, "start", "(Ljava/lang/String;)V", false)

    }

    override fun onMethodExit(opcode: Int) {
        //利用ASM在方法进入的时候 通过插入指令调用耗时统计的方法:end()
        mv.visitLdcInsn(generatorMethodName())
        mv.visitMethodInsn(INVOKESTATIC, traceConfig.mBeatClass, "end", "(Ljava/lang/String;)V", false)
    }

这样,我们就可以在所有配置的在插桩范围内的方法都在方法进入的时候调用TraceMan.start()方法,在方法退出的时候调用TraceMan.end()方法进行耗时统计。而TraceMan这个类也是可配置的,也就是你可以通过配置决定在方法进入和退出的时候调用哪个类的哪个方法。

至于TraceMan.start()TraceMan.end()是如何实现对一个方法的耗时统计,如何输出所有方法的耗时,可以具体看源码里TraceMan类的具体实现,这里就不具体展开了。

4. UI界面展示

    通过上面的方法插桩,以及耗时数据的处理,我们已经可以获取到所有方法的耗时统计,那么为了这个工具的易用性,我们再来实现一个UI展示界面,可以让方法的耗时数据可以实时的展示在浏览器上,并且支持耗时筛选、线程筛选、方法名搜索等功能。

    我们使用React实现了一个UI展示界面,然后在手机上搭建了一个服务器,这样在浏览器上就可以通过地址访问到这个UI展示界面,并且通过socket进行数据传输,我们的插桩代码产生方法耗时数据,然后React实现的UI界面接收数据、消费数据、展示数据。

    UI界面展示这部分的实现说起来比较琐碎,这里就不详细展开了,感兴趣的同学可以看看源码。

该项目的源码和详细的集成以及使用方法,我在github上维护了详细的文档,欢迎提供意见MethodTraceMan

5. 总结

    以上就是我们在优化流畅度的过程中实现的一个协助我们快速解决问题的工具,也简单分享了相关的技术知识,希望对也为页面流畅度苦恼的同学提供一点点想法。之后将分享其他的几个部分,主要包括:Android View绘制原理帧率流畅度监控帧率自动化测试流畅度优化实用技巧等等。当然对于卡顿以及流畅度的监控及优化还有很多需要做的工作,我们的主要目标是希望从监控到排查问题工具再到卡顿解决形成一个闭环的方案,让版本迭代间的流畅度问题做到可控、可发现、易解决,这是我们努力的方向。