Transform+ASM牛刀小试

有腹肌的棒棒糖

问题

之前一直在听他们说函数插桩,字节码插桩,ASM,总感觉很牛逼很高大上,知道一个大概意思,就是Java文件编译成字节码,修改字节码,达到修改函数的目的,那么今天就尝试一个Demo级别的工程,实现APP打包,插入自己的代码,并且通过Plugin插件的方式实现。

  • 什么是Transform,怎么自定义Transform?
  • Transform的作用周期是在哪里呢?在打包的哪一个阶段呢?
  • ASM工具是干嘛用的呢?

真实场景

现在APP的奔溃是一个很正常不过的问题,为了缩小影响范围,因为每一秒对互联网来说都是钱啊,也就有了很多的热修复框架,很多都用到了ASM技术,在打包的时候修改class文件,注入自己的逻辑达到自己的目的。

举个栗子:

正常逻辑的代码,是直接返回字符串的长度,但是没有判空,可能就会有空指针异常,为了安全,一些框架,会在APP打包,class2dex的时候,去干扰class文件,修改字节码,在每个函数的函数体增加if-else的操作,没有异常的时候走正常的逻辑,crash的值就是false,如果发生了奔溃,就会走到修复的逻辑,从而避免奔溃。但是这个注入是怎么操作的呢?这个就设计到ASM字节码插桩了。

// 这个正常逻辑的代码
public int getStringLength(String name){
  return name.length;
}

// 修改字节码之后的代码
public int getStringLength(String name){
  if(crash){
    // 奔溃之后的处理逻辑
    ...
  }else{
    return name.length;
  }
}

什么是Transform

Gradle从1.5开始,内置了Transform的API,我们可以通过插件,在class2dex的时候,对字节码文件进行操作,完成字节码插桩或者代码注入。每一个Transform都是一个任务,都是一个Task,他们是链式结构的,我们只需要实现Transform的接口,并且完成注册,这些Transform就会通过TaskManager串联,每一个的输出都会是下一个输入,依次执行。

Transform核心方法

getName() 指定当前transform的名称

isIncremental() 当前transform是否支持增量编译,增量编译可以加快编译速度。

getInputTypes 指定当前transform要处理的数据类型,可以不只一种类型。比如本地class文件,资源文件等。

getScopes() 指定当前transform的作用域,比如只处理当前项目,只处理jar包等,很好理解。

TransformInvocation核心方法

getInputs() 返回输入文件,一般来说我们关心的是DirectoryInput和JarInput,前者指的是我们源码方式参与编译的代码,后者就是Jar包方式参与编译的代码了。

getOutputProvider() 获取输出,可以获得输出的路径。

Transform使用

注册Transform

image.png

Transform逻辑

目标,在源码的每个方法中,插入System.out.println()输出代码。

模板代码

public class LogTransform extends Transform {

  @Override
  public String getName() {
    // 名称
    return getClass().getSimpleName();
  }

  @Override
  public Set<QualifiedContent.ContentType> getInputTypes() {
    // 需要处理的数据类型
    return TransformManager.CONTENT_CLASS;
  }

  @Override
  public Set<? super QualifiedContent.Scope> getScopes() {
    // 作用范围
    return TransformManager.SCOPE_FULL_PROJECT;
  }

  @Override
  public boolean isIncremental() {
    // 是否支持增量编译
    return true;
  }

  @Override
  public void transform(TransformInvocation transformInvocation) throws IOException {
    boolean incremental = transformInvocation.isIncremental();
    // 获取输出,如果没有上一级的输入,输出可能也就是空的
    TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
    // 如果不支持增量编译,需要把之前生成的都删除掉,不缓存复用
    if (!incremental) {
      outputProvider.deleteAll();
    }
    // 当然任务也可以放在并发的线程池进行,等待任务结束
    for (TransformInput input : transformInvocation.getInputs()) {
      // 处理Jar
      Collection<JarInput> jarInputs = input.getJarInputs();
      if (jarInputs != null && jarInputs.size() > 0) {
        for (JarInput jarInput : jarInputs) {
          processJarFile(jarInput, outputProvider, incremental);
        }
      }
      // 处理source
      Collection<DirectoryInput> directoryInputs = input.getDirectoryInputs();
      if (directoryInputs != null && directoryInputs.size() > 0) {
        for (DirectoryInput directoryInput : directoryInputs) {
          processDirFile(directoryInput, outputProvider, incremental);
        }
      }
    }
  }
  
