字节码插桩--你也能够轻松掌握

1 什么是插桩?

听到关于“插桩”的词语,第一眼以为会很高深,那到底什么是插桩呢?用通俗的话来说,插桩就是将一段代码经过某种策略插入到另外一段代码,或替换另外一段代码。这里的代码能够分为源码和字节码,而咱们所说的插桩通常指字节码插桩。 图1是Android开发者常见的一张图,咱们编写的源码(.java)经过javac编译成字节码(.class),而后经过dx/d8编译成dex文件。 html

图1:Java-字节码-dex,图片来自-极客时间
咱们下面要讲的插桩,就是在.class转为.dex以前,修改.class文件从而达到修改或替换代码的目的。 那有人确定会有这样的疑问?既然插桩是插入或替换代码,那为什么我不本身直接插入或替换呢?为什么还要用这么“复杂”的工具?别着急,第二个问题将会给你答案。

2 插桩的应用场景有哪些?

技术是服务于业务的,一个没法推动业务进步的技术并不值得咱们学习。在上面,咱们对插桩的理解是:插入,替换代码。那么,结合这个核心主线咱们来挖掘插桩能被应用的场景有哪些?java

代码插入

咱们所熟悉的ButterKnife,Dagger这些经常使用的框架,也是在编译期间生成了代码,简化了程序员的操做。假设有这么一个需求,要监控某些或者全部方法的执行耗时?你会怎么作呢?若是你监控的方法只有十几个或者几十个,那么也许经过程序员自身的编码就能轻松解决;可是若是监控的方法达到百千甚至万级别,你还经过编码来解决?那么程序员存在的价值在哪里?面对这样的重复劳动问题,最早想到的就应该是自动化,也就是咱们今天所讲的插桩。经过插桩,咱们扫描每个class文件,并针对特定规则进行字节码修改从而达到监控每一个方法耗时的目的。关于如何实现这样的需求,后面我会详细讲述。node

代码替换

若是遇到这么一个需求,须要将项目中全部使用某个方法(如Dialog.show())的地方替换成本身包装的方法(MyDialog.show()),那么你该如何解决呢?有人会说,直接使用快捷键就能全局替换。那么有两个问题android

  1. 若是有其余类定义了show()方法,并被调用了,直接使用快捷键是否会被错误替换?
  2. 若是其余引用包使用了该方法,你怎么替换呢?

不要紧,插桩一样能够解决你的问题。 综合上面所说的两点,其实不少业务场景都使用了插桩技术,好比无痕埋点,性能监控等。程序员

3 掌握插桩应该具有的基础知识有哪些?

上面讲了插桩的应用场景,是否如今想跃跃欲试呢?别着急,想掌握好插桩技术,练就扎实的插桩功底,咱们是须要具有一些基础知识的。api

  • 熟练掌握字节码相关技术。可参考 一文让你明白Java字节码数组

  • Gradle自定义插件,直接参考官网 Writing Custom pluginsbash

  • 若是你想运用在Android项目中,那么还须要掌握Transform API, 这是android在将class转成dex以前给咱们预留的一个接口,在该接口中咱们能够经过插件形式来修改class文件。app

  • 字节码修改工具。如AspectJ,ASM,javasisst。这里我推荐使用ASM,关于ASM相关知识,在下一章我给你们简单介绍。一样你们能够参考 Asm官方文档框架

  • groovy语言基础

若是你具有了上面5块知识,那么恭喜你,会很顺利的完成字节码插桩技术了。下面,我经过实战一个很简单的例子,带领你们一块儿领略插桩的风采。

4 使用ASM进行字节码插桩

1 什么是ASM?

ASM是生成和转换已编译的Java类工具,就是咱们插桩须要使用的工具。

2 两种API?

ASM提供了两种API来生成和转换已编译类,一个是核心API,以基于事件形式来表示类;另外一个是树API,以基于对象形式来表示类。

3 基于事件形式

