插桩

插桩是什么?你在开发中有用过插桩的技术吗?

所谓的插桩就是在代码编译期间修改已有的代码或者生成新代码。

android studio没有识别到我的手机设备 android studio没有找到目标设备_安卓

插桩具体在编译的哪个流程介入呢?

插桩的作用与场景

  • 代码生成
  • 代码监控
  • 代码修改
  • 代码分析

Java 源文件方式

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_02

类似 AndroidAnnotation/APT(Annotation Processing Tool),可以在代码编译期解析注解,并且生成新的 Java 文件,减少手动的代码输入。 这些代码生成的场景,它们生成的都是 Java 文件,是在编译的最开始介入。典型的有 Greendao、ButterKnife

android studio没有识别到我的手机设备 android studio没有找到目标设备_安卓_03

上图是我们项目内在用的 ORM 映射数据库 Greendao。可见 build 目录下有很多 *.java 后缀的文件,build一般都是放置编译生成后的产物,很显然这些文件就是在我们 build 时候通过注解处理器产生的 Java 文件。

字节码

android studio没有识别到我的手机设备 android studio没有找到目标设备_ASM_04

对于代码监控、代码修改以及代码分析这三个场景,一般采用操作字节码的方式。可以操作“.class”的 Java 字节码,也可以操作“.dex”的 Dalvik 字节码,这取决于我们使用的插桩方法,相对于 Java 文件方式,字节码操作方式功能更加强大,应用场景也更广,但是它的使用复杂度更高。

Java 字节码

对于 Java 平台,Java 虚拟机运行的是 Class 文件,内部对应的是 Java 字节码。

android studio没有识别到我的手机设备 android studio没有找到目标设备_字节码插桩_05

Dalvik 字节码

Android 这种嵌入式平台,为了优化性能,Android 虚拟机运行的是 Dex 文件。dex 我们可以理解为 Android 为移动设备(受限于早年的手机配置远低于 PC) 研发的 class 的压缩格式。Android SDK 工具包里面有 dx 工具可以将 class 文件打包成 dex。又由 Android 虚拟机的 PathClassLoader 装载到内存中。

身边经历过的案例

火箭兔

之前的项目工程,有 Java + kotlin + Flutter 混编,还大量应用了一些注解框架例如:ButterKnife 、Dagger、Eventbus 导致编译的耗时非常感人。时常可能在代码调试的时候只是想增加一行 Log 日志,但是编译花费的时间可能在 3~5 分钟

android studio没有识别到我的手机设备 android studio没有找到目标设备_ASM_06

使用火箭兔进行增量编译示例:

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_07

我们可以看到上图我们修改了一个 Java 文件和一个资源文件。增量编译重启 run 起 apk 的时间仅仅只花了 10s。当然这种增量编译的实现方式实现起来除了要求插桩的技术以外你还需要了解很多其他的诸如编译原理、脚本语言等综合方面的知识,实现成本不低。但是如果能在团队中推广使用用的人越多 ROI (Return on Investment)也就越高。

全局水印

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_08

类似于钉钉、飞书这类办公软件,都有防截图的需求。现在甲方就需求在 APP 的所有 activity 以及 dialog 上加上全局水印,例如带上员工的名称工号等,一个 APP 常规来讲页面加上弹窗可能有上百个。好,我们人多加班加点干,但是应用内的第三方的 Activity ,和 Dialog 你怎么办呢?

android studio没有识别到我的手机设备 android studio没有找到目标设备_ASM_09

通过 AspectJ 的插桩技术我们把几天的工作量可能还容易出错漏的需求,短短几个小时就完成了。掌握了插桩的技术再能想到好的思路(Hook点),能极大的提高我们的工作效率。

插桩方案的对比

AspectJ

AspectJ 的作为一个老牌的插桩框架优点是 1 成熟稳定 2 使用简单。但是 AspectJ 的缺点是,由于其基于规则,所以其切入点相对固定,对于字节码文件的操作自由度以及开发的掌控度就大打折扣。还有就是如果我们要实现对所有方法进行插桩,代码注入后的性能也是我们需要关注的一个重要的点,我们希望只插入我们想插入的代码,而AspectJ会额外生成一些包装代码,对性能以及包大小有一定影响。AspectJX

Javassist

