(4.6.23.1)Android之面向切面编程:AOP 与 Aspect简介

一、OOP的困境

1.1 OOP

ObjectOriented Programming,面向对象编程

在OOP的世界中,问题或者功能都被划分到一个一个的模块里边。每个模块专心干自己的事情,模块之间通过设计好的接口交互.

是一种方法论,一种编程的思想。

1.2 从“打印日志”来看AOP 和 OOP

OOP的精髓是把功能或问题模块化,每个模块处理自己的家务事。但在现实世界中,并不是所有问题都能完美得划分到模块中

举个最简单而又常见的例子:
现在想为每个模块加上日志功能,要求模块运行时候能输出日志
  • OOP的处理方式

    1. 先设计一个日志输出模块,这个模块提供日志输出API,比如Android中的Log类;
    2. 然后,其他模块需要输出日志的时候调用Log类的几个函数,比如e(TAG,…),w(TAG,…),d(TAG,…),i(TAG,…)等。
/***
*代码很简单。但是从这个小例子中,你也会发现要是这个程序比较复杂的话,到处都加*Log,或者在某些特殊函数加权限检查的代码,真的是一件挺繁琐的事情。
*/
public class AopDemoActivity extends Activity {
   private static final String TAG = "AopDemoActivity";
//onCreate,onStart,onRestart,onPause,onResume,onStop,onDestory返回前,都输出一行日志
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.layout_main);
       Log.e(TAG,"onCreate");
    }
   protected void onStart() {
       super.onStart();
        Log.e(TAG, "onStart");
    }
   protected void onRestart() {
       super.onRestart();
        Log.e(TAG, "onRestart");
    }
    protectedvoid onResume() {
       super.onResume();
        Log.e(TAG, "onResume");
  // checkPhoneState会检查app是否申明了android.permission.READ_PHONE_STATE权限
        checkPhoneState();
    }
   protected void onPause() {
       super.onPause();
        Log.e(TAG, "onPause");
    }
   protected void onStop() {
       super.onStop();
        Log.e(TAG, "onStop");
    }
   protected void onDestroy() {
       super.onDestroy();
        Log.e(TAG, "onDestroy");
    }
   private void checkPhoneState(){
       if(checkPermission("android.permission.READ_PHONE_STATE")== false){
           Log.e(TAG,"have no permission to read phone state");
           return;
        }
       Log.e(TAG,"Read Phone State succeed");
       return;
    }
   private boolean checkPermission(String permissionName){
       try{
           PackageManager pm = getPackageManager();
          //调用PackageMangaer的checkPermission函数,检查自己是否申明使用某权限
           int nret = pm.checkPermission(permissionName,getPackageName());
           return nret == PackageManager.PERMISSION_GRANTED;
        }......
    }
}

在没有接触AOP之前,能想到的解决方案基本就是上面的方法。但是,从OOP角度看,除了日志模块本身,其他模块的承载的业务功能都不包含“打印日志”,而仅仅需要这样一个调用。这个日志输出功能,从整体来看,都是一个面上的。而这个面的范围,就不局限在单个模块里了,而是横跨多个模块。

在没有AOP之前,各个模块要打印日志,就是自己处理。通过分散在各处的函数调用从而实现了“日志打印功能”,这有点类似于分权管理,那么AOP就是对应集权管理方式。

  • AOP的处理方式

第一,我们要认识到OOP世界中,有些功能是横跨并嵌入众多模块里的,比如打印日志,比如统计某个模块中某些函数的执行时间等。这些功能在各个模块里分散得很厉害,可能到处都能见到。

第二,AOP的目标是把这些功能集中起来,放到一个统一的地方来控制和管理。如果说,OOP如果是把问题划分到单个模块的话,那么AOP就是把涉及到众多模块的某一类问题进行统一管理。比如我们可以设计两个Aspects,一个是管理某个软件中所有模块的日志输出的功能,另外一个是管理该软件中一些特殊函数调用的权限检查

ps :
AspectJ需要编写aj文件,然后把AOP代码放到aj文件中。但是在Android开发中,我建议不要使用aj文件。因为aj文件只有AspectJ编译器才认识,而Android编译器不认识这种文件。所以当更新了aj文件后,编译器认为源码没有发生变化,所以不会编译它。
当然,这种问题在其他不认识aj文件的java编译环境中也存在。所以,AspectJ提供了一种基于注解的方法来把AOP实现到一个普通的Java文件中。这样我们就把AOP当做一个普通的Java文件来编写、编译就好。