咱们经过上面的基础知识,了解到类的结构,类包含字段,方法,指令等;基于事件的API把类看做是一系列事件来表示,每个类的事件表示一个类的元素。相似解析XML的SAX

4 基于对象形式

基于对象的API将类表示成一棵对象树,每一个对象表示类的一部分。相似解析XML的DOM

5 优缺点比较

事件形式 对象形式
内存占用
实现难度

经过上面表格,咱们清楚的了解到:

  • 事件API内存占用少于对象API,由于事件API不须要在内存中建立和存储对象树
  • 事件API实现难度比对象API大,由于事件API在任意时刻类中只有一个元素可以使用,可是对象API能得到整个类。

那么接下来,咱们就经过比较容易实现的对象API入手,一块儿完成上面的需求。 咱们Android的构建工具是Gradle,所以咱们结合transform和Gradle插件方式来完成该需求,接下来咱们来看看gradle官方提供的3种插件形式 6 Gradle插件的3种形式

插件形式 说明
Build script 直接在build script中写插件代码,不可复用
buildSrc 独立项目结构,只能在本构建体系中复用,没法提供给其余项目
Standalone 独立项目结构,发布到仓库,能够复用

因为咱们是demo,并不须要共享给其余项目,所以采用buildSrc方式便可,可是正常项目中都采用Standalone形式。

5 插桩实践

目标 : 删除全部以test开头的方法

接下来咱们来完成一个很是小的需求,删除全部以test开头的方法。为何说这是一个小需求,由于这并不涉及指令的操做,全部操做经过方法名完成便可。经过完成这个demo,只是抛砖引玉。如若后期须要,能够逐步深刻到指令级别替换。 接下来的步骤就是建立demo的过程

  • 1 新建buildSrc目录,用来存放源代码位置。针对不一样语言能够新建不一样目录。
    图2-项目总体结构
    如上图所示的是buildSrc的结构。
  • 2 在buildSrc的gradle文件中咱们须要配置以下代码
apply plugin: 'groovy'
dependencies {
   compile gradleApi()//在使用自定义插件时候,必定要引用org.gradle.api.Plugin
   compile 'com.android.tools.build:gradle:3.3.2'//使用自定义transform时候,须要引用com.android.build.api.transform.Transform
   compile 'org.ow2.asm:asm:6.0'
   compile 'commons-io:commons-io:2.6'
}
repositories {
   mavenCentral()
   jcenter()
   google()
}
复制代码
  • 3 重写Transform API 在groovy目录下新建一个groovy类并继承Transform,注意导包com.android.build.api.transform,并实现抽象方法和transform方法,以下
class MyTransform extends Transform {
   Project project
   MyTransform(Project project) {
       this.project = project
   }
   @Override
   String getName() {
       return "MyTransform"
   }
   //设置输入类型,咱们是针对class文件处理
   @Override
   Set<QualifiedContent.ContentType> getInputTypes() {
       return TransformManager.CONTENT_CLASS
   }
   //设置输入范围,咱们选择整个项目
   @Override
   Set<? super QualifiedContent.Scope> getScopes() {
       return TransformManager.SCOPE_FULL_PROJECT
   }
   @Override
   boolean isIncremental() {
       return true
   }
   //重点就是该方法,咱们须要将修改字节码的逻辑就从这里开始
   @Override
   void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
       inputs.each {
           TransformInput input ->
               input.getJarInputs().each {
               //处理jar文件,代码太多,这里暂时不贴
               }
               input.getDirectoryInputs().each {
               //处理目录文件,这里的ASMHelper.transformClass()是修改字节码逻辑
                   def destDir = transformInvocation.outputProvider.getContentLocation(
                           "${dir.name}_transformed",
                           dir.contentTypes,
                           dir.scopes,
                           Format.DIRECTORY)
                   if (dir.file) {
                       def modifiedRecord = [:]
                       dir.file.traverse(type: FileType.FILES, nameFilter: ~/.*\.class/) {
                           File classFile ->
                               def className = classFile.absolutePath.replace(dir.getFile().getAbsolutePath(), "")
                               if (!ASMHelper.filter(className)) {
                                   def transformedClass = ASMHelper.transformClass(classFile, dir.file, transformInvocation.context.temporaryDir)
                                   modifiedRecord[(className)] = transformedClass
                               }
                       }
                       FileUtils.copyDirectory(dir.file, destDir)
                       modifiedRecord.each { name, file ->
                           def targetFile = new File(destDir.absolutePath, name)
                           if (targetFile.exists()) {
                               targetFile.delete()
                           }
                           FileUtils.copyFile(file, targetFile)
                       }
                       modifiedRecord.clear()
               }
       }
   }
}
复制代码
  • 4 实现字节码修改逻辑 Transform咱们已经定义完成,接下来就要针对读入的字节码进行修改。咱们采用对象API进行解析class文件。一共就是3个步骤:
  1. 将输入流转化为ClassNode
  2. 处理ClassNode,这里就是咱们的业务逻辑所在
  3. 将ClassNode转为字节数组输出 固然还有其余文件的IO操做,这里由于篇幅限制未贴出,如若须要demo,能够私信。
