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

Posted xhmj12

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了最通俗易懂的字节码插桩实战(Gradle + ASM)—— 自动埋点相关的知识,希望对你有一定的参考价值。

相关阅读:一个90后员工猝死的全过程


作者:miaowmiaow
链接:https://www.jianshu.com/p/c2132273257a

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

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 Transform 、 ASM 及 ASM Bytecode Viewer,现在就正式进入实战,先看下目录结构:

image
1、StatisticPlugin

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

class StatisticPlugin implements Plugin<Project> {

    public final static HashMap<String, BuryPointCell> HOOKS = new HashMap<>()

    @Override
    void apply(Project project) {
        def android = project.extensions.findByType(AppExtension)
        // 注册BuryPointTransform
        android.registerTransform(new BuryPointTransform())
        // 获取gradle里面配置的埋点信息
        def extension = project.extensions.create('buryPoint', BuryPointExtension)
        project.afterEvaluate {
            // 遍历配置的埋点信息,将其保存在HOOKS方便调用
            extension.hooks.each { Map<String, Object> map ->
                BuryPointCell cell = new BuryPointCell()
                if(map.containsKey("isAnnotation")){
                    cell.isAnnotation = map.get("isAnnotation")
                }
                if(map.containsKey("isMethodExit")){
                    cell.isMethodExit = map.get("isMethodExit")
                }
                if(map.containsKey("agentName")){
                    cell.agentName = map.get("agentName")
                }
                if(map.containsKey("agentDesc")){
                    cell.agentDesc = map.get("agentDesc")
                }
                if(map.containsKey("agentParent")){
                    cell.agentParent = map.get("agentParent")
                }
                if (cell.isAnnotation) {
                    if(map.containsKey("annotationDesc")){
                        cell.annotationDesc = map.get("annotationDesc")
                    }
                    if(map.containsKey("annotationParams")){
                        cell.annotationParams = map.get("annotationParams")
                    }
                    HOOKS.put(cell.annotationDesc, cell)
                } else {
                    if(map.containsKey("methodName")){
                        cell.methodName = map.get("methodName")
                    }
                    if(map.containsKey("methodDesc")){
                        cell.methodDesc = map.get("methodDesc")
                    }
                    if(map.containsKey("methodParent")){
                        cell.methodParent = map.get("methodParent")
                    }
                    HOOKS.put(cell.methodName + cell.methodDesc, cell)
                }
            }
        }
    }
}
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)
            }
        }
    }

    /**
     * 处理文件目录下的class文件
     */
    static void handleDirectoryInput(DirectoryInput directoryInput, TransformOutputProvider outputProvider) {
        //是否是目录
        if (directoryInput.file.isDirectory()) {
            //列出目录所有文件(包含子文件夹,子文件夹内文件)
            directoryInput.file.eachFileRecurse { File file ->
                def name = file.name
                if (filterClass(name)) {
                    ClassReader classReader = new ClassReader(file.bytes)
                    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                    ClassVisitor classVisitor = new BuryPointVisitor(classWriter)
                    classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
                    byte[] code = classWriter.toByteArray()
                    FileOutputStream fos = new FileOutputStream(file.parentFile.absolutePath + File.separator + name)
                    fos.write(code)
                    fos.close()
                }
            }
        }
        //文件夹里面包含的是我们手写的类以及R.class、BuildConfig.class以及R$XXX.class等
        // 获取output目录
        def dest = outputProvider.getContentLocation(
                directoryInput.name,
                directoryInput.contentTypes,
                directoryInput.scopes,
                Format.DIRECTORY)
        //这里执行字节码的注入,不操作字节码的话也要将输入路径拷贝到输出路径
        FileUtils.copyDirectory(directoryInput.file, dest)
    }

    /**
     * 处理jar文件,一般是第三方依赖库jar文件
     */
    static void handleJarInput(JarInput jarInput, TransformOutputProvider outputProvider) {
        if (jarInput.file.getAbsolutePath().endsWith(".jar")) {
            //重名名输出文件,因为可能同名,会覆盖
            def jarName = jarInput.name
            def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
            if (jarName.endsWith(".jar")) {
                jarName = jarName.substring(0, jarName.length() - 4)
            }
            JarFile jarFile = new JarFile(jarInput.file)
            Enumeration enumeration = jarFile.entries()
            File tmpFile = new File(jarInput.file.getParent() + File.separator + "temp.jar")
            //避免上次的缓存被重复插入
            if (tmpFile.exists()) {
                tmpFile.delete()
            }
            JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(tmpFile))
            //用于保存
            while (enumeration.hasMoreElements()) {
                JarEntry jarEntry = (JarEntry) enumeration.nextElement()
                String entryName = jarEntry.getName()
                ZipEntry zipEntry = new ZipEntry(entryName)
                InputStream inputStream = jarFile.getInputStream(jarEntry)
                //插桩class
                if (filterClass(entryName)) {
                    //class文件处理
                    jarOutputStream.putNextEntry(zipEntry)
                    ClassReader classReader = new ClassReader(IOUtils.toByteArray(inputStream))
                    ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
                    ClassVisitor classVisitor = new BuryPointVisitor(classWriter)
                    classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
                    byte[] bytes = classWriter.toByteArray()
                    jarOutputStream.write(bytes)
                } else {
                    jarOutputStream.putNextEntry(zipEntry)
                    jarOutputStream.write(IOUtils.toByteArray(inputStream))
                }
                jarOutputStream.closeEntry()
            }
            //结束
            jarOutputStream.close()
            jarFile.close()
            //生成输出路径 + md5Name
            def dest = outputProvider.getContentLocation(
                    jarName + md5Name,
                    jarInput.contentTypes,
                    jarInput.scopes,
                    Format.JAR)
            FileUtils.copyFile(tmpFile, dest)
            tmpFile.delete()
        }
    }

    /**
     * 检查class文件是否需要处理
     * @param fileName
     * @return
     */
    static boolean filterClass(String name) {
        return (name.endsWith(".class")
                && !name.startsWith("R\\$")
                && "R.class" != name
                && "BuildConfig.class" != name)
    }

}
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)
        return new BuryPointMethodVisitor(methodVisitor, methodAccess, methodName, methodDescriptor)
    }

}
4、BuryPointMethodVisitor

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

