gitbook/Android开发高手课/docs/82761.md
2022-09-03 22:05:03 +08:00

24 KiB
Raw Blame History

27 | 编译插桩的三种方法AspectJ、ASM、ReDex

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

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

编译插桩的基础知识

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

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

1. 编译插桩的应用场景

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

  • 代码生成。除了Dagger、ButterKnife这些常用的注解生成框架Protocol Buffers、数据库ORM框架也都会在编译过程生成代码。代码生成隔离了复杂的内部实现让开发更加简单高效而且也减少了手工重复的劳动量降低了出错的可能性。

  • 代码监控。除了网络监控和耗电监控我们可以利用编译插桩技术实现各种各样的性能监控。为什么不直接在源码中实现监控功能呢首先我们不一定有第三方SDK的源码其次某些调用点可能会非常分散例如想监控代码中所有new Thread()调用,通过源码的方式并不那么容易实现。

  • 代码修改。我们在这个场景拥有无限的发挥空间例如某些第三方SDK库没有源码我们可以给它内部的一个崩溃函数增加try catch或者说替换它的图片库等。我们也可以通过代码修改实现无痕埋点就像网易的HubbleData、51信用卡的埋点实践

  • 代码分析。上一期我讲到持续集成里面的自定义代码检查就可以使用编译插桩技术实现。例如检查代码中的new Thread()调用、检查代码中的一些敏感权限使用等。事实上Findbugs这些第三方的代码检查工具也同样使用的是编译插桩技术实现。

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

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

  • Java文件。类似APT、AndroidAnnotation这些代码生成的场景它们生成的都是Java文件是在编译的最开始介入。

  • 字节码Bytecode。对于代码监控、代码修改以及代码分析这三个场景,一般采用操作字节码的方式。可以操作“.class”的Java字节码也可以操作“.dex”的Dalvik字节码这取决于我们使用的插桩方法。

相对于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虚拟机是基于栈实现而Android虚拟机是基于寄存器实现。在ARM平台寄存器实现性能会高于栈实现。

  • 格式结构。对于Class文件每个文件都会有自己单独的常量池以及其他一些公共字段。对于Dex文件整个Dex中的所有Class共用同一个常量池和公共字段所以整体结构更加紧凑因此也大大减少了体积。

  • 指令优化。Dalvik字节码对大量的指令专门做了精简和优化如下图所示相同的代码Java字节码需要100多条而Dalvik字节码只需要几条。

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

编译插桩的三种方法

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

1. AspectJ

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

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

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

  • 成熟稳定。从字节码的格式和各种指令规则来看字节码处理不是那么简单如果处理出错就会导致程序编译或者运行过程出问题。而AspectJ作为从2001年发展至今的框架它已经很成熟一般不用考虑插入的字节码正确性的问题。

  • 使用简单。AspectJ功能强大而且使用非常简单使用者完全不需要理解任何Java字节码相关的知识就可以使用自如。它可以在方法包括构造方法被调用的位置、在方法体包括构造方法的内部、在读写变量的位置、在静态代码块内部、在异常处理的位置等前后插入自定义的代码或者直接将原位置的代码替换为自定义的代码。

在专栏前面文章里我提过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 Developers Notebook,这里我就不详细描述了。

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

  • 切入点固定。AspectJ只能在一些固定的切入点来进行操作如果想要进行更细致的操作则无法完成它不能针对一些特定规则的字节码序列做操作。

  • 正则表达式。AspectJ的匹配规则是类似正则表达式的规则比如匹配Activity生命周期的onXXX方法如果有自定义的其他以on开头的方法也会匹配到。

  • 性能较低。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操作字节码主要的特点有

  • 操作灵活。操作起来很灵活,可以根据需求自定义修改、插入、删除。

  • 上手难。上手比较难需要对Java字节码有比较深入的了解。

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

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

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

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

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

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

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

  • 本地变量表。在使用过程中可以认为本地变量表是存放临时数据的并且本地变量表有个很重要的功能就是用来传递方法调用时的参数当调用方法的时候参数会依次传递到本地变量表中从0开始的位置上并且如果调用的方法是实例方法那么我们可以通过第0个本地变量中获取当前实例的引用也就是this所指向的对象。

  • 操作数栈。可以认为操作数栈是一个用于存放指令执行所需要的数据的位置,指令从操作数栈中取走数据并将操作结果重新入栈。

由于本地变量表的最大数和操作数栈的最大深度是在编译时就确定的所以在使用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的处理。

  • 数据交换。如下图所示在经过IADD指令操作后又通过ISTORE 0指令将栈顶int值存入第1个本地变量中用于临时保存在最后的加法过程中将0和1位置的本地变量取出压入操作数栈中供IADD指令使用。关于本地变量和操作数栈数据交互的指令你可以参考虚拟机规范里面提供了一系列根据不同数据类型的指令。

  • 异常处理。在字节码操作过程中需要特别注意异常处理对操作数栈的影响如果在try和catch之间抛出了一个可捕获的异常那么当前的操作数栈会被清空并将异常对象压入这个空栈中执行过程在catch处继续。幸运的是如果生成了错误的字节码编译器可以辨别出该情况并导致编译异常ASM中也提供了字节码Verify的接口,可以在修改完成后验证一下字节码是否正常。

如果想在一个方法执行完成后增加代码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字节码的库。

  • ASMDEX开发者是ASM库的开发者但很久未更新了。

  • DexterGoogle官方开发的Dex操作库更新很频繁但使用起来很复杂。

  • Dexmaker用来生成Dalvik字节码的代码。

  • Soot修改Dex的方法很另类是先将Dalvik字节码转成一种Jimple three-address code然后插入Jimple Opcode后再转回Dalvik字节码具体可以参考例子

总结

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

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

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

课后作业

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

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

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

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