static byte[] modifyClass(InputStream inputStream) {
       ClassNode classNode = new ClassNode(Opcodes.ASM5)
       ClassReader classReader = new ClassReader(inputStream)
       //1 将读入的字节转为classNode
       classReader.accept(classNode, 0)
       //2 对classNode的处理逻辑
       Iterator<MethodNode> iterator = classNode.methods.iterator();
       while (iterator.hasNext()) {
           MethodNode node = iterator.next()
           if (node.name.startsWith("test")) {
               iterator.remove()
           }
       }
       ClassWriter classWriter = new ClassWriter(0)
       //3  将classNode转为字节数组
       classNode.accept(classWriter)
       return classWriter.toByteArray()
   }
复制代码
  • 5 插件化 上面咱们完成了字节码修改逻辑以及定义Transform,可是并无完成插件的定义。结合Transform API咱们了解到,须要将咱们自定义的Transform注册到插件中,以下
class MyPlugin implements Plugin<Project> {
    @Override
    void apply(Project project) {
        project.android.registerTransform(new MyTransform(project))
    }
}
复制代码
  • 6 提供可对外使用的插件 插件完成了,可是怎么才能对外使用呢?上面咱们说到,咱们采起3种插件形式之一的buildSrc。咱们上文中建立了plugin.properties文件。只须要在该文件中编辑实现类便可
implementation-class=MyPlugin
复制代码
  • 7 应用方应用插件 在应用方的gradle文件中作以下配置
apply plugin: 'plugin'
复制代码

上面代码咱们注意到,plugin这个插件和plugin.properties的文件名是同样的。是的,应用方应用的插件名和咱们定义的properties文件名保持一致。

  • 8 结果展现 源代码以下,通过咱们插件处理以后,编译后的字节码应该没有了testDemo方法。
public class MainActivity extends Activity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(android.R.layout.activity_list_item);
    }
    public void testDemo() {
        System.out.println("demo test");
    }
}
复制代码

那么,处理后的字节码在哪呢?在*$project/build/intermediates/transforms/MyTransform/...* MyTransform是我自定义Transform的类名,下面有debug和release包。继续下去你们应该能找到对应的类。

图3-结果展现.png
上图咱们看到,已经没有的testDemo方法。 成功!

6 结束语

经过上面实战练习,相信你已经初步掌握了插桩的基本技术,可是这还远远不够;在项目中会遇到各式各样的问题,现实状况可能没有demo这么简单;不过不要紧,若是在插桩过程当中遇到任何问题,均可以私信给我,我将尽我所能的给你提供最优质的免费咨询服务。同时,我也很是欢迎你们互相交流技术,共同成长。