从Aspectj来讲Android AOP

1. AOP简介

  大家都知道OOP(面向对象编程),而AOP(Aspect Oriented Programming)面向切面编程和OOP类似,都属于一种编程思想的方法论。

  OOP的思想是模块划分,模块内完成各自的事务处理,模块间通过定义好的接口进行交互。而AOP的思想核心是切面,一个经典的场景就是方法的耗时统计,假如有多个模块,每个模块有多个类,每个类有多个方法。现在想统计每个方法的执行耗时,那么可以在每个方法的执行前记录时间,执行完后输出方法耗时。如果遵循面向对象的编程思想,那么每个方法都要添加代码,这种实现方式繁琐且容易出问题,而AOP就很适合解决这类问题(比如hugo)。下面简要介绍几个AOP相关的术语。

1.1. Advice(增强)

  增强是指被织入到目标代码连接点上的一段代码,也可以理解为Hook,不同的AOP框架支持的增强种类不完全一致。

1.2. JoinPoint(连接点)

  简称JPoint,程序代码中的某个特点位置,可以是类的初始化、方法调用前、方法异常处理、变量的读取等。意味着可以在这些连接点织入代码,对原有代码进行增强。不同的实现框架,能支持的连接点会有所区别,比如AspectJ框架支持变量读取的连接点,而Spring框架不支持。

1.3. PointCut(切点)

  切点是指具体要织入代码的位置,也是我们要关注的连接点。换句话说,切点就是从支持的连接点中选择我们要关注的那部分,所以切点也是连接点的一个子集。

1.4. Aspect(切面)

  切面由切点和增强两部分组成,一般AOP框架所实现的功能就是将切面所定义的增强代码织入到切面所定义的连接点。

1.5. Weaving(织入)

  上文中多次提到织入,是指将增强代码添加到具体的切点的过程。可以根据织入的实现方式对AOP框架进行大致的分类。

1.6. Target(目标)

  用户定义的切面的切点所属的目标类,用户在定义切面时可指定Target,不指定的情况下默认是所有类。

2. AOP的实现方式

  这里按照代码织入时机将AOP的实现方式分为以下两种,对于同一种AOP框架在JVM环境和在Android环境下可能会采用不同的接入方式。

2.1. 编译期织入

  指织入动作发生在编译过程。比如AspectJ框架是通过特殊的Java编译器(ajc),在编译阶段从Java源代码编译成class字节码期间织入代码的。

2.2. 运行时织入

  运行时织入最主要的一种体现是使用动态代理的方式在运行期为目标类生成子类,经典的Spring框架就是采用动态代理实现。
  相比编译期织入方式,运行时织入的优点是对编译时间的影响较小,其缺点也很明显,由于在编译过程可能会动态生成字节码,所以对运行时的性能会有所影响。

3. AspectJ 框架

  AspectJ官网)是在Java语言上实现AOP的框架,前面有提到它的织入时机是在编译期,使用特殊的Java编译器:ajc编译器。正常情况下要使用AspectJ必须使用ajc编译器来编译java源代码。ajc编译器可以直接替代常规的javac编译器。但是对于一个有很多依赖组件的项目而言,这种使用方法有一个比较明显的缺点,即对依赖组件无效。所有依赖的组件最终是以字节码的形式添加到宿主项目中,因此它们无需再次编译,也就无法在里面织入代码。

  由于上述所说的问题,对于Android平台上如果希望AOP框架能对依赖组件生效,可以选择通过gradle插件的形式使用AspectJ,比如沪江技术的AspectJ封装库(简称沪江AspectJ),下图是使用常规方式的AspectJ和使用沪江AspectJ的实现区别。

AspectJ使用不同接入方法的实现区别

3.1. 支持的Advice

  如下表是AspectJ支持的Advice种类和说明,对于不同的Advice种类AspectJ会采用不同的方式织入代码。

