Android 字节码插桩 结合Transform&ASM

篇头语

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

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

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

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

来自老板官网的打包流程

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

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

  • Java编译器对工程本身的java代码进行编译,这些java代码有三个来源:app的源代码,由资源文件生成的R文件(aapt工具),以及有aidl文件生成的java接口文件(aidl工具)。产出为.class文件。

    这里啰嗦一点,因为我在看这个图的时候对这里产生的疑问:为什么这么多种Resources?

    其实细心的可以在上图中看到两个Resources,一个是图中左上角的Application Resources,一个是图中间右侧的Other Resouces,这两个Resources是有区别的,上面的Application Resources其实是指项目中我们常见的放在根目录下的assets目录和medule下的res目录,区别是assets中的资源会被原封不动的打包在apk内,res资源中保存的文件大多会被编译,而且会被赋予资源id,这样就可以在程序中使用id的形式来访问资源,res中根据类型不同分为十种子类型,包括layout、drawable、xml等,这个大家都很熟悉啦~因为其中的资源都是需要高速响应的,需要较高的性能要求,例如支持不同分辨率的屏幕等,所以需要快速定位资源,所以赋予ID,这些ID值以常量的形式定义在一个R.java文件中,然后会生成一个resources.arcs文件,用来描述那些具有ID值的资源的配置信息,想当以索引表的作用,在该文件中,如果某个id对应的是string,那么该文件会直接包含该值,如果id对应的资源是某个layout或者drawable资源,那么该文件会存入对应资源的路径。
    而asset中的文件既然没有ID,访问的时候就要指定文件名:

    AssetManager am= getAssets();    
    InputStream is = assset.open("filename");  
    

    如果你和我一样此时思考到了那asset目录中存放什么呢?我来告诉你,经过了一堆的google、baidu和讨论之后,发现,其实没有什么死区别,但是你从我刚刚说的编译方式可以看出一点眉头吧,就是res中资源编译的过程比较复杂,但是调用性能更好,assets资源不会编译,所以如果你想资源被编译然后对资源调用性能要求很高,你就放在res目录下吧。

    另外,图中间左侧的complied Resources就是包含resources.arcs的.ap_文件和assets资源,而右侧那个Resources是jni(提供api供java和其他语言通信)、.so文件(share object,类似于windows中的链接库,如果你对这两个文件是啥很执着,可以自己去查)

  • .class文件和依赖的三方库文件通过dex工具生成Delvik虚拟机可执行的.dex文件,可能有一个或多个,包含了所有的class信息,包括项目自身的class和依赖的class。产出为.dex文件。

  • apkbuilder工具将.dex文件和编译后的资源文件生成未经签名对齐的apk文件。这里编译后的资源文件包括两部分,一是由aapt编译产生的编译后的资源文件,二是依赖的三方库里的资源文件。产出为未经签名的.apk文件。

  • 分别由Jarsigner和zipalign对apk文件进行签名和对齐,生成最终的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 等。

====引用完毕===

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

  • 如果是如log一样零散的代码直接写不好修改
  • 如果是好多类要加一个方法则要搞共同父类或者装饰者模式使得类特别多
  • 如果是你想修改引用的包,你就只能用这种方式,也不能说只能,当然你可以解压缩jar包再用同名java类编译成class文件替换后在压成jar包,但是这种方式明显是不好的,而且如果你用字节码插桩的话,即使你的一个jar包更新了,你也不用再做任何操作,你的插桩程序会继续有效!
  • (我自己觉得)学会了插桩,你想监控你的项目中的某个或者某种类的行为和性能消耗简直轻而易举,爽呆!

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

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

整体上用到了哪些东西

整体

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

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