/**
*
*
*/


package com.androidaop.demo;
import android.util.Log;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.JoinPoint;

@Aspect   //必须使用@AspectJ标注,这样class DemoAspect就等同于 aspect DemoAspect了
public class DemoAspect {
    staticfinal String TAG = "DemoAspect";
/*
@Pointcut:pointcut也变成了一个注解,这个注解是针对一个函数的,比如此处的logForActivity()
其实它代表了这个pointcut的名字。如果是带参数的pointcut,则把参数类型和名字放到
代表pointcut名字的logForActivity中,然后在@Pointcut注解中使用参数名。
基本和以前一样,只是写起来比较奇特一点。后面我们会介绍带参数的例子
*/
@Pointcut("execution(* com.androidaop.demo.AopDemoActivity.onCreate(..)) ||"
        +"execution(* com.androidaop.demo.AopDemoActivity.onStart(..))")
public void logForActivity(){};  //注意,这个函数必须要有实现,否则Java编译器会报错

/*
@Before:这就是Before的advice,对于after,after -returning,和after-throwing。对于的注解格式为
@After,@AfterReturning,@AfterThrowing。Before后面跟的是pointcut名字,然后其代码块由一个函数来实现。比如此处的log。
*/
    @Before("logForActivity()")
    public void log(JoinPoint joinPoint){
       //对于使用Annotation的AspectJ而言,JoinPoint就不能直接在代码里得到多了,而需要通过
      //参数传递进来。
       Log.e(TAG, joinPoint.toShortString());
    }
}

提示:如果开发者已经切到AndroidStudio的话,AspectJ注解是可以被识别并能自动补齐。
上面的例子仅仅是列出了onCreate和onStart两个函数的日志,如果想在所有的onXXX这样的函数里加上log,该怎么改呢?
@Pointcut("execution(* *..AopDemoActivity.on*(..))")
public void logForActivity(){};

这里写图片描述

二、适用AOP的场景

  • 日志
  • 持久化
  • 性能监控
  • 数据校验
  • 缓存
  • 其他更多

三、工具和库

有一些工具和库帮助我们使用 AOP:
- AspectJ: 一个 JavaTM 语言的面向切面编程的无缝扩展(适用Android)。

  • Javassist for Android: 用于字节码操作的知名 java 类库 Javassist 的 Android 平台移植版。

  • DexMaker: Dalvik 虚拟机上,在编译期或者运行时生成代码的 Java API。

  • ASMDEX: 一个类似 ASM 的字节码操作库,运行在Android平台,操作Dex字节码。

四、AspectJ简介

我们下面的例子选用 AspectJ,有以下原因:
- 功能强大
- 支持编译期和加载时代码注入
- 易于使用

AspectJ会在编译期间增加一个新的步骤处理AspectJ注解,并在对应的切入点中生成和注入必要的样板代码

使用AspectJ有两种方法:

  • 完全使用AspectJ的语言。这语言一点也不难,和Java几乎一样,也能在AspectJ中调用Java的任何类库。AspectJ只是多了一些关键词罢了。
  • 或者使用纯Java语言开发,然后使用AspectJ注解,简称@AspectJ。
    Anyway,不论哪种方法,最后都需要AspectJ的编译工具ajc来编译。由于AspectJ实际上脱胎于Java,所以ajc工具也能编译java源码。

AspectJ现在托管于Eclipse项目中,官方网站是:

五、AOP概念介绍

5.1 Join Points: 何处支持注入

Join Points(以后简称JPoints)是AspectJ中最关键的一个概念。什么是JPoints呢?JPoints就是程序运行时的一些执行点。那么,一个程序中,哪些执行点是JPoints呢?比如:

  • 一个函数的调用可以是一个JPoint。比如Log.e()这个函数。e的执行可以是一个JPoint,而调用e的函数也可以认为是一个JPoint。
  • 设置一个变量,或者读取一个变量,也可以是一个JPoint。比如Demo类中有一个debug的boolean变量。设置它的地方或者读取它的地方都可以看做是JPoints
  • for循环可以看做是JPoint

