Android AOP-ASM字节码插桩+自定义gradle插件

简介

AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。
常见的AOP工具按照生效时机区分主要分为两大类:预编译期及运行期,以下列举出市面上常用的AOP工具及对应开源框架:

1.APT工具

代表开源框架:ButterKnife、Dagger2、DBFlow、AndroidAnnotation 注解处理器 Java5 中叫APT(Annotation Processing Tool),在Java6开始,规范化为 Pluggable Annotation Processing。Apt应该是这其中我们最常见到的了,难度也最低。定义编译期的注解,再通过继承Proccesor实现代码生成逻辑,实现了编译期生成代码的逻辑。

2.AspectJ工具

AspectJ是一种严格意义上的AOP技术,因为它提供了完整的面向切面编程的注解,这样让使用者可以在不关心字节码原理的情况下完成代码的织入,因为编写的切面代码就是要织入的实际代码。

AspectJ实现代码织入有两种方式,一是自行编写.ajc文件,二是使用AspectJ提供的@Aspect、@Pointcut等注解,二者最终都是通过ajc编译器完成代码的织入。

举个简单的例子,假设我们想统计所有view的点击事件,使用AspectJ只需要写一个类即可。

@Aspect
public class MethodAspect {
    private static final String TAG = "MethodAspect5";

    //切面表达式,声明需要过滤的类和方法 
    @Pointcut("execution(* android.view.View.OnClickListener+.onClick(..))")
    public void callMethod() {
    }

    //before表示在方法调用前织入
    @before("callMethod()")
    public void beforeMethodCall(ProceedingJoinPoint joinPoint) {
        //编写业务代码
    }
}
复制代码

注解简明直观,上手难度近乎为0。

常用的函数耗时统计工具Hugo,就是AspectJ的一个实际应用,Android平台Hujiang开源的AspectJX插件灵感也来自于Hugo,详情见旧文Android 函数耗时统计工具之Hugo

AspectJ虽然好用,但也存在一些严重的问题。

  • 重复织入、不织入

AspectJ切面表达式支持继承语法,虽然方便了开发,但存在致命的问题,就是在继承树上的类可能都会织入代码,这在多数业务场景下是不适用的,比如无埋点。

另外Java8语法在aspectjx 2.0.0版本开始支持。

3.ASM

ASM是非常底层的面向字节码编程的AOP框架,理论上可以实现任何关于字节码的修改,非常硬核。许多字节码生成API底层都是用ASM实现,常见比如Groovy、cglib,因此在Android平台下使用ASM无需添加额外的依赖。完整的学习ASM必须了解字节码和JVM相关知识。
比如要织入一句简单的日志输出

Log.d("tag", " onCreate");

复制代码使用ASM编写是下面这个样子,没错因为JVM是基于栈的,函数的调用需要参数先入栈,然后执行函数入栈,最后出栈,总共四条JVM指令。

