Android asm字节码插桩点击防抖以及统计方法耗时

news/2024/5/20 2:51:27 标签: android, java, gradle

1、目标

使用asm字节码插桩的方式,实现给点击事件加上防抖统计方法耗时的功能

2、api介绍

1、Transform API

Transform API 是 AGP1.5 就引入的特性,Android在构建过程中回将Class转成Dex,此API就是提供了在此过程中插入自定逻辑字节码的功能,我们可以使用此API做一些功能,比如无痕埋点,耗时统计等功能。不过此API在AGP7.0已经被废弃,8.0会被移除,取而代之的是Transform Action

2、Transform Action

Transform Action是有Gradle提供的,直接使用Transform Action会有点麻烦,AGP为我们封装了一层AsmClassVisitorFactory,我们一般可以使使用AsmClassVisitorFactory,这样代码量会减少,而且性能还有提升。简单使用的话,整体流程跟Transform API差不多。

然后我们知道ASM有两套API,core api 和tree api(blog.51cto.com/lsieun/4088…),具体区别可以看下链接,tree api使用会更方便一些,实现一些功能会更简单,不过性能上会比core api差一些。因为Transform API废弃了,所以接下来都是以Transform Action为例子。

3、实现方案

我们使用plugin的方式编写插桩代码,然后将它publish到本地,然后在对应工程引用这个plugin

1、新建plugin

这个网上有很多资料,可自行查找,就是配置resources目录,新建 .properties文件,在build.gradle中配置publishing{}即可。

moudle的build.gradle文件中添加一下

group "com.trans.test.plugin"
version "1.0.0"

publishing{ //当前项目可以发布到本地文件夹中
    repositories {
        maven {
            url= '../repo' //定义本地maven仓库的地址
        }
    }

    publications {
        PublishAndroidAssetLibrary(MavenPublication) {
            groupId group
            artifactId artifactId
            version version
        }
    }
}

当修改plugin的代码后,记得publish一下,以更新下本地库


使用的module在build.gradle引用一下

apply plugin: com.example.transformaction.AsmPlugin

根目录的setting.grdle中导入本地路径

maven { url('./repo') }

2、编写插桩代码,这里叙述一下大概的逻辑

1、过滤所有需要的方法

1、正常的点击setOnclickListener(),页面实现OnclickListener接口,重写onClick()方法

2、匿名内部类setOnclickListener()

3、xml点击事件

4、ButterKnife点击事件

2、对方法进行hook插桩

基本逻辑就是,我们用kotlin实现一个“防抖”的功能,然后将这个功能的调用代码以字节码的方式插入到需要hook的方法中(具体实现下面会说明)

4、具体实现步骤

1、先实现一个Plugin

class AsmPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        println("我是插件")
        val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java)
        androidComponents.onVariants { variant ->
            variant.instrumentation.transformClassesWith(
                MyTestTransform::class.java,
                InstrumentationScope.PROJECT) {params->
                params.config.set(ViewDoubleClickConfig())
            }
            variant.instrumentation.setAsmFramesComputationMode(
                FramesComputationMode.COMPUTE_FRAMES_FOR_INSTRUMENTED_METHODS
            )
        }
        println("插件插入完成")
    }
}

从代码可以看出,(Transform API 中我们使用AppExtension)Transform Action使用AndroidComponentsExtension来获取组件,然后一次插入我们班自定义的MyTestTransform来插入我们的字节码。

2、实现MyTestTransform

interface DoubleClickParameters : InstrumentationParameters {
    @get:Input
    val config: Property<ViewDoubleClickConfig>
}

abstract class MyTestTransform: AsmClassVisitorFactory<DoubleClickParameters> {
    override fun createClassVisitor(classContext: ClassContext, nextClassVisitor: ClassVisitor): ClassVisitor {
        return TreeTestVisitor(
           nextClassVisitor = nextClassVisitor,
           config = parameters.get().config.get()
       )
        // return CoreClassVisitor(nextClassVisitor)

    }

    override fun isInstrumentable(classData: ClassData): Boolean {
        return true
    }
}

DoubleClickParameters是用来传参的,TreeTestVisitor使用了传参的方式,所以使用AsmClassVisitorFactory泛型用了DoubleClickParameters。以上代码可以看出MyTestTransform内部createClassVisitor需要返回一个ClassVisitor,我们用两种实现方式(core api 和 tree api )来演示下。

