AOP 之 AspectJ 全面剖析 in Android

AspectJ 是 Android 平臺(tái)上一種比較高效和簡(jiǎn)單的實(shí)現(xiàn) AOP 技術(shù)的方案。

相類似的方案有以下幾種:

  • AspectJ: 一個(gè) JavaTM 語(yǔ)言的面向切面編程的無(wú)縫擴(kuò)展(適用Android)。

  • Javassist for Android :用于字節(jié)碼操作的知名 java 類庫(kù) Javassist 的 Android 平臺(tái)移植版。

  • DexMaker :Dalvik 虛擬機(jī)上,在編譯期或者運(yùn)行時(shí)生成代碼的 Java API。

  • ASMDEX :一個(gè)類似 ASM 的字節(jié)碼操作庫(kù),運(yùn)行在Android平臺(tái),操作Dex字節(jié)碼。

AOP 是什么?

在軟件業(yè),AOP為Aspect Oriented Programming的縮寫,意為:面向切面編程,通過(guò)預(yù)編譯方式和運(yùn)行期動(dòng)態(tài)代理實(shí)現(xiàn)程序功能的統(tǒng)一維護(hù)的一種技術(shù)。AOP是OOP的延續(xù),是軟件開發(fā)中的一個(gè)熱點(diǎn),也是Spring框架中的一個(gè)重要內(nèi)容,是函數(shù)式編程的一種衍生范型。利用AOP可以對(duì)業(yè)務(wù)邏輯的各個(gè)部分進(jìn)行隔離,從而使得業(yè)務(wù)邏輯各部分之間的耦合度降低,提高程序的可重用性,同時(shí)提高了開發(fā)的效率。

------以上解釋來(lái)自百度百科。

簡(jiǎn)單的來(lái)講,AOP是一種:可以在不改變?cè)瓉?lái)代碼的基礎(chǔ)上,通過(guò)“動(dòng)態(tài)注入”代碼,來(lái)改變?cè)瓉?lái)執(zhí)行結(jié)果的技術(shù)。

AOP 能做什么?

  • 日志
  • 持久化
  • 性能監(jiān)控
  • 數(shù)據(jù)校驗(yàn)
  • 緩存
  • 其他更多

AspectJ 術(shù)語(yǔ)

  • JPoint:代碼可注入的點(diǎn),比如一個(gè)方法的調(diào)用處或者方法內(nèi)部、“讀、寫”變量等。
  • Pointcut:用來(lái)描述 JPoint 注入點(diǎn)的一段表達(dá)式,比如:調(diào)用 Animal 類 fly 方法的地方,call(* Animal.fly(..))。
  • Advice:常見的有 Before、After、Around 等,表示代碼執(zhí)行前、執(zhí)行后、替換目標(biāo)代碼,也就是在 Pointcut 何處注入代碼。
  • Aspect:Pointcut 和 Advice 合在一起稱作 Aspect。

引入 AspectJ

app/build.grade加入以下配置項(xiàng):

...
import org.aspectj.bridge.IMessage
import org.aspectj.bridge.MessageHandler
import org.aspectj.tools.ajc.Main

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'org.aspectj:aspectjtools:1.8.1'
    }
}

repositories {
    mavenCentral()
}

android {
    ...
}

dependencies {
    ...
    compile 'org.aspectj:aspectjrt:1.8.1'
}

final def log = project.logger
final def variants = project.android.applicationVariants