——重点,要考,画起来——
  1. mv.visitVarInsn(store, i + methodArgumentSize + 1) 为什么要 +methodArgumentSize 呢?
    答:简单的说就是我们通过visitLdcInsn把注解参数压入到局部变量表中,而局部变量表(Local Variable Table)是一组变量值存储空间,用于存放 方法参数和方法内定义的局部变量,所以在取值的时候要方法参数的数量。
    2.int slotIndex = isStatic(methodAccess) ? 0 : 1 为什么 static 方法是0开始计算?
    答:普通方法的局部变量表第一个参数是this(当前对象的引用)所以要加1。

class BuryPointMethodVisitor extends AdviceAdapter {

    int methodAccess
    String methodName
    String methodDescriptor

    BuryPointMethodVisitor(MethodVisitor methodVisitor, int access, String name, String desc) {
        super(Opcodes.ASM7, methodVisitor, access, name, desc)
        this.methodAccess = access
        this.methodName = name
        this.methodDescriptor = desc
    }

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

                @Override
                void visitEnd() {
                    super.visitEnd()
                    newCell.methodName = methodName
                    newCell.methodDesc = methodDescriptor
                    StatisticPlugin.HOOKS.put(newCell.methodName + newCell.methodDesc, newCell)
                }
            }
        }
        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]
        BuryPointCell cell = StatisticPlugin.HOOKS.get(name + desc)
        if (cell != null) {
            String parent = Type.getReturnType(descriptor).getDescriptor()
            if (parent == cell.methodParent) {
                Handle handle = (Handle) bootstrapMethodArguments[1]
                BuryPointCell newCell = cell.clone()
                newCell.isLambda = true
                newCell.methodName = handle.getName()
                newCell.methodDesc = handle.getDesc()
                StatisticPlugin.HOOKS.put(newCell.methodName + newCell.methodDesc, newCell)
            }
        }
    }

    /**
     * 方法进入时调用
     */
    @Override
    protected void onMethodEnter() {
        super.onMethodEnter()
        BuryPointCell buryPointCell = StatisticPlugin.HOOKS.get(methodName + methodDescriptor)
        if (buryPointCell != null && !buryPointCell.isMethodExit) {
            onMethod(buryPointCell)
        }
    }

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

    private void onMethod(BuryPointCell cell) {
        // 获取方法参数
        Type methodType = Type.getMethodType(methodDescriptor)
        Type[] methodArguments = methodType.getArgumentTypes()
        int methodArgumentSize = methodArguments.size()
        if (cell.isAnnotation) { // 遍历注解参数并赋值给采集方法
            def entrySet = cell.annotationParams.entrySet()
            def size = entrySet.size()
            for (int i = 0; i < size; i++) {
                def key = entrySet[i].getKey()
                if (key == "this") {
                    mv.visitVarInsn(Opcodes.ALOAD, 0)
                } else {
                    def load = entrySet[i].getValue()
                    def store = getVarInsn(load)
                    mv.visitLdcInsn(cell.annotationData.get(key))
                    mv.visitVarInsn(store, i + methodArgumentSize + 1)
                    mv.visitVarInsn(load, i + methodArgumentSize + 1)
                }
            }
            mv.visitMethodInsn(INVOKESTATIC, cell.agentParent, cell.agentName, cell.agentDesc, false)
            // 防止其他类重名方法被插入
            StatisticPlugin.HOOKS.remove(methodName + methodDescriptor, cell)
        } else { // 将扫描方法参数赋值给采集方法
            // 采集数据的方法参数起始索引( 0:this,1+:普通参数 ),如果是static,则从0开始计算
            int slotIndex = isStatic(methodAccess) ? 0 : 1
            // 获取采集方法参数
            Type agentMethodType = Type.getMethodType(cell.agentDesc)
            Type[] agentArguments = agentMethodType.getArgumentTypes()
            List<Type> agentArgumentList = new ArrayList<Type>(Arrays.asList(agentArguments))
            // 遍历方法参数
            for (Type argument : methodArguments) {
                int size = argument.getSize()
                int opcode = argument.getOpcode(ILOAD)
                String descriptor = argument.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, cell.agentParent, cell.agentName, cell.agentDesc, false)
            if(cell.isLambda){
                StatisticPlugin.HOOKS.remove(methodName + methodDescriptor, cell)
            }
        }
    }

    /**
     * 推断类型
     * int ILOAD = 21; int ISTORE = 54;
     * 33 = ISTORE - ILOAD
     *
     * @param load
     * @returno
     */
    private static int getVarInsn(int load) {
        return load + 33
    }

    private static boolean isStatic(int access) {
        return (access & Opcodes.ACC_STATIC) != 0
    }

}
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'
}

