只要简单回顾一下前面课程的内容你就会发现,在启动耗时分析、网络监控、耗电监控中已经不止一次用到编译插桩的技术了。那什么是编译插桩呢?顾名思义,所谓的编译插桩就是在代码编译期间修改已有的代码或者生成新代码。

如上图所示,请你回忆一下Java代码的编译流程,思考一下插桩究竟是在编译流程中的哪一步工作?除了我们之前使用的一些场景,它还有哪些常见的应用场景?在实际工作中,我们应该怎样更好地使用它?现在都有哪些常用的编译插桩方法?今天我们一起来解决这些问题。

编译插桩的基础知识

不知道你有没有注意到,在编译期间修改和生成代码其实是很常见的行为,无论是Dagger、ButterKnife这些APT(Annotation Processing Tool)注解生成框架,还是新兴的Kotlin语言编译器,它们都用到了编译插桩的技术。

下面我们一起来看看还有哪些场景会用到编译插桩技术。

1. 编译插桩的应用场景

编译插桩技术非常有趣,同样也很有价值,掌握它之后,可以完成一些其他技术很难实现或无法完成的任务。学会这项技术以后,我们就可以随心所欲地操控代码,满足不同场景的需求。

“一千个人眼中有一千个哈姆雷特”,通过编译插桩技术,你可以大胆发挥自己的想象力,做一些对提升团队质量和效能有帮助的事情。

那从技术实现上看,编译插桩是从代码编译的哪个流程介入的呢?我们可以把它分为两类:

相对于Java文件方式,字节码操作方式功能更加强大,应用场景也更广,但是它的使用复杂度更高,所以今天我主要来讲如何通过操作字节码实现编译插桩的功能。

2. 字节码

对于Java平台,Java虚拟机运行的是Class文件,内部对应的是Java字节码。而针对Android这种嵌入式平台,为了优化性能,Android虚拟机运行的是Dex文件,Google专门为其设计了一种Dalvik字节码,虽然增加了指令长度但却缩减了指令的数量,执行也更为快速。

那这两种字节码格式有什么不同呢?下面我们先来看一个非常简单的Java类。

public class Sample {
    public void test() {
        System.out.print("I am a test sample!");
    }
}

通过下面几个命令,我们可以生成和查看这个Sample.java类的Java字节码和Dalvik字节码。

javac Sample.java   // 生成Sample.class,也就是Java字节码
javap -v Sample     // 查看Sample类的Java字节码

//通过Java字节码,生成Dalvik字节码
dx --dex --output=Sample.dex Sample.class   

dexdump -d Sample.dex   // 查看Sample.dex的Dalvik的字节码

你可以直观地看到Java字节码和Dalvik字节码的差别。

它们的格式和指令都有很明显的差异。关于Java字节码的介绍,你可以参考JVM文档。对于Dalvik字节码来说,你可以参考Android的官方文档。它们的主要区别有:

关于Java字节码和Dalvik字节码的更多介绍,你可以参考下面的资料:

编译插桩的三种方法

AspectJ和ASM框架的输入和输出都是Class文件,它们是我们最常用的Java字节码处理框架。

1. AspectJ

AspectJ是Java中流行的AOP(aspect-oriented programming)编程扩展框架,网上很多文章说它处理的是Java文件,其实并不正确,它内部也是通过字节码处理技术实现的代码注入。

从底层实现上来看,AspectJ内部使用的是BCEL框架来完成的,不过这个库在最近几年没有更多的开发进展,官方也建议切换到ObjectWeb的ASM框架。关于BCEL的使用,你可以参考《用BCEL设计字节码》这篇文章。

从使用上来看,作为字节码处理元老,AspectJ的框架的确有自己的一些优势。

在专栏前面文章里我提过360的性能监控框架ArgusAPM,它就是使用AspectJ实现性能的监控,其中TraceActivity是为了监控Application和Activity的生命周期。