Advice 种类 说明
@before(JPoint) 某个JPoint执行前
@after(JPoint) 某个JPoint执行后
@afterReturning(JPoint) 某个JPoint(方法)正常返回后
@afterThrowing(JPoint) 某个JPoint(方法)抛出未捕获异常
@around(JPoint) 包围JPoint

  如下是使用各种Advice的示例代码,AdviceTest定义两个方法(在这里就是目标类),AdviceTestAspect是定义切面的类。

public class AdviceTest {

    public String method_1(int arg) {
        String result = "result" + arg;
        return  result;
    }

    public String method_2(int arg) {
        String result = "result" + arg;
        return  result;
    }
}

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

    @Before("execution(* com.aop.test.AdviceTest.method_1(..))")
    public void before(JoinPoint jPoint) {
        Log.e(TAG, "before");
    }

    @After("execution(* com.aop.test.AdviceTest.method_1(..))")
    public void after(JoinPoint jPoint) {
        Log.e(TAG, "after");
    }

    @AfterReturning("execution(* com.aop.test.AdviceTest.method_1(..))")
    public void afterReturning(JoinPoint jPoint) {
        Log.e(TAG, "afterReturning");
    }

    @AfterThrowing("execution(* com.aop.test.AdviceTest.method_1(..))")
    public void afterThrowing(JoinPoint jPoint) {
        Log.e(TAG, "afterThrowing");
    }

    @Around("execution(* com.aop.test.AdviceTest.method_2(..))")
    public Object around(ProceedingJoinPoint jPoint) throws Throwable{
        Log.e(TAG, "around");
        return jPoint.proceed();
    }
}

  我们看下织入后的代码是什么样的,如下是编译后再反编译出来的代码:

public class AdviceTest {
    private static final /* synthetic */ JoinPoint.StaticPart ajc$tjp_0 = null;
    private static final /* synthetic */ JoinPoint.StaticPart ajc$tjp_1 = null;

    static {
        ajc$preClinit();
    }

    private static /* synthetic */ void ajc$preClinit() {
        Factory factory = new Factory("AdviceTest.java", AdviceTest.class);
        ajc$tjp_0 = factory.makeSJP(JoinPoint.METHOD_EXECUTION, (Signature) factory.makeMethodSig("1", "method_1", "com.aop.test.AdviceTest", "int", HelpFormatter.DEFAULT_ARG_NAME, "", "java.lang.String"), 5);
        ajc$tjp_1 = factory.makeSJP(JoinPoint.METHOD_EXECUTION, (Signature) factory.makeMethodSig("1", "method_2", "com.aop.test.AdviceTest", "int", HelpFormatter.DEFAULT_ARG_NAME, "", "java.lang.String"), 10);
    }

    public String method_2(int arg) {
        JoinPoint makeJP = Factory.makeJP(ajc$tjp_1, (Object) this, (Object) this, Conversions.intObject(arg));
        return (String) Log.e("AdviceTestAspect", "around");
    }

    public String method_1(int arg) {
        JoinPoint makeJP = Factory.makeJP(ajc$tjp_0, (Object) this, (Object) this, Conversions.intObject(arg));
        try {
            TTest.aspectOf().before(makeJP);
            String str = "result" + arg;
            TTest.aspectOf().after(makeJP);
            TTest.aspectOf().afterReturning(makeJP);
            return str;
        } catch (Throwable th) {
            TTest.aspectOf().afterThrowing(makeJP);
            throw th;
        }
    }

    private static final /* synthetic */ String method_2_aroundBody0(AdviceTest ajc$this, int arg, JoinPoint joinPoint) {
        return "result" + arg;
    }
}

  对于同一个JPoint不能有多个Advice,会导致不能正常织入代码,编译日志可以看到报错。这也是上面示例代码需要定义两个方法的原因,around类型不能和其他类型一起使用,大部分情况下也没必要,因为around就能实现其他所有类型的功能。

  从上面示例可以看出,around类型会织入较多的代码(至少比其他类型多出两个静态方法),有时候还会生成子类,所以相对而言其它类型会更轻量,实践中可以考虑优先使用其他类型。

3.2. 支持的JPoint

  如下图所示是AspectJ支持的JPoint种类说明和使用表达式。