variants.all { variant ->
    if (!variant.buildType.isDebuggable()) {
        log.debug("Skipping non-debuggable build type '${variant.buildType.name}'.")
        return;
    }

    JavaCompile javaCompile = variant.javaCompile
    javaCompile.doLast {
        String[] args = ["-showWeaveInfo",
                     "-1.5",
                     "-inpath", javaCompile.destinationDir.toString(),
                     "-aspectpath", javaCompile.classpath.asPath,
                     "-d", javaCompile.destinationDir.toString(),
                     "-classpath", javaCompile.classpath.asPath,
                     "-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
        log.debug "ajc args: " + Arrays.toString(args)

        MessageHandler handler = new MessageHandler(true);
        new Main().run(args, handler);
        for (IMessage message : handler.getMessages(null, true)) {
           switch (message.getKind()) {
                case IMessage.ABORT:
                case IMessage.ERROR:
                case IMessage.FAIL:
                    log.error message.message, message.thrown
                    break;
                case IMessage.WARNING:
                    log.warn message.message, message.thrown
                    break;
                case IMessage.INFO:
                    log.info message.message, message.thrown
                    break;
                case IMessage.DEBUG:
                    log.debug message.message, message.thrown
                    break;
            }
        }
    }
}

AspectJ 語(yǔ)法

AspectJ 的使用相對(duì)來(lái)說(shuō)還是有點(diǎn)復(fù)雜,所以我整理了一份參考手冊(cè)來(lái)方便查閱,以下是貼圖,為了方便使用,文章最后會(huì)給出 pdf 版的下載地址。

JPoint 的分類和對(duì)應(yīng)的 Pointcut 如下:


1-1

Pointcut 中的 Signature 參考:

1-2

以上的 Signature 都是由一段表達(dá)式組成,且每個(gè)關(guān)鍵詞之間都有“空格”,下面是對(duì)關(guān)鍵詞的解釋:

1-3

Pointcut 語(yǔ)法熟悉了之后,Advice 就顯得很簡(jiǎn)單了,它包含以下幾個(gè):

1-4
高級(jí)用法

有了以上參考手冊(cè),我們來(lái)看看如何使用吧~

開始使用

一、Method -> call

定義一個(gè) Animal 類,包含一個(gè) fly 方法:

public class Animal {
    private static final String TAG = "Animal";
    public void fly() {
        Log.e(TAG, this.toString() + "#fly");
    }
}

在調(diào)用 fly 的地方,之前插入一段代碼:

@Aspect
public class MethodAspect {
    private static final String TAG = "ConstructorAspect";

    @Pointcut("call(* android.aspectjdemo.animal.Animal.fly(..))")
    public void callMethod() {}

    @Before("callMethod()")
    public void beforeMethodCall(JoinPoint joinPoint) {
        Log.e(TAG, "before->" + joinPoint.getTarget().toString() + "#" + joinPoint.getSignature().getName());
    }
}

使用 @Pointcut 來(lái)注解方法,定義具體的 Pointcut ,call(MethodSignature) 關(guān)鍵字表示方法被調(diào)用,MethodSignature 的定義參考 1-2、1-3表格。

MethodSignature

調(diào)用 fly 之前插入一段代碼,所以 Advice 需要使用 @Before,@Before 的參數(shù)就是 使用@Pointcut 注解的方法名稱。

當(dāng)然,你也可以合并 Pointcut 和 Advice,像這樣:

@Aspect
public class MethodAspect {
    private static final String TAG = "MethodAspect";

    @Before("call(* android.aspectjdemo.animal.Animal.fly(..))")
    public void beforeMethodCall(JoinPoint joinPoint) {
        Log.e(TAG, "before->" + joinPoint.getTarget().toString() + "#" + joinPoint.getSignature().getName());
    }
}

最后,就是在 MethodAspect 加上 @Aspect 注解,這樣 AspectJ 在編譯時(shí)會(huì)查找被 @Aspect 注解的 class,然后 AOP 的過(guò)程會(huì)自動(dòng)完成。

編譯運(yùn)行之后,可以在 app/build/intermediates/classes/debug 目錄查看編譯后的 class 文件:

...
Animal animal = new Animal();
JoinPoint var5 = Factory.makeJP(ajc$tjp_0, this, animal);
MethodAspect.aspectOf().beforeMethodCall(var5);
animal.fly();
...

animal.fly() 之前插入了一段代碼,調(diào)用就是 MethodAspect#beforeMethodCall 方法,查看 logcat 輸出:

beforeMethodCall

如果使用 @After 類型的 Advice,則會(huì)在animal.fly() 之后插入:

...
Animal animal = new Animal();
Animal var6 = animal;
JoinPoint var5 = Factory.makeJP(ajc$tjp_0, this, animal);

try {
    var6.fly();
} catch (Throwable var9) {
    MethodAspect.aspectOf().afterMethodCall(var5);
    throw var9;
}

MethodAspect.aspectOf().afterMethodCall(var5);
...

我們來(lái)看一下 @Around 類型的 Advice,它會(huì)替換原先的執(zhí)行代碼:

/**
 * 不能和Before、After一起使用
 * @param joinPoint
 * @throws Throwable
 */
@Around("call(* android.aspectjdemo.animal.Animal.fly(..))")
public void aroundMethodCall(ProceedingJoinPoint joinPoint) throws Throwable {
    Log.e(TAG, "around->" + joinPoint.getTarget().toString() + "#" + joinPoint.getSignature().getName());

    // 執(zhí)行原代碼
    joinPoint.proceed();
}

@Around 不能和 @Before、@After 一起使用,如果不小心這樣用了,你會(huì)發(fā)現(xiàn)沒(méi)有任何效果。

@Around 會(huì)替換原先執(zhí)行的代碼,但如果你仍然希望執(zhí)行原先的代碼,可以使用joinPoint.proceed()

二、Method -> execution

與 call 類似,只不過(guò)執(zhí)行點(diǎn)(JPoint)在方法內(nèi)部,比如:我們希望在 fly 方法的Log.e(TAG, this.toString() + "#fly")這段代碼執(zhí)行前,插入一段代碼:

@Aspect
public class MethodAspect {
    private static final String TAG = "MethodAspect";

    @Before("execution(* android.aspectjdemo.animal.Animal.fly(..))")
    public void beforeMethodExecution(JoinPoint joinPoint) {
        Log.e(TAG, "before->" + joinPoint.getTarget().toString() + "#" + joinPoint.getSignature().getName());
   }
}

execution 關(guān)鍵字表示方法執(zhí)行內(nèi)部,編譯后 class 文件如下:

public void fly() {
    JoinPoint var1 = Factory.makeJP(ajc$tjp_1, this, this);
    MethodAspect.aspectOf().beforeMethodExecution(var1);
    Log.e("Animal", this.toString() + "#fly");
}

logcat 輸出如下:

beforeMethodExecution

@After 和 @Around 這里就不做演示了,和 Method -> call 中講解的類似,只不過(guò)是在 fly 方法內(nèi)部。

三、Constructor -> call & execution

Constructor 和 Method 幾乎一模一樣,最大的區(qū)別就在 Signature,如下表:


Constructor & Method -> Signature

Constructor 沒(méi)有返回值類型,且函數(shù)名只能是 new,一個(gè)示例如下:

@Aspect
public class ConstructorAspect {
    private static final String TAG = "ConstructorAspect";
    
    @Before("execution(android.aspectjdemo.animal.Animal.new(..))")
    public void beforeConstructorExecution(JoinPoint joinPoint) {
        Log.e(TAG, "before->" + joinPoint.getThis().toString() + "#" + joinPoint.getSignature().getName());
    }
}

四、Field -> get

語(yǔ)法概要:


FieldSignature

Animal 包含年齡age屬性、返回agegetAge方法:

public class Animal {
    private static final String TAG = "Animal";
    private int age;

    public Animal() {
        this.age = 10;
    }

    public int getAge() {
        Log.e(TAG, "getAge: ");
        return this.age;
    }
}

比如,我們希望不管怎么修改age的值,最后獲取的age都為100,那么就需要替換訪問(wèn)age的代碼:

@Aspect
public class FieldAspect {
    private static final String TAG = "FieldAspect";

    @Around("get(int android.aspectjdemo.animal.Animal.age)")
    public int aroundFieldGet(ProceedingJoinPoint joinPoint) throws Throwable {
       // 執(zhí)行原代碼
       Object obj = joinPoint.proceed();
       int age = Integer.parseInt(obj.toString());
       Log.e(TAG, "age: " + age);
       return 100;
   }
}

這樣,animal.getAge()返回的值一定是100:

Animal animal = new Animal();
int age = animal.getAge();
Log.e(TAG, "true age: " + age);

logcat 輸出如下:

aroundFieldGet

同時(shí)看下編譯后的class

public int getAge() {
    Log.e(TAG, "getAge: ");
    JoinPoint var2 = Factory.makeJP(ajc$tjp_2, this, this);
    FieldAspect var10000 = FieldAspect.aspectOf();
    Object[] var3 = new Object[]{this, this, var2};
    return var10000.aroundFieldGet((new Animal$AjcClosure3(var3)).linkClosureAndJoinPoint(4112));
}

可以看到,原先的this.age已經(jīng)被替換成調(diào)用FieldAspect#aroundFieldGet方法了。

五、Field -> set & withincode

與 get 對(duì)應(yīng)的是 set:表示修改某個(gè)屬性,比如setAge方法中的this.age = age

public class Animal {
    private static final String TAG = "Animal";
    private int age;

    public Animal() {
        this.age = 10;
    }

    public void setAge(int age) {
        Log.e(TAG, "setAge: ");
        this.age = age;
    }
}

假如我們希望替換這段代碼,讓調(diào)用方無(wú)法改變age

@Aspect
public class FieldAspect {
    private static final String TAG = "FieldAspect";

    @Around("set(int android.aspectjdemo.animal.Animal.age)")
    public void aroundFieldSet(ProceedingJoinPoint joinPoint) throws Throwable {
        Log.e(TAG, "around->" + joinPoint.getTarget().toString() + "#" + joinPoint.getSignature().getName());
    }
}

編譯后的class

public class Animal {
    private static final String TAG = "Animal";
    private int age;

    public Animal() {
        Log.e("Animal", "Animal構(gòu)造函數(shù)");
        byte var1 = 10;
        JoinPoint var3 = Factory.makeJP(ajc$tjp_0, this, this, Conversions.intObject(var1));
        FieldAspect var10000 = FieldAspect.aspectOf();
        Object[] var4 = new Object[]{this, this, Conversions.intObject(var1), var3};
        var10000.aroundFieldSet((new Animal$AjcClosure1(var4)).linkClosureAndJoinPoint(4112));
    }

    public void setAge(int age) {
        Log.e("Animal", "setAge: ");
        JoinPoint var4 = Factory.makeJP(ajc$tjp_3, this, this, Conversions.intObject(age));
        FieldAspect var10000 = FieldAspect.aspectOf();
        Object[] var5 = new Object[]{this, this, Conversions.intObject(age), var4};
        var10000.aroundFieldSet((new Animal$AjcClosure5(var5)).linkClosureAndJoinPoint(4112));
    }
}

我們發(fā)現(xiàn),setAge方法中的this.age = age的確被替換了,但是原先構(gòu)造函數(shù)初始化age的代碼:this.age = 10也被替換了。

這時(shí)候,就需要使用withincode關(guān)鍵字了。

六、withincode

語(yǔ)法概要:

withincode

比如,我們要排除 Animal 的構(gòu)造函數(shù)修改age的 JPoint,可以這樣寫:

@Aspect
public class FieldAspect {
    private static final String TAG = "FieldAspect";

    @Around("set(int android.aspectjdemo.animal.Animal.age) && !withincode(android.aspectjdemo.animal..*.new(..))")
    public void aroundFieldSet(ProceedingJoinPoint joinPoint) throws Throwable {
        Log.e(TAG, "around->" + joinPoint.getTarget().toString() + "#" + joinPoint.getSignature().getName());
    }
}

Pointcut 多個(gè)條件使用&&、||運(yùn)算符連接,!表示否的意思。

再看一下編譯后的class,構(gòu)造函數(shù)中的 JPoint 已經(jīng)被排除:

public class Animal {
    private static final String TAG = "Animal";
    private int age;

    public Animal() {
        Log.e("Animal", "Animal構(gòu)造函數(shù)");
        this.age = 10;
    }

    public void setAge(int age) {
        Log.e("Animal", "setAge: ");
        JoinPoint var4 = Factory.makeJP(ajc$tjp_3, this, this, Conversions.intObject(age));
        FieldAspect var10000 = FieldAspect.aspectOf();
        Object[] var5 = new Object[]{this, this, Conversions.intObject(age), var4};
        var10000.aroundFieldSet((new Animal$AjcClosure5(var5)).linkClosureAndJoinPoint(4112));
    }
}

七、staticinitialization

語(yǔ)法概要:

staticinitialization

TypeSignature 語(yǔ)法:


TypeSignature

JPoint 為static塊初始化內(nèi):

public class Animal {
    private static final String TAG = "Animal";

    static {
        Log.e(TAG, "static block");
    }
}

如果要在static塊初始化之前,插入代碼:

@Aspect
public class StaticInitializationAspect {
    private static final String TAG = "StaticAspect";

    @Before("staticinitialization(android.aspectjdemo.animal.Animal)")
    public void beforeStaticBlock(JoinPoint joinPoint) {
        Log.d(TAG, "beforeStaticBlock: ");
    }
}

編譯后的class

public class Animal {
    private static final String TAG = "Animal";

    static {
        ajc$preClinit();
        JoinPoint var0 = Factory.makeJP(ajc$tjp_3, (Object)null, (Object)null);
        StaticInitializationAspect.aspectOf().beforeStaticBlock(var0);
        Log.e("Animal", "static block");
    }
}

八、handler

用來(lái)匹配 catch 的異常,比如 Animal 的 hurt 方法:

public class Animal {
    public void hurt(){
        try {
            int i = 4 / 0;
        } catch (ArithmeticException e) {
            e.printStackTrace();
        }
    }
}

如果我們需要統(tǒng)計(jì)所有出現(xiàn)ArithmeticException的點(diǎn),則可以使用 handler:

@Aspect
public class MethodAspect {
    private static final String TAG = "MethodAspect";
    /**
     * handler
     * 不支持@After、@Around
     */
    @Before("handler(java.lang.ArithmeticException)")
    public void handler() {
        Log.e(TAG, "handler");
    }
}

注意 handler 不支持 @After 與 @Around,且異常只支持編譯時(shí)匹配,也就是handler(java.lang.Exception)無(wú)法匹配java.lang.ArithmeticException,雖然ArithmeticException繼承自Exception。

九、Advice 之 @AfterThrowing

@AfterThrowing 屬于 @After 的變種,方法的結(jié)束包括兩種狀態(tài):正常結(jié)束異常退出

我們經(jīng)常需要收集拋出異常的方法信息,這時(shí)候可以使用 @AfterThrowing。

比如 Animal 的hurtThrows會(huì)拋出java.lang.ArithmeticException異常:

public class Animal {
    public void hurtThrows(){
        int i = 4 / 0;
    }
}

我們可以這樣收集異常:

@Aspect
public class MethodAspect {
    private static final String TAG = "MethodAspect";

    @AfterThrowing(pointcut = "call(* *..*(..))", throwing = "throwable")
    public void anyFuncThrows(Throwable throwable) {
        Log.e(TAG, "hurtThrows: ", throwable);
    }
}

call(* *..*(..))表示任意類的任意方法,被調(diào)用的 JPoint。

throwing = "throwable"描述了異常參數(shù)的名稱,也就是anyFuncThrows方法中的參數(shù)throwable。

你可以通過(guò) Throwable 收集方法調(diào)用棧的信息,這里就不做過(guò)多講解了。

這里,需要強(qiáng)調(diào)幾點(diǎn):
1、@AfterThrowing 不支持 Field -> get & set,一般用在 Method 和 Constructor,其他暫時(shí)沒(méi)測(cè)試過(guò);
2、捕獲的是拋出異常的方法,即使這個(gè)方法的調(diào)用方已經(jīng)處理了此異常,比如:

try {
    animal.hurtThrows();
} catch (Exception e) {}

即使這樣,MethodAspect#anyFuncThrows也會(huì)被觸發(fā)。

接下來(lái),我們看下方法正常結(jié)束的情況。

十、Advice 之 @AfterReturning & args

這里講的正常結(jié)束,指的是有返回值的方法。

假如 Animal 有兩個(gè)getHeight方法:

public class Animal {
    public int getHeight() {
        return 0;
    }

    public int getHeight(int sex) {
        switch (sex) {
            case 0:
                return 163;
            case 1:
                return 173;
        }
        return 173;
    }
}

我們想要拿到 getHeight 的返回值,做一些其他事情(比如,數(shù)據(jù)統(tǒng)計(jì)、緩存等),可以這樣做:

@Aspect
public class MethodAspect {
    private static final String TAG = "MethodAspect";

    @AfterReturning(pointcut = "execution(* android.aspectjdemo.animal.Animal.getHeight(..))", returning = "height")
    public void getHeight(int height) {
        Log.d(TAG, "getHeight: " + height);
    }
}

如果你調(diào)用animal.getHeight(),此方法會(huì)得到0;
如果你調(diào)用animal.getHeight(0),此方法會(huì)得到163。

如果你只對(duì)getHeight(int sex)感興趣,有兩種做法:
1、Pointcut 中表示任意參數(shù)的 .. 改為 int

@AfterReturning(pointcut = "execution(* android.aspectjdemo.animal.Animal.getHeight(int))", returning = "height")

2、 && args(int)

@AfterReturning(pointcut = "execution(* android.aspectjdemo.animal.Animal.getHeight(..)) && args(int)", returning = "height")
args

舉個(gè)高級(jí)栗子

這里介紹一個(gè)在實(shí)際項(xiàng)目中,經(jīng)常碰到的一個(gè)問(wèn)題:Android M 6.0+ 之后危險(xiǎn)權(quán)限需要?jiǎng)討B(tài)申請(qǐng)。

