android自定义Gradle插件和asm的用法的一些知识

1 什么是插桩?

听到关于“插桩”的词语,第一眼觉得会很高深,那到底什么是插桩呢?用通俗的话来讲,插桩就是将一段代码通过某种策略插入到另一段代码,或替换另一段代码。这里的代码可以分为源码和字节码,而我们所说的插桩一般指字节码插桩。
图1是Android开发者常见的一张图,我们编写的源码(.java)通过javac编译成字节码(.class),然后通过dx/d8编译成dex文件。

image.png

我们下面要讲的插桩,就是在.class转为.dex之前,修改.class文件从而达到修改或替换代码的目的。
那有人肯定会有这样的疑问?既然插桩是插入或替换代码,那为何我不自己直接插入或替换呢?为何还要用这么“复杂”的工具?别着急,第二个问题将会给你答案。

2 插桩的应用场景有哪些?

技术是服务于业务的,一个无法推进业务进步的技术并不值得我们学习。在上面,我们对插桩的理解是:插入,替换代码。那么,结合这个核心主线我们来挖掘插桩能被应用的场景有哪些?

  • 代码插入

我们所熟悉的ButterKnife,Dagger这些常用的框架,也是在编译期间生成了代码,简化了程序员的操作。假设有这么一个需求,要监控某些或者所有方法的执行耗时?你会怎么做呢?如果你监控的方法只有十几个或者几十个,那么也许通过程序员自身的编码就能轻松解决;但是如果监控的方法达到百千甚至万级别,你还通过编码来解决?那么程序员存在的价值在哪里?面对这样的重复劳动问题,最先想到的就应该是自动化,也就是我们今天所讲的插桩。通过插桩,我们扫描每一个class文件,并针对特定规则进行字节码修改从而达到监控每个方法耗时的目的。关于如何实现这样的需求,后面我会详细讲述。

  • 代码替换

如果遇到这么一个需求,需要将项目中所有使用某个方法(如Dialog.show())的地方替换成自己包装的方法(MyDialog.show()),那么你该如何解决呢?有人会说,直接使用快捷键就能全局替换。那么有两个问题
1 如果有其他类定义了show()方法,并被调用了,直接使用快捷键是否会被错误替换?
2 如果其他引用包使用了该方法,你怎么替换呢?
没关系,插桩同样可以解决你的问题。
综合上面所说的两点,其实很多业务场景都使用了插桩技术,比如无痕埋点,性能监控等。

3 掌握插桩应该具备的基础知识有哪些?

  • 熟练掌握字节码相关技术。可参考 一文让你明白Java字节码

  • Gradle自定义插件,直接参考官网 Writing Custom plugins

  • 如果你想运用在Android项目中,那么还需要掌握Transform API,
    这是android在将class转成dex之前给我们预留的一个接口,在该接口中我们可以通过插件形式来修改class文件。

  • 字节码修改工具。如AspectJ,ASM,javasisst。这里我推荐使用ASM,关于ASM相关知识,在下一章我给大家简单介绍。同样大家可以参考 Asm官方文档

  • groovy语言基础
    如果你具备了上面5块知识,那么恭喜你,会很顺利的完成字节码插桩技术了。下面,我通过实战一个很简单的例子,带领大家一起领略插桩的风采。

4 使用ASM进行字节码插桩

1 什么是ASM?

ASM是生成和转换已编译的Java类工具,就是我们插桩需要使用的工具。

2 两种API?

ASM提供了两种API来生成和转换已编译类,一个是核心API,以基于事件形式来表示类;另一个是树API,以基于对象形式来表示类。

3 基于事件形式

我们通过上面的基础知识,了解到类的结构,类包含字段,方法,指令等;基于事件的API把类看作是一系列事件来表示,每一个类的事件表示一个类的元素。类似解析XML的SAX

4 基于对象形式

基于对象的API将类表示成一棵对象树,每个对象表示类的一部分。类似解析XML的DOM

5 优缺点比较
截屏2020-07-22 下午5.25.43.png

通过上面表格,我们清楚的了解到:

  • 事件API内存占用少于对象API,因为事件API不需要在内存中创建和存储对象树
  • 事件API实现难度比对象API大,因为事件API在任意时刻类中只有一个元素可使用,但是对象API能获得整个类。
    那么接下来,我们就通过比较容易实现的对象API入手,一起完成上面的需求。
    我们Android的构建工具是Gradle,因此我们结合transform和Gradle插件方式来完成该需求,接下来我们来看看gradle官方提供的3种插件形式
