(拉钩)Android工程师进阶34讲-04:编译插桩操纵字节码,实现不可能完成的任务

0. 前言

前面介绍了Java字节码文件的格式,并通过一个Demo手动模拟了JVM解析class文件的过程。

本节看看,对于class文件还有其他什么玩法。

需求:

记录每个页面的打开和关闭事件,并通过各种Data Tracking的框架上传到服务器,用来日后做数据分析。

面对这样的需求,一般人会想到在每个Activity的onCreateonDestory方法中,分别添加页面打开和关闭的逻辑。常见的做法有以下两种:

  • 1、修改项目中现有的每一个Activity,这样做,如果项目以后需要添加新的页面,这套逻辑需要多次拷贝,容易遗漏。
  • 2、将项目中所有的Activity继承BaseActivity,将页面打开和关闭的逻辑添加在BaseActivity,这样做比1要好一些。但是这种方法对于第三方依赖库中的界面就无法添加。

这时有一种更加优雅的处理方式:编译插桩

1. 编译插桩是什么

编译插桩就是在代码编译期间修改已有的代码或者生成新的代码。实际上,项目中用到的Dagger、ButterKnife甚至是Kotlin语言,都用到了编译插桩技术。

Android项目中.java文件的编译过程:

从上图可以看出,在1、2两处对代码进行改造。

  • 1、在.java文件编译成.class文件时,ART、AndroidAnnotation等就是在此处触发代码生成。
  • 2、在.class文件进一步优化成.dex文件时,也就是直接操作字节码文件,也是本节的内容。这种方式功能更加强大,应用场景也更多。但是门槛比较高,需要对字节码有一定理解。

本节主要介绍第2种实现方式,用一张图描述如下过程,其中红色虚框包含了本节的所有内容。

一般情况下,经常会使用编译插桩实现如下几种功能:

  • 日志埋点;
  • 性能监控;
  • 动态权限控制;
  • 业务逻辑跳转时,校验是否已经登录;
  • 甚至是代码调试等。

2. 插桩工具介绍

目前主要流行两种实现编译插桩的方式:

  • AspectJ:AspectJ是老牌的AOP(Aspect-Oriented Programming)框架,如果做过J2EE可能会对这个框架更熟悉,经常拿这个框架和Spring AOP进行比较。其主要优势是成熟稳定,使用者也不需要对字节码有深入的理解。
  • ASM:通过ASM可以修改现有的字节码文件,也可以动态生成字节码文件,并且它是一款完全以字节码层面来操纵字节码并分析字节码的框架。

举例,在Java中实现两个数相加操作,可以如下实现:

如果直接使用ASM直接编写字节码指令,则有可能是如下几个字节码指令:

有工具可以帮助实现这些字节码指令。

本节就是使用ASM来实现简单的编译插桩效果,通过插桩实现最开始提出的需求,在每个Activity打开时输出相应的log日志。

3. 实现思路

主要包含两步:

  • 1、遍历项目中所有的.class文件:如何找到项目中编译生成的所有.class文件,是需要解决的第一个问题。众所周知,Android Studio使用Gradle编译项目中的.java文件,并且从Gradle 1.5.0之后,可以自定义Transform,来获取所有.class文件引用。但是Transform的使用需要依赖Gradle Plugin。因此第一步需要创建一个单独的Gradle Plugin,并在Gradle Plugin中使用自定义Transform找出所有的.class文件
  • 2、遍历到目标.class文件(Activity)之后,通过ASM动态注入需要被插桩的字节码:第一步找到所有的.class文件,接下来就是过滤出目标Activity文件,并在目标Activity文件的onCreate方法中,通过ASM插入相应的log日志字节码

4. 具体实现

4.1 创建ASMLifeCycleDemo项目

创建ASMLifeCycleDemo项目:

4.2 创建自定义Gradle插件

首先在ASMLifeCycleDemo项目中创建一个新的module,并选择Android Library类型,命名为asm_lifecyle_plugin。

并将asm_lifecyle_plugin中除了build.gradle和main文件夹之外的所有内容都删除。然后在main目录下分别创建groovy和java目录。

因为Gradle插件是使用Groovy编写的,所以需要创建一个groovy目录,用来存放插件相关的.groovy类。但是ASM是java层面的框架,所以在java目录里存放ASM相关的类。

