暴力突破 Android 编译插桩(七)- AspectJ 使用

专栏:暴力突破 Android 编译插桩系列

一、AOP 理解


在 Java 当中我们常常提及到的编程思想是 OOP(Object Oriented Programming)面向对象编程,即把功能或问题模块化,每个模块处理自己的事务。但在现实世界中,并不是所有问题都能完美地划分到模块中。比如,我们要完成一个事件埋点的功能,我们希望在原来整个系统当中,加入一些事件的埋点,监控并获取用户的操作行为和操作数据。按照面向对象的思想,我们会设计一个埋点管理器模块,然后在每个需要埋点的地方都加上一段埋点管理器的方法调用的逻辑。看起来好像没有什么问题,并且我们之前也都是这么做的,但当我们要对埋点的功能进行撤销、迁移或者重构的时候,都会存在很大的代价,因为埋点的功能已经侵入到了各个模块。这也是 OOP 很矛盾的地方。

另一种编程思想是 AOP(Aspect Oriented Programming)面向切面编程。AOP 提倡的是针对同一类问题的统一处理。比如我们前面提及到的埋点功能,我们的埋点调用散落在系统的每个角落(虽然我们的核心逻辑可以抽象在一个对象当中)。如果我们将 AOP 与 OOP 两者相结合,将功能的逻辑抽象成对象(OOP),然后在一个统一的地方完成逻辑的调用(AOP,将问题的处理也即是逻辑的调用统一)。

Android 中 AOP 的实际使用场景是无侵入的在宿主系统中插入一些核心的代码逻辑,比如日志埋点、性能监控、动态权限控制、代码调试等等。日志埋点上的应用比较多,推荐看看网易的 HubbleData、51 信用卡的埋点实践。实现 AOP 的的核心技术其实就是代码织入技术(code injection),对应的编程手段和工具其实有很多种,比如 AspectJ、ASM,它们的输入和输出都是 Class 文件,是我们最常用的 Java 字节码处理框架。

 

二、AspectJ 概念和语法


AspectJ 实际上是对 AOP 编程思想的一个实践。AspectJ 提供了一套全新的语法实现,完全兼容Java,同时还提供了纯 Java 语言的实现,通过注解的方式,完成代码编织的功能。因此我们在使用 AspectJ 的时候有以下两种方式:

  • 使用AspectJ的语言进行开发
  • 通过AspectJ提供的注解在Java语言上开发

因为最终的目的其实都是需要在字节码文件中织入我们自己定义的切面代码,不管使用哪种方式接入AspectJ,都需要使用AspectJ提供的代码编译工具ajc进行编译。

在了解 AspectJ 的具体使用之前,先了解一下其中的一些基本的术语概念,这有利于我们掌握 AspectJ 的使用以及 AOP 的编程思想。在下面的关于 AspectJ 的使用相关介绍都是以注解的方式使用作为说明的。

2.1 JoinPoints(连接点)

JoinPoints(简称 JPoints)是 AspectJ 中最关键的一个概念。它是程序运行时的一些执行点,即程序中可能作为代码注入目标的特定的点。一个程序中哪些执行点是 JPoints呢,我们接着往下看。

2.2 PointCuts(切入点)

PointCuts(切入点),其实就是代码注入的位置。与前面的JoinPoints不同的地方在于,PointCuts 是通过语法标准给 JoinPoints 添加了筛选条件限定。

2.2.1 直接对 JoinPoints 的选择

Pointcuts 中最常用的选择条件和 JoinPoint 的类型密切相关,下面这个表可以清晰的看出哪些执行点可以作为 JoinPoints,以及对应的 Pointcut 句法:

JoinPoints PointCut 句法 说明 JoinPoints示例
Method execution execution(MethodSignature)

函数调用

比如调用Log.e()的位置

Method call

call(MethodSignature) 函数执行 比如Log.e()的执行内部
Constructor execution execution(ConstructorSignature) 构造函数调用  
Constructor call call(ConstructorSignature) 构造函数执行  
Class initialization staticinitialization(TypeSignature) 类初始化  
Field read access get(FieldSignature) 获取某个变量  
Field write access set(FieldSignature) 设置某个变量  
Exception handler execution handler(TypeSignature) 异常处理  
Object initialization initialization(ConstructorSignature) 对象初始化  
Object pre-initialization preinitialization(ConstructorSignature) 对象预初始化  
Advice execution adviceexecution() advice执行  