Joint Point 种类 Pointcut 表达式
Method call (方法调用) call(MethodSignature)
Method execution (方法执行) execution(MethodSignature)
Constructor call (构造方法调用) call(MethodSignature)
Constructor execution (构造方法执行) execution(MethodSignature)
Field get (读取某个变量) get(FieldSignature)
Field set (设置某个变量) set(FieldSignature)
Class initialization (类初始化) staticinitialization(TypeSignature)
Exception handler (异常处理) handler(TypeSignature)
Object initialization (对象初始化) initialization(ConstructorSignature)
Object pre-initialization (对象初始化前) preinitialization(ConstructorSignature)
Advice execution adviceexecution()

3.3. JPoint的选择项

  可以使用如下选择项对JPoint进行筛选过滤,选项之间可以使用逻辑表达式结合使用。

JPoint选择 说明
within(TypePattern) TypePattern表示包名或类,支持通配符。表示某个Package或者类中的所有JPoint,静态判断。
withincode(
ConstructorSignature|
MethodSignature)
表示某个构造方法或其他方法代码中涉及到的JPoint
cflow(pointcuts) 表示调用某个方法时所包含的所有JPoint,包含顶级方法的调用本身
cflowbelow(pointcuts) 和cflow一样,不包含顶级方法的调用本身
this(Type) JPoint 代码段所属的 this 对象是否 instanceOf Type,需要动态判断。
target(Type) JPoint 所要搜索的目标对象是否 instanceOf Type,需要动态判断。
args(TypeSignature) 用来对JPoint的参数进行条件搜索

3.4. 切面的定义语法

  定义一个切面的格式:@Pointcut("execution(Signature)"),下面对Signature分类讲解。

3.4.1. MethodSignature

  格式:@注解 访问权限 返回值的类型 包名.方法名(参数)
  1. @注解访问权限(public/private/protect,以及static/final)属于可选项。不设置表示全部包含。
  2. 返回值类型就是普通的方法的返回值类型,可以使用通配符表示不限定类型。
  3. 包名.方法名用于查找匹配的方法。可以使用通配符,包括
和..以及+号。其中*号用于匹配除.号之外的任意字符,而..则表示任意子package,+号表示子类。

com.*.Log  :可以表示com.common.Log,也可以表示com.util.Log
Log*       :可以表示LogUtil,也可以表示Log
com..*     :表示com开头的任意包下的所有类

  4. 方法参数如下, ..代表任意参数个数和类型:
  (int, char):表示参数有两个,第一个参数类型是int,第二个参数类型是char。
  (String, ..):表示至少有一个参数。第一个参数类型是String,后面参数类型和个数不限。

3.4.2. ConstructorSignature

  和MethodSignature类似,只不过构造方法没有返回值,而且方法名必须叫new,*..代表任意包名。比如:
  public *..TestAspect.new(..):表示任意包下的TestAspect类的任意构造方法。

3.4.3. FieldSignature

  格式:@注解 访问权限 类型 类名.成员变量名
  和MethodSignature基本一致,比如:
  set(String TestAspect.tag):表示设置TestAspect.tag变量时的连接点。

3.4.4. TypeSignature

  就是类型,支持通配符,比较简单这里就不举例了。

4. Android AOP 方案

  AspectJ 并不是Android平台上AOP框架的唯一选择,当然可能也并不是最佳选择。这一节主要介绍在Android平台上实现AOP的可选方案。

4.1. Javassist

  是一个用来处理class文件的类库,可以编辑或创建class文件。Javassist在使用上比较友好,是基于java源码级别来编辑class文件。

4.2. Asm

  Asm是一个功能齐全、应用广泛且成熟的Java字节码分析和处理框架。和Javassist类似,可以在字节码层面增强已有的类,也可以生成新的字节码文件。在JVM环境下,Asm支持代码运行期动态增强和新增类。在Android平台下,由于其环境的特殊性导致无法支持在运行期动态增强的特性,Android平台上使用Asm一般是通过Gradle plugin的Transform来实现。因此在Android平台上通过Asm实现AOP只能是编译期织入的形式。

