阅读 4842

AOP:利用Aspectj注入代码,无侵入实现各种功能,比如一个注解请求权限

前言

这篇文章我想了很久不太知道该怎么去写,因为AOP(面向切面编程)在Android上的实践早有人写过,但可能是出于畏难或不了解其应用场景抑或其他什么原因,大家似乎都对它不太感冒。所以今天我以一些Android上的实例,希望能引起大家一些兴趣,适当地使用,真的能减少很多重复工作,而且比手动完成更优质,因为耦合性低,而且几乎是无侵入性的。

简单介绍

Aspect Oriented Programming(AOP),面向切面编程,是一个比较热门的话题。AOP主要实现的目的是针对业务处理过程中的切面进行提取,它所面对的是处理过程中的某个步骤或阶段,以获得逻辑过程中各部分之间低耦合性的隔离效果。

以上摘自百度百科。似懂非懂?没关系。

简单来说,比方我们现在有一个面包(面向对象里的对象),需要把它做成汉堡,所需要的操作就是把它中间切一刀(这就是切面了),然后向切面里塞入一些肉和菜什么的。

对应的Android中呢,比方我们现在有一个Activity,需要把它变成一个带toolbar的Activity,那思考一下,我们需要的就是在onCreate方法这里切一刀,然后塞入一些toolbar的创建和添加的代码。

大概清楚一些了的话,我们就正式开始了。

Gradle接入

今天我们使用的是Aspectj,Aspectj在Android上的集成是比较复杂的,且存在一些问题,但好在已经有人帮我们解决了。

gradle_plugin_android_aspectjx项目地址

再贴一篇掘金上徐宜生大佬介绍的文章 看AspectJ在Android中的强势插入

根据github上的接入指南很容易就完成,先在根目录的gradle文件引入

dependencies {
        classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:1.1.0'
        }
复制代码

然后在app项目或library的gradle里应用插件

apply plugin: 'android-aspectjx'
复制代码

就完成了。我这边使用最新的1.1.1版本报错,使用1.1.0正常。

实例一:为Activity添加Toolbar

话不多说,先看MainActivity代码,很简单,就在onCreate中打印了一个log。

class MainActivity : AppCompatActivity() {
    private val TAG = "MainActivity"
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        Log.d(TAG, " --> onCreate")
    }
}
复制代码

下面开始使用Aspectj了

1、第一次尝试

新建一个MyAspect类,代码如下

@Aspect
public class MyAspect {
    private static final String TAG = "AOPDemo";
    
    @After("execution(* android.app.Activity.onCreate(..))")
    public void addToolbar(JoinPoint joinPoint) throws Throwable {
        String signatureStr = joinPoint.getSignature().toString();
        Log.d(TAG, signatureStr + " --> addToolbar");
    }
}
复制代码

首先,MyAspect类有一个@Aspect注解,它告诉编译器这是一个Aspectj文件,在编译的时候就会去解析这个类里的方法。

下面看addToolbar这个方法,@After注解后有一个挺长的字符串,这个字符串是最关键的地方,它用来指示编译器,我们要在什么地方“切一刀”,我觉得它跟正则表达式很类似,正则表达式是匹配字符串,而它则是匹配切面,即匹配方法或构造函数等。

具体的看一下,首先是execution,字面义:执行,后面一个括号,里面用来指示是哪些方法或构造函数的执行。继续看括号里面,先是一个*,代表返回值,使用*是匹配的方法可以是任意类型的返回值,你也可以指定特定类型;再往后一个空格,后面是类名全路径.方法名(参数),指明我们要“切”的是Activity的onCreate方法,后边的(..)是指定参数数量和类型的,两个点是匹配任意数量、任意类型。

现在切面确定了,还要指明是在切面之前还是之后插入代码,我们想在onCreate之后添加toolbar,所以用的是@After注解,另外还有之前@Before,还有前后都可以处理甚至可以拦截的@Around,这些都是后话,先不深究。

addToolbar方法里的代码就是我们要插入的了,这里并没有真的创建一个toolbar,只是用一个log代替了,但是你创建toolbar用的任何东西,比如所切方法的参数啦,或者所在的对象啦,都可以从JoinPoint中得到的。

现在编写完了,运行一下看是不是我们要的结果吧!

01-06 12:42:06.981 7696-7696/io.github.anotherjack.aopdemo D/AOPDemo: void android.support.v4.app.FragmentActivity.onCreate(Bundle) --> addToolbar
01-06 12:42:06.981 7696-7696/io.github.anotherjack.aopdemo D/AOPDemo: void android.support.v7.app.AppCompatActivity.onCreate(Bundle) --> addToolbar
01-06 12:42:07.007 7696-7696/io.github.anotherjack.aopdemo D/MainActivity:  --> onCreate
01-06 12:42:07.008 7696-7696/io.github.anotherjack.aopdemo D/AOPDemo: void io.github.anotherjack.aopdemo.MainActivity.onCreate(Bundle) --> addToolbar
复制代码

