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

2,068 阅读3分钟

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,重写visitInvokeDynamicInsn方法,调用时就创建一个InvokeDynamicInsnNode,保存到缓存列表里,然后通过判断保存的列表是否包含对应的接口以及方法,就可以判断对应的lambda是否是目标方法:

override fun visitInvokeDynamicInsn(
  name: String?,
  descriptor: String?,
  bootstrapMethodHandle: Handle?,
  vararg bootstrapMethodArguments: Any?
) {
  println("添加lambda:"+ descriptor+"  "+name)
  instructions.add(
      InvokeDynamicInsnNode(
          name, descriptor?.split(")")?.get(1) ?: descriptor, bootstrapMethodHandle, *bootstrapMethodArguments
      )
  )
}
fun filterLambda(filter: (InvokeDynamicInsnNode) -> Boolean): List<InvokeDynamicInsnNode> {
    val mInstructions = instructions
    val dynamicList = mutableListOf<InvokeDynamicInsnNode>()
    mInstructions.forEach { instruction ->
        if (instruction is InvokeDynamicInsnNode) {
            if (filter(instruction)) {
                dynamicList.add(instruction)
            }
        }
    }
    return dynamicList
}

但是获取到是在onMethodEnter之后,没找到像InsnList一样在各个位置插入字节码的api,后续再优化吧。

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

完美!!!

5、总结

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

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

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

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

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

6、鸣谢

本文是参考了 juejin.cn/post/704232… ASM 字节码插桩:实现双击防抖,感谢大佬,原文有源码,可自行下载。

我看过后,想对自己理的解做一下梳理,所以才有此文。自己的代码地址gitee.com/wlr123/tran…