安卓Android收藏集Android

Android 字节码插桩 结合Transform&ASM

2019-08-29  本文已影响0人  小熊兜里有糖

篇头语

应师傅指导,最近研究了一下从Gradle编译入手,实现字节码插桩,进而实现一些功能,其实网上相关文章也不算太少,但是就我一路研究琢磨的过程而言,网上的文章东一块、西一块,每个文章各有其精华,但是你要是想在网上的某几篇文章就搞懂怎么回事还是需要这方面功底的,所以对于小白就不是很友好,包括一会要提的ASM文档写的也实在”抽象“,所以我就此做一个学习总结,希望小白通过我这一篇文章就可以基本上掌握脉络,因为我是一个小白,研究这个方向是一点一点的入手,所以这篇学习分享就站在小白的角度面向小白分享,所以很多基础也会说,大神可以跳过。

对于没有接触过这方面的同学可能一下没有理解本篇标题的意思,这很正常,最开始我也不理解,我先大概解释一下要做什么事情,Android 的java文件会编译成class文件,然后才运行,java是我们手写的,class文件为只读状态,我们不能修改,我们接下来要做的事,简单来说,就是”暗箱操作“一下class文件。使得编译出的class文件达到我们的一些期望。

在说别的之前,我们先看一下android gradle的编译过程

我们直观点先看一张图,这张图的复杂程度我觉得刚刚好,太简单就会忽略掉一些重要的部分,再复杂的过程我们也暂时用不到,所以就先贴这一张图。

来自老板官网的打包流程

ps:上图中绿圈表示的工具、中间过程,不是产物,别看错

从上面的流程图,我们可以看出apk打包流程可以分为以下七步

说了这么多无非是想了解一下编译的大概过程,但是对于此时此刻我们需要关注的就是我们要在.class 文件和第三方库编译成.dex文件这一步动手脚,原因是这一步可以拿到我们手写的java编译的.class也可以拿到我们想修改的jar包,下一步就成了apk,就已经无力回天了,所以这个契机甚好!

走到这你可能就想了,既然我们要修改代码,那为啥不直接回去改代码不就好了?都这么大一个圈子做啥呢??如果你这么想了,那咱俩真是太默契了!我觉得知道为什么这样做很有必要,有对比才有伤害!

动态修改Java代码的原因

我贴一段网上很多地方都引用的例子:(我觉得你如果想了解装饰者模式可以看看,不然直接看我下面的总结也无所谓)

动态生成 Java 类与 AOP 密切相关的。AOP 的初衷在于软件设计世界中存在这么一类代码,零散而又耦合:零散是由于一些公有的功能(诸如著名的 log 例子)分散在所有模块之中;同时改变 log 功能又会影响到所有的模块。出现这样的缺陷,很大程度上是由于传统的 面向对象编程注重以继承关系为代表的“纵向”关系,而对于拥有相同功能或者说方面 (Aspect)的模块之间的“横向”关系不能很好地表达。例如,目前有一个既有的银行管理系统,包括 Bank、Customer、Account、Invoice 等对象,现在要加入一个安全检查模块, 对已有类的所有操作之前都必须进行一次安全检查。

image.png

然而 Bank、Customer、Account、Invoice 是代表不同的事务,派生自不同的父类,很难在高层上加入关于 Security Checker 的共有功能。对于没有多继承的 Java 来说,更是如此。传统的解决方案是使用 Decorator 模式,它可以在一定程度上改善耦合,而功能仍旧是分散的 —— 每个需要 Security Checker 的类都必须要派生一个 Decorator,每个需要 Security Checker 的方法都要被包装(wrap)。下面我们以 Account类为例看一下 Decorator:

首先,我们有一个 SecurityChecker类,其静态方法 checkSecurity执行安全检查功能:

public class SecurityChecker { 
   public static void checkSecurity() { 
       System.out.println("SecurityChecker.checkSecurity ..."); 
       //TODO real security check 
   }  
}

另一个是 Account类:

public class Account { 
  public void operation() { 
      System.out.println("operation..."); 
      //TODO real operation 
  } 
}

若想对 operation加入对 SecurityCheck.checkSecurity()调用,标准的 Decorator 需要先定义一个 Account类的接口:

public interface Account { 
  void operation(); 
}

然后把原来的 Account类定义为一个实现类:

public class AccountImpl extends Account{ 
   public void operation() { 
      System.out.println("operation..."); 
      //TODO real operation 
  } 
}

