ASM+Gradle Transfrom API 实现编译期间代码的修改

ASM 是什么?

AOP(面向切面编程),是一种编程思想,但是它的实现方式有很多,比如:Spring、AspectJ、JavaAssist、ASM 等。

ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

简单点说,通过 javac 将 .java 文件编译成 .class 文件,.class 文件中的内容虽然不同,但是它们都具有相同的格式,ASM 通过使用访问者(visitor)模式,按照 .class 文件特有的格式从头到尾扫描一遍 .class 文件中的内容,在扫描的过程中,就可以对 .class 文件做一些操作了,有点黑科技的感觉

所以ASM 就是一个字节码操作库,可以大大降低我们操作字节码的难度

Android 的打包过程

ASM+Gradle Transfrom API 实现编译期间代码的修改_第1张图片
android 打包流程

如图所示是Android打包流程,.java文件->.class文件->.dex文件,只要在红圈处拦截住,拿到所有方法进行修改完再放生就可以了,而做到这一步也不难,Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API, 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件,我们做的就是实现Transform进行.class文件遍历拿到所有方法,修改完成对原文件进行替换。

原理概述

我们可以自定义一个Gradle Plugin,然后注册一个Transform对象,在tranform方法里,可以分别遍历目录和jar包,然后我们就可以遍历当前应用程序的所有.class文件,然后在利用ASM框架的相关API,去加载响应的.class 文件,并解析,就可以找到满足特定条件的.class文件和相关方法,最后去修改相应的方法以动态插入埋点字节码,从而达到自动埋点的效果。

DEMO

本范例尝试对点击android中的普通点击事件进行一个拦截,并在其中插入代码。