2.2.2 间接对 JoinPoints 的选择

除了上面与 JoinPoint 对应的选择外,Pointcuts 还有其他选择方法:

Pointcuts 说明 示例
within(TypeSignature) 表示在某个类中所有的JoinPoint within(com.example.Test):表示在 com.example.Test 类当中的全部JoinPoint
withincode(MethodSignature) 在某些方法中的 JoinPoint withincode( ..Test(..)):表示在任意包下面的Test函数的所有JoinPoint
withincode(ConstructorSignature) 在某些构造函数中的 JoinPoint  

2.2.3 组合对 JoinPoints 的选择

Pointcut 表达式还可以 !、&&、|| 来组合

组合 说明
!Pointcut 选取不符合 Pointcut 的 Join Point
Pointcut0 && Pointcut1 选取符合 Pointcut0 和 Pointcut1 的 Join Point
Pointcut0 || Pointcut1 选取符合 Pointcut0 或 Pointcut1 的 Join Point

上表中所提及到的 MethodSignature、ConstructorSignature、TypeSignature、FieldSignature,它们的表达式都可以使用通配符进行匹配。我们先来看看常用的通配符:

通配符 意义

示例

* 表示除 ”.” 以外的任意字符串 java.*.Date:可以表示 java.sql.Date 和 java.util.Date
..

匹配任何数量字符的重复,如在类型模式中匹配任何数量子包;

而在方法参数模式中匹配任何数量参数

java..*:表示java任意子包

void getName(..):表示方法参数为任意类型任意个数

+ 表示子类 java..*Model+:表示 java 任意包中以 Model 结尾的子类

接下来我们看看这些 Signature 的定义规则:

Signature 规则
MethodSignature [!] [@Annotation] [public,protected,private] [static] [final] 返回值类型 [类名.]方法名(参数类型列表) [throws 异常类型]
ConstructorSignature [!] [@Annotation] [public,protected,private] [final] [类名.]new(参数类型列表) [throws 异常类型]
FieldSignature [!] [@Annotation] [public,protected,private] [static] [final] 属性类型 [类名.]属性名
TypeSignature TypeSignature其实就是用来指定一个类的。因此我们只需要给出一个类的全路径的表达式即可

需要注意的是 “[]” 当中的内容表示可选项,当没有设定的时候,表示全匹配。另外,需要注意不同项之前是否有空格。

可以通过 @Pointcut 注解声明一个 PointCut,下面我们来看一些使用示例:

@Aspect
public class TestPointcut {

    //--1、通过方法定义切点----------
    @Pointcut("public * *(..)")//匹配所有目标类的public方法
    public void test(){}

    @Pointcut("* *(..) throws Exception")//匹配所有抛出Exception的方法
    public void test1(){}

    @Pointcut("* *To(..)")//匹配目标类所有以To为后缀的方法。第一个*代表返回类型,而*To代表任意以To为后缀的方法
    public void test2(){}

    //--2、通过类定义切点-----------
    @Pointcut("* com.lerendan.Test.*(..)")//匹配Test类(或接口)的所有方法。第一个*代表返回任意类型,第二个*代表所有方法
    public void test3(){}

    @Pointcut("* com.lerendan.Test+.*(..)")//匹配Test类及其所有子类(或接口及其所有实现类)所有的方法
    public void test4(){}

    //--3、通过类包定义切点。在类名模式串中,“.”表示包下的所有类,而“..”表示包、子孙包下的所有类---
    @Pointcut("* com.lerendan.*.*(..)")//匹配com.lerendan包下所有类的所有方法
    public void test5(){}

    @Pointcut("* com.lerendan..*.*(..)")
    //匹配com.lerendan包、子孙包下所有类的所有方法以及包下接口的实现类。“..”出现在类名中时后面必须跟“*”,表示包、子孙包下的所有类
    public void test6(){}

    @Pointcut("* com..*.*Dao.find*(..)")
    //匹配包名前缀为com的任何包下类名后缀为Dao的类中方法名以find为前缀的方法。如com.lerendan.UserDao#findByUserId()。
    public void test7(){}

    //--4、通过方法入参定义切点
    // 切点表达式中方法入参部分比较复杂,可以使用“”和“ ..”通配符,其中“”表示任意类型的参数,而“..”表示任意类型参数且参数个数不限。
    @Pointcut("* joke(String,int)")
    //匹配joke(String,int)方法,且方法的第一个入参是String,第二个入参是int。
    //如果方法中的入参类型是java.lang包下的类,可以直接使用类名,否则必须使用全限定类名,如joke(java.util.List,int)
    public void test8(){}

