最通俗易懂的字节码插桩实战(Gradle + ASM)—— 自动埋点

3,605 阅读7分钟

前言

字节码插桩,看起来挺牛皮,实际上是真的很牛皮。
但是牛皮不代表难学,只需要一点前置知识就能轻松掌握。

Gradle Transform

Google在Android Gradle的1.5.0 版本以后提供了 Transfrom API,允许开发者在项目的编译过程中操作 .class 文件。Transfrom需要介绍的地方不多,唯一的难点就是要熟悉API,我会在文尾推荐相关文章,这里就不过多介绍,影响大家的阅读体验。

ASM

ASM是一种通用Java字节码操作和分析框架。它可以用于修改现有的class文件或动态生成class文件。
刚去了解ASM的时候,我是真的差点被字节码吓退,字节码这东西根本就不是给人读的,在我认知里能去读字节码的都是大神。就在我准备放弃时,ASM Bytecode Viewer从天而降拯救了我。

ASM Bytecode Viewer

ASM Bytecode Viewer是一款能查看字节码和生成ASM代码的插件,帮助我们打败了ASM学习路上最大的拦路虎,剩下就是对ASM的熟悉和使用可以说是so easy。

  1. 在Android Studio中搜索 ASM Bytecode Viewer Support Kotlin 找到并安装。
  2. 代码右键 ASM Bytecode Viewer 便能自动生成ASM插桩代码,效果如下:
实战:

前面介绍了 Gradle TransformASMASM Bytecode Viewer
现在就正式进入实战,先看下目录结构:

1、StatisticPlugin

