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

前言

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

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、BuryPointClassVisitor

通过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、BuryPointAdviceAdapter

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

  • visitAnnotation在扫描到注解时调用。我们通过 descriptor 来判断是否是需要埋点的注解,如果是则保存注解参数和对应的方法名称,等到onMethodEnter时进行代码插入。
  • visitInvokeDynamicInsn在描到lambda表达式时调用,bootstrapMethodArguments[0] 得到方法描述,通过 name + desc 判断当前lambda表达式是否是需要的埋点的方法,如果是则保存lambda方法名称,等到onMethodEnter时进行代码插入。
  • onMethodEnter在进入方法时调用,这里就是我们插入代码的地方了。通过 methodName + methodDescriptor 判断当前方法是否是需要的埋点的方法,如果是则插入埋点方法。
——重点,要考,画起来——
  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变量。

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

}
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

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

项目地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容