3、TreeTestVisitor(tree api)

class TreeTestVisitor(
    private val nextClassVisitor: ClassVisitor,
    private val config: ViewDoubleClickConfig
) : ClassNode(Opcodes.ASM5) {

    private val extraHookPoints = listOf(
        ViewDoubleClickHookPoint(
            interfaceName = "android/view/View$OnClickListener",
            methodName = "onClick",
            nameWithDesc = "onClick(Landroid/view/View;)V"
        ),
        ViewDoubleClickHookPoint(
            interfaceName = "com/chad/library/adapter/base/listener/OnItemClickListener",
            methodName = "onItemClick",
            nameWithDesc = "onItemClick(Lcom/chad/library/adapter/base/BaseQuickAdapter;Landroid/view/View;I)V"
        ),
        ViewDoubleClickHookPoint(
            interfaceName = "com/chad/library/adapter/base/listener/OnItemChildClickListener",
            methodName = "onItemChildClick",
            nameWithDesc = "onItemChildClick(Lcom/chad/library/adapter/base/BaseQuickAdapter;Landroid/view/View;I)V",
        )
)
    
    override fun visitEnd(){
         // 这里就是遍历methods 对方法一一进行Visitor
         // 这里我们要做的就是取出所有的onClick时事件然后一一插入对应的字节码
         // 点击事件有几种情况
         // 1、正常的点击setOnclickListener(),页面实现OnclickListener接口,重写onClick()方法
         // 2、匿名内部类setOnclickListener()
         // 3、xml点击事件
         // 4、ButterKnife点击事件

        // 以上其实可以分为三类 
        // 1、使用注解,判断注解  hasAnnotation()
        // 2、通过MethodNode的interfaces数组,来判断是实现onClickLitener接口(包括列表的onItemClickLisener等)
        // 3、lambda表达式的方式
        
        super.visitEnd()
        val shouldHookMethodList = mutableSetOf<MethodNode>()
        methods.forEach { methodNode ->

            //使用了 ViewAnnotationOnClick 自定义注解的情况
            methodNode.hasAnnotation("Lcom/example/transformsaction/annotation/ViewAnnotationOnClick;") -> {
                shouldHookMethodList.add(methodNode)
            }

            //使用了 Butterknife 注解的情况
            methodNode.hasAnnotation("Lbutterknife/OnClick;") -> {
                shouldHookMethodList.add(methodNode)
            }

            //使用了匿名内部类的情况
            methodNode.isHookPoint() -> {
                shouldHookMethodList.add(methodNode)
            }

            //判断方法内部是否有需要处理的 lambda 表达式
            val dynamicInsnNodes = methodNode.filterLambda {
                val nodeName = it.name
                val nodeDesc = it.desc
                val find = extraHookMethodList.find { point ->
                    nodeName == point.methodName && nodeDesc.endsWith(point.interfaceSignSuffix)
                }
                find != null
            }
            dynamicInsnNodes.forEach {
                val handle = it.bsmArgs[1] as? Handle
                if (handle != null) {
                    //找到 lambda 指向的目标方法
                    val nameWithDesc = handle.name + handle.desc
                    val method = methods.find { it.nameWithDesc == nameWithDesc }!!
                    shouldHookMethodList.add(method)
                }
            }    
        }

        shouldHookMethodList.forEach {
            hookMethod(modeNode = it)
        }
        accept(nextClassVisitor)
    }

    // MethodNode的拓展方法,判断注解
    // 举个栗子:我们新定义了一个注解 ViewAnnotationOnClick
    // annotationDesc就对应 ViewAnnotationOnClick的全限定路径,
    // 即:"Lcom/example/transformsaction/annotation/ViewAnnotationOnClick;"
    fun MethodNode.hasAnnotation(annotationDesc: String): Boolean {
    	return visibleAnnotations?.find { it.desc == annotationDesc } != null
	}

    // MethodNode的拓展方法,匿名内部类
    private fun MethodNode.isHookPoint(): Boolean {
        val myInterfaces = interfaces
        if (myInterfaces.isNullOrEmpty()) {
            return false
        }
        extraHookMethodList.forEach {
            if (myInterfaces.contains(it.interfaceName) && this.nameWithDesc == it.nameWithDesc) {
                return true
            }
        }
        return false
    }

    // MethodNode的拓展方法,lambda表达式
    fun MethodNode.filterLambda(filter: (InvokeDynamicInsnNode) -> Boolean): List<InvokeDynamicInsnNode> {
        val mInstructions = instructions ?: return emptyList()
        val dynamicList = mutableListOf<InvokeDynamicInsnNode>()
        mInstructions.forEach { instruction ->
            if (instruction is InvokeDynamicInsnNode) {
                if (filter(instruction)) {
                    dynamicList.add(instruction)
                }
            }
        }
        return dynamicList
	}

    // 给过滤后的方法插入字节码
    private fun hookMethod(modeNode: MethodNode) {
        // 取出描述
        val argumentTypes = Type.getArgumentTypes(modeNode.desc)
        // 得出对应描述类型在该方法参数中的位置 
        //(主要是新建ViewDoubleClickCheck。onClick(view:View)有个入参,要取被hook函数的参数传入hook方法中)
        val viewArgumentIndex = argumentTypes?.indexOfFirst {
            it.descriptor == ViewDescriptor
        } ?: -1
        if (viewArgumentIndex >= 0) {
            val instructions = modeNode.instructions
            if (instructions != null && instructions.size() > 0) {

                // 插入防抖的字节码
                val listCheck = InsnList()
                // 得出入参要取被hook函数的位置
                val index =  getVisitPosition(
                    argumentTypes,
                    viewArgumentIndex,
                    modeNode.isStatic
                )

                //参数
                listCheck.add(
                    VarInsnNode(
                        Opcodes.ALOAD, index
                    )
                )
                // 插入ViewDoubleClickCheck的调用函数的字节码
                listCheck.add(
                    MethodInsnNode(
                        Opcodes.INVOKESTATIC,
                        "com/example/transformsaction/view/ViewDoubleClickCheck",
                        "onClick",
                        "(Landroid/view/View;)Z"
                    )
                )
                // 因为是插入的字节码为判断语句,不满足的需要return
                val labelNode = LabelNode()
                listCheck.add(JumpInsnNode(Opcodes.IFNE, labelNode))
                listCheck.add(InsnNode(Opcodes.RETURN))
                listCheck.add(labelNode)

                //将新建的字节码插入instructions中
                instructions.insert(listCheck)



                // 目的是在方法末尾插入字节码
                for( node in instructions){
                    //判断是不是方法结尾的AbstractInsnNode
                    if(node.opcode == Opcodes.ARETURN || node.opcode == Opcodes.RETURN){
                        System.out.println("找到了")

                        // 创建字节码容器
                        val listEnd = InsnList()

                        // 字节码方法参数
                        listEnd.add(
                            VarInsnNode(
                                Opcodes.ALOAD, index
                            )
                        )
                        // 插入ToastClick.endClick()
                        listEnd.add(
                            MethodInsnNode(
                                Opcodes.INVOKESTATIC,
                                "com/example/transformsaction/view/ToastClick",
                                "endClick",
                                "()V"
                            )
                        )

                        // 将字节码插入到结尾node之前,使用insertBefore
                        instructions.insertBefore(node,listEnd)
                    }

                }


                // 在方法开始插入字节码
                val list = InsnList()

                list.add(
                    VarInsnNode(
                        Opcodes.ALOAD, index
                    )
                )
                // 插入ToastClick.startClick()
                list.add(
                    MethodInsnNode(
                        Opcodes.INVOKESTATIC,
                        "com/example/transformsaction/view/ToastClick",
                        "startClick",
                       "()V"
                    )
                )
                instructions.insert(list)
            }
        }
    }
    
}