  ...

核心逻辑方法梳理

这份方法是Transform的必须要实现的方法,我们可以从TransformInvocation获取输入,以及输出的路径,去做一些我们自己的逻辑操作,执行转换。这里的逻辑很简单,就是获取输入,然后获取DirectoryInput源码,JarInput的jar包,进行转换,比如修改字节码。

  @Override
  public void transform(TransformInvocation transformInvocation) throws IOException {
    boolean incremental = transformInvocation.isIncremental();
    // 获取输出,如果没有上一级的输入,输出可能也就是空的
    // 之前说过,Transform是链式的,上一个Transform的输出就是当前的输入
    TransformOutputProvider outputProvider = transformInvocation.getOutputProvider();
    // 如果不支持增量编译,需要把之前生成的都删除掉,不缓存复用
    if (!incremental) {
      outputProvider.deleteAll();
    }
    // for循环遍历所有的输入
    for (TransformInput input : transformInvocation.getInputs()) {
      // 处理Jar
      Collection<JarInput> jarInputs = input.getJarInputs();
      if (jarInputs != null && jarInputs.size() > 0) {
        for (JarInput jarInput : jarInputs) {
          processJarFile(jarInput, outputProvider, incremental);
        }
      }
      // 处理source
      Collection<DirectoryInput> directoryInputs = input.getDirectoryInputs();
      if (directoryInputs != null && directoryInputs.size() > 0) {
        for (DirectoryInput directoryInput : directoryInputs) {
          processDirFile(directoryInput, outputProvider, incremental);
        }
      }
    }
  }

这个方式是用来处理Jar文件。首先获取输出文件,输出文件就是转换好之后的文件了,获取信息。然后判断是否是支持增量编译。如果不支持,会把所有的输出删除,直接把当前文件处理,把inputFile复制到指定的输出路径,下一步处理。如果支持,复用之前的输出,那么就需要判断当前文件的状态了。如果是NOTCHANGED,当前InputFile不用任何操作。如果是ADDED、CHANGED,新增或者修改,需要拷贝输入文件到输出。如果是REMOVED,就把之前复用的输出文件移除。很好理解。

/**
 * 处理Jar
 */
private void processJarFile(JarInput input, TransformOutputProvider outputProvider,
    boolean incremental) {
  // 获取到输出文件
  File dest = outputProvider.getContentLocation(input.getFile().getAbsolutePath(),
      input.getContentTypes(), input.getScopes(), Format.JAR);
  File inputFile = input.getFile();
  if (!incremental) {
    transformJarFile(inputFile, dest);
  } else {
    switch (input.getStatus()) {
      case NOTCHANGED:
        break;
      case ADDED:
      case CHANGED:
        transformJarFile(inputFile, dest);
        break;
      case REMOVED:
        deleteIfExists(dest);
        break;
    }
  }
}

private void transformJarFile(File inputFile, File outputFile) {
  try {
    FileUtils.touch(outputFile);
    FileUtils.copyFile(inputFile, outputFile);
  } catch (Exception e) {
    e.printStackTrace();
  }
}

private void deleteIfExists(File file) {
  try {
    if (file.exists()) {
      FileUtils.forceDelete(file);
    }
  } catch (IOException e) {
    e.printStackTrace();
  }
}

这个方法是用来处理源文件的,同理,首先获取输出目录,如果不支持增量编译,移除之前的复用的输出,并尝试重新创建文件夹,如果是文件夹的话。然后判断是不是支持增量编译,如果不支持,直接把当前所有文件复制到输出,如果支持,同样需要判断每一个文件的状态。首先获取改变的文件集合,遍历这个集合,判断器状态NOTCHANGED、ADDED、CHANGED、REMOVED,和Jar的处理相同。最后在transformDirFile方法中,尝试修改字节码。

/**
 * 处理source-file
 */
private void processDirFile(DirectoryInput input, TransformOutputProvider outputProvider,
    boolean incremental) {
  // 处理源文件
  File dest = outputProvider.getContentLocation(input.getFile().getAbsolutePath(),
      input.getContentTypes(), input.getScopes(), Format.DIRECTORY);
  // 创建文件夹
  try {
    FileUtils.forceMkdir(dest);
  } catch (IOException e) {
    e.printStackTrace();
  }
  if (incremental) {
    // 输入的路径
    String inputDirPath = input.getFile().getAbsolutePath();
    // 输出的路径
    String destDirPath = dest.getAbsolutePath();
    // 获取更改的
    Map<File, Status> changedFileMap = input.getChangedFiles();
    // 继续遍历
    for (Map.Entry<File, Status> entry : changedFileMap.entrySet()) {
      File inputFile = entry.getKey();
      String destFilePath = inputFile.getAbsolutePath().replace(inputDirPath, destDirPath);
      File outputFile = new File(destFilePath);
      switch (entry.getValue()) {
        case NOTCHANGED:
          break;
        case ADDED:
        case CHANGED:
          transformDirFile(inputFile, outputFile);
          break;
        case REMOVED:
          deleteIfExists(outputFile);
          break;
      }
    }
  } else {
    copyDir(input.getFile(), dest);
  }
}

private void copyDir(File input, File dest) {
  deleteIfExists(dest);
  String srcDirPath = input.getAbsolutePath();
  String destDirPath = dest.getAbsolutePath();
  File[] inputFiles = input.listFiles();
  if (inputFiles != null && inputFiles.length > 0) {
    for (File file : inputFiles) {
      String destFilePath = file.getAbsolutePath().replace(srcDirPath, destDirPath);
      File destFile = new File(destFilePath);
      if (file.isDirectory()) {
        copyDir(file, destFile);
      } else if (file.isFile()) {
        transformDirFile(file, destFile);
      }
    }
  }
}

private void transformDirFile(File inputFile, File outputFile) {
  try {
    FileUtils.touch(outputFile);
    // 这里要注意下,只修改class文件,只有class文件才有有字节码插桩
    // 不然就会报错,很好理解
    if (inputFile.getName().endsWith(".class")) {
      LogASM.insertCode(inputFile, outputFile);
    } else {
      FileUtils.copyFile(inputFile, outputFile);
    }
  } catch (Exception e) {
    e.printStackTrace();
  }
}
日志Transform小结

至此,Transform逻辑梳理完毕,核心思想就是,首先是不是支持增量编译,如果不支持,就删除复用的所有的输出,判断当前遍历所有的源文件,直接拷贝。如果支持,复用之前的输出,然后遍历文件,找出发生状态更改的文件,选择性的更新。然后在修改单个文件的时候,执行字节码插桩修改,这样就完成了目标逻辑。

ASM简单使用

ASM是一个字节码操作和分析的框架,可以直接用二进制修改现有类或者动态生成一个类,简单来说就是帮助你写class字节码。大概就是这个意思。

引入ASM依赖
// ASM 相关
implementation 'org.ow2.asm:asm:9.2'
implementation 'org.ow2.asm:asm-util:9.1'
implementation 'org.ow2.asm:asm-commons:9.2'
implementation 'androidx.room:room-compiler:2.3.0'
常用对象介绍

ClassVisitor Java类访问用的,都是通过ClassRender调用的,比如访问method,访问filed,annotation等。

MethodVisitor Java中访问读取方法用的

FieldVisitor Java中访问读取字段属性用的

AnnotationVisitor Java中访问读取注解用的,如果在一个类中用了某个注解,你需要做某种操作,可以匹配注解。

ASM简单使用
public class LogASM {