定义一个 Account类的 Decorator,并包装 operation方法:

public class AccountWithSecurityCheck implements Account {     
     private  Account account; 
     public AccountWithSecurityCheck (Account account) { 
         this.account = account; 
     } 
     public void operation() { 
         SecurityChecker.checkSecurity(); 
         account.operation(); 
    } 
}

在这个简单的例子里,改造一个类的一个方法还好,如果是变动整个模块,Decorator 很快就会演化成另一个噩梦。动态改变 Java 类就是要解决 AOP 的问题,提供一种得到系统支持的可编程的方法,自动化地生成或者增强 Java 代码。这种技术已经广泛应用于最新的 Java 框架内,如 Hibernate,Spring 等。

====引用完毕===

我看了一些例子或者说原因总结也就不过几点:

现在我们知道了我们为什么要这么做,应该更有动力学了!

上面说了动手的时机和动手的原因,下面说要怎么撸起袖子干了,其实在很多文章中直接开始上代码讲技术,不讲那一步具体是做什么用的,这样的文章看起来就比较吃力,而且没有头绪,但是我不能这么干,所以为了思路清晰,我先说一下

整体上用到了哪些东西

整体

图画的丑了点,对付着看下,解释一下:

在这些.class文件和.dex的中间过程,其实是一个个Transform,每一个Transform实际上是一个gradle Task,他们想当于加工生产线上的一个个环节,每一次”加工“接收上一次加工的结果作为输入,输出送给下一个”加工“,而我们要做的事情就是创建一个这样的Transform,拿到上一步的输入做一些手脚,再把我们”暗箱操作“的成品传给下一个输入继续编译,通常我们自己创建的Transform会被加到transform队列的第一个,之后再这个transform中使用ASM来处理字节码。而如何把Transform嫁接上去,就要使用到自定义plugin的相关内容,所以先来解释一下

自定义plugin的相关流程

指导图

建立好仓库,就可以引用了,在主项目(app)下的build.gradle中添加

apply plugin: 'com.llew.bytecode.fix'

在根目录下的bulid.gradle的dependencies中添加这么一句

classpath 'com.llew.bytecode.fix:plugin:1.0.0'

别忘了添加本地依赖,因为对于刚刚添加的classpath,任何classpath都需要到repositories所提供的地址中去查找有无,然后进行配置,添加了本地依赖就是为本地的这个插件提供来源。

 buildscript {
     repositories {
        google()
        jcenter()
        maven {// 添加Maven的本地依赖
           url uri('./repository')
       }
   }
}

注意是在根目录的build.gradle中的buildscript中的repositories中添加了maven仓库,可能有小伙伴会问了,build.gradle buildscript 里面的repositories 和allprojects里面 repositories 的区别,简单说一下:buildscript 里面的 repositories 表示只有编译工具才会用这个仓库,而allprojects是项目本身需要的依赖。

然后想查看效果的话可以clean一下,再make project,直接ReBuild就行,但是注意一下输出打印的地方,AS3.0之后的Gradle Console集成在Build中

Gradle Console打开方式

这个效果其实只是告诉我们,我们的插件在gradle编译过程中起作用了,那么可以进行了下一个环节,下一步是

在plugin中插入Transform

在刚刚的com.llew.bytecode.fix包名下建一个transform Package用来存放Transform文件,这里的transform文件可以用java写也可以用groovy写,但是差距不大,即使你用的groovy文件,也可以使用java api,因为groovy更灵活,这里就用groovy文件,再次提醒创建groovy文件的方式是新建file,然后以.groovy结尾

可以看到java文件前面的标记是原型的,groovy是方形的。


区别

新建一个名字为AsmInjectTrans的transform,下面是一个transform的标准结构,无论你是建立java文件还是groovy文件