InsnList

插入字节码的时候是对过滤后的shouldHookMethodList一一进行字节码插入,也就是调用 InsnList的insert方法,简单说下InsnList,InsnList提供许多插入字节码的方法:

add(final AbstractInsnNode insnNode)
末尾插入一个AbstractInsnNode

add(final InsnList insnList)
末尾插入一组InsnList

insert(final AbstractInsnNode insnNode)
头部插入一个AbstractInsnNode

insert(final InsnList insnList)
头部插入一组InsnList

insert(final AbstractInsnNode previousInsn, final AbstractInsnNode insnNode)
在previousInsn后插入一个AbstractInsnNode

insert(final AbstractInsnNode previousInsn, final InsnList insnList)
在previousInsn后插入一组InsnList

insertBefore(final AbstractInsnNode nextInsn, final AbstractInsnNode insnNode)
在previousInsn前插入一个AbstractInsnNode

insertBefore(final AbstractInsnNode nextInsn, final InsnList insnList)
在previousInsn前插入一组InsnList

4、CoreClassVisitor (core api)

class CoreClassVisitor(nextVisitor: ClassVisitor) : ClassVisitor(Opcodes.ASM7, nextVisitor) {

    override fun visitMethod(
        access: Int,
        name: String,
        desc: String,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val methodVisitor = super.visitMethod(access, name, desc, signature, exceptions)
        return MyClickVisitor(Opcodes.ASM7,methodVisitor,access,name,desc)
    }

}