6 Gradle插件的3种形式
截屏2020-07-22 下午5.25.52.png

ASM

ASM是一种基于java字节码层面的代码分析和修改工具,ASM的目标是生成,转换和分析已编译的java class文件,可使用ASM工具读/写/转换JVM指令集。通俗点讲就是来处理javac编译之后的class文件

Java字节码

Java字节码是Java虚拟机执行的一种指令格式。通俗来讲字节码就是经过javac命令编译之后生成的Class文件。Class文件包含了Java虚拟机指令集和符号表以及若干其他的辅助信息。Class是一组以8位字节为基础单位的二进制文件。各个数据项目严格按照顺序紧凑的排列在Class文件之中。中间没有任何分隔符,这使得整个Class文件中存储的内容几乎全是程序运行时的必要数据。

class文件有固定的结构,保留了几乎所有的源代码文件中的符号。class文件的结构:

image.png
  • 描述类的modifier,name,父类,接口和注释
  • 描述类中变量的modfier,名字,类型和注释
  • 描述类中方法和构造函数的modifier,名字参数类型,返回类型,注释等信息,当然也包含已编译成java字节码指令序列的方法具体内容
  • class文件的静态池区域,用来保存所有的数字,字符串,类型的常量,这些常量只被定义过一次且被其他class中区域所引用
    一个Java文件编译之后可能对应多个class文件。