如果有一些老的項(xiàng)目需要適配,一般做法是去修改原有的代碼,比如我們有一個(gè)啟動(dòng)相機(jī)拍照的方法:

public void camera() {
    Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(new File(getExternalCacheDir() + "photo.jpg")));
    startActivity(intent);
}

可能需要這么改:

Utils.requestPermisson(this, Manifest.permission.CAMERA).callback(new Callback(){
    public void onGranted(){
        camera();
    }
    public void onDenied() {}
});

如果你封裝了請(qǐng)求權(quán)限工具類,這樣改看起來(lái)也沒(méi)什么問(wèn)題,無(wú)非就是把所有類似的地方都加上這個(gè)段申請(qǐng)權(quán)限的代碼。

如果沒(méi)有封裝,只能是更痛苦。如果使用 AspectJ,可以通過(guò)一行注解,解決所有需要需要申請(qǐng)權(quán)限的方法。

一、定義注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MPermisson {
    String value();
}

value表示要申請(qǐng)的權(quán)限名稱,比如Manifest.permission.CAMERA。

二、編寫 Aspect

@Aspect
public class PermissonAspect {
    @Around("execution(@android.aspectjdemo.MPermisson * *(..)) && @annotation(permisson)")
    public void checkPermisson(final ProceedingJoinPoint joinPoint, MPermisson permisson) throws Throwable {
        // 權(quán)限
        String permissonStr = permisson.value();
        // 正常需要使用維護(hù)的棧頂Activity作為上下文,這里為了演示需要
        MainActivity mainActivity = (MainActivity) joinPoint.getThis();          // 權(quán)限申請(qǐng)

        Utils.requestPermisson(mainActivity, Manifest.permission.CAMERA).callback(new Callback(){
            public void onGranted(){
                try {
                    // 繼續(xù)執(zhí)行原方法
                    joinPoint.proceed();
                } catch (Throwable throwable) {
                    throwable.printStackTrace();
                }
            }
            public void onDenied() {}
      });
   }
}