理论上说,一个程序中很多地方都可以被看做是JPoint,但是AspectJ中,只有如表1所示的几种执行点被认为是JPoints:

Join Points说明示例
method call函数调用比如调用Log.e(),这是一处JPoint
method execution函数执行比如Log.e()的执行内部,是一处JPoint。注意它和method call的区别。method call是调用某个函数的地方。而execution是某个函数执行的内部。
constructor cal构造函数调用和method call类似
constructor execution构造函数执行和method execution类似
field get获取某个变量比如读取DemoActivity.debug成员
field set设置某个变量比如设置DemoActivity.debug成员
pre-initializationObject在构造函数中做得一些工作。很少使用,详情见下面的例子
initializationObject在构造函数中做得工作详情见下面的例子
static initialization类初始化比如类的static{}
handler异常处理比如try catch(xxx)中,对应catch内的执行
advice execution这个是AspectJ的内容,稍后再说

下面我们来看个例子以直观体会一把,打印出其中所有的join points。

package test;
public class Test{
   static public class TestBase{
        static{
          int x = 0;
        }
        int base = 0;
        public TestBase(int index){
            base = index;
        }
   }
     static  public class TestDerived extends TestBase{
        public int derived = 0;
        public TestDerived(){
            super(0);
            this.derived = 1000;

        }
        public void testMethod() {
            try{
                byte[] test = null;
                test[1] = 0x33;
            }catch(Exception ex){
            }
        }
        static int getFixedIndex(){
           return 1000;
        }
    }
     public static void main(String args[]){
        System.out.println("Test begin...");
        TestDerived derived = new TestDerived();
        derived.testMethod();
        derived.base = 1;
        System.out.println("Test end...");
    }
}

这里写图片描述

  • 左图的第一个红框:

    • staticinitialization(test.Test.):表示当前是哪种类型的JPoint,括号中代表目标对象是谁(此处是指Test class的类初始化)。由于Test类没有指定static block,所以后面的at:Test.java:0 表示代码在第0行(其实就是没有找到源代码的意思)。
    • Test类初始化完后,就该执行main函数了。所以,下一个JPoint就是execution(voidtest.Test.main(String[]))。括号中表示此JPoint对应的是test.Test.main函数。at:Test.java:30表示这个JPoint在源代码的第30行。大家可以对比图2的源码,很准确!
    • main函数里首先是执行System.out.println。而这一行代码实际包括两个JPoint。一个是get(PrintStream java.lang.System.out),get表示Field get,它表示从System中获取out对象。另外一个是call(void java.io.PrintStream.println(String)),这是一个call类型的JPoint,表示执行out.println函数。
  • 左图第二个红框

    • 它表示TestBase的类的初始化,由于源码中为TestBase定义了static块,所以这个JPoint清晰指出了源码的位置是at:Test.java:5
  • 左图第三个红框

    • 它和对象的初始化有关。在源码中,我们只是构造了一个TestDerived对象。它会先触发TestDerived Preinitialization JPoint,然后触发基类TestBase的PreInitialization JPoint。注意红框中的before和after 。在TestDerived和TestBase所对应的PreInitialization before和after中都没有包含其他JPoint。所以,Pre-Initialization应该是构造函数中一个比较基础的Phase。这个阶段不包括类中成员变量定义时就赋值的操作,也不包括构造函数中对某些成员变量进行的赋值操作。
    • 而成员变量的初始化(包括成员变量定义时就赋值的操作,比如源码中的int base = 0,以及在构造函数中所做的赋值操作,比如源码中的this.derived = 1000)都被囊括到initialization阶段。请读者对应图三第二个红框到第三个红框(包括第3个红框的内容)看看是不是这样的
  • 第5个红框。它包括三个JPoint

    • testMethod的call类型JPoint
    • testMethod的execution类型JPonint
    • 以及对异常捕获的Handler类型JPoint

5.2 Pointcuts:定位想要注入的具体连接点

一个程序会有很多的JPoints,即使是同一个函数(比如testMethod这个函数),还分为call类型和execution类型的JPoint。显然,不是所有的JPoint,也不是所有类型的JPoint都是我们关注的。再次以AopDemo为例,我们只要求在Activity的几个生命周期函数中打印日志,只有这几个生命周期函数才是我们业务需要的JPoint,而其他的什么JPoint我不需要关注。