// 在Application onCreate执行的时候调用applicationOnCreate方法
@Pointcut("execution(* android.app.Application.onCreate(android.content.Context)) && args(context)")
public void applicationOnCreate(Context context) {

}
// 在调用applicationOnCreate方法之后调用applicationOnCreateAdvice方法
@After("applicationOnCreate(context)")
public void applicationOnCreateAdvice(Context context) {
    AH.applicationOnCreate(context);
}

你可以看到,我们完全不需要关心底层Java字节码的处理流程,就可以轻松实现编译插桩功能。关于AspectJ的文章网上有很多,不过最全面的还是官方文档,你可以参考《AspectJ程序设计指南》The AspectJ 5 Development Kit Developer’s Notebook,这里我就不详细描述了。

但是从AspectJ的使用说明里也可以看出它的一些劣势,它的功能无法满足我们某些场景的需要。

我举专栏第7期启动耗时Sample的例子,我们希望在所有的方法调用前后都增加Trace的函数。如果选择使用AspectJ,那么实现真的非常简单。

@Before("execution(* **(..))")
public void before(JoinPoint joinPoint) {
    Trace.beginSection(joinPoint.getSignature().toString());
}

@After("execution(* **(..))")
public void after() {
    Trace.endSection();
}

但你可以看到经过AspectJ的字节码处理,它并不会直接把Trace函数直接插入到代码中,而是经过一系列自己的封装。如果想针对所有的函数都做插桩,AspectJ会带来不少的性能影响。

不过大部分情况,我们可能只会插桩某一小部分函数,这样AspectJ带来的性能影响就可以忽略不计了。如果想在Android中直接使用AspectJ,还是比较麻烦的。这里我推荐你直接使用沪江的AspectJX框架,它不仅使用更加简便一些,而且还扩展了排除某些类和JAR包的能力。如果你想通过Annotation注解方式接入,我推荐使用Jake Wharton大神写的Hugo项目。

虽然AspectJ使用方便,但是在使用的时候不注意的话还是会产生一些意想不到的异常。比如使用Around Advice需要注意方法返回值的问题,在Hugo里的处理方法是将joinPoint.proceed()的返回值直接返回,同时也需要注意Advice Precedence的情况。

2. ASM

如果说AspectJ只能满足50%的字节码处理场景,那ASM就是一个可以实现100%场景的Java字节码操作框架,它的功能也非常强大。使用ASM操作字节码主要的特点有:

为了使用简单,相比于BCEL框架,ASM的优势是提供了一个Visitor模式的访问接口(Core API),使用者可以不用关心字节码的格式,只需要在每个Visitor的位置关心自己所修改的结构即可。但是这种模式的缺点是,一般只能在一些简单场景里实现字节码的处理。

事实上,专栏第7期启动耗时的Sample内部就是使用ASM的Core API,具体你可以参考MethodTracer类的实现。从最终效果上来看,ASM字节码处理后的效果如下。

相比AspectJ,ASM更加直接高效。但是对于一些复杂情况,我们可能需要使用另外一种Tree API来完成对Class文件更直接的修改,因此这时候你要掌握一些必不可少的Java字节码知识。

此外,我们还需要对Java虚拟机的运行机制有所了解,前面我就讲到Java虚拟机是基于栈实现。那什么是Java虚拟机的栈呢?,引用《Java虚拟机规范》里对Java虚拟机栈的描述:

每一条Java虚拟机线程都有自己私有的Java虚拟机栈,这个栈与线程同时创建,用于存储栈帧(Stack Frame)。

正如这句话所描述的,每个线程都有自己的栈,所以在多线程应用程序中多个线程就会有多个栈,每个栈都有自己的栈帧。

如下图所示,我们可以简单认为栈帧包含3个重要的内容:本地变量表(Local Variable Array)、操作数栈(Operand Stack)和常量池引用(Constant Pool Reference)。

由于本地变量表的最大数和操作数栈的最大深度是在编译时就确定的,所以在使用ASM进行字节码操作后需要调用ASM提供的visitMaxs方法来设置maxLocal和maxStack数。不过,ASM为了方便用户使用,已经提供了自动计算的方法,在实例化ClassWriter操作类的时候传入COMPUTE_MAXS后,ASM就会自动计算本地变量表和操作数栈。

