Android上的AOP方案及其优缺点

在 Java 开发中,面向对象(OOP)编程是其最大的特色,但有时候为了更优雅得实现某些架构性的需求,单纯的面向对象已经满足不了当下日益复杂的业务需求,比如常见的埋点,方法日志,耗时统计等,这些如果使用简单的OOP实现就会陷入无穷的代码复制粘贴地狱中。所有我们需要引入更高效率的编程方式——AOP 来解决这些问题。AOP(Aspect-Oriented Programming,面向切面编程)是一种新的方法论,是对传统 OOP(Object-Oriented Programming,面向对象编程)的补充, AOP编程操作的主要对象是切面(aspect)。AOP的好处:每个事物逻辑位于一个位置,代码不分散,便于维护和升级业务模块更简洁,只包含核心业务代码。

APT 注解处理器

APT(Annotation Processing Tool)是一种处理注释的工具,它对源代码文件进行检测找出其中的Annotation,使用Annotation进行额外的处理。
Annotation处理器在处理Annotation时可以根据源文件中的Annotation生成额外的源文件或其它的文件(文件具体内容由Annotation处理器的编写者决定)。

ps: 有很多主流框架都使用了APT的技术,代表:Dagger2,ButterKnife,ARouter等等

APT 在所有我所知的AOP工具中属于最早在编译期间处理代码的工具,它是在其编译成 .java 文件前就能介入其中,如图所示

APT,AspectJ,Javassist对应的编译时期

简单使用

接下来我们看看示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47

@AutoService(Processor.class)
public class LogProcessor extends AbstractProcessor {

/**
* 添加需要支持解析的注解
*/
@Override
public Set<String> getSupportedAnnotationTypes() {
Set<String> annotations = new LinkedHashSet<>();
annotations.add(TLog.class.getCanonicalName());
return annotations;
}

/**
* 获取注解并自定义处理方式
*/
@Override
public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) {
//获取所有拥有CLog注解的元素,可能是类,方法,变量等等
Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(CLog.class);
for (Element element : elements) {
if (element.getKind() == ElementKind.CLASS) {
// 一般这里都是使用 JavaPoet 进行类的生成
MethodSpec main = MethodSpec.methodBuilder("main")
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.returns(void.class)
.addParameter(String[].class, "args")
.addStatement("$T.out.println($S)", System.class, "Hello, JavaPoet!")
.build();

TypeSpec helloWorld = TypeSpec.classBuilder("HelloWorld")
.addModifiers(Modifier.PUBLIC, Modifier.FINAL)
.addMethod(main)
.build();

JavaFile javaFile = JavaFile.builder("com.example.helloworld", helloWorld)
.build();

javaFile.writeTo(System.out);
}
}


return false;
}
}

一般写 APT自定义注解时需要使用一个几个库

1
2
3
4
5
implementation 'com.squareup:javapoet:1.11.1'

implementation 'com.google.auto.service:auto-service:1.0-rc6'
//添加这句,使auto-service注解生效
annotationProcessor "com.google.auto.service:auto-service:1.0-rc6"

auto-service是用来帮你自动生成 META-INF 中的 metadata.

javapoet 则是一个帮助你生成 .java 文件的一个库.

编写完成后只需要在 build.gradle 中引入即可

1
2
3
4
5
6
dependencies {

annotationProcessor 'your apt compiler'
// for kotlin
// kapt 'your apt compiler'
}

优缺点

前面说的都是优点,比如 编译生成代码效率高,官方、gradle原生支持,使用上手简单等,但也有一定局限性

1、预留入口不编译会报红,需要先编译一次
2、调用新的生成的类还是需要写代码,使用反射效率又太差
3、无法实现定点插桩,只能生成新的类

故如果上述几个缺点不影响的话,APT绝对是你的第一选择。

AST(Abstract syntax tree)-抽象语法树

那我一定要使用APT,又想克服上述缺点有没有方法,答案是有,就是可以尝试使用 AST.
AST 即为“抽象语法树”,是编辑器对代码的第一步加工之后的结果,是一个树形式表示的源代码。源代码的每个元素映射到一个节点或子树。
Java 的编译过程可以分为三个阶段:
Java编译过程

  1. 所有源文件会被解析成语法树。
  2. 调用注解处理器。如果注解处理器产生了新的源文件,新文件也要进行编译。
  3. 最后,语法树会被分析并转化成类文件。
  4. 大体流程 JavaTXT->词语法分析-> 生成AST ->语义分析 -> 编译字节码

AOP工具介入时机

那么通过操作 AST 是不是就可以通过修改抽象语法树的结构来达到对代码修改的目的呢?本应该可以的,但我自己实践下来发现修改后的 AST 无法反馈到文件上导致修改失败,不知道是什么原因,这里存个疑问。。。

操作 AST 一般通过 Javac 语法修改 或者使用工具库,比如 RewriteJavaParser

具体实现我就不展开了,请自行搜索吧。

AspectJ

AspectJ是目前实现AOP框架中最成熟,功能最丰富的语言,而且,AspectJ与java程序完全兼容,几乎是无缝关联,因此对于有java编程基础的工程师,上手和使用都非常容易。AspectJ是一个java实现的AOP框架,它能够对java代码进行AOP编译(一般在编译期进行),让java代码具有AspectJ的AOP功能(当然需要特殊的编译器)

AspectJ 在 Android 上的使用有两个很经典的参考,一个是 Jake Wharton 写的Hugo 方法耗时日志库,另一个则是可以直接集成到 Android 上使用的项目 AspectJX

基础概念与使用

Advice(通知): 典型的 Advice 类型有 before、after 和 around,分别表示在目标方法执行之前、执行后和完全替代目标方法执行的代码。