buryPoint {
    hooks = [
            [
                    'agentName'   : 'viewOnClick',                                             //采集数据的方法名
                    'agentDesc'   : '(Landroid/view/View;)V',                                  //采集数据的方法描述(参数应在methodDesc范围之内)
                    'agentParent' : 'com/example/fragment/project/utils/StatisticHelper',      //采集数据的方法的路径
                    'isAnnotation': false,
                    'methodName'  : 'onClick',                                                 //插入的方法名
                    'methodDesc'  : '(Landroid/view/View;)V',                                  //插入的方法描述
                    'methodParent': 'Landroid/view/View$OnClickListener;',                     //插入的方法的实现接口
            ],
            [
                    //注解标识
                    'isAnnotation'    : true,
                    //方式插入时机,true方法退出前,false方法进入时
                    'isMethodExit'    : true,
                    //采集数据的方法的路径
                    'agentParent'     : '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'   : org.objectweb.asm.Opcodes.ALOAD,
                            'code'   : org.objectweb.asm.Opcodes.ILOAD,
                            'message': org.objectweb.asm.Opcodes.ALOAD,
                    ]
            ],
    ]
}
6、 运行项目查看输出日志
2021-06-28 20:04:49.544 25211-25211/com.example.fragment.project.debug I/----------自动埋点:注解: MainActivity.onCreate:false
2021-06-28 20:05:03.535 25211-25211/com.example.fragment.project.debug I/----------自动埋点:  ViewId:menu ViewText:null
2021-06-28 20:05:06.085 25211-25211/com.example.fragment.project.debug I/----------自动埋点:  ViewId:coin ViewText:我的积分
2021-06-28 20:05:08.039 25211-25211/com.example.fragment.project.debug I/----------自动埋点:  ViewId:black ViewText:null
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:登录

项目地址


1、滴滴、满帮、Boss直聘都被调查,为啥知乎美国上市没被查?

2、字节跳动重大宣布:取消!员工炸了:直接降薪1

3、再见了,Teamviewer!

4、人脸识别的时候,一定要穿上衣服啊!

5、程序员被公司辞退12天,前领导要求回公司讲清楚代码,结果懵了

以上是关于最通俗易懂的字节码插桩实战(Gradle + ASM)—— 自动埋点的主要内容,如果未能解决你的问题,请参考以下文章

Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战

Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战

Android AOP编程——Gradle插件+TransformAPI+字节码插桩实战

Android Gradle 中的字节码插桩之ASM

Android Gradle 中的字节码插桩之ASM

Android Gradle 中的字节码插桩之ASM