Javassist 源代码级 API 比 ASM 中实际的字节码操作更容易使用。Javassist 在复杂的字节码级操作上提供了更高级别的抽象层。Javassist 源代码级 API 只需要很少的字节码知识,甚至不需要任何实际字节码知识,因此实现起来更容易、更快。Javassist使用反射机制,这使得它比运行时使用 Classworking 技术的ASM慢。Javassist

ASM

相比 AspectJ,ASM 更加直接高效。但是对于一些复杂情况,我们可能需要使用另外一种 Tree API 来完成对 Class 文件更直接的修改,因此这时候你要掌握一些必不可少的 Java 字节码知识,ASM 的特点是功能强大操作灵活,但是上手的难度也会比 AspectJ 更难,但是它能获得更好的性能,更适合大面积的插桩场景。ASM

插桩实战

android studio没有识别到我的手机设备 android studio没有找到目标设备_安卓_10

ASM

我们先对 ASM 的三个重要的角色有个了解,他们分别是:

  • ClassReader
  • ClassVisitor
  • ClassWirter
ClassReader

我们通过上文的内容大概了解到,ASM 插桩对字节码进行修改。这肯定有个读取 Class 字节码的过程。那么 ClassReadr 就是这个读取器,他提供了对字节码的读取的方法。

ClassVisitor

ClassVisitor 是 ASM 插桩的核心,因为字节码的插桩修改就是在这一个步骤进行。ClassVisitor 是基于 访问者模式

ClassWirter

顾名思义,这应该是 ASM 提供的对字节码修改完以后,将修改完的内容进行写入的工具。写入的对象包括上面读取的对象都可以是字节码数组或者他们包装了一层的字节码流(Strem)

了解了上面的 ASM 的三个核心 API 的左右下面我们来进行一个小案例的使用实践:

ASM API 实践
class Printer {
    public static void main(String[] args) {
        b();
    }

    private static void b() {
        long s = System.currentTimeMillis();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        long e = System.currentTimeMillis();
        System.out.println("executed time : " + (e - s) + " :ms");
    }
}

我们在排查一些问题的时候,可能需要去根据某个方法的耗时时间来做定位和判断。一个或者几个方法我们可以通过像上文一样手动去编写。但是我需要给数百个方法或者整个应用所有的方法全部添加这个时候靠人力手动是不现实的,这个时候如何去解决?

依赖包引入
implementation group: 'org.ow2.asm', name: 'asm-commons', version: '9.2'
读取目标字节流
FileInputStream fis = new FileInputStream("/Users/macbook/Documents/thunder/ASMDemo/app/build/intermediates/javac/debug/classes/com/thunder/asmdemo/zxm31/Printer.class");
        ClassReader classReader = new ClassReader(fis);
插入

我们先看一段伪代码或者是未完成的代码:

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_11

具体的实现我们放在后面,先了解主干流程。

写出修改后的字节码
FileOutputStream fos = new FileOutputStream("/Users/macbook/Documents/thunder/ASMDemo/app/build/intermediates/javac/debug/classes/com/thunder/asmdemo/zxm31/Printer.class");
        ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_FRAMES);
        fos.write(classWriter.toByteArray());
        fos.flush();
        fos.close();
Visitor

上文我们先暂时没有实现 accept 方法,这里我们完成它细节的实现:

//真正改变字节码的插桩的核心`
        classReader.accept(new TimeClassVisitor(ASM9, classWriter), ClassReader.EXPAND_FRAMES);

accept 方法接受一个 ClassVisitor 的一个入参:

static class TimeClassVisitor extends ClassVisitor {

        public TimeClassVisitor(int api, ClassVisitor classVisitor) {
            super(api, classVisitor);
        }
    }

ClassVisitor 内部有很多方法可以重写,我们的需求是需要对方法进行插入。这里我们来实现方法相关的函数。

@Override
        public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
            return super.visitMethod(access, name, descriptor, signature, exceptions);
        }

visitMethod 会分析字节码把字节码内所有的方法给我们输出出来,我们来验证一下:

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_12

方法的构造、入口方法、b 方法一目了然,但是 ClassVisitor 只是提供了对类里面的元素的方法,我想具体的将我们自己的代码插入方法体怎么做呢?