然后,在groovy中创建目录tyler.liu.plugin,并在此目录中创建类LifeCyclePlugin.groovy文件。在LifeCyclePlugin中重写apply方法,实现插件逻辑,这里只是简单打印。

可以看出LifeCyclePlugin实现了gradle api中的Plugin接口。当在build.gradle文件中使用此插件时,其LifeCyclePluginapply方法将会被自动调用。

下面将asm_lifecycle_plugin中的build.gradle的内容全部删掉,换成如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
apply plugin: 'groovy'
apply plugin: 'maven'

dependencies {
implementation fileTree(dir: 'libs', includes: ['*.jar'])

implementation gradleApi()
implementation localGroovy()

implementation 'com.android.tools.build:gradle:3.4.2'
}

group = 'tyler.lifecycle.plugin'
version = '1.0.0'

uploadArchives {
repositories {
mavenDeployer {
// 本地的Maven地址配置
repository(url: uri('../asm_lifecycle_repo'))
}
}
}

groupversion都需要在app mudule引用此插件时使用。

所有的插件都需要被部署到maven库中,可以选择部署到远程或本地。这里只是演示,所以只是将插件部署到本地目录中。具体地址通过repository属性设置,这里将其配置在项目根目录下的asm_lifecycle_repo目录下。

最后一步,创建properties文件。

在plugin/src/main目录下新建目录resources/META-INF/gradle-plugins,然后在此目录下新建文件:tyler.asm.lifecycle.properties,其中文件名tyler.asm.lifecycle就是自定义插件的名称,后面在app module中会使用到。

在.properties文件中,需要指定自定义的插件类名LifeCyclePlugin,如下:

至此,自定义Gradle插件就已经写完,现在在Android Studio右栏找到Gradle中点击uploadArchives,执行plugin的部署任务。

构建成功后,在Project的根目录下将会出现一个repo目录,里面存放的就是插件目标文件。

4.3 测试asm_lifecycle_plugin

在app module的build.gradle引用此插件。

  • 1、在自定义Gradle插件中properties的文件名(tyler.asm.lifecycle)。
  • 2、dependencies中的classpathgroup值 + module名 + version。

然后在执行命令行中使用gradlew执行构建命令,如果打印出插件里的log,说明自定义插件可以使用:

也有比较成熟的第三方Gradle插件,如hiBever。

4.4 自定义Transform,实现遍历.class文件

自定义Gradle插件已经写好,接下来就需要实现遍历所有.class的逻辑。这部分主要依赖Transform API。

4.4.1 什么是Transform?

Transform可以被看做是Gradle在编译项目时的一个task,在.class文件转换成.dex的流程中会执行这些Task,对所有.class文件(可包含第三方库的.class)进行转换,转换的逻辑定义在Transform的transform方法中。实际上平时在build.gradle中常用的功能都是通过Transform实现的,比如混淆(proguard)、分包(multi-dex)、jar包合并(jarMerge)。

4.4.2 自定义Transform

在tyler.liu.plugin目录中,新建LifeCycleTransform.groovy,继承Transform类,并需要实现里面的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package tyler.liu.plugin

import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform

public class LifeCycleTransform extends Transform {
@Override
String getName() {
return null
}

@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return null
}

@Override
Set<? super QualifiedContent.Scope> getScopes() {
return null
}

@Override
boolean isIncremental() {
return false
}

@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation)
}
}

