从Android优雅权限框架理解AOP思想(2) 原理篇
前言
上一个大的系列文章叫 "手把手讲解", 历时10个月,出产博文二十余篇,讲解细致,几乎每一篇都提供了详实的原理讲解,提供了可运行
github Demo
,并且针对Demo中的关键地地方进行了重点拆解。相信每一位详细阅读文章的同行都会有所收获。但是,讲解虽详细,但是缺乏对于技术的深度的挖掘。从今天开始开辟新的专题:
移动架构师专业技能深入浅出
,以一步步成为架构师为目标,详述一项架构师技能的最直接使用价值,横向周边知识以及纵深专业技术.最直接使用价值: 网上最怕看到一种文章,全文开篇高大上,让人觉得遥不可及,通篇看下来却没有展示技术如何落地,落地之后是何种效果。文章写出来,就要以最容易让人接受的方式带读者进入作者的世界,而不是装作一副高高在上的样子俯视众生。所以,文章开篇,一定是最直接的展示技术的落地效果。提供可运行Demo可以让读者亲自尝试。
横向周边知识: 一项核心技术,必然不是独立存在,技术是一个体系,但是一篇文章能够详述的技术有限,必然是以一项技术为中心,其他技术作为辅助。核心技术需要详述,但是周边技术,也需要交代,参天大树拔地而起也少不得土壤作为依附。用简明的语言交代周边知识,并提供这些知识正确的研究方向。也是一个负责任的博文作者不可忽视的一步。
纵深专业技术: 做技术,最忌讳的就是浅尝辄止。稍微深入一点就退出去,一来不利于理解底层实现,长此以往永远只是一个技术小白,成不了大师;二来不利于长久记忆, 记忆力再强的人时间长了,技术细节必然会记忆模糊。但是如果深入内核,理解了原理,在技术的大方向上绝对不会偏差。作为要成为架构师的男人,即使记不了那么多细节,但是对于大方向的把握绝对不能错。所以,技术纵深很有必要。
正文大纲
- AOP思想回顾
- AOP实现方式一览
- AspectJ原理详解
正文
承接上文 从Android优雅权限框架理解AOP思想(1) 表层篇
AOP思想回顾
上一篇提到,所谓AOP思想,就是在OOP面向对象编程的基础上,为了避免大量雷同的代码,也为了更加灵活的分离业务代码和辅助代码,我们可以采用AOP的思想进行代码解耦,让业务代码更加纯粹,让程序更加优雅。
上一篇文章的demo 解耦的具体表现为:在原本辅助代码的位置,使用注解来替换原本代码,让辅助代码脱离业务代码,而在框架层面,解读@注解,执行辅助代码,由此达成解耦。
aop (1).jpg
但是,AOP是一个宽泛的概念,可以使用注解作为标记,也可以不用注解。AOP切入的时机也可以不同。
AOP 实现方式一览
java开源框架中,AOP的框架一箩筐,跟我们关系不大,我就不列举了,但是 android开发中,常用的
AOP框架
是传说中的:
AOP三剑客:APT
,AspectJ
,javasist
我们进行
借用一张图android
开发,使用的是java语言(kotlin
代码,但是kotlin也会被当作java
来编译,所以没有区别), 上述这三个AOP框架
分别对应了java
文件到dex
文件的3个阶段:
三个AOP切入时机
- APT:
把java代码编译之前的时机作为切入点,切入之前和之后的区别是,多出了若干个 xxx.java文件, 然后利用反射,在 我们写的业务代码中,去使用多出来的这些java文件. 从而节省业务代码量,也避免了代码的耦合。
- AspectJ
编译器把所有java文件整合好了之后,下一个步骤是将 java文件 用javac命令编译成 class文件。这时,使用AspectJ
,可以干扰.class
二进制字节码文件的生成过程,可以改变 原有.class
文件的内容,也可以生成新的.class
文件。我们想要插入的业务逻辑,把编译期作为 切入点,众多class
文件,都会集中打包成dex文件。
- Javassist:
所有的.class
都已经生成,即将用dex命令
打包成classes.dex
文件。这个时候,我们还是有机会去干扰业务逻辑。使用Javassist
框架,赶在dex命令执行之前, 对现有的class文件
进行更改。
AspectJ原理详解
现象
Demo地址:https://github.com/18598925736/GracefulPermissionFramework/commits/dev_aspectJ
以上面的Demo为例:上一篇文章 从Android优雅权限框架理解AOP思想(1) 表层篇说道,我们利用框架思想,将 权限申请的 多种情形 的差异化消除,让所有需要使用权限的地方都能以普通java类的形式来编码。形如:
public class LocationUtil {
@PermissionNeed(
permissions = {Manifest.permission.BODY_SENSORS},
requestCode = 0)
public void getLocation() {
Log.d("LocationUtilTag","普通Java类:权限已获得");
ToastUtil.showToast("普通Java类:权限已获得");
}
/**
* 这里写的要特别注意,denied方法,必须是带有一个int参数的方法,下面的也一样
*
* @param requestCode
*/
@PermissionDenied
private void denied(int requestCode) {
Log.d("LocationUtilTag","普通Java类:权限被拒绝");
ToastUtil.showToast("普通Java类:权限被拒绝");
}
@PermissionDeniedForever
private void deniedForever(int requestCode) {
Log.d("LocationUtilTag","普通Java类:权限被永久拒绝");
ToastUtil.showToast("普通Java类:权限被永久拒绝");
}
}
只需要3个注解
@PermissionNeed
@PermissionDenied
@PermissionDeniedForever
就能完成权限的动态申请,以及 处理 申请结果回调. 所以说,到底是怎么做到的???
让我们把思维归0,就以此处为起点,来探索AspectJ是如何做到 逻辑插入的。
- Apk反编译,看一眼 apk dex里面的LocationUtil这个类变成了什么样儿。
(推荐一个apk反编译工具jadx-0.6.1
,可以直接看到apk的dex源代码,但,如果混淆了,你看到的将会是混淆之后的源码.)
public class LocationUtil {
private static /* synthetic */ Annotation ajc$anno$0;
private static final /* synthetic */ StaticPart ajc$tjp_0 = null;
public class AjcClosure1 extends AroundClosure {
public AjcClosure1(Object[] objArr) {
super(objArr);
}
public Object run(Object[] objArr) {
objArr = this.state;
LocationUtil.getLocation_aroundBody0((LocationUtil) objArr[0], (JoinPoint) objArr[1]);
return null;
}
}
static {
ajc$preClinit();
}
private static /* synthetic */ void ajc$preClinit() {
Factory factory = new Factory("LocationUtil.java", LocationUtil.class);
ajc$tjp_0 = factory.makeSJP(JoinPoint.METHOD_EXECUTION, factory.makeMethodSig("1", "getLocation", "com.zhou.graceful.LocationUtil", "", "", "", "void"), 20);
}
static final /* synthetic */ void getLocation_aroundBody0(LocationUtil ajc$this, JoinPoint joinPoint) {
joinPoint = "普通Java类:权限已获得";
Log.d("LocationUtilTag", joinPoint);
ToastUtil.showToast(joinPoint);
}
@PermissionNeed(permissions = {"android.permission.BODY_SENSORS"}, requestCode = 0)
public void getLocation() {
JoinPoint makeJP = Factory.makeJP(ajc$tjp_0, this, this);
PermissionAspect aspectOf = PermissionAspect.aspectOf();
ProceedingJoinPoint linkClosureAndJoinPoint = new AjcClosure1(new Object[]{this, makeJP}).linkClosureAndJoinPoint(69648);
Annotation annotation = ajc$anno$0;
if (annotation == null) {
annotation = LocationUtil.class.getDeclaredMethod("getLocation", new Class[0]).getAnnotation(PermissionNeed.class);
ajc$anno$0 = annotation;
}
aspectOf.doPermission(linkClosureAndJoinPoint, (PermissionNeed) annotation);
}
@PermissionDenied
private void denied(int requestCode) {
String str = "普通Java类:权限被拒绝";
Log.d("LocationUtilTag", str);
ToastUtil.showToast(str);
}
@PermissionDeniedForever
private void deniedForever(int requestCode) {
String str = "普通Java类:权限被永久拒绝";
Log.d("LocationUtilTag", str);
ToastUtil.showToast(str);
}
}
经过两方对比,我们发现AspectJ给我们原本的LocationUtil.java源码中做的改动为:
- 新增了 2个成员变量,1个内部类,1个静态代码块,2个成员方法
- 改动了原本的 getLocation() 方法 的逻辑
从结论上来看,AspectJ进行AOP的手段为: 在我们编写好源码之后,改动 class字节码,让程序执行的逻辑发生了变化.
- 还是jadx,现在看看整个工程的源码目录结构。
AspectJ 在我们的字节码class中加入了一个新的package,里面全都是框架性质的代码。
小结
AspectJ 通过改动字节码class 从而改变了程序逻辑。而且是可以像我们操作源码一样,随意新增新的类以及改动现有的类。相当于把我们的源码进行了重构.
那么随之而来,就有下一个疑问,AspectJ是按照什么思路重构我们原有的代码逻辑的。
但是在此之前,现象背后有几个概念先声明:
现象背后
在此感谢博主:https://blog.csdn.net/woshimalingyi/article/details/73252013 ,网上翻了很多博客,就这个写的比较完善。
AspectJ 的核心分为两部分: 编译器 ajc 和 织入器 weaver , 在源代码的编译期间扫描目标程序,根据切入点PointCut的匹配,将我们编写的 Aspect切面程序 编织到目标程序的.class中,对目标文件做了重构。然后我们就看到了上一节 中,class反编译出来 之后 和 我们编写的源文件有很大区别。
概念
- Aspect切面
定义一个切面类,通常以功能作为划分。比如,日志打印切面,方法执行耗时切面,权限申请切面等。每一个切面都有它的独特功能。定义了切面类,该切面的所有功能就被集中在这一个类中,该类的名字要有意义。
具体的做法非常简单,只需要加入一个@Aspect
注解在类的头上。
@Aspect // 把一个类定义为切面类
public class PermissionAspect {// 权限切面类,集中处理所有权限问题
//...
}
- PointCut 切入点
定义切入点,来让AspectJ框架知道 我们要改变 目标程序的哪些Method的逻辑,通过
最常用的PointCut切入点MethodSignature
匹配。
所谓MethodSignature
就是,写一个表达式,给AspectJ框架去解析,让他找出目标程序中所有符合要求的Method,然后作为切入点,准备插入切面逻辑。
其写法规则如下:
除此之外,还有一些不那么常用的切入点,算是高级特性:
image.png
- JoinPoint 连接点
当我们确定了切入点之后,这个切入点上原来的代码逻辑,就是连接点,因为 我们插入的逻辑,终将和原来的逻辑合为一体。 这里说的原来的逻辑,就称为 JoinPoint连接点。举个生动的例子,树木的嫁接。
image.png
如图所示,原来的树木,我们劈开一个切口,就相当于 PointCut切入点,表示 这里就作为原木和新木的交接处。而图中,PointCut的左边,是原木,也就是 我们的连接点JoinPoint 。右边,则是 新木,就是我们定义的Aspect切面。当我们的程序最终编译之后,就会是右边树木的最终样子,合并成一个class,逻辑也合并到一起。
JoinPoint
常用的只有2个接口:
JoinPoint.java
和ProceedingJoinPoint.java
,他们的关系为ProceedingJoinPoint extends JoinPoint
- Advise 切入策略
我们有了切入点和连接点,现在需要确定 我们要插入的逻辑以什么样的方式和原代码逻辑合并。
- @Before、@After
插入的逻辑放在 原JoinPoint
逻辑的前面或者后面,不影响原逻辑- @Around
插入的逻辑完全替代原JoinPoint
逻辑,但是保留是否要执行原逻辑的决定权。- @AfterThrowing
在原JoinPoint
逻辑 抛出异常之后,该操作优先于下一个切点的@Before()
现在来看看AspectJ对我们的LocationUtil类做了什么
image.png
上图中,划分为3个部分,
1) 平白无故多出来的部分
2) 原先存在的getLocation()方法被修改了,方法体被完全替换
3) 并未改动的部分
按照这个类被加载时的执行顺序来看:
首先是 静态代码块:
static {
ajc$preClinit();
}
private static /* synthetic */ void ajc$preClinit() {
Factory factory = new Factory("LocationUtil.java", LocationUtil.class);
ajc$tjp_0 = factory.makeSJP(JoinPoint.METHOD_EXECUTION,
factory.makeMethodSig("1", "getLocation", "com.zhou.graceful.LocationUtil", "", "", "", "void"), 20);
}
在JVM开始创建这个类的class对象的时候,ajc$preClinit()
方法便开始执行。
创建了一个Factory,然后用Factory.makeSJP()方法创建了StaticPart.
读一遍代码,可以发现:
LocationUtil.java
是PointCut切入点类。
JoinPoint.METHOD_EXECUTION
PointCut切入点的其中一个时机 execution.
factory.makeMethodSig(...)
返回的是MethodSignature,也就是用于标记Method的签名。
可以作结论了,第一步,AspectJ框架,先通过注入代码的方式,确定了LocationUtil类的PointCut切入点,切入点就是getLocation方法,并且最终创建了一个静态的StaticPart ajc$tjp_0对象,保存了起来(其实它就是一个中间产物"静态部分信息",后续将会用它来创建JointPoint对象).
我们继续。
发现一个奇怪的东西: 闭包
public class AjcClosure1 extends AroundClosure {
public AjcClosure1(Object[] objArr) {
super(objArr);
}
public Object run(Object[] objArr) {
objArr = this.state;
LocationUtil.getLocation_aroundBody0((LocationUtil) objArr[0], (JoinPoint) objArr[1]);
return null;
}
}
static final /* synthetic */ void getLocation_aroundBody0(LocationUtil ajc$this, JoinPoint joinPoint) {
joinPoint = "普通Java类:权限已获得";
Log.d("LocationUtilTag", joinPoint);
ToastUtil.showToast(joinPoint);
}
关于闭包的概念:闭包是一段程序的封装,它不同于method方法,它可以在java代码中作为参数传递给其他闭包或method.
这个闭包把我们原先的getLocation()方法的方法体,重新封装起来了。既然是封装起来,那么 肯定是要使用的。
继续往下走,进入改过之后的getLocation()
:
@PermissionNeed(permissions = {"android.permission.BODY_SENSORS"}, requestCode = 0)
public void getLocation() {
JoinPoint makeJP = Factory.makeJP(ajc$tjp_0, this, this);
PermissionAspect aspectOf = PermissionAspect.aspectOf();
ProceedingJoinPoint linkClosureAndJoinPoint = new AjcClosure1(new Object[]{this, makeJP}).linkClosureAndJoinPoint(69648);
Annotation annotation = ajc$anno$0;
if (annotation == null) {
annotation = LocationUtil.class.getDeclaredMethod("getLocation", new Class[0]).getAnnotation(PermissionNeed.class);
ajc$anno$0 = annotation;
}
aspectOf.doPermission(linkClosureAndJoinPoint, (PermissionNeed) annotation);
}
- 第一行
Factory.makeJP(ajc$tjp_0, this, this);
使用之前的中间产物 StaticPart 构建一个JoinPoint连接点对象;
注意: 后面两个this,第一个是 给_this赋值,后一个是 target赋值。_this是当前插入代码的类的对象。target则是当前插入代码的类的调用者的对象。在这里,我们都传的this,这是因为我们用的是切入点pointcut是execution,如果是call,两种则不会相同。- 第二行
PermissionAspect aspectOf = PermissionAspect.aspectOf();
得到了一个PermissionAspect
类对象,即 我们最先定义的@Aspect
修饰的切面类。- 第三行
ProceedingJoinPoint linkClosureAndJoinPoint = new AjcClosure1(new Object[]{this, makeJP}).linkClosureAndJoinPoint(69648);
利用第一行得到的JoinPoint
对象,配合之前的闭包AjcClosure1
,构建一个ProceedingJoinPoint
对象。(他是@Around
策略特有的JoinPoint子类,因为@Around
要保留执行原有逻辑的可能,ProceedingJoinPoint
有一个proceed()
方法)
跟踪参数this, makeJP
: this是当前运行时对象,即LocationUtil类对象,makeJP是JoinPoint对象。把这两个传给闭包,闭包内部的 LocationUtil.getLocation_aroundBody0() 方法才能执行,调用到当前运行时对象的getLocation_aroundBody0() 方法。- 第四行到倒数第二行
这里由于我们的优雅权限框架使用了自定义注解,所以,这里拿到Annotation
对象并且缓存到静态成员变量中.- 最后一行
使用@Aspect
切面类PermissionAspect
,执行doPermission()
方法。并且把之前的前面得到的ProceedingJoinPoint
和Annotation
作为参数传递给doPermission(ProceedingJoinPoint joinPoint, PermissionNeed permissionNeed)
方法。
做个结论
上面的分析,非常直观的展示了AspectJ对我们的原逻辑一顿猛如虎的操作之后,LocationUtil类中的逻辑被完全重构,但是并没有对我们手动编码的部分造成影响,而是只影响了生成的class. 这就是AOP的目的,在不干扰原有业务逻辑的情况下,改变现有逻辑,或者加入新的逻辑。
特别注意
image.png
@Pointcut(argNames = "permissionNeed", value = "execution(@com.zhou.zpermission.annotation.PermissionNeed * *(..)) && @annotation(permissionNeed)")
这种方法签名很容易写错,特别是针对不同需求,当你想直接在签名中加入参数列表的,或者注解的时候,写法还各不一样。目前我还没特别总结过,目前常用的就两种:参数args / 注解@annotation
注解args的写法如Demo中所示
而 参数args写法 则是 如下:
image.png
预计接下来研究方向
现在知道了 AspectJ 这个AOP框架对我们的 源代码逻辑做了什么改动。但是具体是如何改动的,尚未可知。 其实,AspectJ的底层是ASM,一种更加底层字节码操作框架,class内容重构的api都包含在内。计划下一步继续深入挖掘。
结语
出于篇幅原因,本文只是 分析了@Around策略下 AspectJ的代码织入过程。其实要写详细,要做的事情还有很多,demo也可以更加完善。后续的有时间再详解吧。