4.3. cglib

  cglib是一个动态代理框架,由于动态代理框架的实现方式非常符合AOP思想,因此动态代理也可以作为实现AOP的一种方式。

  jdk有标准的动态代理实现库,但有一个比较明显的缺点:被代理的类必须实现一个公开的接口,由于现实中的代码很难都满足这个条件,因此诞生了cglib。其实cglib就是通过Asm库实现的动态代理,它不要求被代理的类一定要实现接口,因此更灵活。前面提到的Spring框架,实现原理就是基于jdk标准的动态代理实现库和cglib共同来完成其AOP特性(通过被代理的类是否有实现接口来选择实现方案)。

JDK和CGLIB库动态代理模型

  cglib虽好,遗憾的是它并不能直接在Android平台上使用,因为它是通过运行期动态生成字节码来实现动态代理,但动态生成的字节码无法在Android App运行过程中直接使用。Android App运行过程中动态加载的是dex文件。因此如果要使用cglib,当前有一种方案是结合dexmaker动态生成dex实现(有兴趣可参考这里:项目地址)。dexmaker是一种Android App运行时代码生成器,可在运行期动态生成dex文件,然后通过类加载器加载到内存使用(项目地址)。

4.4. Dexposed 和 epic

  Dexposed项目大概是6年前阿里开源的,能够在Dalvik上实现Java运行时AOP。实现方式基于Dalvik下的底层Hook技术,跟Dalvik的实现机制紧紧绑定一起,但从Android M开始,ART取代了Dalvik成为了Android平台上的Java运行时环境,因此Dexposed从M开始就无法正常使用。

  epic是基于ART重新实现了Dexposed的版本,这种真正运行时的动态AOP框架,可以用于实现轻量级的热修复功能。具体实现方式可看这篇博客:我为Dexposed续一秒。总体来说,基于底层Hook技术的方案的稳定性和系统版本兼容性都比较差,因此不建议用在项目的线上环境。

5. AspectJ应用案例——方法日志工具

  基于hugo框架,在其基础上进行优化和扩展,实现一个快速打印方法日志的工具。如下是使用该日志工具示例代码,支持两种使用方式@DebugLog@CustomLog,后者支持定制化输出日志。

public class LogTest {

    @DebugLog
    public String methodDebug(int vInt) throws Exception {
        Thread.sleep(300);
        return "callLog1 result " + vInt;
    }

    @CustomLog(tag = "customLog_test", tagClass = false, time = true, parameter = true, thread = true, level = LevelEnum.ERROR)
    public String methodCustom(int vInt, List<String> vList, int[] vArray) throws Exception {
        Thread.sleep(300);
        return "callLog2 result " + vInt;
    }
}

  当上面methodDebugmethodCustom方法被调用后,就会输出如下日志内容:

D/DEBUG_LOG: [LogTest] ⇢ methodDebug(vInt=1) [Thread:"main"] (LogTest.java:16)
D/DEBUG_LOG: [LogTest] ⇠ methodDebug [301ms] = "callLog1 result 1"
E/customLog_test: [LogTest] ⇢ methodCustom(vInt=2, vList=[eme_1, eme_2], vArray=[7, 8, 9]) [Thread:"main"] (LogTest.java:26)
E/customLog_test: [LogTest] ⇠ methodCustom [301ms] = "callLog2 result 2"

  切面定义代码如下:

@Aspect
public class CustomLogAspect {

    /**
     * 使用{@link CustomLog}注解的类的所有方法
     */
    @Pointcut("within(@com.aop.log.annotation.CustomLog *)")
    public void withinAnnotatedClass() {
    }

    /**
     * 使用{@link CustomLog}注解的类的所有方法(排除编译器生成的内部类)
     */
    @Pointcut("execution(!synthetic * *(..)) && withinAnnotatedClass()")
    public void methodInsideAnnotatedType() {
    }