字节码描述符


  • Class文件中使用全限定名来表示一个类的引用,即把类名所有“.”换成了“/”,例如:
    android.content.Context在class中文android/content/Context

  • 数据类型
    数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值以及代表无返回值的void类型都用一个大写字符来表示


    截屏2020-07-22 下午5.54.04.png

    如:
    String[] -> [Ljava/lang/String;
    int[][] -> [[I;

  • 方法
    方式使用(), 按照参数列表,返回值的顺序表示。 例如:
    void init() -> ()V
    void test(object) -> (Ljava/lang/object;)V
    String[] getArray(String s) -> (Ljava/lang/String;)[Ljava/lang/String;

1. 采用groovy创建插件

一,新建一个Java Library module,在新建的javaModule中,删除 src->main 下面的java目录,新建一个groovy目录在groovy目录下创建类似javapackage 实际上是groovy语言的包。
注意:如果是kotlin 语言建立java的目录,java语言也是java目录,根据不同的语言来建立文件夹 这边是groovy语言就用groovy目录

二,在 src->main 下面创建一个 resources 目录,在resources目录下依次创建META-INF/gradle-plugins 目录,最后在该目录下创建一个名为 com.hm.plugin.lifecycle.properties的文本文件,文件名是你要定义的插件名,按需自定义即可。最后的工程结构如图所示:
注意: 这边的META-INF/gradle-plugins不是一个文件夹名字叫META-INF/gradle-plugins 而是META-INF文件夹下有个gradle-plugins文件夹

image.png

修改module的build.gradle文件,引入groovy插件等:

apply plugin: 'java-library'
apply plugin: 'groovy'
apply plugin: 'maven'

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    compile gradleApi()
    compile localGroovy()
    compile 'com.android.tools.build:transform-api:1.5.0'
    compile 'com.android.tools.build:gradle:3.0.1'
}

sourceCompatibility = "1.7"
targetCompatibility = "1.7"

//通过maven将插件发布到本地的脚本配置,根据自己的要求来修改
uploadArchives {
    repositories.mavenDeployer {
        pom.version = '1.0.0'
        pom.artifactId = 'hmlifecyclepluginlocal'
        pom.groupId = 'com.heima.iou'
        repository(url: "file:///Users/hjy/.m2/repository/")
    }
}

注意:根据不同语言依赖不同的语言插件

apply plugin:'java-library'
apply plugin:'kotlin'
apply plugin:'groovy'
apply plugin:'maven'

如果是kotlin语言编写插件,就要用apply plugin:'kotlin'

这里有几点需要说明的是:

  1. 通常都是采用groovy语言来创建gradle plugin的,groovy是兼容java的,你完全可以采用java来编写插件。关于groovy语言,了解一些基础语法就足够支撑我们去编写插件了。
  2. src/main/resources/META-INF/gradle-plugins目录下定义插件声明,*.properties文件的文件名就是插件名称。

2. 实现Plugin接口

要编写一个插件是很简单的,只需实现Plugin接口即可。

package com.hm.iou.lifecycle.plugin

import com.android.build.gradle.AppExtension
import org.gradle.api.Plugin
import org.gradle.api.Project

class LifeCyclePlugin implements Plugin<Project>{
    @Override
    void apply(Project project) {
        println "------LifeCycle plugin entrance-------"
    }
}

接着在com.hm.plugin.lifecycle.properties文件里增加配置:

implementation-class=com.hm.iou.lifecycle.plugin.LifeCyclePlugin

其中implementation-class的值为Plugin接口的实现类的全限定类名,至此为止一个最简单的插件编写好了,它的功能很简单,仅仅是在控制台打印一句文本而已。

插件的发布

截屏2020-07-22 下午2.30.27.png
截屏2020-07-22 下午5.06.42.png

我们通过maven将该插件发布到本地的maven仓库里,发布成功后,我们在app module里引入该插件,修改app module目录下的build.gradle文件,增加如下配置

apply plugin: 'com.android.application'
//引入自定义插件,插件名与前面的*.properties文件的文件名是一致的
apply plugin: 'com.hm.plugin.lifecycle'
buildscript {
    repositories {
        google()
        jcenter()
        //自定义插件maven地址,替换成你自己的maven地址
        maven { url 'file:///Users/hjy/.m2/repository/' }
    }
    dependencies {
        //通过maven加载自定义插件
        classpath 'com.heima.iou:hmlifecyclepluginlocal:1.0.0'
    }
}

我们build一下工程,在Gradle Console里会打印出"------LifeCycle plugin entrance-------"来,这说明我们的自定义插件成功了。

讲到这里可以看到,按这个步骤实现一个gradle插件是很简单的,它并没有我们想象中那么高深莫测,你也可以自豪地说我会制作gradle插件了。

3. Gradle Transform

然而前面这个插件并没有什么卵用,它仅仅只是在编译时,在控制台打印一句话而已。那么怎么通过插件在打包前去扫描所有的class文件呢,幸运的是官方给我们提供了Gradle Transform技术,简单来说就是能够让开发者在项目构建阶段即由class到dex转换期间修改class文件,Transform阶段会扫描所有的class文件和资源文件,具体技术我这里不详细展开,下面通过伪代码部分说下我的思路。

public class CustomTransform extends Transform {
    @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation);
        //当前是否是增量编译(由isIncremental() 方法的返回和当前编译是否有增量基础)
        boolean isIncremental = transformInvocation.isIncremental();
        //消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
        Collection<TransformInput> inputs = transformInvocation.getInputs();
        //OutputProvider管理输出路径,如果消费型输入为空,你会发现OutputProvider == null
        TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();

        for(TransformInput input : inputs) {
            for(JarInput jarInput : input.getJarInputs()) {
                File dest = outputProvider.getContentLocation(
                        jarInput.getFile().getAbsolutePath(),
                        jarInput.getContentTypes(),
                        jarInput.getScopes(),
                        Format.JAR);
                //将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了        
                FileUtils.copyFile(jarInput.getFile(), dest);
            }

            for(DirectoryInput directoryInput : input.getDirectoryInputs()) {
                File dest = outputProvider.getContentLocation(directoryInput.getName(),
                        directoryInput.getContentTypes(), directoryInput.getScopes(),
                        Format.DIRECTORY);
                //将修改过的字节码copy到dest,就可以实现编译期间干预字节码的目的了        
                FileUtils.copyDirectory(directoryInput.getFile(), dest);
            }
        }
    }
    @Override
    public String getName() {
        return "CustomTransform";
    }
    @Override 
    public boolean isIncremental() {
        return true; //是否开启增量编译
    }
    @Override
    public Set<QualifiedContent.ContentType> getInputTypes() {
        return TransformManager.CONTENT_CLASS;
    }
    @Override
    public Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT;
    }
}

Transform两个过滤纬度

image.png

ContentType,数据类型,有CLASSES和RESOURCES两种。
其中的CLASSES包含了源项目中的.class文件和第三方库中的.class文件。
RESOURCES仅包含源项目中的.class文件。
对应getInputTypes() 方法。

Scope,表示要处理的.class文件的范围,主要有
PROJECT, SUB_PROJECTS,EXTERNAL_LIBRARIES等。
对应getScopes() 方法。

什么是增量编译