Transform的主要作用是检索项目编译过程中的所有文件。通过这几个方法,可以对自定义Transform设置一些遍历规则,具体如下:

  • getName():设置自定义的Transform对应的Task名称。Gradle在编译的时候,会将这个名称显示在控制台上。比如:app:transformClassesWithXXXForDebug

  • getInputTypes():在项目中会有各种各样格式的文件,通过getInputType()可以设置LifeCycleTransform接收的文件类型,此方法的返回值类型是Set<QualifiedContent.ContentType>

    ContentType有以下两种取值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    /**
    * The type of of the content.
    */
    enum DefaultContentType implements ContentType {
    /**
    * The content is compiled Java code. This can be in a Jar file or in a folder. If
    * in a folder, it is expected to in sub-folders matching package names.
    */
    CLASSES(0x01),

    /** The content is standard Java resources. */
    RESOURCES(0x02);

    private final int value;

    DefaultContentType(int value) {
    this.value = value;
    }

    @Override
    public int getValue() {
    return value;
    }
    }
    • CLASSES:表示只检索.class文件。
    • RESOURCES:表示检索java标准资源文件。
  • getScopes():这个方法规定自定义Transform检索的范围。
    有以下取值:

    1
    2
    3
    4
    5
    6
    7
    PROJECT                 // 只有项目内容
    SUB_PROJECTS // 只有子项目
    EXTERNAL_LIBRARIES // 只有外部库
    TESTED_CODE // 由当前变量(包括依赖项)测试的代码
    PROVIDED_ONLY // 只提供本地或远程依赖项
    PROJECT_LOCAL_DEPS // 只有项目的本地依赖(本地jar)
    SUB_PROJECTS_LOCAL_DEPS // 只有子项目的本地依赖项(本地jar)
  • isIncremental:表示当前Transform是否支持增量编译,不需要增量编译,就直接返回false

  • transform():在自定义Transform中最重要的方法。在这个方法中可以获取两个数据的流向。

    • inputsinputs中传过来的输入流,其中有两种格式,一种是jar包格式,一种是directory(目录格式)。
    • outputProvideroutputProvider获取到输出目录,最后将修改的文件复制到输出目录,这一步必须做,否则编译报错。

下面实现了一个简单的LifeCycleTransform,功能是打印出所有.class文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package tyler.liu.plugin

import com.android.build.api.transform.DirectoryInput
import com.android.build.api.transform.QualifiedContent
import com.android.build.api.transform.Transform
import com.android.build.api.transform.TransformException
import com.android.build.api.transform.TransformInput
import com.android.build.api.transform.TransformInvocation
import com.android.build.gradle.internal.pipeline.TransformManager
import groovy.io.FileType

public class LifeCycleTransform extends Transform {
// 1
@Override
String getName() {
return "LifeCycleTransform";
}

// 2
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}

// 3
@Override
Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.PROJECT_ONLY
}

@Override
boolean isIncremental() {
return false
}

// 4
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
// 拿到所有的class文件
Collection<TransformInput> transformInputs = transformInvocation.inputs
transformInputs.each { TransformInput transformInput ->
// directoryInputs代表以源码方式参与项目编译的所有目录结构及其目录下的源码文件
// 比如手写的类以及R.class、BuildConfig.class以及MainActivity.class等
transformInput.directoryInputs.each { DirectoryInput directoryInput ->
File dir = directoryInput.file
if (dir) {
dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) { File file ->
System.out.println("find class: " + file.name)
}
}
}
}
}
}
  • 1、自定义的Transform的名称为LifeCycleTransform
  • 2、检索项目中的.class类型的目录或者文件。
  • 3、设置当前Transform检索范围为当前项目。
  • 4、设置过滤文件为.class文件(去除文件夹类型),并打印文件名称。

4.4.2 将自定义的LifeCycleTransform注册到Gradle插件中

LifeCyclePlugin中添加如下代码:

1
2
3
4
5
6
7
8
9
10
public class LifeCyclePlugin implements Plugin<Project> {
void apply(Project project) {
System.out.println("==LifeCyclePlugin gradle plugin==")

def android = project.extensions.getByType(AppExtension)
println '------------- registering AutoTrackTransform -------------'
LifeCycleTransform transform = new LifeCycleTransform()
android.registerTransform(transform)
}
}

再次执行build,可以看到LifeCycleTransform检索出的所有.class文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
Executing tasks: [clean, :app:assembleDebug] in project E:\Project\MyStudyDemo\ASMLifeCycleDemo


> Configure project :app
==LifeCyclePlugin gradle plugin==
------------- registering AutoTrackTransform -------------

> Task :clean
> Task :app:clean UP-TO-DATE
> Task :asm_lifecycle_plugin:clean UP-TO-DATE
> Task :app:preBuild UP-TO-DATE
> Task :app:preDebugBuild UP-TO-DATE
> Task :app:generateDebugBuildConfig
> Task :app:compileDebugAidl NO-SOURCE
> Task :app:compileDebugRenderscript NO-SOURCE
> Task :app:javaPreCompileDebug
> Task :app:generateDebugResValues
> Task :app:generateDebugResources
> Task :app:createDebugCompatibleScreenManifests
> Task :app:extractDeepLinksDebug
> Task :app:processDebugManifest
> Task :app:mergeDebugResources
> Task :app:processDebugResources
> Task :app:compileDebugJavaWithJavac
> Task :app:compileDebugSources
> Task :app:mergeDebugShaders
> Task :app:compileDebugShaders NO-SOURCE
> Task :app:generateDebugAssets UP-TO-DATE
> Task :app:mergeDebugAssets
> Task :app:processDebugJavaRes NO-SOURCE