顾名思义就是我们本次编写的插件,在apply 方法的注册 BuryPointTransform,读取 build.gradle 里面配置的需要埋点的方法和注解。(Gradle Transform属实没啥好介绍,这里就不过多哔哔,请大家直接看代码把。熟悉并觉得无聊可直接跳到 BuryPointMethodVisitor

class StatisticPlugin implements Plugin<Project> {

    public static Map<String, BuryPointEntity> BURY_POINT_MAP

    @Override
    void apply(Project project) {
        def android = project.extensions.findByType(AppExtension)
        // 注册BuryPointTransform
        android.registerTransform(new BuryPointTransform())
        // 获取gradle里面配置的埋点信息
        def statisticExtension = project.extensions.create('statistic', StatisticExtension)
        project.afterEvaluate {
           // 遍历配置的埋点信息,将其保存在BURY_POINT_MAP方便调用
            BURY_POINT_MAP = new HashMap<>()
            def buryPoint = statisticExtension.getBuryPoint()
            if (buryPoint != null) {
                buryPoint.each { Map<String, Object> map ->
                    BuryPointEntity entity = new BuryPointEntity()

                    ...省略中间非关键代码,详细请到github中查看...

                    if (entity.isAnnotation) {
                        if (map.containsKey("annotationDesc")) {
                            entity.annotationDesc = map.get("annotationDesc")
                        }
                        if (map.containsKey("annotationParams")) {
                            entity.annotationParams = map.get("annotationParams")
                        }
                        BURY_POINT_MAP.put(entity.annotationDesc, entity)
                    } else {
                        if (map.containsKey("methodOwner")) {
                            entity.methodOwner = map.get("methodOwner")
                        }
                        if (map.containsKey("methodName")) {
                            entity.methodName = map.get("methodName")
                        }
                        if (map.containsKey("methodDesc")) {
                            entity.methodDesc = map.get("methodDesc")
                        }
                        BURY_POINT_MAP.put(entity.methodName + entity.methodDesc, entity)
                    }
                }
            }
        }
    }
}
2、BuryPointTransform

通过transform 方法的 Collection<TransformInput> inputs 对 .class文件遍历拿到所有方法

class BuryPointTransform extends Transform {

    ...省略中间非关键代码,详细请到github中查看...

    /**
     *
     * @param context
     * @param inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历
     * @param outputProvider 输出路径
     */
    @Override
    void transform(
            @NonNull Context context,
            @NonNull Collection<TransformInput> inputs,
            @NonNull Collection<TransformInput> referencedInputs,
            @Nullable TransformOutputProvider outputProvider,
            boolean isIncremental
    ) throws IOException, TransformException, InterruptedException {
        if (!incremental) {
            //不是增量更新删除所有的outputProvider
            outputProvider.deleteAll()
        }
        inputs.each { TransformInput input ->
            //遍历目录
            input.directoryInputs.each { DirectoryInput directoryInput ->
                handleDirectoryInput(directoryInput, outputProvider)
            }
            // 遍历jar 第三方引入的 class
            input.jarInputs.each { JarInput jarInput ->
                handleJarInput(jarInput, outputProvider)
            }
        }
    }

}
3、BuryPointVisitor

通过visitMethod拿到方法进行修改

class BuryPointVisitor extends ClassVisitor {

    ...省略中间非关键代码,详细请到github中查看...

    /**
     * 扫描类的方法进行调用
     * @param access 修饰符
     * @param name 方法名字
     * @param descriptor 方法签名
     * @param signature 泛型信息
     * @param exceptions 抛出的异常
     * @return
     */
    @Override
    MethodVisitor visitMethod(int methodAccess, String methodName, String methodDescriptor, String signature, String[] exceptions) {
        MethodVisitor methodVisitor = super.visitMethod(methodAccess, methodName, methodDescriptor, signature, exceptions)
        if ((methodAccess & Opcodes.ACC_INTERFACE) == 0 && "<init>" != methodName && "<clinit>" != methodName) {
            methodVisitor = new BuryPointAdviceAdapter(api, methodVisitor, methodAccess, methodName, methodDescriptor)
        }
        return methodVisitor
    }

}
4、BuryPointMethodVisitor

终于到了本次文章的核心代码了。

  • visitAnnotation在扫描到注解时调用。我们通过 descriptor 来判断是否是需要埋点的注解,如果是则保存注解参数和对应的方法名称,等到onMethodEnter时进行代码插入。
  • visitInvokeDynamicInsn在描到lambda表达式时调用,bootstrapMethodArguments[0] 得到方法描述,通过 name + desc 判断当前lambda表达式是否是需要的埋点的方法,如果是则保存lambda方法名称,等到onMethodEnter时进行代码插入。
  • onMethodEnter在进入方法时调用,这里就是我们插入代码的地方了。通过 methodName + methodDescriptor 判断当前方法是否是需要的埋点的方法,如果是则插入埋点方法。
class BuryPointAdviceAdapter extends AdviceAdapter {

    ...省略中间非关键代码,详细请到github中查看...

    /**
     * 扫描类的注解时调用
     * @param descriptor 注解名称
     * @param visible
     * @return
     */
    @Override
    AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
        AnnotationVisitor annotationVisitor = super.visitAnnotation(descriptor, visible)
        // 通过descriptor判断是否是需要扫描的注解
        BuryPointEntity entity = StatisticPlugin.BURY_POINT_MAP.get(descriptor)
        if (entity != null) {
            BuryPointEntity newEntity = entity.clone()
            return new BuryPointAnnotationVisitor(api, annotationVisitor) {
                @Override
                void visit(String name, Object value) {
                    super.visit(name, value)
                    // 保存注解的参数值
                    newEntity.annotationData.put(name, value)
                }

                @Override
                void visitEnd() {
                    super.visitEnd()
                    newEntity.methodName = methodName
                    newEntity.methodDesc = methodDesc
                    StatisticPlugin.BURY_POINT_MAP.put(newEntity.methodName + newEntity.methodDesc, newEntity)
                }
            }
        }
        return annotationVisitor
    }

    /**
     * lambda表达式时调用
     * @param name
     * @param descriptor
     * @param bootstrapMethodHandle
     * @param bootstrapMethodArguments
     */
    @Override
    void visitInvokeDynamicInsn(String name, String descriptor, Handle bootstrapMethodHandle, Object... bootstrapMethodArguments) {
        super.visitInvokeDynamicInsn(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments)
        String desc = (String) bootstrapMethodArguments[0]
        BuryPointEntity entity = StatisticPlugin.BURY_POINT_MAP.get(name + desc)
        if (entity != null) {
            String parent = Type.getReturnType(descriptor).getDescriptor()
            if (parent == entity.methodOwner) {
                Handle handle = (Handle) bootstrapMethodArguments[1]
                BuryPointEntity newEntity = entity.clone()
                newEntity.isLambda = true
                newEntity.methodName = handle.getName()
                newEntity.methodDesc = handle.getDesc()
                StatisticPlugin.BURY_POINT_MAP.put(newEntity.methodName + newEntity.methodDesc, newEntity)
            }
        }
    }

    /**
     * 方法进入时调用
     */
    @Override
    protected void onMethodEnter() {
        super.onMethodEnter()
        BuryPointEntity entity = StatisticPlugin.BURY_POINT_MAP.get(methodName + methodDesc)
        if (entity != null && !entity.isMethodExit) {
            onMethod(entity)
        }
    }

    /**
     * 方法退出前调用
     */
    @Override
    protected void onMethodExit(int opcode) {
        BuryPointEntity entity = StatisticPlugin.BURY_POINT_MAP.get(methodName + methodDesc)
        if (entity != null && entity.isMethodExit) {
            onMethod(entity)
        }
        super.onMethodExit(opcode)
    }

    private void onMethod(BuryPointEntity entity) {
        if (entity.isAnnotation) {
            // 遍历注解参数并赋值给采集方法
            for (Map.Entry<String, String> entry : entity.annotationParams.entrySet()) {
                String key = entry.getKey()
                if (key == "this") {
                    //所在方法的当前对象的引用
                    mv.visitVarInsn(ALOAD, 0)
                } else {
                    mv.visitLdcInsn(entity.annotationData.get(key))
                    Type type = Type.getType(entry.getValue())
                    int slotIndex = newLocal(type)
                    mv.visitVarInsn(type.getOpcode(ISTORE), slotIndex)
                    mv.visitVarInsn(type.getOpcode(ILOAD), slotIndex)
                }
            }
            mv.visitMethodInsn(INVOKESTATIC, entity.agentOwner, entity.agentName, entity.agentDesc, false)
            // 防止其他类重名方法被插入
            StatisticPlugin.BURY_POINT_MAP.remove(methodName + methodDesc, entity)
        } else {
            // 获取方法参数
            Type methodType = Type.getMethodType(methodDesc)
            Type[] methodArguments = methodType.getArgumentTypes()
            // 采集数据的方法参数起始索引( 0:this,1+:普通参数 ),如果是static,则从0开始计算
            int slotIndex = (methodAccess & ACC_STATIC) != 0 ? 0 : 1
            // 获取采集方法参数
            Type agentMethodType = Type.getMethodType(entity.agentDesc)
            Type[] agentArguments = agentMethodType.getArgumentTypes()
            List<Type> agentArgumentList = new ArrayList<Type>(Arrays.asList(agentArguments))
            // 将扫描方法参数赋值给采集方法
            for (Type methodArgument : methodArguments) {
                int size = methodArgument.getSize()
                int opcode = methodArgument.getOpcode(ILOAD)
                String descriptor = methodArgument.getDescriptor()
                Iterator<Type> agentIterator = agentArgumentList.iterator()
                // 遍历采集方法参数
                while (agentIterator.hasNext()) {
                    Type agentArgument = agentIterator.next()
                    String agentDescriptor = agentArgument.getDescriptor()
                    if (agentDescriptor == descriptor) {
                        mv.visitVarInsn(opcode, slotIndex)
                        agentIterator.remove()
                        break
                    }
                }
                slotIndex += size
            }
            if (agentArgumentList.size() > 0) { // 无法满足采集方法参数则return
                return
            }
            mv.visitMethodInsn(INVOKESTATIC, entity.agentOwner, entity.agentName, entity.agentDesc, false)
            if (entity.isLambda) {
                StatisticPlugin.BURY_POINT_MAP.remove(methodName + methodDesc, entity)
            }
        }
    }

}
——重点,要考,画起来——
  1. mv.visitVarInsn(type.getOpcode(ISTORE), slotIndex)slotIndex 是怎么的来的呢?
    答:因为我们要通过visitVarInsn把注解参数压入到局部变量表中,而局部变量表(Local Variable Table)是一组变量值存储空间,用于存放 方法参数和方法内定义的局部变量。具体的顺序是 this-方法接收的参数-方法内定义的局部变量。因此我们要通过newLocal(type)来获取 slotIndex 按顺序把注解参数压入到局部变量表中。

  2. isStatic(methodAccess) ? 0 : 1 为什么 static 方法是0开始计算?
    答:对于非静态方法(non-static method)来说,索引位置为0的位置存放的是this变量,所以要加1;对于静态方法(static method)来说,索引位置为0的位置则不需要存储this变量。