ClassWriter(ClassWriter.COMPUTE_MAXS)

下面以一个简单的“1+2“为例,它的操作数以LIFO(后进先出)的方式进行操作。

ICONST_1将int类型1推送栈顶,ICONST_2将int类型2推送栈顶,IADD指令将栈顶两个int类型的值相加后将结果推送至栈顶。操作数栈的最大深度也是由编译期决定的,很多时候ASM修改后的代码会增加操作数栈最大深度。不过ASM已经提供了动态计算的方法,但同时也会带来一些性能上的损耗。

在具体的字节码处理过程中,特别需要注意的是本地变量表和操作数栈的数据交换和try catch blcok的处理。

如果想在一个方法执行完成后增加代码,ASM相对也要简单很多,可以在字节码中出现的每一条RETURN系或者ATHROW的指令前,增加处理的逻辑即可。

3. ReDex

ReDex不仅只是作为一款Dex优化工具,它也提供了很多的小工具和文档里没有提到的一些新奇功能。比如在ReDex里提供了一个简单的Method Tracing和Block Tracing工具,这个工具可以在所有方法或者指定方法前面插入一段跟踪代码。

官方提供了一个例子,用来展示这个工具的使用,具体请查看InstrumentTest。这个例子会将InstrumentAnalysis的onMethodBegin方法插入到除黑名单以外的所有方法的开头位置。具体配置如下:

"InstrumentPass" : {
    "analysis_class_name":      "Lcom/facebook/redextest/InstrumentAnalysis;",  //存在桩代码的类
    "analysis_method_name": "onMethodBegin",    //存在桩代码的方法
    "instrumentation_strategy": "simple_method_tracing"
,   //插入策略,有两种方案,一种是在方法前面插入simple_method_tracing,一种是在CFG 的Block前后插入basic_block_tracing
}

ReDex的这个功能并不是完整的AOP工具,但它提供了一系列指令生成API和Opcode插入API,我们可以参照这个功能实现自己的字节码注入工具,这个功能的代码在Instrument.cpp中。

这个类已经将各种字节码特殊情况处理得相对比较完善,我们可以直接构造一段Opcode调用其提供的Insert接口即可完成代码的插入,而不用过多考虑可能会出现的异常情况。不过这个类提供的功能依然耦合了ReDex的业务,所以我们需要提取有用的代码加以使用。

由于Dalvik字节码发展时间尚短,而且因为Dex格式更加紧凑,修改起来往往牵一发而动全身。并且Dalvik字节码的处理相比Java字节码会更加复杂一些,所以直接操作Dalvik字节码的工具并不是很多。

市面上大部分需要直接修改Dex的情况是做逆向,很多同学都采用手动书写Smali代码然后编译回去。这里我总结了一些修改Dalvik字节码的库。

总结

今天我介绍了几种比较有代表性的框架来讲解编译插桩相关的内容。代码生成、代码监控、代码魔改以及代码分析,编译插桩技术无所不能,因此需要我们充分发挥想象力。

对于一些常见的应用场景,前辈们付出了大量的努力将它们工具化、API化,让我们不需要懂得底层字节码原理就可以轻松使用。但是如果真要想达到随心所欲的境界,即使有类似ASM工具的帮助,也还是需要我们对底层字节码有比较深的理解和认识。

当然你也可以成为“前辈”,将这些场景沉淀下来,提供给后人使用。但有的时候“能力限制想象力”,如果能力不够,即使想象力到位也无可奈何。

课后作业

你使用过哪些编译插桩相关的工具?使用编译插桩实现过什么功能?欢迎留言跟我和其他同学一起讨论。

今天的课后作业是重温专栏第7期练习Sample的实现原理,看看它内部是如何使用ASM完成TAG的插桩。在今天的Sample里,我也提供了一个使用AspectJ实现的版本。想要彻底学会编译插桩的确不容易,单单写一个高效的Gradle Plugin就不那么简单。

除了上面的两个Sample,我也推荐你认真看看下面的一些参考资料和项目。

欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

评论