> Task :app:transformClassesWithLifeCycleTransformForDebug
find class: BuildConfig.class
find class: MainActivity.class

> Task :app:checkDebugDuplicateClasses
> Task :app:dexBuilderDebug
> Task :app:mergeDebugJavaResource
> Task :app:mergeDebugJniLibFolders
> Task :app:validateSigningDebug
> Task :app:mergeExtDexDebug
> Task :app:mergeDebugNativeLibs
> Task :app:stripDebugDebugSymbols NO-SOURCE
> Task :app:mergeDexDebug
> Task :app:packageDebug
> Task :app:assembleDebug

Deprecated Gradle features were used in this build, making it incompatible with Gradle 7.0.
Use '--warning-mode all' to show the individual deprecation warnings.
See https://docs.gradle.org/6.1.1/userguide/command_line_interface.html#sec:command_line_warnings

BUILD SUCCESSFUL in 5s
24 actionable tasks: 22 executed, 2 up-to-date

Build Analyzer results available

可以看到,Gradle编译时,多了一个自定义的LifeCycleTransform类型的任务,并且将所有.class文件打印出来,其中包含目标文件MainActivity.class。

4.5 使用ASM,插入字节码到Activity中

ASM是一套开源框架,其中几个常见的API如下:

  • ClassReader:负责解析.class文件中的字节码,并将所有字节码传递给ClassWriter
  • ClassVisitor:负责访问.class文件中的各个元素,ClassVisitor就是用来解析上节说到的.class文件结构,当解析到某些特定结构时(比如类变量、方法),它会自动调用内部相应的FieldVisitorMethodVisitor的方法,进一步解析或修改.class文件内容。
  • ClassWriter:继承自ClassVisitor,它是生成字节码的工具类,负责将修改后的字节码输出为byte数组。

4.5.1 添加ASM依赖

asm_lifecycle_pligun的build.gradle中,添加ASM的依赖:

1
2
3
4
5
6
dependencies {
...
// ASM依赖
implementation "org.ow2.asm:asm:7.1"
implementation "org.ow2.asm:asm-commons:7.1"
}

4.5.2 创建自定义ASM Visitor类

asm_lifecycle_plugin中的src/main/java目录下创建包tyler.liu.asm,并分别创建LifeCycleClassVisitor.javaLifeCycleMethodVisitor.java

LifeCycleClassVisitor.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package tyler.liu.asm;

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

/**
* @author Liuyang
* @date 2020/6/29
*/
public class LifecycleClassVisitor extends ClassVisitor {
private String className;
private String superName;

public LifecycleClassVisitor(ClassVisitor classVisitor) {
super(Opcodes.ASM5, classVisitor);
}

@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, name, signature, superName, interfaces);
this.className = className;
this.superName = superName;
}

@Override
public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
System.out.println("ClassVisitor visitMethod name -----" + name + ", superName is" + superName);
MethodVisitor mv = cv.visitMethod(access, name, descriptor, signature, exceptions);

if ("android/support/v7/app/AppCompatActivity".equals(superName)) {
if (name.startsWith("onCreate")) {
// 处理onCreate方法
return new LifeCycleMethodVisitor(mv, className, name);
}
}
return mv;
}

@Override
public void visitEnd() {
super.visitEnd();
}
}

"android/support/v7/app/AppCompatActivity".equals(superName),用来过滤出继承AppCompatActivity的文件,并在LifeCycleMethodVisitor.java中对onCreate进行改造。

LifeCycleMethodVisitor.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package tyler.liu.asm;

import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

/**
* @author Liuyang
* @date 2020/6/29
*/
public class LifeCycleMethodVisitor extends MethodVisitor {
private String className;
private String methodName;

public LifeCycleMethodVisitor(MethodVisitor methodVisitor, String className, String methodName) {
super(Opcodes.ASM5, methodVisitor);
this.className = className;
this.methodName = methodName;
}

/**
* 方法执行前插入
*/
@Override
public void visitCode() {
super.visitCode();
System.out.println("MethodVisitor visitCode ----------------");

mv.visitLdcInsn("TAG");
mv.visitLdcInsn(className + "----->" + methodName);
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "android/util/Log", "i", "(Ljava/lang/String;Ljava/lang/String;)I", false);
mv.visitInsn(Opcodes.POP);
}
}