我注意到 visitMethod 的返回值是 MethodVisitor ,没有错就是它:

android studio没有识别到我的手机设备 android studio没有找到目标设备_ASM_13

我们来看下 MethodVisitor 内部提供了各种对方法体操作的 API ,但是针对我们这样在方法前后进行代码插入统计的需求,commons 包已经给我们提供了更简便的子类实现 AdviceAdapter,来查看一下它的继承关系:

android studio没有识别到我的手机设备 android studio没有找到目标设备_安卓_14

查看一下里面和 method 相关的方法

android studio没有识别到我的手机设备 android studio没有找到目标设备_移动开发_15

onMethodEnter
onMethodExit

正是两个空实现给我们去重写,在方法前后做插入使用 , 我们来做下日志打印的验证。

android studio没有识别到我的手机设备 android studio没有找到目标设备_安卓_16

唉~ 是我们想要的效果。铺垫了这么多,我们现在终于可以开始进行字节码插桩的修改了:

我们在 enter 想要插入的是:

long s = System.currentTimeMillis();

在 exit 想要插入的是:

long e = System.currentTimeMillis();
        System.out.println("executed time : " + (e - s) + " :ms");

我们来看下完整的字节码指令:

class com/thunder/asmdemo/zxm31/Printer {


  // access flags 0x0
  <init>()V
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x9
  public static main([Ljava/lang/String;)V
    // parameter  args
    INVOKESTATIC com/thunder/asmdemo/zxm31/Printer.b ()V
    RETURN
    MAXSTACK = 0
    MAXLOCALS = 1

  // access flags 0xA
  private static b()V
    TRYCATCHBLOCK L0 L1 L2 java/lang/InterruptedException
    INVOKESTATIC java/lang/System.currentTimeMillis ()J
    LSTORE 0
   L0
    LDC 2000
    INVOKESTATIC java/lang/Thread.sleep (J)V
   L1
    GOTO L3
   L2
    ASTORE 2
    ALOAD 2
    INVOKEVIRTUAL java/lang/InterruptedException.printStackTrace ()V
   L3
    INVOKESTATIC java/lang/System.currentTimeMillis ()J
    LSTORE 2
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    NEW java/lang/StringBuilder
    DUP
    INVOKESPECIAL java/lang/StringBuilder.<init> ()V
    LDC "executed time : "
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    LLOAD 2
    LLOAD 0
    LSUB
    INVOKEVIRTUAL java/lang/StringBuilder.append (J)Ljava/lang/StringBuilder;
    LDC " :ms"
    INVOKEVIRTUAL java/lang/StringBuilder.append (Ljava/lang/String;)Ljava/lang/StringBuilder;
    INVOKEVIRTUAL java/lang/StringBuilder.toString ()Ljava/lang/String;
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
    RETURN
    MAXSTACK = 6
    MAXLOCALS = 4
}

看着上面一堆的字节码指令,我们依稀的可以看到 b() 方法里面的内容:

似乎有一句

INVOKESTATIC java/lang/System.currentTimeMillis ()J

大概能看懂是调用一个静态方法,对应的呢我们也在 visit 中找到了一个

android studio没有识别到我的手机设备 android studio没有找到目标设备_移动开发_17

我们简单的分析下第一行语句的字节码

JVM 字节码对照语句

methodVisitor.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
            methodVisitor.visitVarInsn(LSTORE, 2);

android studio没有识别到我的手机设备 android studio没有找到目标设备_安卓_18

如果每一行指令集我们都要去这样的处理。对我们的字节码知识的要求以及对 ASM 的 API 的掌握程度有很高的要求,还慢容易出现错漏上手要求高,有没有更简便的办法呢?有:

ASM Bytecode Viewer

后文会介绍安装方式,我们先来看下使用:

android studio没有识别到我的手机设备 android studio没有找到目标设备_移动开发_19

通过这个插件,我们能够直接将 Java&Kotlin 的源代码转化成 ASM 需要的 API,如上图:

@Override
        protected void onMethodEnter() {
            System.out.println("start : ");
            visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
            visitVarInsn(LSTORE, 0);
            super.onMethodEnter();
        }