    @Pointcut("* joke(String,*)")//匹配目标类中的joke()方法,第一个入参为String,第二个入参可以是任意类型
    public void test9(){}

    @Pointcut("* joke(String,..)")//匹配目标类中的joke()方法,第一个入参为String,后面可以有任意个入参且入参类型不限
    public void test10(){}

    @Pointcut("* joke(Object+)")//匹配目标类中的joke()方法,方法拥有一个入参,且入参是Object类型或该类的子类。
    public void test11(){}

    //--5、通过构造函数定义切点---------
    @Pointcut("@com.logaop.annotation.Log *.new(..)")//	被注解Log修饰的所有构造函数,这个比较特殊
    public void test12(){}

}

2.3 Advice(通知)

Advice 是在切入点上织入的代码,在 AspectJ 中有以下几种类型。

Advice 修饰的方法的参数 说明
@Before JoinPoint 在执行 JoinPoint 之前
@After JoinPoint 在执行 JoinPoint 之后,包括正常的 return 和 throw 异常
@AfterReturning JoinPoint JoinPoint 为方法调用且正常 return 时,不指定返回类型时匹配所有类型
@AfterThrowing JoinPoint JoinPoint 为方法调用且抛出异常时,不指定异常类型时匹配所有类型
@Around ProceedingJoinPoint 替代 JoinPoint 的代码,如果要执行原来代码的话,要使用 ProceedingJoinPoint.proceed()

使用示例:

// 这里使用@Aspect注解,表示这个类是一个切片代码类。
@Aspect
public class AspectJTest {

    private static final String TAG = "AspectJTest";
    
    //@After,表示使用After类型的advice,里面的value其实就是一个poincut,"value="可以省略
    @After(value = "staticinitialization(*..People)")
    public void afterStaticInitial() {
        Log.d(TAG, "the static block is initial");
    }
    
    @Pointcut(value = "handler(Exception)")
    public void handleException() {
    }

    @Pointcut(value = "within(*..MainActivity)")
    public void codeInMain() {
    }

    // 这里通过&&操作符,将两个Pointcut进行了组合
    // 表达的意思其实就是:在MainActivity当中的catch代码块
    @Before(value = "codeInMain() && handleException()")
    public void catchException(JoinPoint joinPoint) {
        Log.d(TAG, "this is a try catch block");
    }

}

通过上述代码可以看到我们可以直接在 Advice 注解的参数里写一个 PointCut 表达式,或者先通过 @Pointcut 注解定义 PointCut,然后在 Advice 注解的参数里填入 @Pointcut 注解修饰的方法名。

2.4 Aspect(切面)

Aspect 就是 AOP 中的关键单位:切面,我们一般会把相关 Pointcut 和 Advice 放在一个 Aspect 类中。在基于 AspectJ 注解开发方式中只需要在类的头部加上 @Aspect 注解即可。另外 @Aspect 不能修饰接口。

 

三、AspectJ 在 Android 中的使用方式


3.1 引入 AspectJ 的方式

3.1.1 直接引入

步骤1、在工程根目录的 build.gradle 里面,buildscript-dependencies 下面添加:

classpath 'org.aspectj:aspectjtools:1.8.9'

步骤2、在你开发 aspectj 的 library module 的 build.gradle 里面添加(如果我们的切面代码并不是独立为一个 module 的可以忽略这一步):

import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main
apply plugin: 'com.android.library'
android {
    // ...
}
dependencies {
    // ...
    implementation 'org.aspectj:aspectjrt:1.8.9'
}