visitCode()中,是真正执行插入字节码的逻辑。可以看出ASM都是直接以字节码指令的方式进行操作的,所以如果想使用ASM,需要对字节码由一定了解。如果对字节码不是很了解,也可以使用第三方工具ASM ByteCode Outline来生成想要的字节码。

4.5.3 修改LifeCycleTransform的tranform方法,使用ASM

各种Visitor定义好之后,就可以修改LifeCycleTransformtransform方法,并将需要插桩的字节码插入到MainActivity.class文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package tyler.liu.plugin

import com.android.build.api.transform.*
import com.android.build.gradle.internal.pipeline.TransformManager
import com.android.utils.FileUtils
import groovy.io.FileType
import org.objectweb.asm.ClassReader
import org.objectweb.asm.ClassVisitor
import org.objectweb.asm.ClassWriter
import tyler.liu.asm.LifecycleClassVisitor

public class LifeCycleTransform extends Transform {
// 1
@Override
String getName() {
return "LifeCycleTransform";
}

// 2
@Override
Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS
}

// 3
@Override
Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.PROJECT_ONLY
}

@Override
boolean isIncremental() {
return false
}

// 4
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
// 拿到所有的class文件
Collection<TransformInput> transformInputs = transformInvocation.inputs
TransformOutputProvider outputProvider = transformInvocation.outputProvider

transformInputs.each { TransformInput transformInput ->
// directoryInputs代表以源码方式参与项目编译的所有目录结构及其目录下的源码文件
// 比如手写的类以及R.class、BuildConfig.class以及MainActivity.class等
transformInput.directoryInputs.each { DirectoryInput directoryInput ->
File dir = directoryInput.file
if (dir) {
dir.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) { File file ->
System.out.println("find class: " + file.name)
// 对class文件进行读取和解析
ClassReader classReader = new ClassReader(file.bytes)
// 对class文件的写入
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS)
// 访问class文件相应的内容,解析到某一个结构就会通知到ClassVisitor的相应方法
ClassVisitor classVisitor = new LifecycleClassVisitor(classWriter)
// 依次调用ClassVisitor接口的各个方法
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
// toByteArray方法会将最终修改的字节码以byte数组形式返回
byte[] bytes = classWriter.toByteArray()
// 通过文件流写入方式覆盖掉原先的内容,实现class文件的改写
FileOutputStream outputStream = new FileOutputStream(file.path)
outputStream.write(byte)
outputStream.close()
}
}
// 处理完传输文件后,把输出传给下一个文件
def dest = outputProvider.getContentLocation(directoryInput.name, directoryInput.contentTypes,
directoryInput.scopes, Format.DIRECTORY)
FileUtils.copyDirectory(directoryInput.file, dest)
}
}
}
}

4.5.4 重新部署自定义Gradle插件,并运行主项目

重新点击uploadAtchives重新部署LifeCyclePlugin。

部署成功后,重新运行APP主项目,当MainActivity被打开时,会在logcat看到如下日志:

1
2020-06-30 10:00:10.333 5421-5421/com.ly.asmlifecycledemo I/TAG: com/ly/asmlifecycledemo/MainActivity---->onCreate

如果后续有新的Activity,如BActivity.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.ly.asmlifecycledemo;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;

/**
* @author Liuyang
* @date 2020/6/30
*/
public class BActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_b);
}
}

并在MainActivity中实现点击按钮跳转到BActivity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

Button btn = findViewById(R.id.btn);
btn.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
startActivity(new Intent(MainActivity.this, BActivity.class));
}
});
}
}

打印日志如下:

1
2
2020-06-30 10:13:21.105 5647-5647/com.ly.asmlifecycledemo I/TAG: com/ly/asmlifecycledemo/MainActivity---->onCreate
2020-06-30 10:13:31.546 5647-5647/com.ly.asmlifecycledemo I/TAG: com/ly/asmlifecycledemo/BActivity---->onCreate

如果在项目中打开混淆,注入的字节码还能正常工作吗?混淆其实也是一个Transform,叫做ProguardTransform,它是在自定义的Transform之后执行。

  • Copyrights © 2019-2020 Tyler Liu

请我喝杯咖啡吧~

支付宝
微信