public class AsmInjectTrans extends Transform {
private static final String TAG = "BytecodeFixTransform"

@Override
String getName() {
    return TAG
}

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

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

@Override
boolean isIncremental() {
    return false
}

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

方法

过滤流程

ContentType,顾名思义,就是数据类型,在插件开发中,我们一般只能使用CLASSES和RESOURCES两种类型,注意,其中的CLASSES已经包含了class文件和jar文件

ContentType

从图中可以看到,除了CLASSES和RESOURCES,还有一些我们开发过程无法使用的类型,比如DEX文件,这些隐藏类型在一个独立的枚举类ExtendedContentType中,这些类型只能给Android编译器使用。另外,我们一般使用 TransformManager中提供的几个常用的ContentType集合和Scope集合,如果是要处理所有class和jar的字节码,ContentType我们一般使用TransformManager.CONTENT_CLASS

注意一下,这个TransformManager,如果你找不到这个类,记得提升gradle-api版本到3.1.4以上

implementation 'com.android.tools.build:gradle-api:3.1.4'

Scope相比ContentType则是另一个维度的过滤规则,

Scope枚举

我们可以发现,左边几个类型可供我们使用,而我们一般都是组合使用这几个类型,TransformManager有几个常用的Scope集合方便开发者使用。
如果是要处理所有class字节码,Scope我们一般使用TransformManager.SCOPE_FULL_PROJECT

两种写法除了语法上略有不同以外,总体思路是一样的,从transformInvocation获取输入,然后从中获取jar包和class文件,一波操作之后再用outputProvider获取文件的出口,注释中也写的很明白了


观察点

架子搭起来,但是此时的transform还没有引用到plugin中,下文会讲解插入方式。
这里插一嘴比较重要的,我们编写的transform还有即将在transform中加入的ASM代码,还有外层的plugin中的代码,甚至包括我们整个的plugin这个module,这些都属于我们自定义插件中的内容,如果有所修改,一定要重新uploadArchives一下,也就是更新库,不然你用的还是旧的代码哦~ 而更新库的时候并不会执行transform中的内容,注意编译时执行和库发布是不一样的

ASM

终于到了重头戏,调整一下思路

如果做一个比喻的话我个人比较喜欢把整个流程比喻成一个水源过滤水管

这样比喻是不是各个环节的作用就清晰很多啦~

因为ASM是对字节码进行操作,所以需要掌握关于字节码的知识,

在编译过程中,我们的java文件会被javac编译器编译成.class文件,如果你单独打开class文件会发现是这样的结构:

class文件

这一看你可能会觉得这是个什么东西?但是其实我们不用也不可能去了解数字的排列所代表的意义,我们只需要关注他的组成结构:


java类文件

篇幅原因,更多相关知识详见该字节码博客,总之上面的数字经过javap可以反编译成下面的格式

Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class
  Last modified 2018-4-7; size 362 bytes
  MD5 checksum 4aed8540b098992663b7ba08c65312de
  Compiled from "Main.java"
public class com.rhythm7.Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#18         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#19         // com/rhythm7/Main.m:I
   #3 = Class              #20            // com/rhythm7/Main
   #4 = Class              #21            // java/lang/Object
   #5 = Utf8               m
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/rhythm7/Main;
  #14 = Utf8               inc
  #15 = Utf8               ()I
  #16 = Utf8               SourceFile
  #17 = Utf8               Main.java
  #18 = NameAndType        #7:#8          // "<init>":()V
  #19 = NameAndType        #5:#6          // m:I
  #20 = Utf8               com/rhythm7/Main
  #21 = Utf8               java/lang/Object
{
  private int m;
    descriptor: I
    flags: ACC_PRIVATE

  public com.rhythm7.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/rhythm7/Main;

  public int inc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field m:I
         4: iconst_1
         5: iadd
         6: ireturn
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  this   Lcom/rhythm7/Main;
}
SourceFile: "Main.java"

查看方法:


目录结构

然后找一个编辑器打开就好,mac的文本编辑好像不行,你可以用sublime打开
但是如果你直接用编译器打开会发现是这样的是因为编译器自动帮我们进行了解码


编译器显示class文件结果

回归正题,为什么要了解反编译的字节码?因为ASM不是直接对数字字节码进行操作,而是对类似于”com/rhythm7/Main.m:I“这种字节码反编译后的格式进行操作,之后的处理过程我们无需过问,既然是这样的格式,对于开发者就友好的多,我们无需关注class文件冗长的数字中方法的偏移量、编码方式、指代含义等,只需要关注字节码指令即可。

ASM提供很多vistor接口供我们使用,在 ASM 中,提供了一个 ClassReader类,这个类可以直接由字节数组或由 class 文件间接的获得字节码数据,它能正确的分析字节码,构建出抽象的树在内存中表示字节码。它会调用 accept方法,这个方法接受一个继承了 ClassVisitor抽象类的对象实例作为参数,然后依次调用 ClassVisitor接口的各个方法。字节码空间上的偏移被转换成 visit 事件时间上调用的先后,所谓 visit 事件是指对各种不同 visit 函数的调用,ClassReader知道如何调用各种 visit 函数。在这个过程中用户无法对操作进行干涉,因为遍历的算法是框架提供的、确定的,具体详细的代码可以点进ClassReader类中进行查看,但是用户可以做的是提供不同的 Visitor ,重写Visitor中的不同的 visit方法来对字节码树进行不同的修改。ClassVisitor会产生一些子过程,比如 visitMethod会返回一个实现 MethordVisitor接口的实例,visitField会返回一个实现 FieldVisitor接口的实例,完成子过程后控制返回到父过程,继续访问下一节点。因此对于 ClassReader来说,其内部顺序访问是有一定要求的。实际上用户还可以不通过 ClassReader类,自行手工控制这个流程,只要按照一定的顺序,各个 visit 事件被先后正确的调用,最后就能生成可以被正确加载的字节码。当然获得更大灵活性的同时也加大了调整字节码的复杂度。

各个 ClassVisitor通过职责链 (Chain-of-responsibility) 模式,可以非常简单的封装对字节码的各种修改,而无须关注字节码的字节偏移,因为这些实现细节对于用户都被隐藏了,用户要做的只是覆写相应的 visit 函数,说了这么多可能一下子难以理解具体如何使用,那么就来实际操练一下。
在使用ASM之前你需要在使用ASM的插件的gradle中配置一下

//ASM相关
dependencies {
     ...
    implementation 'org.ow2.asm:asm:5.1'
    implementation 'org.ow2.asm:asm-util:5.1'
    implementation 'org.ow2.asm:asm-commons:5.1'
     ...
}

修改transform文件内容变成以下

void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        super.transform(transformInvocation)
       transformInvocation.inputs.each {
           TransformInput input -> input.directoryInputs.each {
               DirectoryInput directoryInput ->
                   if (directoryInput.file.isDirectory()){
                       directoryInput.file.eachFileRecurse { File file ->
                           def name = file.name
                       // 上面的👆代码的作用前文说过了
                       // 下面是判断语句,含义都看的懂,过滤一下class文件
                           if (name.endsWith(".class")
                               && !name.endsWith("R.class")
                               && !name.endsWith("BuildConfig.class")
                               && !name.contains("R\$")

                           ){
                           //打印log
                               println("==== directoryInput file name == "+ file.getAbsolutePath())
                           // 获取ClassReader,参数是文件的字节数组
                               ClassReader classReader = new ClassReader(file.bytes)
                            // 获取ClassWriter,参数1是reader,参数2用于修改类的默认行为,一般传入ClassWriter.COMPUTE_MAXS
                               ClassWriter classWriter = new ClassWriter(classReader,ClassWriter.COMPUTE_MAXS)
                           //自定义ClassVisitor
                               AsmClassVisitor classVisitor = new AsmClassVisitor(Opcodes.ASM5,classWriter)        
                             //执行过滤操作                     
                               classReader.accept(classVisitor,ClassReader.EXPAND_FRAMES)
                               byte[] bytes = classWriter.toByteArray()
                               File destFile = new File(file.parentFile.absoluteFile,name)
                               FileOutputStream fileOutputStream = new FileOutputStream(destFile);
                               fileOutputStream.write(bytes)
                               fileOutputStream.close()
                           }
                       }
                   }

                   def dest = transformInvocation.outputProvider.getContentLocation(directoryInput.name,directoryInput.contentTypes,directoryInput.scopes,Format.DIRECTORY)
                   FileUtils.copyDirectory(directoryInput.file,dest)
           }

           input.jarInputs.each {JarInput jarInput ->
                    def jarName = jarInput.name
                    def md5Name = DigestUtils.md5Hex(jarInput.file.getAbsolutePath())
                    if (jarName.endsWith(".jar")){
                        jarName = jarName.substring(0,jarName.length() - 4)
                    }

                    def dest = transformInvocation.outputProvider.getContentLocation(jarName+md5Name,jarInput.contentTypes,jarInput.scopes,Format.JAR)
                    FileUtils.copyFile(jarInput.file,dest)
                }
}}