5、 如何使用?
5.1、 先打包插件到本地仓库进行引用

5.2、 在项目的根build.gradle加入插件的依赖
    repositories {
        google()
        mavenCentral()
        jcenter()
        maven{
            url uri('repos')
        }
    }
    dependencies {
        classpath "com.android.tools.build:gradle:$gradle_version"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath 'com.meituan.android.walle:plugin:1.1.7'
        // 使用自定义插件
        classpath 'com.example.plugin:statistic:1.0.0'
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
5.3、 在app的build.gradle中使用并配置参数
plugins {
    id 'com.android.application'
    id 'statistic'
}

statistic {
    buryPoint = [
            [
                    //注解标识
                    'isAnnotation'    : true,
                    //方式插入时机,true方法退出前,false方法进入时
                    'isMethodExit'    : true,
                    //采集数据的方法的路径
                    'agentOwner'      : 'com/example/fragment/library/common/utils/StatisticHelper',
                    //采集数据的方法名
                    'agentName'       : 'testAnnotation',
                    //采集数据的方法描述(对照annotationParams,注意参数顺序)
                    'agentDesc'       : '(Ljava/lang/Object;ILjava/lang/String;)V',
                    //扫描的注解名称
                    'annotationDesc'  : 'Lcom/example/fragment/library/common/utils/TestAnnotation;',
                    //扫描的注解的参数
                    'annotationParams': [
                            //参数名 : 参数类型(对应的ASM指令,加载不同类型的参数需要不同的指令)
                            //this  : 所在方法的当前对象的引用(默认关键字,按需可选配置)
                            'this'   : 'Ljava/lang/Object;',
                            'code'   : 'I',
                            'message': 'Ljava/lang/String;',
                    ]
            ],
    ]
}
6、 运行项目查看输出日志
2021-06-28 20:04:49.544 25211-25211/com.example.fragment.project.debug I/----------自动埋点:注解: MainActivity.onCreate:false
2021-06-28 20:05:06.085 25211-25211/com.example.fragment.project.debug I/----------自动埋点:  ViewId:coin ViewText:我的积分
2021-06-28 20:05:11.616 25211-25211/com.example.fragment.project.debug I/----------自动埋点:  ViewId:username ViewText:去登录
2021-06-28 20:05:16.816 25211-25211/com.example.fragment.project.debug I/----------自动埋点:  ViewId:login ViewText:登录

参考

在AndroidStudio中自定义Gradle插件
史上最通俗易懂的ASM教程
Android函数插桩(Gradle + ASM)

Thanks

以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。 如果喜欢的话希望点个赞吧,您的鼓励是我前进的动力。 谢谢~~

项目地址