Transform是Android官方插件提供给开发者在项目构建阶段件的一套api。目前典型的由class到dex转换之前修改Class应用就是字节码插桩技术
Transform的整体运行流程
这里的resource不是指的是安卓中的resource,而是指Java的资源:
不过一般的,我们都是用来处理class文件。
我们所写的配置文件一般都会被打包成一个jar包,jar包里边会包含resource资源。
另外,对于每一个transform,在build阶段,都会出现如下的log:
js
:app:transformClassesWithTestForDebug
其中,transform是固定的;Test表示的是当前transform的名称;ForDebug表示的是当前是在debug环境下构建的。 当前除了这种自定义的,还有很多系统的流程,这都是由安卓系统实现的:
js
:app:transformClassesWithDexBuilderForDebug
这个就是class转化成dex文件。
我们自定的transform一般是在最开始执行的。这也是需要的,因为我们改完之后的代码需要被加载进入dex文件才能正常运行,实现编译时修改代码的能力。
java
@Override
void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
TransformOutputProvider outputProvider = transformInvocation.outputProvider
//清理文件
outputProvider.deleteAll()
def inputs = transformInvocation.inputs
inputs.each {
// 所有的jar文件的输入,包含了所有的非自己的模块,有aar和jar。三方的jar也在里边。
def jarInputs = it.jarInputs
jarInputs.each {
// 如果开启了增量编译(isIncremental() = true),这里会看到jar包的状态, 删除,新增等。
println (it.status)
}
// 所有的目录的输入。就是自己写的代码的,如下图所示
def dIs = it.directoryInputs
dIs.each {
// 文件有
def changeFiles = it.changedFiles
changeFiles.entrySet().each {
// 如果开启了增量编译,这里会看到类的状态, 删除,新增等。
println(it.key.name + it.value.name())
}
}
}
}
isIncremental()如果这个值为false,就相当于每一次都是初次构建。
上图就是directoryInputs的位置。
自定义的Transform的全部代码以及对应的解释:
typescript
class ASMTransform extends Transform {
@Override
public String getName() {
return "asm";
}
/**
* 处理所有class
*
* @return
*/
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS;
}
/**
* 用来处理范围,可以决定是否包含三方库等。
* 范围是整个项目所有的类,包含依赖的库
*
* @return
*/
@Override
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.SCOPE_FULL_PROJECT;
}
/**
* 不使用增量
* @return true表示
*/
@Override
public boolean isIncremental() {
return false;
}
/**
* android插件将所有的class通过这个方法告诉给我们
* 我们这个transform的输出就是下一个transform的输入。
* @param transformInvocation
* @throws TransformException
* @throws InterruptedException
* @throws IOException
*/
@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
TransformOutputProvider outputProvider = transformInvocation.outputProvider
//清理文件
outputProvider.deleteAll()
def inputs = transformInvocation.inputs
inputs.each {
// 所有的jar文件的输入,包含了所有的非自己的模块,有aar和jar。三方的jar也在里边。
def jarInputs = it.jarInputs
jarInputs.each {
// 如果开启了增量编译(isIncremental() = true),这里会看到jar包的状态, 删除,新增等。
println (it.status)
}
// 所有的目录的输入。就是自己写的代码的,如下图所示
def dIs = it.directoryInputs
dIs.each {
// 文件有
def changeFiles = it.changedFiles
changeFiles.entrySet().each {
// 如果开启了增量编译,这里会看到类的状态, 删除,新增等。
println(it.key.name + it.value.name())
}
}
}
}
}
ASM插桩
首先我们需要准备一份class文件,用来做插桩用,接着需要利用ClassReader这样一个类。它里边有一个accept这样一个api,通过里边的ClassVisitor,可以用来访问类,访问方法,访问注解,或者操作他们。然后通过MethodVisitor来实现插入。
例如我们想在所有的增加了@ASMTest注解的代码中增加统计执行时长的代码:
java
public class InjectTest {
public InjectTest() {
}
@ASMTest
public static void main(String[] var0) throws InterruptedException {
Thread.sleep(1000L);
}
}
// 我们希望修改成这样。
public class InjectTest {
public InjectTest() {
}
@ASMTest
public static void main(String[] var0) throws InterruptedException {
long var1 = System.currentTimeMillis();
Thread.sleep(1000L);
long var3 = System.currentTimeMillis();
System.out.println("execute:" + (var3 - var1));
}
}
为了实现修改,我们需要分别将上边的两段代码编译成字节码文件,查看其中的差异,然后再通过上边图中提到的方式来对字节码进行修改。 这样就实现了字节码插桩。
方法签名
这些就是字节码插桩的时候需要注意的:
例如这个指令:
bash
INVOKESTATIC java/lang/System.currentTimeMillis ()J
我们在进行插桩的时候,需要对照表,System是一个对象,因此需要在最前边加上L, ()J表示一个方法的签名标识。 修改完成的代码就是这样的:
less
invokeStatic(Type.getType("Ljava/lang/System;"), new Method("currentTimeMillis", "()J"));
Transform与ASM联动
联动的方式就是通过实现一个Plugin来做,这相当于增加了一个gradle任务:
java
public class ASMPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
BaseExtension android = project.getExtensions().getByType(BaseExtension.class);
// android 插件 能够获得所有的class
// 同时他提供一个接口,能够让我们也获得所有class
android.registerTransform(new ASMTransform());
}
}
ASMTransform是我们自定义的Transform,就是通过他来实现字节码插桩:
java
public class ASMTransform extends Transform {
@Override
public String getName() {
return "asm";
}
/**
* 处理所有class
*
* @return
*/
@Override
public Set<QualifiedContent.ContentType> getInputTypes() {
return TransformManager.CONTENT_CLASS;
}
/**
* 用来处理范围,可以决定是否包含三方库等。
* PROJECT_ONLY:范围仅仅包含我们自己写的代码中的java或者kotlin文件
*
* @return
*/
@Override
public Set<? super QualifiedContent.Scope> getScopes() {
return TransformManager.PROJECT_ONLY;
}
/**
* 不使用增量
* @return true表示
*/
@Override
public boolean isIncremental() {
return false;
}
/**
* android插件将所有的class通过这个方法告诉给我们
* 我们这个transform的输出就是下一个transform的输入。
* @param transformInvocation
* @throws TransformException
* @throws InterruptedException
* @throws IOException
*/
@Override
public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
super.transform(transformInvocation);
TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
// 因为不是增量构建,所以可以对之前的文件进行清理
outputProvider.deleteAll();
// 得到所有的输入
Collection<TransformInput> inputs = transformInvocation.getInputs();
for (TransformInput input : inputs) {
// 处理class目录
for (DirectoryInput directoryInput : input.getDirectoryInputs()) {
// 直接复制输出到对应的目录
String dirName = directoryInput.getName();
File src = directoryInput.getFile();
System.out.println("目录:" + src);
String md5Name = DigestUtils.md5Hex(src.getAbsolutePath());
File dest = outputProvider.getContentLocation(dirName + md5Name/*用来作为输出的唯一标记,为什么要做么做?这是因为Transform是一个一个执行的,
上一个作为下一个输入,所以我们将通过Transform得到的结果写入 outputprovider 获得的一个file中去,然后outputprovider获取的文件的第一个参数就需要给一个唯一的标记
*/,
directoryInput.getContentTypes()/*类型*/, directoryInput.getScopes()/*作用域*/,
Format.DIRECTORY);
// todo 插桩
processInject(src, dest);
}
// 处理jar(依赖)的class todo 先不处理了
for (JarInput jarInput : input.getJarInputs()) {
String jarName = jarInput.getName();
File src = jarInput.getFile();
System.out.println("jar包:" + src);
String md5Name = DigestUtils.md5Hex(src.getAbsolutePath());
if (jarName.endsWith(".jar")) {
jarName = jarName.substring(0, jarName.length() - 4);
}
File dest = outputProvider.getContentLocation(jarName + md5Name,
jarInput.getContentTypes(), jarInput.getScopes(), Format.JAR);
FileUtils.copyFile(src, dest);
}
}
}
private void processInject(File src, File dest) throws IOException {
String dir = src.getAbsolutePath();
FluentIterable<File> allFiles = FileUtils.getAllFiles(src);
for (File file : allFiles) {
FileInputStream fis = new FileInputStream(file);
// 插桩
ClassReader cr = new ClassReader(fis);
// 写出器
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
// 分析,处理结果写入cw
cr.accept(new ClassInjectTimeVisitor(cw,file.getName()), ClassReader.EXPAND_FRAMES);
byte[] newClassBytes = cw.toByteArray();
// class 文件
String absolutePath = file.getAbsolutePath();
// class文件的绝对地址去掉目录,得到的全类名.
String fullClassPath = absolutePath.replace(dir, "");
// 完成文件覆盖
File outFile = new File(dest, fullClassPath);
FileUtils.mkdirs(outFile.getParentFile());
FileOutputStream fos = new FileOutputStream(outFile);
fos.write(newClassBytes);
fos.close();
}
}
}
整体结构就是,通过注解标记哪些方法需要插桩,完成插桩代码编写之后,通过Transform遍历class插桩代码插入到class文件中。最后在编译的时候自动运行这个任务完成自动插桩。