1、创建android工程,只写一个简单点击事件即可(

代码..略

2、创建plugin lib module

1、修改plugin的gradle

apply plugin: 'groovy'
apply plugin: 'maven'
dependencies {
    compile gradleApi()
    compile localGroovy()

    compile 'org.ow2.asm:asm:6.0'
    compile 'org.ow2.asm:asm-commons:6.0'
    compile 'org.ow2.asm:asm-analysis:6.0'
    compile 'org.ow2.asm:asm-util:6.0'
    compile 'org.ow2.asm:asm-tree:6.0'
    compileOnly 'com.android.tools.build:gradle:3.2.1', {//这里注意需要保持版本一致,否则会报错
        exclude group:'org.ow2.asm'
    }
}
repositories {
    jcenter()
}

//调试模式下在本地生成仓库(也可推入自己已有的maven仓库)
uploadArchives {
    repositories.mavenDeployer {
        //本地仓库路径,以放到项目根目录下的 repo 的文件夹为例
        repository(url: uri('../repo'))

        //groupId ,自行定义
        pom.groupId = 'com.canzhang.android'

        //artifactId
        pom.artifactId = 'bury-point-com.canzhang.plugin'

        //插件版本号
        pom.version = '1.0.0-SNAPSHOT'
    }
}

2、在main目录下新建groovy包

groovy 是一种语言,和java语法比较类似

ASM+Gradle Transfrom API 实现编译期间代码的修改_第2张图片
image.png

3、创建transform类
这个类的作用就是在被编译成dex之前能够拦截到.class文件,然后找到匹配我们需求的,进行修改调整。

/**
 * Google官方在Android Gradle的1.5.0 版本以后提供了 Transfrom API,
 * 允许第三方 Plugin 在打包 dex 文件之前的编译过程中操作 .class 文件,
 * 我们做的就是实现Transform进行.class文件遍历拿到所有方法,修改完成对原文件进行替换。
 */
class AnalyticsTransform extends Transform {
    private static Project project
    private AnalyticsExtension analyticsExtension

    AnalyticsTransform(Project project, AnalyticsExtension analyticsExtension) {
        this.project = project
        this.analyticsExtension = analyticsExtension
    }

    /**
     * /返回该transform对应的task名称(编译后会出现在build/intermediates/transform下生成对应的文件夹)
     * @return
     */
    @Override
    String getName() {
        return AnalyticsSetting.PLUGIN_NAME
    }

    /**
     * 需要处理的数据类型,有两种枚举类型
     * CLASSES 代表处理的 java 的 class 文件,RESOURCES 代表要处理 java 的资源
     * @return
     */
    @Override
    Set getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 指 Transform 要操作内容的范围,官方文档 Scope 有 7 种类型:
     * 1. EXTERNAL_LIBRARIES        只有外部库
     * 2. PROJECT                   只有项目内容
     * 3. PROJECT_LOCAL_DEPS        只有项目的本地依赖(本地jar)
     * 4. PROVIDED_ONLY             只提供本地或远程依赖项
     * 5. SUB_PROJECTS              只有子项目。
     * 6. SUB_PROJECTS_LOCAL_DEPS   只有子项目的本地依赖项(本地jar)。
     * 7. TESTED_CODE               由当前变量(包括依赖项)测试的代码
     * @return
     */
    @Override
    Set getScopes() {
        //点进去可以看到这个包含(项目、项目依赖、外部库)
        //Scope.PROJECT,
        //Scope.SUB_PROJECTS,
        //Scope.EXTERNAL_LIBRARIES
        return TransformManager.SCOPE_FULL_PROJECT
//        return Sets.immutableEnumSet(
//                QualifiedContent.Scope.PROJECT,
//                QualifiedContent.Scope.SUB_PROJECTS)
    }

    @Override
    boolean isIncremental() {//是否增量构建
        return false
    }

    //这里需要注意,就算什么都不做,也需要把所有的输入文件拷贝到目标目录下,否则下一个Task就没有TransformInput了,
    // 如果是此方法空实现,最后会导致打包的APK缺少.class文件
    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        _transform(transformInvocation.context, transformInvocation.inputs, transformInvocation.outputProvider, transformInvocation.incremental)
    }

    void _transform(Context context, Collection inputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        if (!incremental) {
            outputProvider.deleteAll()
        }

        /**Transform 的 inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历 */
        inputs.each { TransformInput input ->
            /**遍历目录*/
            input.directoryInputs.each { DirectoryInput directoryInput ->
                /**当前这个 Transform 输出目录*/
                File dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes, directoryInput.scopes, Format.DIRECTORY)
                File dir = directoryInput.file

                if (dir) {
                    HashMap modifyMap = new HashMap<>()
                    /**遍历以某一扩展名结尾的文件*/
                    dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
                        File classFile ->
                            if (AnalyticsClassModifier.isShouldModify(classFile.name, analyticsExtension)) {
                                File modified = AnalyticsClassModifier.modifyClassFile(dir, classFile, context.getTemporaryDir())
                                if (modified != null) {
                                    /**key 为包名 + 类名,如:/cn/data/autotrack/android/app/MainActivity.class*/
                                    String ke = classFile.absolutePath.replace(dir.absolutePath, "")
                                    modifyMap.put(ke, modified)//修改过后的放到一个map中然后在写回源目录,覆盖原来的文件
                                }
                            }
                    }
                    FileUtils.copyDirectory(directoryInput.file, dest)
                    modifyMap.entrySet().each {
                        Map.Entry en ->
                            File target = new File(dest.absolutePath + en.getKey())
                            if (target.exists()) {
                                target.delete()
                            }
                            FileUtils.copyFile(en.getValue(), target)
                            en.getValue().delete()
                    }
                }
            }

            /**遍历 jar*/
            input.jarInputs.each { JarInput jarInput ->
                String destName = jarInput.file.name

                /**截取文件路径的 md5 值重命名输出文件,因为可能同名,会覆盖*/
                def hexName = DigestUtils.md5Hex(jarInput.file.absolutePath).substring(0, 8)
                /** 获取 jar 名字*/
                if (destName.endsWith(".jar")) {
                    destName = destName.substring(0, destName.length() - 4)
                }

                /** 获得输出文件*/
                File dest = outputProvider.getContentLocation(destName + "_" + hexName, jarInput.contentTypes, jarInput.scopes, Format.JAR)

                def modifiedJar = AnalyticsClassModifier.modifyJar(jarInput.file, context.getTemporaryDir(), true, analyticsExtension)
                if (modifiedJar == null) {
                    modifiedJar = jarInput.file
                }
                FileUtils.copyFile(modifiedJar, dest)
            }
        }
    }

  
}

3、创建插件类

/**
 * 可以通过配置主工程目录中的gradle.properties 中的
 * canPlugin.disablePlugin字段来控制是否开启此插件
 */
class AnalyticsPlugin implements Plugin {
    void apply(Project project) {

        //这个AnalyticsExtension 以及canPlugin名称,可以提供我们在外层配置一些参数,从而支持外层扩展
        AnalyticsExtension extension = project.extensions.create("canPlugin", AnalyticsExtension)

        //这个可以读取工程的gradle.properties 里面的can.disablePlugin 字段,控住是否注册此插件
        boolean disableAnalyticsPlugin = false
        Properties properties = new Properties()
        if (project.rootProject.file('gradle.properties').exists()) {
            properties.load(project.rootProject.file('gradle.properties').newDataInputStream())
            disableAnalyticsPlugin = Boolean.parseBoolean(properties.getProperty("disablePlugin", "false"))
        }

        if (!disableAnalyticsPlugin) {
            println("------------您开启了全埋点插桩插件--------------")
            AppExtension appExtension = project.extensions.findByType(AppExtension.class)
            //注册我们的transform类
            appExtension.registerTransform(new com.canzhang.plugin.AnalyticsTransform(project, extension))
        } else {
            println("------------您已关闭了全埋点插桩插件--------------")
        }
    }
}