怎么从一堆一堆的JPoints中选择自己想要的JPoints呢?恩,这就是Pointcuts的功能。一句话,Pointcuts的目标是提供一种方法使得开发者能够选择自己感兴趣的JoinPoints。 类似于一种正则规则

5.1的例子中,怎么把Test.java中所有的Joinpoint选择出来呢?用到的pointcut格式为:
pointcuttestAll():within(Test)。

5.2.1 Pointcuts基本结构

  1. 选择那些调用println(而且不考虑println函数的参数是什么)的Joinpoint。
  2. 另外,调用者的类型不要是TestAspect的。 (TestAspect为当前类名)
//现在我想把5.1中的示例代码中,那些调用println的地方找到,该怎么弄?代码该这么写:
public pointcut  testAll(): call(public  *  *.println(..)) && !within(TestAspect) ;  

private static final String POINTCUT_METHOD =" call(public  *  *.println(..))";
@Pointcut(POINTCUT_METHOD )
public void testAll() {}
  • 第一个public:表示这个pointcut是public访问。这主要和aspect的继承关系有关,属于AspectJ的高级玩法,本文不考虑。
  • pointcut:关键词,表示这里定义的是一个pointcut。pointcut定义有点像函数定义。总之,在AspectJ中,你得定义一个pointcut。
  • testAll():pointcut的名字。在AspectJ中,定义Pointcut可分为有名和匿名两种办法。个人建议使用named方法。因为在后面,我们要使用一个pointcut的话,就可以直接使用它的名字就好。
  • testAll后面有一个冒号,这是pointcut定义名字后,必须加上。冒号后面是这个pointcut怎么选择Joinpoint的条件。
  • call(public * *.println(..))是一种选择条件
    • call:表示我们选择的Joinpoint类型为call类型
    • public :由于我们这里选择的JoinPoint类型为call类型,它对应的目标JPoint一定是某个函数。所以我们要找到这个/些函数。public 表示目标JPoint的访问类型(public/private/protect)
    • 第一个*表示返回值的类型是任意类型
    • 第二个*用来指明包名。此处不限定包名
    • 紧接其后的println是函数名。这表明我们选择的函数是任何包中定义的名字叫println的函数
    • (..)函数参数指明了目标函数的参数应该是什么样子的。比如这里使用了通配符..,代表任意个数的参数,任意类型的参数
  • &&:AspectJ 可以把几个条件组合起来,目前支持 &&,||,以及!这三个条件。这三个条件的意思不用我说了吧?和Java中的是一样的。
  • !within(TestAspectJ)
    • 前面的!表示不满足某个条件
    • within是另外一种类型选择方法,特别注意,这种类型和前面讲到的joinpoint的那几种类型不同

这里写图片描述

5.2.2 Joinpoint类型的直接选择

5.2.1中示例定位了“调用println”的连接点,如果要改为函数的执行,也就是methodexecution的JPoint,那么pointcuts的写法就得包括execution(XXX)来限定。

5.2.2.1 Joinpoint类型一览

这里写图片描述

5.2.2.2 定位正则
5.2.2.2.1 Method Signature
@注解 访问权限 返回值的类型 包名.函数名(参数)  
  • @注解 属于可选项
  • 访问权限(public/private/protect,以及static/final)属于可选项
    如果不设置它们,则默认都会选择。以访问权限为例,如果没有设置访问权限作为条件,那么public,private,protect及static、final的函数都会进行搜索。
  • 返回值类型就是普通的函数的返回值类型。如果不限定类型的话,就用*通配符表示
  • 包名.函数名用于查找匹配的函数。可以使用通配符,包括和..以及+号。其中号用于匹配除.号之外的任意字符,而..则表示任意子package,+号表示子类
    • java.*.Date:可以表示java.sql.Date,也可以表示java.util.Date
    • Test*:可以表示TestBase,也可以表示TestDervied
    • java..*:表示java任意子类
    • java..*Model+:表示Java任意package中名字以Model结尾的子类,比如TabelModel,TreeModel
  • 函数的参数,参数匹配比较简单,主要是参数类型..
    在参数匹配中,“..”代表任意参数个数和类型
    • (int, char):表示参数只有两个,并且第一个参数类型是int,第二个参数类型是char
    • (String, ..):表示至少有一个参数。并且第一个参数类型是String,后面参数类型不限
    • (Object …):表示不定个数的参数,且类型都是Object,这里的…不是通配符,而是Java中代表不定参数的意思