android.libraryVariants.all { variant ->
    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast {
        //下面的1.8是指我们兼容的jdk的版本
        String[] args = [
                "-showWeaveInfo",
                "-1.8",
                "-inpath", javaCompile.destinationDir.toString(),
                "-aspectpath", javaCompile.classpath.asPath,
                "-d", javaCompile.destinationDir.toString(),
                "-classpath", javaCompile.classpath.asPath,
                "-bootclasspath", android.bootClasspath.join(File.pathSeparator)
        ]
        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler)
        def log = project.logger
        for (IMessage message : handler.getMessages(null, true)) {
            switch (message.getKind()) {
                case IMessage.ABORT:
                case IMessage.ERROR:
                case IMessage.FAIL:
                    log.error message.message, message.thrown
                    break;
                case IMessage.WARNING:
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }
}

步骤3、在 app 的 build.gradle 里面,添加:

import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main
apply plugin: 'com.android.application'
android {
    // ...
}
dependencies {
    // ...
    implementation 'org.aspectj:aspectjrt:1.8.9'
}

final def log = project.logger
final def variants = project.android.applicationVariants
variants.all { variant ->
    if (!variant.buildType.isDebuggable()) {
        log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
        return;
    }
    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast {
        String[] args = ["-showWeaveInfo",
                         "-1.8",
                         "-inpath", javaCompile.destinationDir.toString(),
                         "-aspectpath", javaCompile.classpath.asPath,
                         "-d", javaCompile.destinationDir.toString(),
                         "-classpath", javaCompile.classpath.asPath,
                         "-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
        log.debug "ajc args: " + Arrays.toString(args)
        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler);
        for (IMessage message : handler.getMessages(null, true)) {
            switch (message.getKind()) {
                case IMessage.ABORT:
                case IMessage.ERROR:
                case IMessage.FAIL:
                    log.error message.message, message.thrown
                    break;
                case IMessage.WARNING:
                    log.warn message.message, message.thrown
                    break;
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }
}

需要注意的是如果是其他 module 需要该功能,则每一个需要的 module,都需要加上在你开发 aspectj 的 library module 的 build.gradle 里面添加的代码。并且也需要依赖你编写aspectj的那个module。

其实,第二步和第三步的配置是一样的,并且在配置当中,我们使用了 gradle 的 log 日志打印对象 logger。因此我们在编译的时候,可以获得关于代码织入的一些异常信息。我们可以利用这些异常信息帮助检查我们的切面代码片段是否语法正确。要注意的是:logger 的日志输出是在 android studio 的 Gradle Console 控制台显示的,并不是我们常规的 logcat。

通过我们前面 《Gradle 专栏》 的学习,这里其实我们也可以定义一个 gradle plugin,将上述配置放到 plugin 中达到一个自动配置的效果。

3.1.2 通过第三方插件引入

通过上面的方式,我们就完成了在 android studio 中的 android 项目工程接入 AspectJ 的配置工作。这个配置有点繁琐,因此网上其实已经有人写了相应的 gradle 插件 gradle_plugin_android_aspectjx。直接利用这个 gradle 插件就可以了,具体的可以参考它的文档。

3.2 使用方式

以 Pointcut 切入点作为区分,AspectJ 有两种用法:侵入式和非侵入式

3.2.1 侵入式

侵入式一般会使用自定义注解,以此作为选择切入点的规则。侵入式 AspectJ 的特点是:

  • 需要自定义注解
  • 切入点需要添加注解,会侵入切入点代码
  • 不需要修改 Aspect 切面代码,就可以随意修改切入点

它的实现代表就是 JakeWharton 大神的 hugo 。不熟悉如何自定义注解的同学可以看本博客《编译插桩专栏》里的 APT 部分。下面我们来看看 hugo 的实现:

首先新增自定义注解:

@Target({TYPE, METHOD, CONSTRUCTOR}) @Retention(CLASS)
public @interface DebugLog {
}

 上面定义了 @DebugLog 注解,可以修饰类、接口、方法和构造函数。由于 AspectJ 的输入是 class 文件,所以可在 Class 文件中保留,编译期可用。接下来看看 hugo 的切面代码:

@Aspect
public class Hugo {
  private static volatile boolean enabled = true;
  // @DebugLog 修饰的类、接口的 Join Point
  @Pointcut("within(@hugo.weaving.DebugLog *)")
  public void withinAnnotatedClass() {} 

  // synthetic 是内部类编译后添加的修饰语,所以 !synthetic 表示非内部类的
  // 执行 @DebugLog 修饰的类、接口中的方法,不包括内部类中方法
  @Pointcut("execution(!synthetic * *(..)) && withinAnnotatedClass()")
  public void methodInsideAnnotatedType() {} 

  // 执行 @DebugLog 修饰的类中的构造函数,不包括内部类的构造函数
  @Pointcut("execution(!synthetic *.new(..)) && withinAnnotatedClass()")
  public void constructorInsideAnnotatedType() {} 

  // 执行 @DebugLog 修饰的方法,或者 @DebugLog 修饰的类、接口中的方法
  @Pointcut("execution(@hugo.weaving.DebugLog * *(..)) || methodInsideAnnotatedType()")
  public void method() {} 

  // 执行 @DebugLog 修饰的构造函数,或者 @DebugLog 修饰的类中的构造函数
  @Pointcut("execution(@hugo.weaving.DebugLog *.new(..)) || constructorInsideAnnotatedType()")
  public void constructor() {} 
  ...

  @Around("method() || constructor()")
  public Object logAndExecute(ProceedingJoinPoint joinPoint) throws Throwable {
    enterMethod(joinPoint); // 打印切入点方法名、参数列表
    long startNanos = System.nanoTime();
    Object result = joinPoint.proceed(); // 调用原来的方法
    long stopNanos = System.nanoTime();
    long lengthMillis = TimeUnit.NANOSECONDS.toMillis(stopNanos - startNanos);
    exitMethod(joinPoint, result, lengthMillis); // 打印切入点方法名、返回值、方法执行时间
    return result;
  }
...

从上面代码可以看出 hugo 是以 @DebugLog 作为选择切入点的条件,只需要用 @DebugLog 注解类或者方法就可以打印方法调用的信息。 

3.2.2 非侵入式

非侵入式,就是不需要使用额外的注解来修饰切入点,不用修改切入点的代码。

 

四、AspectJ 的优缺点


AspectJ 一个显著的缺点就是性能较低,它在实现时会包装自己的一些类,逻辑比较复杂,不仅生成的字节码比较大,而且对原函数的性能也会有所影响。

AspectJ 是通过对目标工程的 .class 文件进行代码注入的方式将通知(Advise)插入到目标代码中。 

  • 第一步:根据pointCut切点规则匹配的joinPoint; 
  • 第二步:将Advise插入到目标JoinPoint中。 

这样在程序运行时被重构的连接点将会回调 Advise方法,就实现了AspectJ代码与目标代码之间的连接。举个例子:

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

经过 AspectJ 处理后:

暴力突破 Android 编译插桩(七)- AspectJ 使用_第1张图片

可以看到经过 AspectJ 的字节码处理,它并不会直接把 Trace 函数直接插入到代码中,而是经过一系列自己的封装。如果想针对所有的函数都做插桩,AspectJ 会带来不少的性能影响。不过大部分情况,我们可能只会插桩某一小部分函数,这样 AspectJ 带来的性能影响就可以忽略不计了。

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

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

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

 

五、AspectJ 实战


如果你看到这里,说明你对 AspectJ 的使用已经有了一定的了解,下面我们来看看几个实战的小例子。

5.1 统计 Application 中所有方法的耗时

@Aspect
public class ApplicationAspect {
    @Around("call (* com.json.chao.application.BaseApplication.**(..))")
    public void getTime(ProceedingJoinPoint joinPoint) {
        Signature signature = joinPoint.getSignature();
        String name = signature.toShortString();
        long time = System.currentTimeMillis();
        try {
            joinPoint.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        Log.i("ApplicationAop", name + " cost" + (System.currentTimeMillis() - time));
    }
}

可以看到 Around 和 Before、After 的最大区别就是 ProceedingPoint 不同于 JoinPoint,其提供了 proceed 方法执行目标方法。

5.2 对 App 中所有的方法进行 Systrace 函数插桩

@Aspect
public class SystraceTraceAspect {
    private static final String TAG = "SystraceTraceAspectj";

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

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

}

使用 Systrace 对函数进行插桩,从而能够查看应用中方法的耗时与 CPU 情况。学习了 AspectJ 之后,我们就可以利用它实现对 App 中所有的方法进行 Systrace 函数插桩了。了解了 AspectJX 的基本使用之后,我们使用 AspectJ 去打造一个简易版的 APM(性能监控框架)。

 

 

参考文献

https://www.eclipse.org/aspectj/doc/released/aspectj5rt-api/index.html

极客时间《Android 开发高手课》27丨编译插桩的三种方法:AspectJ、ASM、ReDex

Android Aop之Aspectj

Android AOP学习之:AspectJ实践

https://github.com/JakeWharton/hugo

AOP之@AspectJ技术原理详解

51 信用卡 Android 自动埋点实践

网易HubbleData之Android无埋点实践

你可能感兴趣的:(编译插桩)