Welcome to Android AOP

作者: MasterNeo | 来源:发表于2017-02-20 16:51 被阅读465次

    What?

    As we all know,在进行项目构建时,追求各模块高内聚,模块间低耦合。然而现实并不总是如此美好,某些通用功能是横跨并嵌入到其他各模块中的。比如日志打印、方法的执行时间统计、参数校验等。这些功能星罗棋布得分散在项目工程中,既不方便统一管理,也不利于后期维护。

    如何对这些零散却通用的功能做一些优化处理呢? AOP(Aspect-Oriented Programming),为我们提供了一种新的思路。AOP翻译成中文是面向切面编程,其与OOP一样,是一种编程范式。如果说OOP是把问题划分到单个模块的话,那么AOP就是把涉及到众多模块的某一类问题进行统一管理。AOP可通过预编译和运行时动态代理的方式实现,从而更好地组织工程和代码结构,AOP的实现原理如图1所示。

    图1 AOP实现原理

    AOP已广泛应用于服务端编程,并取得了巨大的成功,如大名鼎鼎的Spring框架。而在本文中,我们将AOP引入Android工程,用以解决开篇提到的编程痛点。阅读完本文后便会发现,AOP在Android项目中同样可以做一些有趣的事。

    Where?

    AOP在Android工程中能用在哪?或者说能用AOP来做什么?下面列举了一些使用场景:

    • 日志记录;
    • 持久化;
    • 性能监控;
    • 方法的参数校验;
    • 缓存;
    • 权限检查:业务权限(如登陆,或用户等级)、系统权限(如拍照定位)。
    • 其他更多

    当然,用AOP还可以实现一些具体的业务需求。

    How?

    介绍了这么多AOP的概念,那么如何进行具体应用呢?下面介绍几种现有的工具和类库,可以很方便地实现AOP编程。

    • AspectJ 一种基于JavaTM编程语言的面向切面编程无缝扩展,适用Android,编译期织入目标字节码文件中,实现无缝侵入。
    • Javassist for Android 知名Java类库Javassist的Android平台移植版,同样操作字节码文件。
    • DexMaker 在Dalvik VM上编译期或运行时生成目标代码的Java API。
    • ASMDEX 一个类似ASM的字节码操作库,运行在Android平台,操作字节码文件。

    琳琅满目的兵器库中,当然要选择一件最趁手的,就是AspectJ了。选择AspectJ的具体原因主要有三点:1、功能强大,能满足大部分需求;2、支持编译期和加载时代码注入,无侵入实现;3、易于使用。

    AspectJ是一种几乎和Java完全一样的语言而且完全兼容Java。使用AspectJ有两种方法,除了使用AspectJ的语法之外,还支持原生Java。然而,无论使用哪种方法,最后都需要AspectJ的编译工具ajc来实现编译,由于AspectJ实际上脱胎于Java,所以ajc工具也能编译Java源码。

    AspectJ支持编译期和加载时代码注入,可以理解为是一种Hook。在开始之前,我们先看几个关键词:Join Point、Pointcut、Advice、Aspect、Weaving。这几个关键词在AspectJ中的抽象关系如图2所示。

    图2 AspectJ中各组件的抽象关系

    接下来详细阐述各个关键词的含义和作用。

    • Join point: 程序中可能作为代码注入目标的特定的入口。通俗来说就是可能被我们Hook的入口,这个入口可以是方法调用、执行、某个变量、类初始化、异常处理等,Join Point的常用类型如表所示。
    Join Point 描述 示例
    method call 方法调用 如调用a.method(),此处为Join Point
    method execution 方法执行 如a.method()执行内部,此处为Join Point
    constructor call 构造方法调用 同method call类似
    constructor execution 构造方法执行 同method execution类似
    field get get 变量 如读取a.field成员变量,此处为Join Point
    field set set 变量 如设置a.field成员变量,此处为Join Point
    static initialization 类初始化 如class A中的static{},此处为Join Point
    handler 异常处理 如try-catch(xxx)中对应catch内部的执行
    • Pointcut: 告诉代码注入工具,在何处注入一段特定代码的表达式。可以将Ponitcut理解为Join Point的过滤规则,通过过滤规则筛选出一些特定的Join Point。
      举个Pointcut的栗子可能更容易说明问题:

    public pointcut test call(public * *.method(…)) && !within(A);

    • 第一个public:表示该pointcut的访问权限是public的,这与AspectJ的继承关系有关,属于AspectJ的高级语法,涉及到的应用场景较少,此处暂不讨论;
    • pointcut:关键字,表示该语句是一个pointcut;
    • test:pointcut可分为具名和匿名两种形式,考虑方便调用起见,建议使用具名的方式。namedPointcut的冒号后面部分是真正pointcut的内容,也就是筛选规则;
    • call:表示选择的Join point是一种“方法调用”类型,对应表一的method call,下表展示了常用的Pointcut类型;
    Join Point类型 Pointcut类型
    method call call(MethodSignature)
    method execution execution(MethodSignature)
    constructor call call(ConstructorSignature)
    constructor execution execution(ConstructorSignature)
    Field read access get(FieldSignature)
    field write access set(FieldSignature)
    static initialzation staticinitialzation(TypeSignature)
    handler handler(TypeSignature)

    Pointcut的过滤规则语法内容较多,规则如下:

    @注解(可选) 访问权限(可选) 返回值的类型 包名.函数名(参数)

    • 第二个public:表示的是目标Join Point的访问权限,为可选项,如目标Join Point是一个方法,则该方法的访问类型为public。如果不设置,则默认所有访问权限(public|protected|defult|private)都匹配;
    • **.method(..):第一个*表示的是方法返回值类型,此处为任意类型。第二个*表示完整的类名,此处为任意包名和类名。method表示方法名称,..表示参数类型和参数个数,此处意为任意类型和任意参数个数。包名.方法名用于查找匹配的方法,可以使用通配符。包括*和..和+。其中*匹配除.之外的任意字符,而..则匹配某个包下的任意子包,+匹配某个类的任意子类。举些栗子:

    com.*.util:可以表示com.netease.util,也可以表示com.google.util;
    com.netease.*AOP,可以表示com.netease.AndroidAOP,也可以表示com.neteast.JavaAOP;
    com..*表示com包中任意子包下的任意子类;
    com..*AOP+表示com包下任意子包下以AOP结尾的子类,如AndroidAop的子类;

    方法参数也有相应的匹配规则:

    (int, String):表示第一个参数为int,第二个参数为String;
    (int, ..):表示第一个参数为int,后面参数任意数量和类型;
    (String …):表示不定参数,且类型都为String;
    (..):表示任意数量和类型的参数;

    • !within(A):这是对Join Point的附加筛选。与Java语法相似,!代表非逻辑,within(A)表示某个包或类中的所有Join Point,此处为类A中所有Join Point。常用的附加筛选条件如表所示。
    附加筛选 描述 示例
    within(TypeSignature) 参数表示package或class,可使用通配符 如within(A),表示class A中的所有Join Point
    withincode(Constructor&Method) 与within类似,匹配精确到方法 如within(A.method())表示class的method()涉及到的所有Join Point
    this(Type) 判断Join Point是否是Type类型 Join Point属于某个类,this用以判断该Join Point所在的类是否是Type类型
    args(TypeSignature) 对参数进行条件搜索 args(String...),表示参数为String的不定参数

    所以,总结起来,上文的Pointcut的栗子的语义就是:

    选择那些任意包名和类名的&&访问权限为public的&&方法名为method的&&参数任意的方法作为目标Join Point。但需要排除class A中的所有Join Point。

    • Advice: 典型的 Advice 类型有 before、after 和 around,分别表示在目标方法执行前、执行后和完全替代目标方法执行的代码。Advice的作用就是,在Pointcut筛选出特定的执行点和入口,做真正的Hook操作。常见的Advice类型如表所示。
    Advice类型 描述
    before() 表示在Join Points之前前Hook并执行
    after() 表示在Join Points执行完之后Hook并执行
    after():returning(返回值类型) 方法执行完正常返回处Hook并执行
    after():throwing(异常类型) 方法异常退出点Hook并执行
    around() around作用是取代原Join Points
    • Aspect: Pointcut 和 Advice的组合可看做切面Aspect,这是一个抽象的概念,理解即可。例如,我们在应用中通过定义一个 Pointcut 和给定恰当的Advice,添加一个日志切面,将该切面切入原有的业务代码中。
    • Weaving: 注入代码(Advices)到目标位置(Join points)的过程,由编译器ajc完成。

    引入AOP的栗子

    在项目中引入AOP的方式共有三种,分别是原生AspectJ语言,在Java代码中使用AspectJ,结合自定义注解的方式在Java代码中使用AspectJ。考虑到易用性和非侵入性,本栗子的方式是在Java代码中使用AspectJ,在不修改业务代码的前提下,实现onCreate方法性能的监控。

    首先考虑未引入AOP的情形,我们如何完成对业务代码的性能监控。

    public class MainActivity extends AppCompatActivity
            implements NavigationView.OnNavigationItemSelectedListener {
        private static final String TAG = MainActivity.class.getSimpleName();
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
    
            TimeWatcher watcher = new TimeWatcher();
            watcher.start();
    
            initView();
    
            watcher.stop();
            Log.i(TAG, "onCreate execute with " + watcher.getTotalTime() + "ms");
        }
    

    很容易可以发现,业务代码和性能监控的功能性代码交织在一起,结构不清晰,耦合度较高。而且更关键的是,每当一处业务代码需要做性能统计,就需要把统计代码重新复制一份。后期如果需要修改性能统计代码的逻辑,比如修改统计日志内容、增加日志上传功能,需要找到每一处引用到的代码进行修改,这样无疑增加了工作量和出错的概率。

    引入AOP可以很好地剥离项目中业务代码和性能统计代码。首先创建aspect Module,引入AspectJ的相关类库,并构建主类和辅助类,项目结构如图3所示。

    图3 项目结构

    在aspect这个module下新建一个Java类。

    @Aspect
    public class TimeWatchAspect {
        private static final String POINTCUT_TIME_WATCH =
                "execution(* com.netease.aopdemo.MainActivity.onCreate(..))";
    
        @Pointcut(POINTCUT_TIME_WATCH)
        public void timeWatch() {}
    
        @Around("timeWatch()")
        public Object weaveJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
            MethodSignature ms = (MethodSignature) joinPoint.getSignature();
            String methodName = ms.getName();
            String className = ms.getDeclaringType().getSimpleName();
    
            TimeWatcher watcher = new TimeWatcher();
            watcher.start();
            Object result = joinPoint.proceed();
            watcher.stop();
    
            Log.i(className, methodName + " execute with " + watcher.getTotalTime() + "ms");
    
            return result;
        }
    }
    

    打上@Aspect注解,则该类可以被ajc编译器识别为一个Asepct,在工程项目编译时便能非常方便地实现代码织入。图5中可以看到AspectJ的三个要素,Join Point、Advice和Aspect。好像少了Join Point?Join Point早已定义在Pointcut的字符串常量中,即MainActivity的onCreate方法。Pointcut以注解的形式定义,注解了timeWatch方法,从而timeWatch就是这个Pointcut的名称,注解参数则使用定义好的字符串常量,作为Join Point的过滤规则。同样,Advice也是将类型关键字(此处为Around)注解在特定的方法weaveJoinPoint之上,注解的参数为具名的Pointcut,即timeWatch。上文提到Around类型即用该方法替换原Join Point的实现,图5中Object result = joinPoint.proceed()等价于原有的被Hook方法,即MainActivity的onCreate()。在该语句的前后,是性能统计的代码片段。

    public class MainActivity extends AppCompatActivity
            implements NavigationView.OnNavigationItemSelectedListener {
        private static final String TAG = MainActivity.class.getSimpleName();
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            initView();
        }
    

    再看一眼我们重构后的业务代码,仅需要专注于业务逻辑处理,是不是变得非常简洁。

    编译并运行项目代码后,结果如图4所示。

    图4 AOP运行结果

    那么ajc编译器在字节码层面到底做了什么呢?

      protected void onCreate(Bundle paramBundle)
      {
        JoinPoint localJoinPoint = Factory.makeJP(ajc$tjp_0, this, this, paramBundle);
        TimeWatchAspect.aspectOf().weaveJoinPoint(new MainActivity.AjcClosure1(new Object[] { this, paramBundle, localJoinPoint }).linkClosureAndJoinPoint(69648));
      }
    
    
      public class MainActivity$AjcClosure1 extends AroundClosure
    {
      public MainActivity$AjcClosure1(Object[] paramArrayOfObject)
      {
        super(paramArrayOfObject);
      }
    
      public Object run(Object[] paramArrayOfObject)
      {
        Object[] arrayOfObject = this.state;
        MainActivity.onCreate_aroundBody0((MainActivity)arrayOfObject[0], (Bundle)arrayOfObject[1], (JoinPoint)arrayOfObject[2]);
        return null;
      }
    }
    

    反编译项目生成的apk后可以看到,ajc在Join Point处织入了代码,用TimeWatchAspect.aspectOf().weaveJoinPoint()实现了替换。

    踩过的坑

    正所谓人不能两次踏进同一条河流,我踩过的坑希望大家可以避免。
    由于AspectJ在字节码层面将功能性代码织入业务代码中,源码层面无法看到变化,且无法在功能性代码中进行断点调试。所以一旦出错,调试成本相对较高。如果项目运行结果与预期不符,首先检查编译问题,能否正常实现代码织入(可以看apk中的class文件树结构),再检查Join Point、Pointcut和Advice是否符合AspectJ语法,Hook是否正确。

    如果Android Studio中的Instant Run开启,则在编译时可能会影响代码的正常织入,所以建议关闭Instant Run。

    另外,一般初级阶段会选择日志打印的方式验证AspectJ接入的可行性。如果测试机是魅族系列手机,则注意把项目中Log等级提升到D以上,或者在手机的开发者选项中选择显示所有等级的日志,否则默认情况下你看不到D及D以下等级日志的输出(惨痛的教训,浪费了两天时间排查问题)。

    总结

    本文主要介绍了Android AOP的编程思想和应用场景,AOP这种编程范式已经在服务端取得了成功,我相信在Android客户端编程中,也一样可以有用武之地。通过AspectJ这种工具可以很方便地实现AOP编程,在文中重点介绍了AspectJ的语法和使用方式,并以实例的方式展示了如何通过AspectJ重构代码,降低了耦合度并利于后期维护。最后介绍了我自己踩过的坑,避免重复入坑。AOP是一种编程思想,理解并运用到工程中将会大有裨益,AspectJ同样是非常强大的工具,文中只介绍了一些基本用法,还有很多高级特性待探索和实践。只要脑洞够大,一定能实现一些意想不到的效果。最后,希望我的文章能给大家带来一些启发,在今后的开发过程中,可以用AOP来做一些exciting的事情。

    参考文章

    1. http://fernandocejas.com/2014/08/03/aspect-oriented-programming-in-android/
    2. https://blog.egorand.me/going-aspect-oriented-with-aspectj-and-google-analytics/
    3. http://blog.csdn.net/innost/article/details/49387395

    Demo源码

    https://github.com/Master-Neo/AopDemo

    相关文章

      网友评论

        本文标题:Welcome to Android AOP

        本文链接:https://www.haomeiwen.com/subject/padewttx.html