到这里插件和gradle的tranform类我们都创建好了,下面需要看该怎么修改我们想修改的类了。
4、ASM中的ClassVisitor
ClassVisitor:主要负责遍历类的信息,包括类上的注解、构造方法、字段等等。
所以我们可以在这个类中筛选出符合我们条件的类或者方法,然后去修改,实现我们的目的。
比如我们本例子就是为了找到实现了View$OnClickListener接口的类,然后遍历这个类,并找到重写后的onClick(View v)方法。

这里就细节贴代码了,不懂得地方可以看注释

/**
 * 使用ASM的ClassReader类读取.class的字节数据,并加载类,
 * 然后用自定义的ClassVisitor,进行修改符合特定条件的方法,
 * 最后返回修改后的字节数组
 */
class AnalyticsClassVisitor extends ClassVisitor implements Opcodes {

//插入的外部类具体路径
    private String[] mInterfaces
    private ClassVisitor classVisitor
    private String mCurrentClassName

    AnalyticsClassVisitor(final ClassVisitor classVisitor) {
        super(Opcodes.ASM6, classVisitor)
        this.classVisitor = classVisitor
    }

    private
    static void visitMethodWithLoadedParams(MethodVisitor methodVisitor, int opcode, String owner, String methodName, String methodDesc, int start, int count, List paramOpcodes) {
        for (int i = start; i < start + count; i++) {
            methodVisitor.visitVarInsn(paramOpcodes[i - start], i)
        }
        methodVisitor.visitMethodInsn(opcode, owner, methodName, methodDesc, false)
    }

    /**
     * 这里可以拿到关于.class的所有信息,比如当前类所实现的接口类表等
     * @param version 表示jdk的版本
     * @param access 当前类的修饰符 (这个和ASM 和 java有些差异,比如public 在这里就是ACC_PUBLIC)
     * @param name 当前类名
     * @param signature 泛型信息
     * @param superName 当前类的父类
     * @param interfaces 当前类实现的接口列表
     */
    @Override
    void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
        super.visit(version, access, name, signature, superName, interfaces)
        mInterfaces = interfaces
        mCurrentClassName = name

        AnalyticsUtils.logD("当前的类是:" + name)
        AnalyticsUtils.logD("当前类实现的接口有:" + mInterfaces)
    }

    /**
     * 这里可以拿到关于method的所有信息,比如方法名,方法的参数描述等
     * @param access 方法的修饰符
     * @param name 方法名
     * @param desc 方法签名(就是(参数列表)返回值类型拼接)
     * @param signature 泛型相关信息
     * @param exceptions 方法抛出的异常信息
     * @return
     */
    @Override
    MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)

        String nameDesc = name + desc

        methodVisitor = new AnalyticsDefaultMethodVisitor(methodVisitor, access, name, desc) {

            @Override
            void visitEnd() {
                super.visitEnd()
            }

            @Override
            void visitInvokeDynamicInsn(String name1, String desc1, Handle bsm, Object... bsmArgs) {
                super.visitInvokeDynamicInsn(name1, desc1, bsm, bsmArgs)
            }

            @Override
            protected void onMethodExit(int opcode) {//方法退出节点
                super.onMethodExit(opcode)
            }

            @Override
            protected void onMethodEnter() {//方法进入节点
                super.onMethodEnter()

                if ((mInterfaces != null && mInterfaces.length > 0)) {
                    //如果当前类实现的接口有View$OnClickListener,并且当前进入的方法是onClick(Landroid/view/View;)V
                    //这里如果不知道怎么写,可以写个demo打印一下,就很快知道了,这里涉及一些ASM和Java中不同的写法。
                    if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
                        AnalyticsUtils.logD("插桩:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)
                         //这里就是插代码逻辑了
                        methodVisitor.visitVarInsn(ALOAD, 1)
                        methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
                    }
                }
            }

            @Override
            AnnotationVisitor visitAnnotation(String s, boolean b) {
                return super.visitAnnotation(s, b)
            }
        }
        return methodVisitor
    }
}

要插入的代码

public class MySdk {
    /**
     * 常规view 被点击,自动埋点
     *
     * @param view View
     */
    @Keep
    public static void onViewClick(View view) {
        Log.e("Test","成功插入 666666:"+view);
    }
}