5.2.2.2.2 Constructor signature

Constructorsignature和Method Signature类似,只不过构造函数没有返回值,而且函数名必须叫new

public *..TestDerived.new(..)
  • public:选择public访问权限
  • *..代表任意包名
  • TestDerived.new:代表TestDerived的构造函数
  • (..):代表参数个数和类型都是任意
5.2.2.2.3 Field Signature
@注解 访问权限 类型 类名.成员变量名 
set(int test..TestBase.base):表示设置TestBase.base变量时的JPoint   
  • @注解和访问权限是可选的
  • 类型:成员变量类型,*代表任意类型
  • 类名.成员变量名:成员变量名可以是*,代表任意成员变量
5.2.2.2.4 Type Signature
  • staticinitialization(test..TestBase):表示TestBase类的static block
  • handler(NullPointerException):表示catch到NullPointerException的JPoint。注意,图2的源码第23行截获的其实是Exception,其真实类型是NullPointerException。但是由于JPointer的查询匹配是静态的,即编译过程中进行的匹配,所以handler(NullPointerException)在运行时并不能真正被截获。只有改成handler(Exception),或者把源码第23行改成NullPointerException才行

5.2.3 Joinpoint类型的间接选择

除了根据前面提到的Signature信息来匹配JPoint外,AspectJ还提供其他一些选择方法来选择JPoint。比如某个类中的所有JPoint,每一个函数执行流程中所包含的JPoint。
特别强调,不论什么选择方法,最终都是为了找到目标的JPoint。
这里写图片描述

注意:this()和target()匹配的时候不能使用通配符。

这里写图片描述
注意,不是所有的AOP实现都支持本节所说的查询条件。比如Spring就不支持withincode查询条件。

5.3 advice: 注入代码的执行时间点

//testAll()是前面定义的pointcuts
//而before()定义了在这个pointcuts选中的JPoint执行前我们要干的事情。
before():testAll(){  
   System.out.println("before calling: " + thisJoinPoint);//打印这个JPoint的信息  
  System.out.println("      at:" + thisJoinPoint.getSourceLocation());//打印这个JPoint对应的源代码位置  
}  

恭喜,看到这个地方来,AspectJ的核心部分就掌握一大部分了。现在,我们知道如何通过pointcuts来选择合适的JPoint。那么,下一步工作就很明确了,选择这些JPoint后,我们肯定是需要干一些事情的。比如前面例子中的输出都有before,after之类的。这其实JPoint在执行前,执行后,都执行了一些我们设置的代码。在AspectJ中,这段代码叫advice。简单点说,advice就是一种Hook。

TablesAreCool
before()before advice表示在JPoint执行之前,需要干的事情
after()after advice表示JPoint自己执行完了后,需要干的事情。
返回值类型 around()before和around是指JPoint执行前或执行后备触发,而around就替代了原JPointaround是替代了原JPoint,如果要执行原JPoint的话,需要调用proceed
after():returning(返回值类型)after():throwing(异常类型)returning和throwing后面都可以指定具体的类型,如果不指定的话则匹配的时候不限定类型假设JPoint是一个函数调用的话,那么函数调用执行完有两种方式退出,一个是正常的return,另外一个是抛异常。注意,after()默认包括returning和throwing两种情况

注意,after和before没有返回值,但是around的目标是替代原JPoint的,所以它一般会有返回值,而且返回值的类型需要匹配被选中的JPoint。

这里写图片描述

  • 第一个红框是修改后的testMethod,在这个testMethod中,肯定会抛出一个空指针异常。
  • 第二个红框是我们配置的advice,除了before以外,还加了一个around。我们重点来看around,它的返回值是Object。虽然匹配的JPoint是testMethod,其定义的返回值是void。但是AspectJ考虑的很周到。在around里,可以设置返回值类型为Object来表示返回任意类型的返回值。AspectJ在真正返回参数的时候,会自动进行转换。比如,假设inttestMethod定义了int作为返回值类型,我们在around里可以返回一个Integer,AspectJ会自动转换成int作为返回值
  • 再看around中的//proceed()这句话。这代表调用真正的JPoint函数,即testMethod。由于这里我们屏蔽了proceed,所以testMethod真正的内容并未执行,故运行的时候空指针异常就不会抛出来。也就是说,我们完全截获了testMethod的运行,甚至可以任意修改它,让它执行别的函数都没有问题。