        @Override
        protected void onMethodExit(int opcode) {
            System.out.println("end : ");
            visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J", false);
            visitVarInsn(LSTORE, 2);
            visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
            visitTypeInsn(NEW, "java/lang/StringBuilder");
            visitInsn(DUP);
            visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
            visitLdcInsn("executed time : ");
            visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            visitVarInsn(LLOAD, 2);
            visitVarInsn(LLOAD, 0);
            visitInsn(LSUB);
            visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
            visitLdcInsn(" :ms");
            visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);
            visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
            visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            super.onMethodExit(opcode);
        }

执行,查看 Printer.class

android studio没有识别到我的手机设备 android studio没有找到目标设备_移动开发_20

我们的插桩算是成功了,但是有两个小问题。

1 每个方法都给插桩了,其中还存在一些没有意义我们不需要去检测的构造方法,我们是完全不需要插桩的。

2 方法打印这么多没有标识类名和方法名,到时候也分不清楚是哪个类的哪个方法耗费的多少时长、

我们利用注解来解决,我们先来定义一个注解如下:

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.METHOD)
@interface StatisticTime {
}

将其标记在我们需要的方法上,然后我们在 MethondVisitor 中有没有和注解相关的方法,还真有:

/**
   * Visits an annotation of this method.
   *
   * @param descriptor the class descriptor of the annotation class.
   * @param visible {@literal true} if the annotation is visible at runtime.
   * @return a visitor to visit the annotation values, or {@literal null} if this visitor is not
   *     interested in visiting this annotation.
   */
  public AnnotationVisitor visitAnnotation(final String descriptor, final boolean visible) {
    if (mv != null) {
      return mv.visitAnnotation(descriptor, visible);
    }
    return null;
  }

将它的描述打印:

android studio没有识别到我的手机设备 android studio没有找到目标设备_ASM_21

定义一个成员变量来标识是否对该方法进行注入;

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_22

现在只有 b 方法被注入了,我们特地在 main 方法上加个 @derprecated 注解作为测试,也正确判断了。现在第一个问题得以解决。

解决第二个问题也简单,我们再次对 ASM 的字节码信息进行改造 (onMethidExit) 。方法名好说,直接就在本类 AdviceAdapter 中可以获取,类名我们也可以通过 ClassVisitor 的 visit 方法拿到然后通过构造间接传进来,我们看改造后的字节码以及输出:

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_23

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_24

ASM Bytecode Outline 失效与解决

很多插桩的文章都提及到 ASM Bytecode Outline 工具。但是实测已经在 AS(4.2.1) 上失效,提示如下:

android studio没有识别到我的手机设备 android studio没有找到目标设备_字节码插桩_25

具体原因可能是 Android Studio 升级版本的兼容性问题,这边亲测一个可用的字节码插桩工具,推荐如下:

android studio没有识别到我的手机设备 android studio没有找到目标设备_字节码插桩_26

Displays bytecode for Java or Kotlin classes and ASMified code which will help you in your class generation. 该工具除了 Java 之外还额外支持 kotlin 的字节码查看与生成。

用法举例:

1 Preference -> Plugins -> Marketplace 中找到该插件 install 重启 AS
2 右键 Dialog -> ASM Bytecode Viewer

android studio没有识别到我的手机设备 android studio没有找到目标设备_字节码插桩_27

3 稍等工具的转化时间,就可以再 AS 的右侧面板上找到如下:

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_28

ASMified 下方展示的就是我们需要的插桩用的改变字节码文件的片段以及 ASM API 。这个工具能够简化我们插桩开发需要的知识难度,只需要了解少量或者无需了解字节码的知识我们就能完成 ASM 的插桩工作。

GradlePlugin & Transform

通过上文我们已经了解到 ASM 的用法以及字节码插桩的原理,但是似乎还有一个问题。

我们都是通过 Main 方法去做的插桩执行,如何在 Android 中自动使用呢,并且插桩的目标字节码文件都是我们自己去手动读取写入?

针对上述的问题,这个时候 GradlePlugin & Transform 就登场了

GradlePlugin 的定义有三种方式:

1 脚本 task 方式:

最简单的定义一个 task app build.gradle

task("hello"){
    doLast {
        println("--------")
    }
}

sync now 一下,拉开右侧面板

android studio没有识别到我的手机设备 android studio没有找到目标设备_安卓_29