对于上面的代码,分成两部分来看,上面那部分是对于文件的处理,是这个demo的处理部分,所以重点看上面的部分,下面的部分是对于jar包的处理,我这次没有处理jar包,但是依然需要写出这一部分,因为你不可能在编译过程过滤的时候把jar包给丢掉了,你总要把不处理的jar传送给下一个transform,不然运行时自然会崩溃。

还有一个注意的点,就是在你添加ClassVisitor相关类的时候需要添加相关类引用,


相关包

如果你引用了别的包中的类,整个过程不会有问题但是会导致编译错误,一定要引用org.objectweb.asm包中的。

上面的代码是把ASM集成到我们的自定义transform中,可以说写法基本固定,修改无非是增添更多的自定义ClassVisitor添加到责任链之中,你完全可以增加很多的ClassVisitor用于不同方面的改写,有利于单一职责和解耦,而且上面的代码只是对class文件进行了操作,jar包并没有处理,具体对于字节码的操作细节在我们的自定义AsmClassVisitor中,你可以在你的groovy下新建一个包用于存放你自定义的ClassVisitor文件

目录结构

AsmClassVisitor

public class AsmClassVisitor extends ClassVisitor {

    public AsmClassVisitor(int i) {
        super(i);
    }

    public AsmClassVisitor(int i, ClassVisitor classVisitor) {
        super(i, classVisitor);
    }