    /**
     * 使用{@link CustomLog}注解的类的所有方法(排除编译器生成的内部类的构造方法)
     */
    @Pointcut("execution(!synthetic *.new(..)) && withinAnnotatedClass()")
    public void constructorInsideAnnotatedType() {
    }

    /**
     * 使用{@link CustomLog}注解的方法,或使用{@link CustomLog}注解的类的所有方法(排除编译器生成的内部类)
     */
    @Pointcut("execution(@com.aop.log.annotation.CustomLog * *(..)) || methodInsideAnnotatedType()")
    public void method() {
    }

    /**
     * 使用{@link CustomLog}注解的构造方法,或使用{@link CustomLog}注解的类的所有方法(排除编译器生成的内部类的构造方法)
     */
    @Pointcut("execution(@com.aop.log.annotation.CustomLog *.new(..)) || constructorInsideAnnotatedType()")
    public void constructor() {
    }

    /**
     * 包含以上两种
     */
    @Pointcut("method() || constructor()")
    public void logMethod() {
    }

    @Before("logMethod() && @annotation(customLog)")
    public void before(JoinPoint joinPoint, CustomLog customLog) {
        LogController.before(joinPoint, customLog);
    }

    @AfterThrowing(value = "method()", throwing = "throwable")
    public void afterThrowing(JoinPoint joinPoint, Throwable throwable) {
        LogController.afterThrowing(joinPoint, throwable);
    }

    @AfterReturning(value = "method()", returning = "result")
    public void afterReturning(JoinPoint joinPoint, Object result) {
        LogController.afterReturning(joinPoint, result);
    }

    @After("constructor()")
    public void after(JoinPoint joinPoint) {
        LogController.after(joinPoint);
    }

}

6. 总结

6.1. AOP在Android应用场景

  事件全埋点,慢函数监控,依赖组件的代码修复能力,排查未知调用问题的利器,特定方法搜索和拦截等。
  大部分App都会依赖较多的组件,而有些被依赖的组件的源代码并不是宿主项目所能控制的,当被依赖的组件出问题时,宿主项目对于依赖组件代码的控制能力就显得尤其重要,无论是AspectJ还是Asm都是应对这类问题的一种强有力的工具。

6.2. Android平台下使用AspectJ的注意事项

  1. 观察编译日志,有时候能正常编译打包并不意识着全部织入成功,而只要有一个地方织入失败,都有可能导致在运行期出现不可预期的崩溃(比如找不到类),可以通过包体对比来辅助确保打出来的包没有问题。

  2. 反编译apk观察代码的织入情况。由于Aspectj在Android上的使用还不是很普及,相关的介绍文档还不够齐全,所以这一步很重要,一定要反编译看。

  3. 前面有提到around是比较重量的,所以优先选择其他4种类型会更轻量。

6.3. Android环境下AOP的选择和展望

  AspectJ框架是一个较为完备的AOP框架,功能强大灵活,快速接入使用,目前来看是一个很好的选择。但是也存在一些缺点:
  1. 非官方支持,AspectJ官方目前还没有对其在Android平台上提供支持方案。
  2. 在Android平台上的使用案例不多,需要自行摸索才能搞清楚其最佳使用方法。

  考虑到AspectJ的上述缺点,从长远来看Asm可能是更好的选择,虽然Asm本身并不是AOP框架,但这并不重要,重要的是我们可以借助Asm处理AOP以及其它事务,当然Asm的接入成本比AspectJ高一些,但其优点也比较明显:
  1. 官方支持,使用Asm不需要再借助三方提供的封装库,直接通过Android官方支持的编译时transform任务,然后使用官方的Asm库对字节码操作即可。
  2. 灵活,直接对字节码操作,不被限制在AOP框架支持的范围内,能实现AOP所不具备的能力。自行实现方式也可以避免产生过多的类文件。
  3. 高效,Asm的字节码处理效率相比Javassist高很多,减少了对编译速度的影响。
  4. 使用广泛成熟,前面有提到cglibSpring都是基于Asm去实现相应功能。

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

推荐阅读更多精彩内容