自定义plugin的相关流程

  • 概述
    Gradle 提供了很多官方插件,用于支持Java、Groovy等工程的构建和打包。同时也提供了自定义插件机制,让每个人都可以通过插件来实现特定的构建逻辑,并可以把这些逻辑打包起来,分享给其他人。插件的源码可以使用Scale、groovy、java编写,你可以会哪个就用哪个,groovy中完美的继承了java

  • 三种方式

    • 你可以直接写在build.gradle中,给个例子

      /**
        * 分别定义Extension1 和 Extension2 类,申明参数传递变量
        */
      class Extension1 {
         String testVariable1 = null
      }
      
       class Extension2 {
          String testVariable2 = null
       }
        /**
         * 插件入口类
         */
      class TestPlugin implements Plugin<Project> {
          @Override
          void apply(Project project) {
              //利用Extension创建e1 e2 闭包,用于接受外部传递的参数值
              project.extensions.create('e1', Extension1)
              project.extensions.create('e2', Extension2)
      
                //创建readExtension task 执行该task 进行参数值的读取以及自定义逻辑...
              project.task('readExtension') << {
                      println 'e1 = ' + project['e1'].testVariable1
                      println 'e2 = ' + project['e2'].testVariable2
                }
          }
      }
            /**
           * 依赖我们刚刚自定义的TestPlugin,注意 使用e1 {} || e2{} 一定要放              在 apply plugin:TestPlugin 后面, 因为 app plugin:TestPlugin
           * 会执行 Plugin的apply 方法,进而利用Extension 将e1 、e2 和 Extension1 Extension2 绑定,编译器才不会报错
           */
      apply plugin: TestPlugin
      
        e1 {
             testVariable1 = 'testVariable1'
          }
      
        e2 {
              testVariable2 = 'testVariable2'
          }
      

    自定义插件中官方给了很多相关的api,如果想要仔细了解可以查看官方文档

    • 第二种是写在一个module中,只对一个项目可见,试用于逻辑比较复杂但是对外不可见的插件,这里不展开,整体写法和第三种差不多,第三种会了第二种稍微改动就OK

    • 第三种是写成独立项目

      先在项目根目录下建立一个module (Android Library Module),在3.4.1版本的Android studio中new plugin选择Android library,取名为plugin,这个plugin就是插件,清空plugin目录下的其他文件,只保留src/main这个目录和build.gradle,注意src/main下的东西都清空,然后注意build.gradle中内容更改为以下,然后sync(不然下面的步骤将无法正常进行,编译器不会把即将新建的groovy目录识别为groovy源代码目录,不能在该目录下新建包,即将新建的resources也不会被编译器识别为资源文件夹)

         apply plugin: 'groovy'
         apply plugin: 'maven'
         dependencies{
             // gradle sdk
             compile gradleApi()
             // groovy sdk
             compile localGroovy()
             compile 'com.android.tools.build:gradle:3.4.1'
          }
          repositories{
              mavenCentral()
          }
      

      注意上面的gradle版本其实是有讲究的,版本最好不要太低。然后在main目录下新建一个groovy文件夹,因为我们开发的插件相当于一个Groovy项目,在groovy目录下新建包名,com.llew.bytecode.fix.plugin ,然后在这个包名下新建BytecodeFixPlugin.groovy文件,因为要创建Gradle插件就必须要实现Gradle包中的org.gradle.api.Plugin接口,所以BytecodeFixPlugin.groovy内容如下所示:

      package com.llew.bytecode.fix.plugin;
          import org.gradle.api.Plugin;
          import org.gradle.api.Project;
      public class BytecodeFixPlugin implements Plugin<Project> {
          @Override
          void apply(Project project) {
              println "this is a gradle plugin, (*^__^*)……"
         }
      }
      

      插件定义好之后我们要告诉Gradle哪一个类是我们定义的插件类,因此需要在main目录下创建resources目录,然后在resources目录下创建META-INF目录,接着在META-INF目录下创建gradle-plugins目录,gradle-plugins目录是自定义Gradle插件的必备目录,然后在该目录下创建一个properties文件,文件名为com.llew.bytecode.fix.properties,这个文件名是有技巧的,当起完名字后如果要使用插件,就可以这样:apply plugin 'com.llew.bytecode.fix';起完名字后还不可以使用该插件,还要告诉Gradle自定义插件的具体实现类是哪一个,在com.llew.bytecode.fix.properties文件中添加如下内容:

      implementation-class=com.llew.bytecode.fix.plugin.BytecodeFixPlugin
      

      这样就告诉了Gradle插件的实现类是com.llew.bytecode.fix.plugin.BytecodeFixPlugin,定义完了以上配置后,还需要把插件打包到Maven仓库后才可以使用,为了简单起见,我们直接把插件打包到本地Maven仓库,在plugin的build.gradle中完整配置如下:

      apply plugin: 'groovy'
      apply plugin: 'maven'
      
      repositories {
             jcenter()
              mavenCentral()
      }
      
      dependencies {
          compile gradleApi()
          compile localGroovy()
          compile 'com.android.tools.build:gradle:3.4.1'
      }
      group   = 'com.llew.bytecode.fix'
      version = '1.0.0'
      
      uploadArchives {
           repositories {
              mavenDeployer {
                 repository(url: uri("../repository"))
              }
           }
       }
      

      这时候maven,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)
  
}
}