我理解的增量编译:
1、基于Task的上次输出快照和这次输入快照对比,如果相同,则跳过相应任务;
2、基于Task本身是否支持增量更新。

3.4、增量编译实验

3.4.1、Transform 的isIncremental()返回true。
@Override
public boolean isIncremental() {
    return true;
}

(1)、clean之后,第一次编译,即使Transform里面isIncremental()返回true,Transform开启了增量编译,此时对Transform来说仍然不是增量编译, transform方法中isIncremental = false;

(2)、不做任何改变直接进行第二次编译,Transform别标记为up-to-date,被跳过执行;

(3)、修改一个文件中代码,进行第三次编译,此时对Transform来说是增量编译,transform方法中isIncremental = true。

3.4.2、Transform 的isIncremental()返回false。

@Override
public boolean isIncremental() {
    return false;
}

(1)、clean之后,第一次编译,此时对Transform来说不是增量编译, transform方法中isIncremental = false;

(2)、不做任何改变直接进行第二次编译,Transform别标记为up-to-date,被跳过执行;

(3)、修改一个文件中代码,进行第三次编译,此时对Transform来说不是增量编译,transform方法中isIncremental = false。

结论:1、一次编译对Transform来说是否是增量编译取决于两个方面:
(1)、当前编译是否有增量基础;
(2)、当前Transform是否开启增量编译。

结论:2、不管Transform是否开启增量编译,若TransformTask的当前输入快照和上次输出快照相同,则跳过当前TransformTask。

2.5、支持增量编译

Transform支持增量编译分为两步:
(1)重写Transform的接口方法:isIncremental(),返回true。

@Override 
public boolean isIncremental() {
    return true;
}

2)判断当前编译对于Transform是否是增量编译:
如果不是增量编译,就按照前面的方式,依次处理所有的class文件;
(比如说clean之后的第一次编译没有增量基础,即使Transform的isIncremental放回true,当前编译对Transform仍然不是增量编译,所有需要依次处理所有的class文件)
如果是增量编译,根据每个文件的Status,处理文件:
如果文件有改变,就按照前面的方式,去处理这个问题。
如果文件没有改变,就不需要进行处理,因为在输出目录已经有一个上次处理过的class文件了
(NOTCHANGED: 当前文件不需处理,甚至复制操作都不用;
ADDED、CHANGED: 正常处理,输出给下一个任务;
REMOVED: 移除outputProvider获取路径对应的文件。)

注意:当前编译对于Transform是否是增量编译受两个方面的影响:
(1)isIncremental() 方法的返回值;
(2)当前编译是否有增量基础;(clean之后的第一次编译没有增量基础,之后的编译有增量基础)

增量的时间缩短为全量的速度提升了3倍多,而且这个速度优化会随着工程的变大而更加显著。

2.6、支持并发编译

private WaitableExecutor waitableExecutor = WaitableExecutor.useGlobalSharedThreadPool();
//异步并发处理jar/class
waitableExecutor.execute(() -> {
    bytecodeWeaver.weaveJar(srcJar, destJar);
    return null;
});
waitableExecutor.execute(() -> {
    bytecodeWeaver.weaveSingleClassToFile(file, outputFile, inputDirPath);
    return null;
});  
//等待所有任务结束
waitableExecutor.waitForTasksWithQuickFail(true);

为什么要等待所有任务结束?
如果不等待,主线程就会进入下一个任务的处理,可能当前的任务的处理工作还没完成。

并发Transform和非并发Transform下,编译速度提高了80%。

修改Plugin接口实现类,在插件中注册该Transfrom:

class LifeCyclePlugin implements Plugin<Project>{
    @Override
    void apply(Project project) {
        println "------LifeCycle plugin entrance-------"
        def android = project.extensions.getByType(AppExtension)
        android.registerTransform(new LifeCycleTransform(project))
    }
}

运行完以后,在引用这个插件的目录下的build目录下会看到如下这个目录

