Android 编译插桩- AspectJ 使用

一、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 句法:


Pointcut 句法
2.2.2 间接对 JoinPoints 的选择

除了上面与 JoinPoint 对应的选择外,Pointcuts 还有其他选择方法:
间接对 JoinPoints 的选择
2.2.3 组合对 JoinPoints 的选择

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

组合对 JoinPoints 的选择

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

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

需要注意的是 “[]” 当中的内容表示可选项,当没有设定的时候,表示全匹配。另外,需要注意不同项之前是否有空格。
可以通过 @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.eason.Test.*(..)")//匹配Test类(或接口)的所有方法。第一个*代表返回任意类型,第二个*代表所有方法
    public void test3(){}
 
    @Pointcut("* com.eason.Test+.*(..)")//匹配Test类及其所有子类(或接口及其所有实现类)所有的方法
    public void test4(){}
 
    //--3、通过类包定义切点。在类名模式串中,“.”表示包下的所有类,而“..”表示包、子孙包下的所有类---
    @Pointcut("* com.lerendan.*.*(..)")//匹配com.lerendan包下所有类的所有方法
    public void test5(){}
 
    @Pointcut("* com.lerendan..*.*(..)")
    //匹配com.eason包、子孙包下所有类的所有方法以及包下接口的实现类。“..”出现在类名中时后面必须跟“*”,表示包、子孙包下的所有类
    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

使用示例:

// 这里使用@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 直接引入
classpath 'org.aspectj:aspectjtools:1.9.6'

步骤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)
        ]
        // 注意这里为 kotlin 配置,当项目中有使用到kotlin时,需要添加以下配置
        def fullName = ""
        variant.name.tokenize('-').eachWithIndex { token, index ->
            fullName = fullName + (index == 0 ? token : token.capitalize())
        }
      String[] kotlinArgs = ["-showWeaveInfo",
                               "-1.8",
                               "-inpath", project.buildDir.path + "/tmp/kotlin-classes/" + fullName,
                               "-aspectpath", javaCompile.classpath.asPath,
                               "-d", project.buildDir.path + "/tmp/kotlin-classes/" + fullName,
                               "-classpath", javaCompile.classpath.asPath,
                               "-bootclasspath", project.android.bootClasspath.join(
                File.pathSeparator)]
        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler)
        // kotlin 配置
        new Main().run(kotlinArgs, 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 里面,添加:

apply plugin: 'com.android.application'
android {
    // ...
}
dependencies {
    // ...
    implementation 'org.aspectj:aspectjrt:1.8.9'
}
 


import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main


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)]
        def fullName = ""
        variant.name.tokenize('-').eachWithIndex { token, index ->
            fullName = fullName + (index == 0 ? token : token.capitalize())
        }
        String[] kotlinArgs = ["-showWeaveInfo",
                               "-1.8",
                               "-inpath", project.buildDir.path + "/tmp/kotlin-classes/" + fullName,
                               "-aspectpath", javaCompile.classpath.asPath,
                               "-d", project.buildDir.path + "/tmp/kotlin-classes/" + fullName,
                               "-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);
        new Main().run(kotlinArgs, 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。

3.1.2 通过第三方插件引入

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

3.2 使用方式

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

3.2.1 侵入式

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

  • 需要自定义注解
  • 切入点需要添加注解,会侵入切入点代码
    不需要修改 Aspect 切面代码,就可以随意修改切入点
    它的实现代表就是 JakeWharton 大神的 hugo 。不下面我们来看看 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 的字节码处理,它并不会直接把 Trace 函数直接插入到代码中,而是经过一系列自己的封装。如果想针对所有的函数都做插桩,AspectJ 会带来不少的性能影响。不过大部分情况,我们可能只会插桩某一小部分函数,这样 AspectJ 带来的性能影响就可以忽略不计了。

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

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

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

五、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));
    }
}

参考文献:
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无埋点实践

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

推荐阅读更多精彩内容