这种方式一般用来定义一些简单的不需要复用的任务

2 buildSrc 方式:

这种方式必须要求 module 的命名也是 buildSrc,应该是介于第一种和第三种的方式中间。适合定义本工程目录下使用的方式 buildSrc方式实现自定义Gradle插件

3 Gradle 插件方式,比第一种复杂。更适合对外发布使用,更适合需要复用的场景

Gradle 插件自定义

我们来学习一下最复杂的但是应用最广泛的 GradlePlugin 的定义:

Step 1 : 创建一个 module Java 或者 Libray 都可以,最后都得大改

android studio没有识别到我的手机设备 android studio没有找到目标设备_字节码插桩_30

定义 gradle 目录,添加 groovy 语法需要的相关依赖这样后面写 groovy 文件代码才有提示。这点 intelli IDEA 支持得更好,gradle 结构如下,脚本很简单。就是提供了对 groovy 的支持,记得 sync。

plugins {
    id 'groovy'
    id 'maven'
    id 'java'
}

repositories {
    mavenCentral()
    jcenter()
}

dependencies {
    implementation gradleApi()
    implementation localGroovy()
}

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

Step 2 : main 文件下(与 Java 同级) java 文件夹后续没有用到可以删除,创建 groovy 和 resource 资源文件。

groovy 文件夹下就是我们写插件代码的地方,resource提供对外的调用

android studio没有识别到我的手机设备 android studio没有找到目标设备_移动开发_31

需要注意的是,properties 的文件命名是最终在 apply 应用的名字,这个点后文还会再讲到。另外需要注意的是上图中 TimePlugin 是可以正常跳转到 groovy 文件夹下的 TimePlugin.groovy 中,这里容易出错的就是一些包名的细节没有对应上。

我们在 groovy 下的 TimePlugin 做一个简单的 Task 构建:

class TimePlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        project.task("customPlugin") {
            doLast {
                println("I am plugin ~ !")
            }
        }
    }
}

这样其实一个最简单的 gradle 插件已经开发完毕了,这个时候我们怎么运用到我们的应用中呢?

先把插件进行上传,有两种方式一种本地方式、还有一种是将本地的插件产物上传到远程公有仓库或者自己的私有 maven 仓库。其实两者依赖的东西都是一样的,我们这里采用本地的方式:

uploadArchives {
    repositories {
        mavenDeployer {
            //deploy到maven仓库
            // 调用方式就是 'com.thunder.plugin:timeplugin:1.0-SNAPSHOT'
            pom.groupId = 'com.thunder.plugin'
            pom.version = '1.0-SNAPSHOT'
            pom.artifactId = 'timeplugin'
            repository(url: uri('../../repo')) //deploy到本地仓库
        }
    }
}

在 gradle 脚本中我们添加上传到本地的 Task 任务,Sync 后执行这个任务。找到对应的目录我们去看一下插件产物

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_32

好了,所有的准备工作完毕了。终于我们可以在 APP 中应用这个插件了:

自定义插件的应用与验证

首先在整个工程的根 build.gradle 下(注意此处是根 build.gradle )添加:

android studio没有识别到我的手机设备 android studio没有找到目标设备_安卓_33

然后 Sync,这个时候很有可能成功也有可能遇到如下的情况:

android studio没有识别到我的手机设备 android studio没有找到目标设备_字节码插桩_34

这个报错就是没有找到本地的插件产物,切记检查

maven {url uri('../../repo')}

这个路径的层级需要比 uploadArchives 中的少一级,因为根 gradle 和插件的 gradle 的相对层级就是少一级

根 gradle 依赖配置完毕然后就是 app 的 gradle 中的应用了

plugins {
    id 'com.android.application'
    id 'time-plugin'
}

time-plugin 这个 id 刚好和插件中的 resouce 的名称对应上:

android studio没有识别到我的手机设备 android studio没有找到目标设备_ASM_35

Sync 后我们来查看一下 APP 的右侧 task 中有没有我们 plugin 插件中定义的 task

android studio没有识别到我的手机设备 android studio没有找到目标设备_字节码插桩_36

android studio没有识别到我的手机设备 android studio没有找到目标设备_ASM_37

可以找到,我们执行一下

android studio没有识别到我的手机设备 android studio没有找到目标设备_安卓_38