mv.visitLdcInsn("tag");
mv.visitLdcInsn("onCreate");
mv.visitMethodInsn(INVOKESTATIC, "android/util/Log", "d", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(POP);

复制代码可以看出ASM与AspectJ有很大的不同,AspectJ织入的代码就是实际编写的代码,但ASM必须使用其提供的API编写指令。一行java代码可能对应多行ASM API代码,因为一行java代码背后可能隐藏这多个JVM指令。
你不必担心不会编写ASM代码,官方提供了ASM Bytecode Outline插件可以直接将java代码生成ASM代码。

4.Javassist

javassit是一个开源的字节码创建、编辑类库,现属于Jboss web容器的一个子模块,特点是简单、快速,与AspectJ一样,使用它不需要了解字节码和虚拟机指令,这里是官方文档

javassit核心的类库包含ClassPool,CtClass ,CtMethod和CtField。

  • ClassPool:一个基于HashMap实现的CtClass对象容器。
  • CtClass:表示一个类,可从ClassPool中通过完整类名获取。
  • CtMethods:表示类中的方法。
  • CtFields :表示类中的字段。

javassit API简洁直观,比如我们想动态创建一个类,并添加一个helloWorld方法。

ClassPool pool = ClassPool.getDefault();
//通过makeClass创建类
CtClass ct = pool.makeClass("test.helloworld.Test");//创建类
//为ct添加一个方法
CtMethod helloMethod = CtNewMethod.make("public void helloWorld(String des){ System.out.println(des);}",ct);
ct.addMethod(helloMethod);
//写入文件
ct.writeFile();
//加载进内存
// ct.toClass();
复制代码

然后,我们想在helloWorld方法前后织入代码。

ClassPool pool = ClassPool.getDefault();
//获取class
CtClass ct = pool.getCtClass("test.helloworld.Test");
//获取helloWorld方法
CtMethod m = ct.getDeclaredMethod("helloWorld");
//在方法开头织入
m.insertBefore("{ System.out.print(\"before insert\");");
//在方法末尾织入 可使用this关键字
m.insertAfter("{System.out.println(this.x); }");
//写入文件
ct.writeFile();
复制代码

javassit的语法直观简洁的特点,使得在很多开源项目中都有它的身影。

5.动态代理

动态代理是代理模式的一种实现,用于在运行时动态增强原始类的行为,实现方式是运行时直接生成class字节码并将其加载进虚拟机。

各类框架总结

image.png

下面我们就以ASM这个框架给大家举例讲解

一、最终实现的效果

这次我们的目标是在Demo App启动后在MainActivity的onCreate()方法之前自动输出一段简单的日志信息“Log.e("TAG", "===== This is just a test message =====");”也就是最终我们需要将这个 代码插入到MainActivity的onCreate()方法之前。**

要达到这样的目的我们就需要使用ASM,ASM 是一个 Java 字节码操控的框架,也就是说我们可以直接操作.class文件。这样我们就可以在不侵入MainActivity类的情况下,直接达到目的。
为了实现目标我们首先需要知道几个简单的类:

1.1、ClassVisitor

首先我们是要处理单个.class文件,那肯定需要访问到这个.class文件的内容,ClassVisitor就是处理这些的,他可以拿到class文件的类名,父类名,接口,包含的方法,等等信息。

1.2、MethodVisitor

因为我们需要在方法执行前插入一些字节码,所以我们需要MethodVisitor来帮我们处理并插入字节码。真正进行方法插桩的地方。

1.3、Transform

Transform是gradle构建的时候从class文件转换到dex文件期间处理class文件的一套方案,也就是说处理class的吧。上文的ClassVisitor可以是看做处理单个class文件,那这里的话Transform可以处理一系列的class文件:从查找到所有class文件,到交给ClassVisitor和MethodVisitor处理后,再到重新覆盖原来的class文件这么一个流程。

二、开始编程

根据上文的步骤我们顺序在gradleAOP工程的plugin模块中编写ClassVisitor、MethodVisitor、以及Transform。

这里选用kotlin来编写所有脚本。所以plugin插件的module看起来是这样的:main文件夹下kotlin来分别存储对应的代码


image.png

另外要想实现这样根据语言分文件夹的效果需要在插件module的build.gradle中配置一下sourceSets ,如下代码所示。除了这些,还添加了kotlin插件以及kotlin和gradle的依赖,因为开发Transform的需要。最后是插件仓库地址的配置信息.

apply plugin: 'kotlin'
apply plugin: 'maven'

sourceSets {
    main {
        kotlin {
            srcDir "src/main/kotlin"
        }

        resources {
            srcDir 'src/main/resources'
        }
    }
}

dependencies {
    implementation gradleApi()
    implementation 'org.ow2.asm:asm:7.1'
    implementation 'com.android.tools.build:gradle:4.0.2'
}

uploadArchives {
    repositories {
        mavenDeployer {
            pom.groupId = 'com.cjh.plugin'
            pom.artifactId = 'plugin'
            pom.version = '1.0'
            //生成的文件地址
            repository(url: uri('E:/Repo'))
        }
    }
}

2.1、ClassVisitor

在ClassVisitor中我们拿到相应class的类名,比如这时候是MainActivity.class,那么类名就是““com/example/mygradleaop/MainActivity””,你可以自行打印尝试【注意这里的包名是app工程的包名,而不是gradleAOP工程的包名,因为我们是要处理的是app对吧】。匹配到类名后覆写visitMethod()方法,根据当前方法名是否匹配onCreate方法来将具体的插桩操作交给DemoMethodVisitor处理。

DemoClassVisitor类源码如下

class DemoClassVisitor(classVisitor: ClassVisitor) : ClassVisitor(Opcodes.ASM5, classVisitor) {

    private var className: String? = null

    override fun visit(
        version: Int,
        access: Int,
        name: String?,
        signature: String?,
        superName: String?,
        interfaces: Array<out String>?
    ) {
        super.visit(version, access, name, signature, superName, interfaces)
        className = name
    }


    //关键方法重写visitMethod方法
    //匹配MainActivity的onCreate方法
    //匹配到之后进去DemoMethodVisitor方法进行插桩
    override fun visitMethod(
        access: Int,
        name: String?,
        descriptor: String?,
        signature: String?,
        exceptions: Array<out String>?
    ): MethodVisitor {
        val methodVisitor = super.visitMethod(access, name, descriptor, signature, exceptions)
        //com.example.mygradleaop.MainActivity
        if (className.equals("com/example/mygradleaop/MainActivity")) {
            if (name.equals("onCreate")) {
                return DemoMethodVisitor(methodVisitor)
            }
        }

        return methodVisitor
    }
}
2.2、MethodVisitor

经过上一步ClassVisitor的处理我们已经匹配到onCreate方法了,此时我们需要在DemoMethodVisitor类中进行插入字节码操作。如下所示,直接继承自MethodVisitor,并覆写visitCode()方法。其中的代码就是我们要插入的代码了,乍一看完全不是我们平常那种Log.e("TAG", "===== This is just a test message =====");的写法,而是复杂了很多。是的,这时候你就知道visitCode中的代码和我们上边的Log信息等价就好了,等这篇文章阅读完,咱们就可以去深入学习JVM字节码的相关信息了,现在不要想那么多,直接拿去用。

DemoMethodVisitor类源码如下:

class DemoMethodVisitor(methodVisitor: MethodVisitor) : MethodVisitor(Opcodes.ASM5, methodVisitor) {

    //插入:Log.e("TAG", "===== This is just a test message =====");
    override fun visitCode() {
        super.visitCode()

        mv.visitLdcInsn("TAG")
        mv.visitLdcInsn("===== This is just a test message cjh=====")
        mv.visitMethodInsn(
            Opcodes.INVOKESTATIC,
            "android/util/Log",
            "e",
            "(Ljava/lang/String;Ljava/lang/String;)I",
            false
        )
        mv.visitInsn(Opcodes.POP)
    }
}
2.3、Transform

经过前两步的处理我们已经可以将字节码插入到MainActivity.class的onCreate方法前了,但是此时我们怎么去找到想要的.class文件呢,字节码插入完后我们又要怎么写回到.class文件呢?Transform就可以登场了,如下所示,DemoTransform继承自Transform,同时实现Plugin接口,这个plugin接口还熟悉吧,应用到resources/META-INF/gradle-plugins/xxx.properties的时候需要。然后依次实现所有必须的方法,除了transform()方法其他都是一些比较固定的写法了,直接搬过去即可:

package com.cooloongwu.plugin1

import com.android.build.api.transform.Format
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformInvocation
import com.android.build.gradle.AppExtension
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.utils.FileUtils
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassWriter
import java.io.FileOutputStream


class DemoTransform : Transform(), Plugin<Project> {

    override fun apply(project: Project) {
        println(">>>>>> 1.1.1 this is a log just from DemoTransform")
        val appExtension = project.extensions.getByType(AppExtension::class.java)
        appExtension.registerTransform(this)
    }

    override fun getName(): String {
        return "KotlinDemoTransform"
    }

    override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
        return TransformManager.CONTENT_CLASS
    }

    override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    override fun isIncremental(): Boolean {
        return false
    }

    override fun transform(transformInvocation: TransformInvocation?) {
        super.transform(transformInvocation)
    }

}