注意:从技术上说,around是完全可以替代before和after的。图7中第二个红框还把after给注释掉了。如果不注释掉,编译时候报错,[error]circular advice precedence: can’t determine precedence between two or morepieces of advice that apply to the same join point: method-execution(voidtest.Test$TestDerived.testMethod())(大家可以自己试试)。我猜测其中的原因是around和after冲突了。around本质上代表了目标JPoint,比如此处的testMethod。而after是testMethod之后执行。那么这个testMethod到底是around还是原testMethod呢?真是傻傻分不清楚!
(我觉得再加一些限制条件给after是可以避免这个问题的,但是没搞成功…)

5.4 参数传递和JPoint信息

5.4.1 参数传递

  • pointcuts修改:像定义函数一样定义pointcuts,然后在this,target或args中绑定参数名(注意,不再是参数类型,而是参数名)。
  • advice修改:也像定义函数一样定义advice,然后在冒号后面的pointcuts中绑定参数名(注意是参数名)
  • 在advice的代码中使用参数名。

前面介绍的advice都是没有参数信息的,而JPoint肯定是或多或少有参数的。而且advice既然是对JPoint的截获或者hook也好,肯定需要利用传入给JPoint的参数干点什么事情。比方所around advice,我可以对传入的参数进行检查,如果参数不合法,我就直接返回,根本就不需要调用proceed做处理。

往advice传参数比较简单,就是利用前面提到的this(),target(),args()等方法。另外,整个pointcuts和advice编写的语法也有一些区别。具体方法如下:

5.4.1.1 先在pointcuts定义时候指定参数类型和名字
pointcut testAll(Test.TestDerived derived,intx):call(*Test.TestDerived.testMethod(..))  
             && target(derived)&& args(x)  

注意上述pointcuts的写法,首先在testAll中定义参数类型和参数名。这一点和定义一个函数完全一样.
接着看target和args。此处的target和args括号中用得是参数名。而参数名则是在前面pointcuts中定义好的。这属于target和args的另外一种用法。
 注意,增加参数并不会影响pointcuts对JPoint的匹配,上面的pointcuts选择和

pointcut testAll():call(*Test.TestDerived.testMethod(..)) && target(Test.TestDerived) &&args(int)是一样的  
5.4.1.2 修改advice
Object around(Test.TestDerived derived,int x):testAll(derived,x){  
     System.out.println("     arg1=" + derived);  
     System.out.println("     arg2=" + x);  
      return proceed(derived,x); //注意,proceed就必须把所有参数传进去。  
}  
  • advice的定义现在也和函数定义一样,把参数类型和参数名传进来。
  • 接着把参数名传给pointcuts,此处是testAll。注意,advice必须和使用的pointcuts在参数类型和名字上保持一致。
  • 然后在advice的代码中,你就可以引用参数了,比如derived和x,都可以打印出来。

5.4.2 JPoint信息

打印出了JPoint的信息,比如当前调用的是哪个函数,JPoint位于哪一行代码。这些都属于JPoint的信息。AspectJ为我们提供如下信息

六、常见问题

  • can’t determine superclass of missing type xxx

issue
1. 升级最新的插件
2. 使用

aspectjx {
    //includes the libs that you want to weave
    includeJarFilter 'universal-image-loader', 'AspectJX-Demo/library'

    //excludes the libs that you don't want to weave
    excludeJarFilter 'universal-image-loader'
}
  • Caused by: java.lang.NoSuchMethodError: No static method aspectOf()L
Caused by: java.lang.NoSuchMethodError: No static method aspectOf()Lcom/sangfor/moacache/aspect/CacheFuntAspect; in class Lcom/sangfor/moacache/aspect/CacheFuntAspect; or its super classes (declaration of 'com.sangfor.moacache.aspect.CacheFuntAspect' appears in /data/app/com.sangfor.pocket-2/base.apk)

一般为使用了excludeJarFilter 导致的,合理排外,或者用include

参考资料

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值