方法

  • getName()不用说了

  • getInputTypes(),getScopes(),这个要多说几句,在这一方面km上有一篇文章写的很细了,对于上面的transform图,只是展示Transform的其中一种情况。而Transform其实可以有两种输入,一种是消费型的,当前Transform需要将消费型型输出给下一个Transform,另一种是引用型的,当前Transform可以读取这些输入,而不需要输出给下一个Transform,比如Instant Run就是通过这种方式,检查两次编译之间的diff的。至于怎么在一个Transform中声明两种输入,以及怎么处理两种输入,后面将有示例代码。而Scope和contentType是transform输入的两种过滤机制

过滤流程

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

  • isIncremental()是否增量编译

  • transform()重点关注的方法,我们在这里做相关操作,

    我分别贴一个java版和groovy版

     //java
     @Override
    public void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
      super.transform(transformInvocation);
      //消费型输入,可以从中获取jar包和class文件夹路径。需要输出给下一个任务
      Collection<TransformInput> inputs = transformInvocation.getInputs();
      //引用型输入,无需输出。
      Collection<TransformInput> referencedInputs = transformInvocation.getReferencedInputs();
      //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);
          }
      }
    }
    
    //groovy
     @Override
      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
                         if (name.endsWith(".class")
                             && !name.endsWith("R.class")
                             && !name.endsWith("BuildConfig.class")
                             && !name.contains("R\$")
    
                         ){
                             println("==== directoryInput file name == "+ file.getAbsolutePath())
                             ClassReader classReader = new ClassReader(file.bytes)
                             ClassWriter classWriter = new ClassWriter(classReader,ClassWriter.COMPUTE_MAXS)
                             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)
         }
     }
    

    }

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


观察点

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

ASM

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

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

  • .class gradle编译到.dex是插入时机,相当于我们要剪开过滤水管的一个特定部分
  • 自定义插件(plugin)是我们打开这个过滤水管的钳子
  • transform是我们要接入这个过滤水管的一段自制水管
  • 而ASM是我们自制水管的过滤网,至于怎么过滤全看ASM的操作

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

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

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

class文件

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