不太对劲,addToolbar的log居然打印了三次,这要是真添加三个toolbar得多匪夷所思。而通过日志里的signature可以发现,这三次分别是FragmentActivity、AppCompatActivity,到最后才是MainActivity。

这里说一下我的理解,aspectj是在编译期插入的代码,注意,编译期,我们的app代码,和library是编译期打包进去的,而手机系统的东西编译期是改不了的,比如android.app.Activity就是存在于Android系统中的。也很好理解,你只是打包了一个apk,怎么能够着把用户的手机系统给改了呢。而aspectj匹配方法的时候也很实在,只要你是Activity,并且有onCreate方法,那我就给你插入代码。我们上边的MainActivity是继承自AppCompatActivity,而AppCompatActivity又继承自FragmentActivity,FragmentActivity才继承自了Activity,归根结底,它们三个都是Activity,所以它们的onCreate方法都被插入了addToolbar方法。而MainActivity的onCreate调用了super.onCreate,另两个同理,所以就出现了addToolbar三次的情况。

这么着肯定不行的,那么该怎么解决呢?

2、进行调整

思考一下,我们上边的问题归根结底就是匹配的面太广了,所以,我们要做的就是再给它加限定条件,缩窄匹配的条件,不让它所有的Activity都匹配,只给特定条件的Activity插入代码就行了。

下面我采用注解来限定,创建一个名为ToolbarActivity的注解

@Target(ElementType.TYPE)
public @interface ToolbarActivity {

}
复制代码

接着修改addToolbar方法上边的@After注解

@After("execution(* android.app.Activity.onCreate(..)) && within(@io.github.anotherjack.testlib.annotation.ToolbarActivity *)")
复制代码

可以看到是在execution之后又通过&&增加了一个within条件,within字面义:在……里面,这里是限定所在的类有@ToolbarActivity注解。

最后在MainActivity上增加@ToolbarActivity,再运行一下,你会发现正常了。这样,我们如果希望哪个Activity带toolbar,只需要给它加@ToolbarActivity注解就好了……呃,也不完全是。注意一下,编译器真的真的很实在,它匹配方法就真的只是去你的类里找有没有onCreate这个方法,不会考虑从父类继承到的onCreate方法,而很多人封装BaseActivity的时候选择把onCreate方法封装一下,只暴露给子类一个initView方法,这时候编译器会认为子类Activity没有onCreate方法,自然也就不会给它插入代码了,这点要注意一下。

实例二:拦截并修改toast

1、通过@Before拦截Toast的show方法

下面我们尝试拦截toast。正如之前所说,因为android.widget.Toast是属于系统里的,所以编译期是无法通过execution给Toast的show方法插入代码的。然而“执行”的代码在系统里,可是“调用”的代码是我们自己写的啊。所以就轮到call登场啦!先上代码

MainActivity中,点击按钮弹出toast。

beforeShowToast.setOnClickListener {
            Toast.makeText(this,"原始的toast",Toast.LENGTH_SHORT).show()
        }
复制代码

MyAspect中

@Before("call(* android.widget.Toast.show())")
    public void changeToast(JoinPoint joinPoint) throws Throwable {
        Toast toast = (Toast) joinPoint.getTarget();
        toast.setText("修改后的toast");
        Log.d(TAG, " --> changeToast");
    }
复制代码

这次使用@Before,与之前最大的不同,是不再使用execution,而是call,字面义:调用。在方法内部我们通过joinPoint.getTarget()获取到了目标toast对象,并通过setText改变了文字,运行一下你会发现弹出来的是“修改后的toast”。完成。这个例子应该能让大家对execution和call的区别有所理解吧。

2、使用@Around处理Toast的setText方法

还是对toast,这次不是show方法了,这次对setText方法操刀。

MainActivity代码,正常应该弹出“没处理的toast”

handleToastText.setOnClickListener {
            val toast = Toast.makeText(this,"origin",Toast.LENGTH_SHORT)
            toast.setText("没处理的toast")
            toast.show()
        }
复制代码

MyAspect中代码,记得先把上一个对show方法的拦截注释掉

@Around("call(* android.widget.Toast.setText(java.lang.CharSequence))")
    public void handleToastText(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        Log.d(TAG," start handleToastText");
        proceedingJoinPoint.proceed(new Object[]{"处理过的toast"}); //这里把它的参数换了
        Log.d(TAG," end handleToastText");

    }
复制代码

注意这个方法的参数不再是JoinPoint了,而是ProceedingJoinPoint,通过它的proceed方法可以调用拦截到的方法,在调用前后都可以插入代码处理,甚至可以不调用proceed方法,直接把这个方法拦截,不让它调用。

这个例子中是在前后各打了一个log,同时proceed方法改变成了新的参数“处理过的toast”。当然你也可以通过getTarget方法得到toast对象,根据toast对象得到文字,并做相应处理。运行一下弹出的是“处理过的toast”,且打印了两行log,是我们预期的结果。