就是重写一下visitMethod函数,然后将具体的逻辑传入到MyClickVisitor去实现,这里只演示在方法插入防抖的实现

class MyClickVisitor(api: Int, methodVisitor: MethodVisitor?, access: Int, name: String?,
                     val descriptor: String?
) : AdviceAdapter(api, methodVisitor,
    access,
    name, descriptor
) {

    // 注解缓存
    var visibleAnnotations: ArrayList<AnnotationNode>? = null

    // 获取注解 参考了tree api
    override fun visitAnnotation(descriptor: String?, visible: Boolean): AnnotationVisitor? {
        val annotation = AnnotationNode(descriptor)
        if(null == visibleAnnotations){
            visibleAnnotations = ArrayList()
        }
        if (visible) {
            println("添加注解:"+ descriptor)
            visibleAnnotations?.add(annotation)
        }
        return annotation
    }
     
    override fun onMethodEnter() {
        val viewArgumentIndex = argumentTypes?.indexOfFirst {
            it.descriptor == ViewDescriptor
        } ?: -1

        println("打印注解列表长度"+visibleAnnotations?.size)
        // 
        if (matchMethod(name, descriptor) || matchExitMethod()) {
            println("拦截一个")
            mv.visitVarInsn(ALOAD, getVisitPosition(
                argumentTypes,
                viewArgumentIndex,
                access and Opcodes.ACC_STATIC != 0
            ))
            mv.visitMethodInsn(
                INVOKESTATIC,
                "com/example/transformsaction/view/ViewDoubleClickCheck",
                "onClick",
                "(Landroid/view/View;)Z",
                false
            )
            val label0 = Label()
            mv.visitJumpInsn(IFNE, label0)
            mv.visitInsn(RETURN)
            mv.visitLabel(label0)
        }
        super.onMethodEnter()
    }

    private fun matchMethod(name: String, desc: String?): Boolean {
        println("拦截判断$name  $desc")
        return  (name == "onClick" && desc == "(Landroid/view/View;)V")
                || (name == "onItemClick" && desc == "(Lcom/chad/library/adapter/base/BaseQuickAdapter;Landroid/view/View;I)V")
                || (name == "onItemChildClick" && desc == "(Lcom/chad/library/adapter/base/BaseQuickAdapter;Landroid/view/View;I)V")
    }

    private fun matchExitMethod(): Boolean {
        return (hasCheckViewAnnotation() || hasButterKnifeOnClickAnnotation())
    }

    private fun hasCheckViewAnnotation(): Boolean {
        return hasAnnotation("Lcom/example/transformsaction/annotation/ViewAnnotationOnClick;")
    }

    private fun hasButterKnifeOnClickAnnotation(): Boolean {
        return hasAnnotation("Lbutterknife/OnClick;")
    }

    fun hasAnnotation(annotationDesc: String): Boolean {
        var value = visibleAnnotations?.find { it.desc == annotationDesc } != null
        println("判断注解:"+ value)
        return value
    }
}

实现逻辑基本跟TreeTestVisitor差不多,无非就是一个是用InsnList,另一个使用MethodVisitor的api进行插入。有一点就是lambda表达式的hook,我没想到好的方法,参考了tree api,能获取到对应的lambda,但是获取到是在onMethodEnter之后,没找到像InsnList一样在各个位置插入字节码的api