核心代码分析

            @Override
            protected void onMethodEnter() {//方法进入节点
                super.onMethodEnter()

                if ((mInterfaces != null && mInterfaces.length > 0)) {
                    //如果当前类实现的接口有View$OnClickListener,并且当前进入的方法是onClick(Landroid/view/View;)V
                    //这里如果不知道怎么写,可以写个demo打印一下,就很快知道了,这里涉及一些ASM和Java中不同的写法。
                    if ((mInterfaces.contains('android/view/View$OnClickListener') && nameDesc == 'onClick(Landroid/view/View;)V')) {
                        AnalyticsUtils.logD("插桩:OnClickListener nameDesc:" + nameDesc + " currentClassName:" + mCurrentClassName)

                        //这里就是插代码逻辑了
                        methodVisitor.visitVarInsn(ALOAD, 1)
                        methodVisitor.visitMethodInsn(INVOKESTATIC, "com/canzhang/asmdemo/sdk/MySdk", "onViewClick", "(Landroid/view/View;)V", false)
                    }
                }
            }

当方法进入的时候,如果判断符合我们的条件,则进行方法插入。

  • 问题1:nameDesc为啥这么写。
    nameDesc == 'onClick(Landroid/view/View;)V'为什么是这样写的,后面的V是个什么东东。
    首先grovvy中是可以使用==号来判断字符串是否相等的,其次方法名是和java有一些差异,这个我们可以深入去了解这些差异学习,就可以理解为何这么写。还有一种简单的方法,可以直接打印日志的方式来快速知道我们需要的方法应该怎么写。
    入参对应关系表
    ASM+Gradle Transfrom API 实现编译期间代码的修改_第3张图片
    image.png

例子

ASM+Gradle Transfrom API 实现编译期间代码的修改_第4张图片
image.png

  • 问题2: 这插入的是什么鬼,怎么有点看不懂,如何知道怎么插。
    ASM就是帮助我们操作字节码的,封装了一些api可供我们调用,这个转换可以使用一个插件 ASM Bytecode outline ,android studio 可以下载此插件(参考教程
    )。

5、创建配置文件
按照如图所示创建对应路径和配置文件com.canzhang.plugin.properties,这里需要注意

  • 配置文件的名字:com.canzhang.plugin就是插件的名称,就是稍后我们生成插件后,引用此插件的module需要声明的那个:apply plugin: 'com.canzhang.plugin'
  • 配置内容就是我们插件的的包名和类名
# 此文件名为插件引用名,下面这行则是对应的插件路径
implementation-class=com.canzhang.plugin.AnalyticsPlugin
ASM+Gradle Transfrom API 实现编译期间代码的修改_第5张图片
image.png

6、然后我们就可以运行构建plugin了


ASM+Gradle Transfrom API 实现编译期间代码的修改_第6张图片
image.png

构建好之后我们就可以在本地看到这样一个文件夹


ASM+Gradle Transfrom API 实现编译期间代码的修改_第7张图片
image.png

7、使用插件

  • 项目gradle配置(配置本地仓库、并引入插件)
buildscript {
    
    repositories {
        google()
        jcenter()
        //本地调试仓库
        maven {
            url uri('repo')
        }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:3.2.0'

        //引用插件
        classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}
  • 主module gradle 配置
apply plugin: 'com.canzhang.plugin'

然后运行编译之后,就可以看到我们打印的日志了。

更多细节待续....
Demo:https://github.com/gudujiucheng/ASMDemo.git

注意事项:

  • 没有生成插件之前,要把依赖去掉,不然跑不起来
    主module屏蔽
apply plugin: 'com.canzhang.plugin'

主工程的gradle屏蔽

classpath 'com.canzhang.android:canzhang_plugin:1.0.0-SNAPSHOT'

屏蔽之后先build项目成功后,在触发生成插件,然后在放开屏蔽的两项,就可以了


ASM+Gradle Transfrom API 实现编译期间代码的修改_第8张图片
生成插架

ASM+Gradle Transfrom API 实现编译期间代码的修改_第9张图片
生成的插件
  • 插件插入不存在的代码也是不会报错的,因为是在编译后插入的,直到运行的时候才会报错,所以要注意插入代码的正确性。

参考文章:

本文主要是用于记录,参考自神策全埋点教程
https://www.jianshu.com/p/9039a3e46dbc
https://www.jianshu.com/p/c2c1d350d245
https://www.jianshu.com/p/16ed4d233fd1

你可能感兴趣的:(ASM+Gradle Transfrom API 实现编译期间代码的修改)