实例三:动态请求权限

相比以上两个例子,这个例子要更具实用性。

这里我们模拟点击按钮拍照的场景,6.0以上系统需要动态请求权限。MainActivity中的代码如下

takePhoto.setOnClickListener {
            takePhoto()
        }
复制代码

takePhoto方法代码如下

//模拟拍照场景
    @RequestPermissions(Manifest.permission.CAMERA,Manifest.permission.WRITE_EXTERNAL_STORAGE)
    private fun takePhoto(){
        Toast.makeText(this,"咔嚓!拍了一张照片!",Toast.LENGTH_SHORT).show()
    }
复制代码

可以看到我们又定义了一个@RequestPermissions注解,代码如下

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestPermissions {
    String[] value() default {};
}
复制代码

value是个String数组,是我们要请求的权限,比如在takePhoto方法中我们请求了相机和外部存储的权限。

接着来看最重要的地方,MyAspect里面

    //任意注解有@RequestPermissions方法的调用
    @Around("call(* *..*.*(..)) && @annotation(requestPermissions)")
    public void requestPermissions(final ProceedingJoinPoint proceedingJoinPoint, RequestPermissions requestPermissions) throws Exception{
        Log.d(TAG,"----------request permission");
        String[] permissions = requestPermissions.value(); //获取到注解里的权限数组

        Object target = proceedingJoinPoint.getTarget();
        Activity activity = null;
        if (target instanceof Activity){
            activity = (Activity) target;
        }else if (target instanceof Fragment){
            activity = ((Fragment)target).getActivity();
        }

        RxPermissions rxPermissions = new RxPermissions(activity);
        final Activity finalActivity = activity;
        rxPermissions.request(permissions)
                .subscribe(new Consumer<Boolean>(){
                    @Override
                    public void accept(Boolean granted) throws Exception {
                        if(granted){
                            try {
                                proceedingJoinPoint.proceed();
                            } catch (Throwable throwable) {
                                throwable.printStackTrace();
                            }
                        }else {
                            Toast.makeText(finalActivity,"未获取到权限,不能拍照",Toast.LENGTH_LONG).show();
                        }
                    }
                });

    }
复制代码

先看这个方法的参数,之前的几个例子中都是只有一个JointPoint参数,而这个多了一个参数,是我们上边定义的那个注解类型,同时在方法上边的@Around注解中有个 @annotation(requestPermissions),仔细看这个括号中本应是个全路径的signature,但这里却是requestPermissions,没错,它就是对应的方法中的参数,这样就相当于是参数类型的全路径放在了那里,而我们也可以在方法中直接使用这个注解了。我们当然也可以从JoinPoint利用反射获取到注解,就像下面这样,但是使用参数的形式很明显要方便多了,而且反射是会影响性能的。同理,target、以及args等也都可以这样转成方法的参数,就不多介绍了。

RequestPermissions requestPermissions1 = ((MethodSignature) proceedingJoinPoint.getSignature()).getMethod().getAnnotation(RequestPermissions.class);

复制代码

继续看方法内的详细代码,先从注解中得到了要请求的权限,然后获取到了target,根据类型得到activity,然后就是请求权限了,这里我是通过RxPermissions处理的。如果获取到了权限就proceedingJoinPoint.proceed()让拦截到的方法正常执行,否则就toast提醒用户没获取权限。最后记得在Manifest中增加相机和外部存储的权限,运行项目,测试一下吧。

这样以后我们需要在哪个方法调用前请求一些权限,只需要给该方法加上@RequestPermissions注解并把要请求的权限传进去即可,是不是很方便。

以上算是举了几个例子,主要是让大家对面向切面编程有个初步的认识,在实际开发中也可以试着使用,希望大家能大开脑洞,琢磨出更多用法,让Android开发更加简单且富有乐趣。

最后

可能有些朋友感觉我们实现的效果就像hook到了方法一样,其实我最初也是寻找hook方法的时候才接触到了Aspectj,但慢慢我觉得它不像是一种hook,hook一般是运行时,而Aspectj更倾向于是一种在编译期插入代码的方式,和我们手动插的效果一样,只不过插入代码的行为由编译器帮我们做了。

面向切面编程最关键的是找到合适的切入点,而切入点的匹配可不只是文章中用的execution、call和within等,还有很多其他的。我在文章中也没有扯出一些Pointcuts、Advice之类的专业名词,相反是采用一种易于理解的方式,这种方式让人容易接受,但缺点就是不够系统,所以,如果这篇文章让你对AOP(面向切面编程)产生了一点点兴趣的话,不妨再去网上找一些“正式”一点的教程学习一下,对其中的一些概念有个认知吧!😊

参考

最后是demo的地址,demo就不求star了,觉得文章还行的话在掘金上点个喜欢就好了😄 AOPDemo项目地址

文章分类
Android
文章标签