至此,我们一个简单的自定义插件完毕。

Transform

完成了插件的自定义和应用后我们来看一下 Transform

android studio没有识别到我的手机设备 android studio没有找到目标设备_字节码插桩_39

Gradle Transform 是 Android 官方提供给开发者在项目构建阶段(.class -> .dex转换期间)用来修改 .class 文件的一套标准API

Plugin gradle 中添加 :

implementation 'com.android.tools.build:gradle:4.2.2', {
        exclude group:'org.ow2.asm'
    }

否则会找不到目标的 Transform,注意它的包名:

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_40

时间关系,这边 transform 的处理基本都是模板代码,就是对自身文件目录和第三方的处理。直接上代码了

import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import org.apache.commons.io.FileUtils
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.ClassWriter
import org.objectweb.asm.Opcodes

class TimeTransform extends Transform {

    @Override
    String getName() {
        return "TimeTransform"
    }

    /**
     * 需要处理的数据类型,有两种枚举类型
     * CLASS->处理的java的class文件
     * RESOURCES->处理java的资源
     * @return
     */
    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS
    }

    /**
     * 指 Transform 要操作内容的范围,官方文档 Scope 有 7 种类型:
     * 1. EXTERNAL_LIBRARIES        只有外部库
     * 2. PROJECT                   只有项目内容
     * 3. PROJECT_LOCAL_DEPS        只有项目的本地依赖(本地jar)
     * 4. PROVIDED_ONLY             只提供本地或远程依赖项
     * 5. SUB_PROJECTS              只有子项目。
     * 6. SUB_PROJECTS_LOCAL_DEPS   只有子项目的本地依赖项(本地jar)。
     * 7. TESTED_CODE               由当前变量(包括依赖项)测试的代码
     * @return
     */
    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    /**
     * 是否增量编译
     * @return
     */
    @Override
    boolean isIncremental() {
        return false
    }
    
    /**
     *
     * @param context
     * @param inputs 有两种类型,一种是目录,一种是 jar 包,要分开遍历
     * @param outputProvider 输出路径
     */
    @Override
    void transform(Context context,
                   Collection<TransformInput> inputs,
                   Collection<TransformInput> referencedInputs,
                   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 TimeClassVisitor(Opcodes.ASM9, 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)
    }

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

注意看到我们需要重写的 transform 方法就在遍历 APP 中的 class , 其他的代码就是上文 ASM 中照搬过来改下包名基本不需要做改动。

android studio没有识别到我的手机设备 android studio没有找到目标设备_字节码插桩_41

就不一一贴出来了

最终如何对定义好的 transform 做使用呢?

class TimePlugin implements Plugin<Project> {

    @Override
    void apply(Project project) {
        def android = project.extensions.findByType(AppExtension)
        android.registerTransform(new TimeTransform())
    }
}

两行代码注册即可,注册完毕后我们更新插件产物,Sync。然后去 APP 里面找 Android 环境试一下:

android studio没有识别到我的手机设备 android studio没有找到目标设备_移动开发_42

观察日志:

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_43

大功告成,我们从零开始,熟悉 ASM 、GradlePlugin、Transform 实现了一个简单的字节码插桩,虽然简单。但是再复杂的 ASM + GradlePlugin + Transform 的插桩流程和核心就是如此,只是对 API 的掌握程度不一样,未来我们也一定能够利用插桩的技术和 AOP 的思想来更好的帮助我们编程开发。

Transform API 的废弃与插桩技术未来

技术一直在迭代变化,有消息称在 Gradle 7.0 Transform API 被废弃,这是不是我们之前的学习没有用了呢。我们完全不用担心,目前我们工程里面(Thunder)的 Gradle 版本才 4.8。我们工作开发多年 JDK 也才从 5 ~ 8 的版本

android studio没有识别到我的手机设备 android studio没有找到目标设备_android_44

但是 Oracle 的官网已经更新到了 17,另外一点不管插桩技术怎么变迁那些底下核心的例如:注解、反射、ClassLoader、字节码等核心技术应该还是不会变的、只要我们把这些核心技术掌握好,新的技术出来我们也能够很快去掌握。

结语

插桩技术能就像孙悟空领悟了七十二变。你的脑洞有多大,背后的世界就有多大。