对比一下插入之前和之后的代码吧:

完美!!!

5、总结

基本逻辑是如上述所示,实现字节码插桩,主要考虑两个个问题:

1、字节码插桩位置在哪?

就是怎么去过滤对应的方法,使用tree api可以通过MethodNode内部的变量来过滤,即visibleAnnotations(注解),interfaces(实现的接口),instructions(可用于判断lambda表达式对应的概关键信息,以点击事件为例,lambdab表达式方法最终会被转成一个静态方法,方法名类似于“onCreatelambdalambdalambda0”,关键信息会被放在instructions中,所以可以通过instructions判断。

2、怎么把字节码插入进去 ?

就是对asm API的调用了,这个慢慢学习吧

作者:我有一头小毛驴你有吗
本文转自 [https://juejin.cn/post/7187664156995584059]

最后

如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
在这里插入图片描述
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

全套视频资料:

一、面试合集在这里插入图片描述
二、源码解析合集
在这里插入图片描述

三、开源框架合集
在这里插入图片描述
欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取↓↓↓


http://www.niftyadmin.cn/n/1035340.html

相关文章

MediaPlayer的核心-NuPlayer

MediaPlayer的核心-NuPlayer 之前整理过[Android MediaPlayer源码分析]&#xff0c;知道MediaPlayer的核心是NuPlayer实现视频的解码、渲染、同步、输出&#xff0c;这篇深入分析NuPlayer相关的知识体系 整体设计架构 下图是MediaPlayer在Android架构中的工作流程 下图是NuP…

MongoDB的安装及连接

注&#xff1a;本文基于CentOS 7.2编写 1、安装 使用yum方式安装&#xff0c;因此需要先添加repo配置&#xff0c; [rootcentos7 yum.repos.d]# cat mongodb-org-4.2.repo [mongodb-org-4.2] nameMongoDB Repository baseurlhttps://repo.mongodb.org/yum/redhat/$releasev…

通知栏的那些奇技淫巧

一、问题的由来 前几天&#xff0c;一个网友在微信群提了一个问题&#xff1a; 通知栏监听模拟点击如何实现&#xff1f; 我以为业务情景是在自己应用内&#xff0c;防止脚本模拟点击而引申出来的一个需求&#xff0c;心里还在想&#xff0c;是否可以使用自定义View——onTouch…

MongoDB的CURD基本操作(一)——创建

注&#xff1a;本文基于MongoDB 4.2.6编写 1、查看基本信息 查看数据库 > show dbs admin 0.000GB config 0.000GB local 0.000GB mydb 0.000GB查看当前使用的数据库 > db mydb切换当前使用的数据库 > use mydb switched to db mydb查看当前数据库的集合…

泛型使用方法

泛型又叫参数化类型&#xff0c;其主要描述的是在进行类&#xff0c;接口&#xff0c;方法的定义时&#xff0c;使用抽象的数据结构或者进行简单的约束&#xff0c;其真实装载的数据结构或对象关系由开发者在创建该类&#xff0c;接口&#xff0c;方法时实现&#xff0c;Androi…

MongoDB的CURD基本操作(二)——更新

注&#xff1a;本文基于MongoDB 4.2.6编写 1、更新 替换更新 比如将hello的值替换为basketball > db.bb.find().pretty() { "_id" : ObjectId("5ec29c1e6b02bc57526eb598"), "hello" : "ball" } > db.bb.update({hello: &quo…

MongoDB的CURD基本操作(三)——查找

注&#xff1a;本文基于MongoDB 4.2.6编写 1、查找 查找集合所有文档 > db.bb.find() { "_id" : ObjectId("5ec29c1e6b02bc57526eb598"), "hello" : "ball", "animals" : { "pig" : 80, "cow" : …

MongoDB的CURD基本操作(四)——删除、备份及恢复

注&#xff1a;本文基于MongoDB 4.2.6编写 1、删除 删除某个文档 > db.bb.find().pretty() {"_id" : ObjectId("5ed6549830571733ccb3d678"),"jordan" : 23,"haha" : "ending" } {"_id" : ObjectId("5…