java类文件
  • Magic:该项存放了一个 Java 类文件的魔数(magic number)和版本信息。一个 Java 类文件的前 4 个字节被称为它的魔数。每个正确的 Java 类文件都是以 0xCAFEBABE 开头的,这样保证了 Java 虚拟机能很轻松的分辨出 Java 文件和非 Java 文件。
  • Version:该项存放了 Java 类文件的版本信息,它对于一个 Java 文件具有重要的意义。因为 Java 技术一直在发展,所以类文件的格式也处在不断变化之中。类文件的版本信息让虚拟机知道如何去读取并处理该类文件。
  • Constant Pool:该项存放了类中各种文字字符串、类名、方法名和接口名称、final 变量以及对外部类的引用信息等常量。虚拟机必须为每一个被装载的类维护一个常量池,常量池中存储了相应类型所用到的所有类型、字段和方法的符号引用,因此它在 Java 的动态链接中起到了核心的作用。常量池的大小平均占到了整个类大小的 60% 左右。
  • Access_flag:该项指明了该文件中定义的是类还是接口(一个 class 文件中只能有一个类或接口),同时还指名了类或接口的访问标志,如 public,private, abstract 等信息。
  • This Class:指向表示该类全限定名称的字符串常量的指针。
  • Super Class:指向表示父类全限定名称的字符串常量的指针。
  • Interfaces:一个指针数组,存放了该类或父类实现的所有接口名称的字符串常量的指针。以上三项所指向的常量,特别是前两项,在我们用 ASM 从已有类派生新类时一般需要修改:将类名称改为子类名称;将父类改为派生前的类名称;如果有必要,增加新的实现接口。
  • Fields:该项对类或接口中声明的字段进行了细致的描述。需要注意的是,fields 列表中仅列出了本类或接口中的字段,并不包括从超类和父接口继承而来的字段。
  • Methods:该项对类或接口中声明的方法进行了细致的描述。例如方法的名称、参数和返回值类型等。需要注意的是,methods 列表里仅存放了本类或本接口中的方法,并不包括从超类和父接口继承而来的方法。使用 ASM 进行 AOP 编程,通常是通过调整 Method 中的指令来实现的。
  • Class attributes:该项存放了在该文件中类或接口所定义的属性的基本信息。

篇幅原因,更多相关知识详见该字节码博客,总之上面的数字经过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);
        }
    }
}

分析一下这个方法

  • 在AsmClassVisitor类中的visitMethod方法返回一个MethodVisitor抽象类的子类,AsmMethodVisitor继承的是AdviceAdapter,AdviceAdapter是MethodVisitor的子类的子类。。之所以选择继承他是为了要使用onMethodEnter()方法,在访问方法的开头执行下列语句,当然如果你可以直接继承MethodVisitor然后重写visitCode方法,效果是一样的,MethodVisitor的不同子类提供了大量的切入口供用户选择。
  • 细心的同学可能会注意到,AsmClassVisitor的visitMethod方法和AsmMethodVisitor的onMethodEnter方法中分别使用了cv和mv,cv是我们在实例化AsmClassVisitor时传入的classVisitor(classWriter),这个classWriter中集成了带有class文件字节码的classReader,所以要使用它来进行操作,mv同理,是AsmMethodVisitor实例化时传入的cv的MethodVisitor。

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

时序图
  • 其实也就是外层的自定义ClassVisitor用来对接类的大块区域,如变量部分,方法部分,注解部分
  • 而自定义ClassVisitor中各种方法返回的类用于对区域内更细节的部分进行操作,例如访问一个类的开始、写入、结束时机。

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: ");
           }
        });
    }
}

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

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

推荐阅读更多精彩内容

  • *本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布 前言 第一次看到插桩,是在Android开发...
    带心情去旅行阅读 45,106评论 25 218
  • 1 什么是插桩? 听到关于“插桩”的词语,第一眼觉得会很高深,那到底什么是插桩呢?用通俗的话来讲,插桩就是将一段代...
    黑马有点白986阅读 21,307评论 7 54
  • 正好赶上变天,喝了水。比过去快乐了,吃饭时有人陪。 微微困了,小时候妈妈搂着我讲故事,我听得认真。 好...
    Lan_9e0f阅读 145评论 0 1
  • “滴答,滴答”,时间一秒一秒的飞逝,今天老李座在家中想写一写自己失业的这段经历,也算是给自己悲哀的那颗心一点儿安...
    ligang_1315阅读 468评论 1 2
  • 1、首先需要用一个工具class-dump,点我下载 2、下载好以后解压如图: 3、把class-dump-3.5...
    liangdahong阅读 4,195评论 5 12