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的事情。
参考文章
- http://fernandocejas.com/2014/08/03/aspect-oriented-programming-in-android/
- https://blog.egorand.me/going-aspect-oriented-with-aspectj-and-google-analytics/
- http://blog.csdn.net/innost/article/details/49387395
网友评论