@annotation(permisson)用來(lái)表示permisson參數(shù)是注解類型。

三、使用 @MPermisson

在需要申請(qǐng)權(quán)限的方法上加上@MPermisson注解,其它代碼不用做修改:

@MPermisson(value = Manifest.permission.CAMERA)
public void camera() {
    Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(new File(getExternalCacheDir() + "photo.jpg")));
    startActivity(intent);
}

如果你項(xiàng)目中有其他地方,也需要申請(qǐng)權(quán)限,只需要在涉及到權(quán)限的方法上加上@MPermisson(value = "你的權(quán)限")即可。

寫在最后

以上介紹的內(nèi)容,基本上已經(jīng)涵蓋了 AspectJ 所有常用的方法。

剩余的幾個(gè)用法,比如adviceexecution()、within(TypePattern)、cflow(pointcuts)、cflowbelow(pointcuts)、this(Type)、target(Type)等,讀者可以自行嘗試以下,篇幅原因不做細(xì)談。

或者參考文章最后給出的 Demo 源碼,這個(gè) Demo 包含了本文演示的所有示例。
Demo 地址:AspectJDemo
AspectJ 參考手冊(cè):AspectJ.pdf


春節(jié)將至,希望已經(jīng)回家的、沒(méi)回家的和正在回家路上的你都能過(guò)一個(gè)開開心心的好年。

最后,預(yù)祝大家在新的一年里:身體健康,龍馬精神,事業(yè)有成,步步高升。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容