接下来是transform()方法里的内容,大致流程就是查找到所有的.class文件【代码中还添加了一些条件,过滤掉了一些class文件】,然后通过ClassReader读取并解析class文件,然后又经由我们编写的ClassVisitor和MethodVisitor处理后交给ClassWriter,最后通过FileOutputStream将新的字节码内容写回到class文件。

 /*
    * 接下来是transform()方法里的内容,大致流程就是查找到所有的.class文件
    * 【代码中还添加了一些条件,过滤掉了一些class文件】,
    * 然后通过ClassReader读取并解析class文件,然后又经由
    * 我们编写的ClassVisitor和MethodVisitor处理后交给ClassWriter,
    * 最后通过FileOutputStream将新的字节码内容写回到class文件。
    *
    *
    * */
    override fun transform(transformInvocation: TransformInvocation?) {
        super.transform(transformInvocation)
        val inputs = transformInvocation?.inputs
        val outputProvider = transformInvocation?.outputProvider

        if (!isIncremental) {
            outputProvider?.deleteAll()
        }

        inputs?.forEach { it ->
            it.directoryInputs.forEach {
                if (it.file.isDirectory) {
                    FileUtils.getAllFiles(it.file).forEach {
                        val file = it
                        val name = file.name
                        //1.过滤其他不合符条件的class文件
                        if (name.endsWith(".class") && name != ("R.class")
                            && !name.startsWith("R\$") && name != ("BuildConfig.class")
                        ) {

                            val classPath = file.absolutePath
                            println(">>>>>> classPath :$classPath")
                            //2.ClassReader读取并解析class文件
                            val cr = ClassReader(file.readBytes())
                            val cw = ClassWriter(cr, ClassWriter.COMPUTE_MAXS)
                            //3.经由我们编写的ClassVisitor和MethodVisitor处理
                            val visitor = DemoClassVisitor(cw)
                            cr.accept(visitor, ClassReader.EXPAND_FRAMES)

                            //4.通过FileOutputStream将新的字节码内容写回到class文件
                            val bytes = cw.toByteArray()
                            val fos = FileOutputStream(classPath)
                            fos.write(bytes)
                            fos.close()
                        }
                    }
                }

                val dest = outputProvider?.getContentLocation(
                    it.name,
                    it.contentTypes,
                    it.scopes,
                    Format.DIRECTORY
                )
                FileUtils.copyDirectoryToDirectory(it.file, dest)
            }

            //  !!!!!!!!!! !!!!!!!!!! !!!!!!!!!! !!!!!!!!!! !!!!!!!!!!
            //使用androidx的项目一定也注意jar也需要处理,否则所有的jar都不会最终编译到apk中,千万注意
            //导致出现ClassNotFoundException的崩溃信息,当然主要是因为找不到父类,因为父类AppCompatActivity在jar中
            it.jarInputs.forEach {
                val dest = outputProvider?.getContentLocation(
                    it.name,
                    it.contentTypes,
                    it.scopes,
                    Format.JAR
                )
                FileUtils.copyFile(it.file, dest)
            }
        }

至此,所有的插件内容基本完成了,最后就是在resources/META-INF/gradle-plugins/myplugin.properties文件中写入我们新的Plugin类:

implementation-class=com.example.gradleaop.DemoTransform

然后右侧gradle任务中执行uploadArchives,发布我们的插件到本地仓库中。
发布完成后在Demo的根build.gradle中添加依赖信息如下:

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
    ext.kotlin_version = "1.3.72"
    repositories {
        google()
        jcenter()
        maven{
            url 'E:/Repo'
        }
    }
    dependencies {
        classpath "com.android.tools.build:gradle:4.0.2"
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        //implementation-class=com.example.gradleaop.DemoTransform
        //这里的路径就是gradle插件里面发布本地插件时写的
        //classpath 'groupId:artifactId:version'
        /*
           mavenDeployer {
            pom.groupId = 'com.cjh.plugin'
            pom.artifactId = 'plugin'
            pom.version = '1.0'
            //生成的文件地址
            repository(url: uri('E:/Repo'))
        }
        */
        classpath 'com.cjh.plugin:plugin:1.0'

        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

allprojects {
    repositories {
        google()
        jcenter()
        maven{
            url 'E:/Repo'
        }
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

最后在app model下面build.gradle添加插件.这里的名称就是我们gradle插件里面清单文件
resources/META-INF/gradle-plugins/com.geo.plugin.properties的名称com.geo.plugin

apply plugin: 'com.geo.plugin'

此时直接运行Demo工程,app运行起来后在控制台是不是就看到了相应的信息呢:

2020-04-08 21:50:17.750 3804-3804/com.cooloongwu.asmdemo E/TAG: ===== This is just a test message =====

此时我们最终在MainActivity的onCreate方法前面插入了这行日志代码

三、总结

1)先明白自己想要干什么,像这个例子我们是需要在某个类的某个方法前面插入一行代码,那我们其实就是对方法进行插桩
2)先通过DemoClassVisitor匹配到需要插桩的类,这里就是MainActivity.class.匹配到onCreate方法后,就对方法进行插桩,实现类是DemoMethodVisitor
3)DemoMethodVisitor里面重写visitCode方法,把需要插入的代码转换成字节码的形式就是插入即可。这里就是最关键的地方,我们 可以利用ASM插件把对应的java代码转换成这种字节码,然后照着写入即可。
4)最后一步也就是使用Transform进行关联。需要用Transform拿到所有的类,然后中途交给前面我们编写的DemoClassVisitor和DemoMethodVisitor处理进行插桩,最后还是通过Transform写回去,这样就实现中途插入字节码的功能了,这就是字节码插桩。

项目源码:https://gitee.com/canjunhao/MyGradleAOP
引用:https://blog.csdn.net/u010976213/article/details/105395590

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

推荐阅读更多精彩内容