  public static void insertCode(File inputFile, File outputFile) {

    try {
      FileInputStream fileInputStream = new FileInputStream(inputFile);
      FileOutputStream fileOutputStream = new FileOutputStream(outputFile);
      ClassReader classReader = new ClassReader(fileInputStream);
      ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
      classReader
          .accept(new LogMethodVisitor(Opcodes.ASM7, classWriter), ClassReader.EXPAND_FRAMES);
      fileOutputStream.write(classWriter.toByteArray());
      fileInputStream.close();
      fileOutputStream.close();
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

public class LogMethodVisitor extends ClassVisitor {

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

  @Override
  public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
      String[] exceptions) {
    MethodVisitor methodVisitor =
        super.visitMethod(access, name, descriptor, signature, exceptions);
    return new MethodVisitor(api, methodVisitor) {
      @Override
      public void visitMethodInsn(int opcode, String owner, String name, String descriptor,
          boolean isInterface) {
        mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitLdcInsn("ASM Transform Running");
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V",
            false);
        super.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
      }
    };
  }
}

核心就是访问class文件,访问方法,注入代码。具体的详细规则,可以自己找资料,详细了解一下ASM用法,还可以找相应的插件帮助你了解字节码。

关键截图

目录在app/build/transforms/LogTransform

image.png

总结

这篇文章,我们需要知道Transform是什么,什么是ASM,我们通过两者可以做一些什么事情就可以了。我也自我总结一下,我发现之前看过的JVM一书,还是不够彻底,字节码还是要多了解,而且我对打包的流程也更加了解了,真的是遇到问题才会更加容易的发现问题。

阅读 463

只想做技术岗上的一颗螺丝钉

20 声望
1 粉丝
0 条评论

只想做技术岗上的一颗螺丝钉

20 声望
1 粉丝
文章目录
宣传栏