    @Override
    public MethodVisitor visitMethod(int i, String s, String s1, String s2, String[] strings) {
        MethodVisitor mv = cv.visitMethod(i,s,s1,s2,strings);
        AsmMethodVisitor asmClassVisitor = new  AsmMethodVisitor(Opcodes.ASM5,mv,i,s,s1);
        return asmClassVisitor;
    }

}

在这个类中,除了构造方法外,我只重写了一个visitMethod方法,因为我要关注method,如果你想关注类中的变量或者注解部分,可以添加visitFieldvisitAnnotation方法,还有一些其他的方法,详见ASM官网,说到这个官方文档是真的迷,咱一会儿再说,先回到这个方法中,可以看到重写的visitMethod方法中返回了一个自定义的MethodVisitor

public class AsmMethodVisitor extends AdviceAdapter {

    private String methodName;
    private String methodDes;

    protected AsmMethodVisitor(int i, MethodVisitor methodVisitor, int i1, String s, String s1) {
        super(i, methodVisitor, i1, s, s1);
        methodName = s;
        methodDes = s1;
    }

    @Override
    protected void onMethodEnter() {
        if ("onClick".equals(methodName)&&"(Landroid/view/View;)V".equals(methodDes)){
          //将引用变量推送到栈顶
            mv.visitVarInsn(ALOAD,1);
         //添加方法
            mv.visitMethodInsn(Opcodes.INVOKESTATIC,"java/util/plugindemo2/LogUtils","system","(Landroid/view/View;)V",false);
        }
    }
}

分析一下这个方法

可以看一下整个操作流程的时序图

时序图

java文件

//MainActivity.java
public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";
    @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) {
                Log.d(TAG, "onClick: ");
            }
        });


        Button btn2 = findViewById(R.id.btn2);
        btn2.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Log.d(TAG, "onClick: ");
            }
        });
    }
}
//LogUtils.java
package java.util.plugindemo2;

import android.util.Log;
import android.view.View;

public class LogUtils {
    private static final String TAG = "LogUtils";
    public static void system(View v){
        Log.d(TAG, "system: "+ v.getId());
    }
}

这个小小的Demo就算是完成了,但是在顺利执行之前还差最后一步,就是把这个Transform整合到plugin上去,不然即使写好了Transform,没有给他接到“水管”上去也就依然没有用处,如何把它衔接到plugin上去就要看这个继承了Plugin接口的类的写法:

def android = project.extensions.getByType(AppExtension)
android.registerTransform(new AsmInjectTrans())

之后别忘记在Gradle的plugin的uploadArchive,这样插件才会被重新安装生效,再rebuild就可以使用了,编译之后我们查看class文件

public class MainActivity extends AppCompatActivity {
    private static final String TAG = "MainActivity";

    public MainActivity() {
    }

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        this.setContentView(2131296284);
        Button btn = (Button)this.findViewById(2131165218);
        btn.setOnClickListener(new OnClickListener() {
            public void onClick(View var1) {
                LogUtils.system(var1);
                Log.d("MainActivity", "onClick: ");
            }
        });
        Button btn2 = (Button)this.findViewById(2131165219);
        btn2.setOnClickListener(new OnClickListener() {
            public void onClick(View var1) {
                LogUtils.system(var1);
                Log.d("MainActivity", "onClick: ");
           }
        });
    }
}

官网中有太多其他的方法可以是做很多不同的事情,时间原因展示一个小小的例子,持续更新

上一篇 下一篇

猜你喜欢

热点阅读