Joint point(连接点): 程序中可能作为代码注入目标的特定的点和入口。

Pointcut(切入点): 告诉代码注入工具,在何处注入一段特定代码的表达式。

Aspect(切面): Pointcut 和 Advice 的组合看做切面。例如,在本例中通过定义一个 pointcut 和给定恰当的advice,添加一个了内存缓存的切面。

Weaving(织入): 注入代码(advices)到目标位置(joint points)的过程。

在 Android 中一般都是通过注解来使用,示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Aspect
public class TestAspect {

@Pointcut("execution(* android.view.View.OnClickListener.onClick(android.view.View))")
public void custom(){
}

@Before("custom()")
public void before(JoinPoint joinPoint){
Log.d("cpacm","OkHttpAspect before");
}

@After("custom()")
public void after(){
Log.d("cpacm","OkHttpAspect after");
}
}

具体可以参考 AspectJX

优缺点

AspectJ 方案成熟,使用起来更是方便,但集成到 Android 环境上比较麻烦,即便使用 AspectJX 框架也不能 100% 避免与 gradle 或者其他第三方框架的冲突。

Gradle Plugin

Google 提供了一整套相关 gradle 的库,即 gradle-transform-api 工具,基于这个工具可以在 gradle 的 task 中获取已经编译的 class 文件进行编辑,并替换原有 class 文件以达到字节插桩的目的。

常见使用场景:HotFix,InstantRun等

使用示例

一般使用 Gradle Plugin 插件最好的做法是将其作为一个 Java 模块,常用项目结构图如下:
Gradle Plugin Demo

logger 作为代码真正执行者,可供插件或者开发者直接调用;
logger-annotations 作为注解库
logger-compiler apt的实现代码
logger-plugin gradle 的插件库

在插件中,你可以使用 groovy 或者 java 甚至 kotlin 来进行开发

logger-plugin 插件目录如下

1
2
3
4
5
6
7
8
9
10
11
12
13
├── logger-plugin
│ ├── build.gradle
│ └── src
│ └── main
│ ├── java
│ │ └── com
│ │ └── cpacm
│ │ └── log
│ │ ├── LogPlugin
│ └── resources
│ └── META-INF
│ └── gradle-plugins
│ └── com.cpacm.log.properties

其中 LogPlugin 为插件实现者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class LogPlugin : Plugin<Project> {
override fun apply(project: Project) {

// create a extension
project.extensions.create("loggerConfig", LogExtension::class.java)

val logTransform = LogTransform(project)
// 'android' extension for 'com.android.application' projects.
val android = project.extensions.findByType(AppExtension::class.java)

if (android == null) {
// in library module
val library = project.extensions.findByType(LibraryExtension::class.java)

logTransform.isAndroidApp = false
library?.registerTransform(logTransform)
} else {
// bind logtransform
logTransform.isAndroidApp = true
android.registerTransform(logTransform)
}

}
}

META-INF 则是作为插件注册文件,里面内容为

1
implementation-class=com.cpacm.log.LogPlugin

Plugin 实现的核心代码中需要一个继承 Transform 的类,示例中为LogTransform,来实现字节码的插桩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* <p>
* 在这里可以对已经编译完毕的class文件进行修改
* 常用修改工具:ASM:ClassVisitor可以查找相关特征
* 和javassist
* @author cpacm 2019-10-24
*/
class LogTransform(private val project: Project) : Transform() {


override fun getName(): String {
return TAG
}

override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> {
return mutableSetOf(QualifiedContent.DefaultContentType.CLASSES)
}

/**
* 只允许修改自己项目下的代码
*/
override fun getScopes(): MutableSet<in QualifiedContent.Scope> {
val scopes = hashSetOf<QualifiedContent.Scope>()
scopes.add(QualifiedContent.Scope.PROJECT)
if (isAndroidApp) {
//application module中加入此项可以处理第三方jar包
//scopes.add(QualifiedContent.Scope.EXTERNAL_LIBRARIES)
}
return scopes
}

override fun isIncremental(): Boolean {
return true
}

override fun transform(transformInvocation: TransformInvocation?) {
super.transform(transformInvocation)

}

companion object {
val TAG = "SimpleLogger"
}

}
  1. getName() 用来定义transform任务的名称,随意定一个就好
  2. getInputTypes() 用来限定这个transform能处理的文件类型,一般来说我们要处理的都是class文件,就返回TransformManager.CONTENT_CLASS
  3. getScopes() 指定文件的范围,一般分为 主项目,子模块,第三方jar等等
  4. inIncremental() 是否支持增量编译
  5. transform() 主要方法在这个方法中获取输入的class文件,然后做些修改,最后输出修改后的class文件

处理字节码

字节码处理这里推荐两个库,一个是 JavassistASM

这俩个库各有优势,Javassist 封装的更好,比较容易上手,而ASM则是更偏重于字节码的编辑,比较灵活,但需要使用者有一些基础的字节码知识。

总结

复习一下,现在 Android 上比较知名的AOP技术方案有三种:

  1. APT(Annotation Processing Tool) 最基础也是最有效率的注解处理工具,能够在编译早期根据注解生成新的类;
  2. AspectJ 目前最成熟的AOP工具,能根据方法名,注解等自行决定切入时机,功能齐全使用方便;
  3. Gradle Plugin 则是依托于 Google 提供的 gradle-transform-api 工具,借助 ASM 等工具对字节码进行修改。

三种方案比较推荐的是APT,但APT的局限性强,如果无法满足需求则再自己评测一下使用 Aspectj 或者 Gradle Plugin

文章作者: cpacm
文章链接: http://www.cpacm.net/2019/11/20/Android上的AOP方案及其优缺点/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 cpacm
打赏
  • 微信
  • 支付宝

评论