注意:在transform中的transform(TransformInvocation transformInvocation)``` 这个方法中如果啥都不写会报这个错误
截屏2020-07-22 下午3.38.19.png

就是因为在这个方法中必须要实现如下内容


截屏2020-07-22 下午3.45.29.png

这个方法对应的就是下面这个目录文件,/intermediates/transforms/LifeCycleTransform/debug 这个目录
如果没有写会是这个样子的

截屏2020-07-22 下午3.52.21.png

这样就会导致截屏2020-07-22 下午3.38.19.png,你如果之前没有写截屏2020-07-22 下午3.45.29.png这个代码,导致了上边的错误,但是如果你在transform 中已经写了这段代码,还是报截屏2020-07-22 下午3.38.19.png这个错误

那是因为,你没有删除app的目录下的build的目录,
clean project下,就把app的build的目录和project的build的目录都就清空了,这样运行,就没有截屏2020-07-22 下午3.38.19.png这个错误,

/intermediates/transforms/LifeCycleTransform/debug 这个目录下,如下

image.png

在app->build->intermediates->transforms中,可以看到所有的Transform,包括我们刚才自定义的Transform。从上图中可以看到,这里的0.jar、1.jar、2.jar等等,都是通过outputProvider.getContentLocation()方法来生成的,这个Transform目录下的class文件、jar包等,会当做下一个Transform的inputs传递过去。

asm的原理:

image.png

ASM Core API可以类比解析XML文件中的SAX方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。好处是非常节约内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用Core API。在Core API中有以下几个关键类:

• ClassReader:用于读取已经编译好的.class文件。

• ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。

• 各种Visitor类:如上所述,CoreAPI根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的Visitor,比如用于访问方法的MethodVisitor、用于访问类变量的FieldVisitor、用于访问注解的AnnotationVisitor等。为了实现AOP,重点要使用的是MethodVisitor。

4. 通过ASM动态修改字节码

到现在,我们只剩下最后一步了,那就是如何注入代码了。ASM 是一个 Java 字节码操控框架,它能被用来动态生成类或者增强既有类的功能。我这里对ASM不做详细介绍了,主要是介绍使用ASM动态注入代码的思路。

首先,我们修改一下AppLifeCycleManager类,增加动态注入字节码的入口方法:

    /**
     * 通过插件加载 IAppLike 类
     */
    private static void loadAppLike() {
    }

    //通过反射去加载 IAppLike 类的实例
    private static void registerAppLike(String className) {
        if (TextUtils.isEmpty(className))
            return;
        try {
            Object obj = Class.forName(className).getConstructor().newInstance();
            if (obj instanceof IAppLike) {
                APP_LIKE_LIST.add((IAppLike) obj);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 初始化
     *
     * @param context
     */
    public static void init(Context context) {
        //通过插件加载 IAppLike 类
        loadAppLike();
        Collections.sort(APP_LIKE_LIST, new AppLikeComparator());
        for (IAppLike appLike : APP_LIKE_LIST) {
            appLike.onCreate(context);
        }
    }

相比之前,这里增加了一个loadAppLike()方法,在init()方法调用时会先执行。通过前面Transform步骤之后,我们现在的目标是把代码动态插入到loadAppLike()方法里,下面这段代码是我们期望插入后的结果:

private static void loadAppLike() {
  registerAppLike("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleAAppLike$$Proxy");
  registerAppLike("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleBAppLike$$Proxy");
  registerAppLike("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleCAppLike$$Proxy");
  registerAppLike("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleDAppLike$$Proxy");
}

这样在初始化时,就已经知道要加载哪些生命周期类,来看看具体实现方法,关于ASM不了解的地方,需要先搞清楚其使用方法再来阅读:

class AppLikeCodeInjector {

    //扫描出来的所有 IAppLike 类
    List<String> proxyAppLikeClassList

    AppLikeCodeInjector(List<String> list) {
        proxyAppLikeClassList = list
    }

    void execute() {
        println("开始执行ASM方法======>>>>>>>>")

        File srcFile = ScanUtil.FILE_CONTAINS_INIT_CLASS
        //创建一个临时jar文件,要修改注入的字节码会先写入该文件里
        def optJar = new File(srcFile.getParent(), srcFile.name + ".opt")
        if (optJar.exists())
            optJar.delete()
        def file = new JarFile(srcFile)
        Enumeration<JarEntry> enumeration = file.entries()
        JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(optJar))
        while (enumeration.hasMoreElements()) {
            JarEntry jarEntry = enumeration.nextElement()
            String entryName = jarEntry.getName()
            ZipEntry zipEntry = new ZipEntry(entryName)
            InputStream inputStream = file.getInputStream(jarEntry)
            jarOutputStream.putNextEntry(zipEntry)

            //找到需要插入代码的class,通过ASM动态注入字节码
            if (ScanUtil.REGISTER_CLASS_FILE_NAME == entryName) {
                println "insert register code to class >> " + entryName

                ClassReader classReader = new ClassReader(inputStream)
                // 构建一个ClassWriter对象,并设置让系统自动计算栈和本地变量大小
                ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS)
                ClassVisitor classVisitor = new AppLikeClassVisitor(classWriter)
                //开始扫描class文件
                classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)

                byte[] bytes = classWriter.toByteArray()
                //将注入过字节码的class,写入临时jar文件里
                jarOutputStream.write(bytes)
            } else {
                //不需要修改的class,原样写入临时jar文件里
                jarOutputStream.write(IOUtils.toByteArray(inputStream))
            }
            inputStream.close()
            jarOutputStream.closeEntry()
        }

        jarOutputStream.close()
        file.close()

        //删除原来的jar文件
        if (srcFile.exists()) {
            srcFile.delete()
        }
        //重新命名临时jar文件,新的jar包里已经包含了我们注入的字节码了
        optJar.renameTo(srcFile)
    }

    //插入字节码的逻辑,都在这个类里面
    class AppLikeClassVisitor extends ClassVisitor {
        AppLikeClassVisitor(ClassVisitor classVisitor) {
            super(Opcodes.ASM5, classVisitor)
        }

        @Override
        MethodVisitor visitMethod(int access, String name,
                                  String desc, String signature,
                                  String[] exception) {
            println "visit method: " + name
            MethodVisitor mv = super.visitMethod(access, name, desc, signature, exception)
            //找到 AppLifeCycleManager里的loadAppLike()方法,我们在这个方法里插入字节码
            if ("loadAppLike" == name) {
                mv = new LoadAppLikeMethodAdapter(mv, access, name, desc)
            }
            return mv
        }
    }

    class LoadAppLikeMethodAdapter extends AdviceAdapter {

        LoadAppLikeMethodAdapter(MethodVisitor mv, int access, String name, String desc) {
            super(Opcodes.ASM5, mv, access, name, desc)
        }

        @Override
        protected void onMethodEnter() {
            super.onMethodEnter()
            println "-------onMethodEnter------"
            //遍历插入字节码,其实就是在 loadAppLike() 方法里插入类似registerAppLike("");的字节码
            proxyAppLikeClassList.forEach({proxyClassName ->
                println "开始注入代码:${proxyClassName}"
                def fullName = ScanUtil.PROXY_CLASS_PACKAGE_NAME.replace("/", ".") + "." + proxyClassName.substring(0, proxyClassName.length() - 6)
                println "full classname = ${fullName}"
                mv.visitLdcInsn(fullName)
                mv.visitMethodInsn(INVOKESTATIC, "com/hm/lifecycle/api/AppLifeCycleManager", "registerAppLike", "(Ljava/lang/String;)V", false);
            })
        }

        @Override
        protected void onMethodExit(int opcode) {
            super.onMethodExit(opcode)
            println "-------onMethodEnter------"
        }
    }

}

最后重新编译插件再运行,验证结果。

这里有个比较困难的地方,就是需要使用ASM编写class字节码。我这里推荐一个比较好用的方法:

  1. 将要注入的java源码先写出来;
  2. 通过javac编译出class文件;
  3. 通过asm-all.jar反编译该class文件,可得到所需的ASM注入代码;

执行命令如下:

java -classpath "asm-all.jar" org.objectweb.asm.util.ASMifier com/hm/lifecycle/api/AppLifeCycleManager.class

从中找到loadAppLike()方法字节码处,这样通过ASM注入代码就比较简单了:

{
mv = cw.visitMethod(ACC_PRIVATE + ACC_STATIC, "loadAppLike", "()V", null, null);
mv.visitCode();
mv.visitLdcInsn("com.hm.iou.lifecycle.apt.proxy.Heima$$ModuleAAppLike$$Proxy");
mv.visitMethodInsn(INVOKESTATIC, "com/hm/lifecycle/api/AppLifeCycleManager", "registerAppLike", "(Ljava/lang/String;)V", false);
mv.visitInsn(RETURN);
mv.visitMaxs(1, 0);
mv.visitEnd();
}

注意:

  1. 每个module的build的目录,每一次build的时候,都会生成一个build的目录

  2. transform 每一次的调试,用println来调试,会发现运行了一次transform,第二次在运行程序,就会发现看不到Log了,如果你要看Log,就要把依赖插件的Module的build的目录删除掉,才可以看到Log 或者clean project

  3. TransformInput:所谓Transform就是对输入的class文件转变成目标字节码文件,TransformInput就是这些输入文件的抽象。目前它包括两部分:DirectoryInput集合与JarInput集合

  4. DirectoryInput:它代表着以源码方式参与项目编译的所有目录结构及其目录下的源码文件,可以借助于它来修改输出文件的目录结构、已经目标字节码文件

  5. JarInput:它代表着以jar包方式参与项目编译的所有本地jar包或远程jar包,可以借助于它来动态添加jar包。

  6. TransformOutputProvider:它代表的是Transform的输出,例如可以通过它来获取输出路径

  7. getName:用于指明本Transform的名字,也是代表该Transform的task的名字

  8. ContentType,数据类型,
    有CLASSES和RESOURCES两种。
    其中的CLASSES包含了源项目中的.class文件和第三方库中的.class文件。
    RESOURCES仅包含源项目中的.class文件

  9. getInputTypes:用于指明Transform的输入类型,可以作为输入过滤的手段。在TransformManager定义了如下的几种类型:

dir.traverse(type: FileType.FILES,nameFilter:~/.*\.class/) { file ->
  System.out.println("find class:" + file.name)
 }

这个是遍历目录 File.traverse File里边的方法
~/.*\.class/这个是正则表达式

  1. 遍历文件夹
def dir = new File("/")
//eachFile()方法返回该目录下的所有文件和子目录,不递归
dir.eachFile { file ->
    println file.name
}
dir.eachFileMatch(~/.*\.txt/) {file ->
    println file.name
}
  1. 递归遍历文件夹
def dir = new File("/")
//dir.eachFileRecurse()方法会递归显示该目录下所有的文件和目录
dir.eachFileRecurse { file ->
    println file.name
}
dir.eachFileRecurse(FileType.FILES) { file ->
    println file.name
}
  1. 一些更复杂的遍历方法你可以使用traverse方法,但需要你设置一个特殊的标志指示如何遍历:
dir.traverse { file ->
    //如果当前文件是一个目录且名字是bin,则停止遍历
    if (file.directory && file.name=='bin') {
        FileVisitResult.TERMINATE
    //否则打印文件名字并继续
    } else {
        println file.name
        FileVisitResult.CONTINUE
   }
}
  1. gradlew命令:在MacOs系统,在Android studio下面的终端,使用./gradlew aR命令打包的时候,

gradle明明一般是./gradlew +参数, gradlew代表 gradle wrapper,意思是gradle的一层包装,大家可以理解为在这个项目本地就封装了gradle,即gradle wrapper, 在gradle/wrapper/gralde-wrapper.properties文件中声明了它指向的目录和版本。只要下载成功即可用grdlew wrapper的命令代替全局的gradle命令。
./gradlew -v 版本号
./gradlew clean 清除app目录下的build文件夹
./gradlew build 检查依赖并编译打包
./gradlew tasks 列出所有task
这里注意的是 ./gradlew build 命令把debug、release环境的包都打出来,如果正式发布只需要打Release的包,该怎么办呢,下面介绍一个很有用的命令 assemble, 如:
./gradlew assembleDebug 编译并打Debug包
./gradlew assembleRelease 编译并打Release的包
除此之外,assemble还可以和productFlavors结合使用:
./gradlew installRelease Release模式打包并安装
./gradlew uninstallRelease 卸载Release模式包

  1. android 的文件夹开头为小写字母
    包名都是小写字母的
    如果文件夹开头是大写的话,会找不到dataBinding

androidStudio 调试插件开发

1、创建remote调试任务:
选择 Eidt Configurations


image.png

点左上角的 + 号,选择 remote。Name可以随意命名,其他配置可以不用动,端口就5005,点ok关闭


image.png

配置完以后


image.png

2、打开Terminal窗口(一般在底下的工具栏上),在当前的工程目录下,输入 (注意:在android studio的窗口下的Terminal中执行)
执行如下命令:

./gradlew assembleDebug -Dorg.gradle.daemon=false -Dorg.gradle.debug=true。

assembleDebug 可以为其他的构建命令,但参数-Dorg.gradle.daemon=false -Dorg.gradle.debug=true要有。

如果报:-bash: ./gradlew: Permission denied
修改权限:chmod +x gradlew

Terminal的命令中点回车后,会出现 To honour the JVM settings for this build a new JVM will be forked. 这行提示,并且会一直停在这里,说明在等待调试

开始调试

这时候选择第二步中创建的remote任务,并使用调试启动(下图最右边的调试按钮) 就是选择 debug 调试 这个时候打开Terminal 就可以看到了调试的过程

image.png

run是插入的一些知识)

image.png

这边run的按钮,运行的assembleDebug/assembleRelease
执行的是 assembleRelease 还是 assembleDebug 实际是由 build variants 设置的类型决定的。

image.png
image.png

运行是没有调试功能的,直接运行转手机

debug:直接段点调试

attach debug
想象一下下面的场景:你的APK如果已经运行在普通模式(非Debug)的情况下,你突然想Debug,而又不想重新运行浪费时间,该怎么办呢?

这边的普通模式,就是点击了run 按钮

普通模式下想设置断点进行调试可不可以呢?

当然是可以的,不仅可以,这种方式已经渐渐替代了原先的方案,毕竟很方便,不是吗?那具体要怎么做呢


image.png

二、点击Attach调试

image.png
image.png

attach process到指定进程,条件触发之后就可以直接进入调试模式

jclasslib bytecode viewer 字节码查看

jclasslib bytecode viewer 是一个可以可视化已编译Java类文件和所包含的字节码的工具。 另外,它还提供一个库,可以让开发人员读写Java类文件和字节码

2.3 安装和使用
2.3.1 安装

建议直接通过idea的插件库搜索安装然后重启即可,下面我已经安装过了。


image.png

点击 Install安装,安装后点击 Restart IDE 重启 IDEA


image.png

这边注意,用这个插件看字节码,要是java文件或者groovy文件,kotlin文件看不到,kotlin文件需要dex反编译成class文件,用jd-gui.jar查看

2.3.2使用
使用时直接选择 View --> Show Bytecode With jclasslib


image.png

注意:如果是自己项目的源码需要先编译


image.png

jclasslib窗口(查看字节码)


image.png

可以查看基本信息、常量池、接口、属性、函数等信息。
主要优点:

1 不需要使用javap指令,使用简单

2 点击字节码指令可以跳转到 java虚拟机规范对应的章节。

比如我们想了解 putstatic 的含义,可以点击该指令

如何查看插桩后字节码的效果呢?(反编译)

就是把字节码插桩完以后,在apk中找到那个文件进行反编译
1、dex2jar 官方下载地址
作用:将APK直接解压后,目录下包含的一个classes.dex文件反编译为classes-dex2jar.jar文件。

2、jd-gui.jar 官方下载地址
作用:直接查看classes-dex2jar.jar文件。

方法:

将dex2jar.jar解压成文件夹
将test.apk后缀名修改为.rar然后解压(.apk 也可以直接解压)
将test.apk解压后的目录下包含的classes.dex文件复制到dex2jar解压后的文件夹中

(classes.dex文件与d2j-dex2jar.bat文件同在一个目录中)


image.png

打开cmd命令编辑器

进入classes.dex文件与d2j-dex2jar.bat所在文件目录

输入命令sh d2j-dex2jar.sh classes.dex

此时可以看到目录中多出了classes-dex2jar.jar文件

报错命令如下:
d2j-dex2jar.sh: line 36: ./d2j_invoke.sh: Permission denied
解决方案:sudo chmod +x d2j_invoke.sh
image.png

3、jd-gui(这个工具是把class文件翻译成基本的代码)
双击运行 jd-gui-1.4.0.jar 文件,
将.jar文件拖到工作区即可打开。


image.png
截屏2020-07-23 下午3.54.26.png

asm bytecode 查看,就是asm中插入代码用的

查看 Bytecode 方法
查看 Java 代码:
通过 ASM Bytecode Outline 插件生成代码
1、在 Android Studio 中安装 ASM Bytecode Outline 插件;
2、安装后,在编译器中,点击右键,选择 Show Bytecode outLine; 3、在 ASM 标签中选择 ASMified,即可在右侧看到当前类对应的 ASM 代码。
查看 kotlin 代码:
通过 AS 自带的工具查看
路径:AS 导航栏-Tools-kotlin-show kotlin bytecode

java 用ASM Bytecode Outline 查看asmBytecode
image.png
kotlin 用自带AS 导航栏-Tools-kotlin-show kotlin bytecode
image.png

javap指令查看字节码(和jclasslib效果一样)

javap -v xx.class

借鉴连接

三种方式插桩

Android中Gradle插件和Transform

组件化自定义Gradle插件

字节码链接

查看asmbyteCode的连接

Android Gradle Transform 详解

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