AOP應用場景實戰-基於AspectJX開發效能提升工具

前言

關於AOP思想AspectJX框架大家都耳熟能詳,AspectJ爲開發者提供了實現AOP的基礎能力,可以通過它來實現符合各自業務需求的功能。

這裏藉助AspectJX框架來實現效能提升相關的一些有意思的功能,AspectJX框架的配置和使用在README中有詳細步驟,也可以參考官方demo

AspectJ中的語法說明詳見:
https://github.com/hiphonezhu/Android-Demos/blob/master/AspectJDemo/AspectJ.pdf
https://github.com/HujiangTechnology/AspectJX-Demo/blob/master/docs/aspectj_manual.md

場景實戰

日誌打印

日常開發中,經常會在某個關鍵方法中打印Log輸出一段字符串和參數變量的值來進行分析調試,或者在方法執行前後打印Log來查看方法執行的耗時。

痛點

如果需要在業務主流程中的多個關鍵方法中增加日誌,查看方法執行的輸入參數和返回結果是否正確,只能繁瑣的在每個方法開頭添加Log調用打印輸出每個參數。若該方法有返回值,則在return前再添加Log打印輸出返回值。若該方法中有多個if分支進行return,還得在每個分支return前打印Log。

統計方法耗時需要在方法開頭記錄時間,在每個return前計算時間並打印Log。不僅繁瑣,還容易遺漏。

解決

可以通過給想要打印日誌的方法上標記一個註解,在編譯時給標記註解的方法織入代碼,自動打印這個方法運行時的輸入輸出信息和耗時信息。

定義註解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AutoLog {

    /** logcat篩選tag */
    String tag();

    /** 打印日誌級別(默認VERBOSE) */
    LogLevel level() default LogLevel.VERBOSE;
}

AutoLog.java

自定義一個註解AutoLog,用於給想要打印日誌的方法做標記。

定義切面和切入點
@Aspect
public class LogAspect {

    /**
     * 切入點,添加了AutoLog註解的所有方法體內
     */
    @Pointcut("execution (@com.cdh.aop.toys.annotation.AutoLog * *(..))")
    public void logMethodExecute() {
    }
    
    // Advice ···
}

創建一個日誌切面LogAspect,在其中定義一個切入點,對所有添加了AutoLog註解的方法進行代碼織入。

切入點中的execution表示在該方法體內進行代碼織入,@com.cdh.aop.toys.annotation.AutoLog表示添加了該註解的方法,第一個星表示不限return type,第二個星表示匹配任意方法名稱,(…)表示不限方法入參。

@Aspect
public class LogAspect {

    // Pointcut ···
    
    /**
     * 對上面定義的切入點的方法進行織入,Around的作用是替代原方法體內代碼
     */
    @Around("logMethodExecute()")
    public Object autoLog(ProceedingJoinPoint joinPoint) {
        try {
            // 獲取被織入方法的簽名信息,MethodSignature包含方法的詳細信息
            MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
            // 獲取方法上添加的AutoLog註解
            AutoLog log = methodSignature.getMethod().getAnnotation(AutoLog.class);
            if (log != null) {
                // 用於拼接日誌詳細信息
                StringBuilder sb = new StringBuilder();

                // 拼接方法名稱
                String methodName = methodSignature.getMethod().getName();
                sb.append(methodName);

                // 拼接每個參數的值
                Object[] args = joinPoint.getArgs();
                if (args != null && args.length > 0) {
                    sb.append("(");
                    for (int i=0; i<args.length; i++) {
                        sb.append(args[i]);
                        if (i != args.length-1) {
                            sb.append(",");
                        }
                    }
                    sb.append(")");
                }

                // 記錄開始執行時的時間
                long beginTime = System.currentTimeMillis();
                // 執行原方法代碼,並獲得返回值
                Object result = joinPoint.proceed();
                // 計算方法執行耗時
                long costTime = System.currentTimeMillis() - beginTime;

                if (methodSignature.getReturnType() != void.class) {
                    // 若該方法返回類型不是void,則拼接返回值
                    sb.append(" => ").append(result);
                }

                // 拼接耗時
                sb.append(" | ").append("cost=").append(costTime);

                // 拼接方法所在類名和行號
                String className = methodSignature.getDeclaringType().getSimpleName();
                int srcLine = joinPoint.getSourceLocation().getLine();
                sb.append(" | [").append(className).append(":").append(srcLine).append("]");

                // 打印日誌,使用AutoLog註解設置的tag和級別調用Log類的對應方法
                LogUtils.log(log.level(), log.tag(), sb.toString());

                return result;
            }
        } catch (Throwable t) {
            t.printStackTrace();
        }
        return null;
    }
}

LogAspect.java

使用Around可以替換原方法中的邏輯,也可以通過ProceedingJoinPoint.proceed繼續執行原方法邏輯。這裏在執行原方法邏輯之外,還進行了方法參數信息的拼接和耗時計算,最後打印日誌輸出。

到這裏完成了一個基本的日誌切面織入功能,接下來在想要自動打印日誌的方法上添加註解即可。

使用示例

隨意寫幾個方法調用,在這幾個方法上添加AutoLog註解。

public class AddOpWithLog extends BaseOp {

    public AddOpWithLog(BaseOp next) {
        super(next);
    }

    @Override
    @AutoLog(tag=TAG, level=LogLevel.DEBUG)
    protected int onOperate(int value) {
        return value + new Random().nextInt(10);
    }
}

AddOpWithLog.java

public class SubOpWithLog extends BaseOp {

    public SubOpWithLog(BaseOp next) {
        super(next);
    }

    @Override
    @AutoLog(tag=TAG, level=LogLevel.WARN)
    protected int onOperate(int value) {
        return value - new Random().nextInt(10);
    }
}

SubOpWithLog.java

public class MulOpWithLog extends BaseOp {

    public MulOpWithLog(BaseOp next) {
        super(next);
    }

    @Override
    @AutoLog(tag=TAG, level=LogLevel.WARN)
    protected int onOperate(int value) {
        return value * new Random().nextInt(10);
    }
}

MulOpWithLog.java

public class DivOpWithLog extends BaseOp {

    public DivOpWithLog(BaseOp next) {
        super(next);
    }

    @Override
    @AutoLog(tag=TAG, level=LogLevel.DEBUG)
    protected int onOperate(int value) {
        return value / (new Random().nextInt(10)+1);
    }
}

DivOpWithLog.java

@AutoLog(tag = BaseOp.TAG, level = LogLevel.DEBUG)
public void doWithLog(View view) {
    BaseOp div = new DivOpWithLog(null);
    BaseOp mul = new MulOpWithLog(div);
    BaseOp sub = new SubOpWithLog(mul);
    BaseOp add = new AddOpWithLog(sub);
    int result = add.operate(100);
    Toast.makeText(this, result+"", Toast.LENGTH_SHORT).show();
}

MainActivity.java

運行doWithLog方法,查看logcat輸出日誌:

log1

效果如圖所示,打印方法名稱以及每個入參的值和直接結果返回值(若是void則不打印返回值),還有該方法的執行耗時(單位ms)。

線程切換

日常開發中經常會涉及線程切換操作,例如網絡請求、文件IO和其他耗時操作需要放在自線程中執行,UI操作需要切回主線程執行。

痛點

每次切換線程時需要創建Runnable,在它的run方法中執行業務邏輯,或者利用AsyncTask和Executor(切回主線程還需要利用Handler),需要在方法調用處或方法體內部增加這些代碼來切換線程運行。

如果能通過給方法加個標記,就能自動讓該方法在主或子線程執行,就可以讓方法調用過程變得清晰和極大的減少代碼量。

解決

同樣可以利用註解給方法標記,在編譯器織入線程調用的代碼,自動進行線程切換。
注意:這裏的實現方案較爲雞肋,僅提供一個思路和演示。

定義註解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AutoThread {
    /**
     * 指定方法運行在主/子線程
     * 可選枚舉值: MAIN(期望運行在主線程) BACKGROUND(期望運行在子線程)
     */
    ThreadScene scene();
    /**
     * 設置是否阻塞等待該方法執行完成才返回(默認true)
     */
    boolean waitUntilDone() default true;
}

AutoThread.java
自定義註解AutoThread,用於標記想要自動切換線程運行的方法。

定義切面和切入點
@Aspect
public class ThreadAspect {

    @Pointcut("execution (@com.cdh.aop.toys.annotation.AutoThread * *(..))")
    public void threadSceneTransition() {
    }
    
    // Advice ···
}

ThreadAspect.java
這裏定義了一個切面ThreadAspect和切入點threadSceneTransition

切入點中的execution表示在該方法體內進行代碼織入,@com.cdh.aop.toys.annotation.AutoThread表示添加了該註解的方法,第一個星表示不限return type,第二個星表示匹配任意方法名稱,(…)表示不限方法入參。

@Aspect
public class ThreadAspect {

    // Pointcut ···
    
    @Around("threadSceneTransition()")
    public Object executeInThread(final ProceedingJoinPoint joinPoint) {
        // result用於保存原方法執行結果
        final Object[] result = {null};
        try {
            MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
            // 獲取我們添加的方法註解AutoThread
            AutoThread thread = methodSignature.getMethod().getAnnotation(AutoThread.class);
            if (thread != null) {
                // 獲取註解中設置的ThreadScene值,
                ThreadScene threadScene = thread.scene();
                if (threadScene == ThreadScene.MAIN && !ThreadUtils.isMainThread()) {
                    // 若期望運行在主線程,但當前不在主線程
                    // 切換到主線程執行
                    ThreadUtils.runOnMainThread(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                // 執行原方法,並保存結果
                                result[0] = joinPoint.proceed();
                            } catch (Throwable throwable) {
                                throwable.printStackTrace();
                            }
                        }
                    }, thread.waitUntilDone());
                } else if (threadScene == ThreadScene.BACKGROUND && ThreadUtils.isMainThread()) {
                    // 若期望運行在子線程,但當前在主線程
                    // 切換到子線程執行
                    ThreadUtils.run(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                // 執行原方法,並保存結果
                                result[0] = joinPoint.proceed();
                            } catch (Throwable throwable) {
                                throwable.printStackTrace();
                            }
                        }
                    }, thread.waitUntilDone());
                } else {
                    // 直接在當前線程運行
                    result[0] = joinPoint.proceed();
                }
            }
        } catch (Throwable t) {
            t.printStackTrace();
        }
        // 返回原方法返回值
        return result[0];
    }
}

這裏使用Around替換原方法邏輯,在執行原方法之前,先進行線程判斷,然後切換到對應線程再執行原方法。

線程切換方法

上面看到,當需要切換主線程時,調用ThreadUtils.runOnMainThread來執行原方法,看看這個方法的內部實現:

/**
 * 主線程執行
 *
 * @param runnable 待執行任務
 * @param block 是否等待執行完成
 */
public static void runOnMainThread(Runnable runnable, boolean block) {
    if (isMainThread()) {
        runnable.run();
        return;
    }

    // 利用CountDownLatch來阻塞當前線程
    CountDownLatch latch = null;
    if (block) {
        latch = new CountDownLatch(1);
    }
    // 利用Pair保存Runnable和CountDownLatch
    Pair<Runnable, CountDownLatch> pair = new Pair<>(runnable, latch);
    // 將Pair參數發送到主線程處理
    getMainHandler().obtainMessage(WHAT_RUN_ON_MAIN, pair).sendToTarget();

    if (block) {
        try {
            // 等待CountDownLatch降爲0
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

private static class MainHandler extends Handler {

    MainHandler() {
        super(Looper.getMainLooper());
    }

    @Override
    public void handleMessage(Message msg) {
        if (msg.what == WHAT_RUN_ON_MAIN) {
            // 取出Pair參數
            Pair<Runnable, CountDownLatch> pair = (Pair<Runnable, CountDownLatch>) msg.obj;
            try {
                // 取出Runnable參數運行
                pair.first.run();
            } finally {
                if (pair.second != null) {
                    // 使CountDownLatch降1,這裏會降爲0,喚醒前面的阻塞等待
                    pair.second.countDown();
                }
            }
        }
    }
}

ThreadUtils.java
切換到主線程的方式還是利用主線程Handler。若設置等待結果返回,則會創建CountDownLatch,阻塞當前調用線程,等待主線程中執行完任務後才返回。

接下來看看切換子線程執行的方法ThreadUtils.run:

/**
 * 子線程執行
 *
 * @param runnable 待執行任務
 * @param block 是否等待執行完成
 */
public static void run(final Runnable runnable, final boolean block) {
    Future future = getExecutorService().submit(new Runnable() {
        @Override
        public void run() {
            // 通過線程池運行在子線程
            runnable.run();
        }
    });

    if (block) {
        try {
            // 等待執行結果
            future.get();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

切換到子線程就是通過線程池提交任務執行。

使用示例

同樣寫幾個方法,然後加上AutoThread註解

public class AddOpInThread extends BaseOp {

    public AddOpInThread(BaseOp next) {
        super(next);
    }

    @Override
    @AutoThread(scene = ThreadScene.BACKGROUND)
    protected int onOperate(int value) {
        // 打印該方法運行時所在線程
        Log.w(BaseOp.TAG, "AddOpInThread onOperate: " + java.lang.Thread.currentThread());
        return value + new Random().nextInt(10);
    }
}

AddOpInThread.java
方法註解指定運行在子線程。

public class SubOpInThread extends BaseOp {

    public SubOpInThread(BaseOp next) {
        super(next);
    }

    @Override
    @AutoThread(scene = ThreadScene.MAIN)
    protected int onOperate(int value) {
        // 打印該方法運行時所在線程
        Log.w(BaseOp.TAG, "SubOpInThread onOperate: " + java.lang.Thread.currentThread());
        return value - new Random().nextInt(10);
    }
}

SubOpInThread.java
指定運行在主線程。

public class MulOpInThread extends BaseOp {

    public MulOpInThread(BaseOp next) {
        super(next);
    }

    @Override
    @AutoThread(scene = ThreadScene.MAIN)
    protected int onOperate(int value) {
        // 打印該方法運行時所在線程
        Log.w(BaseOp.TAG, "MulOpInThread onOperate: " + java.lang.Thread.currentThread());
        return value * new Random().nextInt(10);
    }
}

MulOpInThread.java
指定運行在主線程。

public class DivOpInThread extends BaseOp {

    public DivOpInThread(BaseOp next) {
        super(next);
    }

    @Override
    @AutoThread(scene = ThreadScene.BACKGROUND)
    protected int onOperate(int value) {
        // 打印該方法運行時所在線程
        Log.w(BaseOp.TAG, "DivOpInThread onOperate: " + java.lang.Thread.currentThread());
        return value / (new Random().nextInt(10)+1);
    }
}

DivOpInThread.java
指定運行在子線程。

接下來調用方法:

public void doWithThread(View view) {
    BaseOp div = new DivOpInThread(null);
    BaseOp mul = new MulOpInThread(div);
    BaseOp sub = new SubOpInThread(mul);
    BaseOp add = new AddOpInThread(sub);
    int result = add.operate(100);
    Toast.makeText(this, result+"", Toast.LENGTH_SHORT).show();
}

MainActivity.java

運行doWithThread方法,查看logcat輸出日誌:
log2

可以看到第一個方法已經切換到子線程中運行,第二、三個方法又運行在主線程中,第四個方法又運行在子線程中。

線程名稱檢測

通常我們在創建使用Thread時,需要給它設置一個名稱,便於分析和定位該Thread所屬業務模塊。

痛點

開發過程中出現疏漏或者引入的第三方庫中不規範使用線程,例如直接創建線程運行,或者匿名線程等。當想要分析線程時,就會看到很多Thread-1、2、3的線程,如果有一個清晰的名稱就容易一眼看出該線程所屬的業務。

解決

可以通過攔截所有的Thread.start調用時機,在start之前檢測線程名稱。若是默認名稱,則進行警告,並且自動修改線程名稱。

定義切面和切入點

這裏把線程相關織入操作都放在一個切面ThreadAspect中:

@Aspect
public class ThreadAspect {

    private static final String TAG = "ThreadAspect";
    
    @Before("call (* java.lang.Thread.start(..))")
    public void callThreadStart(JoinPoint joinPoint) {
        try {
            // 獲取joinPoint所在對象,即執行start方法的那個Thread實例
            Thread thread = (Thread) joinPoint.getTarget();
            // 通過正則檢測線程名稱
            if (ThreadUtils.isDefaultThreadName(thread)) {
                // 打印警告信息(線程對象和該方法調用的位置)
                LogUtils.e(TAG, "發現啓動線程[" + thread + "]未自定義線程名稱! [" + joinPoint.getSourceLocation() + "]");
                // 設置線程名稱,名稱拼接該方法調用處上下文this對象
                thread.setName(thread.getName() + "-" + joinPoint.getThis());
            }
        } catch (Throwable t) {
            t.printStackTrace();
        }
    }
}

ThreadAspect.java

Before表示在切入點前織入,call表示在該方法的調用處,第一個星表示不限return type,java.lang.Thread.start表示完全匹配Thread類的start方法,(…)表示不限方法參數。

該切入點會在所有調用thread.start的地方前面織入名稱檢測和設置名稱的代碼。

線程名稱檢測

若thread未設置名稱,則會使用默認名稱,可以看Thread的構造方法。

/* For autonumbering anonymous threads. */
private static int threadInitNumber;
private static synchronized int nextThreadNum() {
    return threadInitNumber++;
}

public Thread() {
    // 第三個參數即默認名稱
    init(null, null, "Thread-" + nextThreadNum(), 0);
}

Thread在創建時會設置一個默認名稱,Thread-數字遞增,所以可以通過匹配這個名稱來判斷Thread是否設置了自定義名稱。

看ThreadUtils.isDefaultThreadName方法:

public static boolean isDefaultThreadName(Thread thread) {
    String name = thread.getName();
    String pattern = "^Thread-[1-9]\\d*$";
    return Pattern.matches(pattern, name);
}

通過正則表達式來判斷,若完全匹配則表示當前是默認名稱。

使用示例

創建幾個Thread,分別設置名稱和不設置名稱,然後啓動運行。

public void renameThreadName(View view) {
    // 未設置名稱
    new Thread(new PrintNameRunnable()).start();

    // 設置名稱
    Thread t = new Thread(new PrintNameRunnable());
    t.setName("myname-thread-test");
    t.start();
}

private static class PrintNameRunnable implements Runnable {
    @Override
    public void run() {
        // 打印線程名稱
        Log.d(TAG, "thread name: " + Thread.currentThread().getName());
    }
}

運行後查看logcat輸出日誌:

log3
可以看到檢測到一個線程啓動時未設置自定義名稱,並且打印出該方法調用位置。

log4
當線程啓動後,在Runnable中打印當前線程名稱,可以看到線程名稱已經被設置,並且可以知道thread啓動所在上下文。

工信部檢查

工信部發文要求APP在用戶未同意隱私協議之前,不得收集用戶、設備相關信息,例如imei、device id、設備已安裝應用列表、通訊錄等能夠唯一標識用戶和用戶設備隱私相關的信息。

注意,這裏的用戶同意隱私協議不同於APP權限申請,是屬於業務層面上的隱私協議。若用戶未同意隱私協議,即使在系統應用設置中打開該APP的所有權限,業務代碼中也不能獲取相關信息。

如圖,必須用戶同意後,業務代碼中才能獲取需要的信息。

痛點

要對代碼中所有涉及隱私信息獲取的地方做檢查,容易疏漏。萬一出現遺漏,將面臨工信部的下架整改處罰。而且部分三方SDK中沒有嚴格按照工信部要求,會私自進行用戶、設備相關信息的獲取。

解決

可以在所有調用隱私信息API的地方前面織入檢查代碼,一舉涵蓋自身業務代碼和三方SDK代碼進行攔截。

注意,通過動態加載的代碼中的調用行爲和native層中的行爲無法徹底攔截。

攔截API直接調用
@Aspect
public class PrivacyAspect {

    // 攔截獲取手機安裝應用列表信息的調用
    private static final String POINT_CUT_GET_INSTALLED_APPLICATION = "call (* android.content.pm.PackageManager.getInstalledApplications(..))";
    private static final String POINT_CUT_GET_INSTALLED_PACKAGES = "call (* android.content.pm.PackageManager.getInstalledPackages(..))";

    // 攔截獲取imei、device id的調用
    private static final String POINT_CUT_GET_IMEI = "call (* android.telephony.TelephonyManager.getImei(..))";
    private static final String POINT_CUT_GET_DEVICE_ID = "call(* android.telephony.TelephonyManager.getDeviceId(..))";

    // 攔截getLine1Number方法的調用
    private static final String POINT_CUT_GET_LINE_NUMBER = "call (* android.telephony.TelephonyManager.getLine1Number(..))";

    // 攔截定位的調用
    private static final String POINT_CUT_GET_LAST_KNOWN_LOCATION = "call (* android.location.LocationManager.getLastKnownLocation(..))";
    private static final String POINT_CUT_REQUEST_LOCATION_UPDATES = "call (* android.location.LocationManager.requestLocationUpdates(..))";
    private static final String POINT_CUT_REQUEST_LOCATION_SINGLE = "call (* android.location.LocationManager.requestSingleUpdate(..))";
    
    // ···
    
    @Around(POINT_CUT_GET_INSTALLED_APPLICATION)
    public Object callGetInstalledApplications(ProceedingJoinPoint joinPoint) {
        return handleProceedingJoinPoint(joinPoint, new ArrayList<ApplicationInfo>());
    }

    @Around(POINT_CUT_GET_INSTALLED_PACKAGES)
    public Object callGetInstalledPackages(ProceedingJoinPoint joinPoint) {
        return handleProceedingJoinPoint(joinPoint, new ArrayList<PackageInfo>());
    }

    @Around(POINT_CUT_GET_IMEI)
    public Object callGetImei(ProceedingJoinPoint joinPoint) {
        return handleProceedingJoinPoint(joinPoint, "");
    }

    @Around(POINT_CUT_GET_DEVICE_ID)
    public Object callGetDeviceId(ProceedingJoinPoint joinPoint) {
        return handleProceedingJoinPoint(joinPoint, "");
    }

    @Around(POINT_CUT_GET_LINE_NUMBER)
    public Object callGetLine1Number(ProceedingJoinPoint joinPoint) {
        return handleProceedingJoinPoint(joinPoint, "");
    }

    @Around(POINT_CUT_GET_LAST_KNOWN_LOCATION)
    public Object callGetLastKnownLocation(ProceedingJoinPoint joinPoint) {
        return handleProceedingJoinPoint(joinPoint, null);
    }

    @Around(POINT_CUT_REQUEST_LOCATION_UPDATES)
    public void callRequestLocationUpdates(ProceedingJoinPoint joinPoint) {
        handleProceedingJoinPoint(joinPoint, null);
    }

    @Around(POINT_CUT_REQUEST_LOCATION_SINGLE)
    public void callRequestSingleUpdate(ProceedingJoinPoint joinPoint) {
        handleProceedingJoinPoint(joinPoint, null);
    }
    
    // ···
}

PrivacyAspect.java

定義一個切面PrivacyAspect,和需要檢查調用的方法的切入點。其中使用Around替換對敏感API的調用的代碼,調用handleProceedingJoinPoint處理,第一個參數是連接點ProceedingJoinPoint,第二個參數是默認返回值(若原方法有返回值,則會返回結果)。

接着進入handleProceedingJoinPoint方法:

private Object handleProceedingJoinPoint(ProceedingJoinPoint joinPoint, Object fakeResult) {
    if (!PrivacyController.isUserAllowed()) {
        // 若用戶未同意
        StringBuilder sb = new StringBuilder();
        // 打印調用的方法和該調用所在位置
        sb.append("用戶未同意時執行了").append(joinPoint.getSignature().toShortString())
                .append(" [").append(joinPoint.getSourceLocation()).append("]");
        LogUtils.e(TAG,  sb.toString());
        // 返回一個空的默認值
        return fakeResult;
    }

    try {
        // 執行原方法,返回原結果
        return joinPoint.proceed();
    } catch (Throwable throwable) {
        throwable.printStackTrace();
    }
    return fakeResult;
}

該方法中判斷用戶是否同意。若未同意,則返回空的返回值。否則放行,調用原方法。

攔截API反射調用

部分三方SDK中會通過反射調用敏感API,並且對方法名稱字符串做加密處理,以繞過靜態檢查,因此也需要對反射調用進行攔截。

@Aspect
public class PrivacyAspect {
    
    // 攔截反射的調用
    private static final String POINT_CUT_METHOD_INVOKE = "call (* java.lang.reflect.Method.invoke(..))";
    // 反射方法黑名單
    private static final List<String> REFLECT_METHOD_BLACKLIST = Arrays.asList(
            "getInstalledApplications",
            "getInstalledPackages",
            "getImei",
            "getDeviceId",
            "getLine1Number",
            "getLastKnownLocation",
            "loadClass"
    );
    
    @Around(POINT_CUT_METHOD_INVOKE)
    public Object callReflectInvoke(ProceedingJoinPoint joinPoint) {
        // 獲取該連接點調用的方法名稱
        String methodName = ((Method) joinPoint.getTarget()).getName();
        if (REFLECT_METHOD_BLACKLIST.contains(methodName)) {
            // 若是黑名單中的方法,則進行檢查
            return handleProceedingJoinPoint(joinPoint, null);
        }

        try {
            // 執行原方法,返回原結果
            return joinPoint.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        return null;
    }
}

通過攔截Method.invoke的調用,判斷反射調用的方法是不是黑名單中的方法。

攔截動態加載的調用
@Aspect
public class PrivacyAspect {

    // 攔截加載類的調用
    private static final String POINT_CUT_DEX_FIND_CLASS = "call (* java.lang.ClassLoader.loadClass(..))";
    
    @Around(POINT_CUT_DEX_FIND_CLASS)
    public Object callLoadClass(ProceedingJoinPoint joinPoint) {
        Object result = null;
        try {
            result = joinPoint.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }

        // 打印該連接點的相關信息
        StringBuilder sb = new StringBuilder();
        sb.append(joinPoint.getThis()).append("中動態加載");
        Object[] args = joinPoint.getArgs();
        if (args != null && args.length > 0) {
            sb.append("\"").append(args[0]).append("\"");
        }
        sb.append("得到").append(result);
        sb.append(" ").append(joinPoint.getSourceLocation());
        LogUtils.w(TAG, sb.toString());

        return result;
    }
}

攔截到loadClass後,打印日誌輸出調用處的位置。

使用示例

public void interceptPrivacy(View view) {
    Log.d(TAG, "用戶同意: " + PrivacyController.isUserAllowed());

    // 獲取手機安裝應用信息
    List<ApplicationInfo> applicationInfos = DeviceUtils.getInstalledApplications(this);
    if (applicationInfos != null && applicationInfos.size() > 5) {
        applicationInfos = applicationInfos.subList(0, 5);
    }
    Log.d(TAG, "getInstalledApplications: " + applicationInfos);

    // 獲取手機安裝應用信息
    List<PackageInfo> packageInfos = DeviceUtils.getInstalledPackages(this);
    if (packageInfos != null && packageInfos.size() > 5) {
        packageInfos = packageInfos.subList(0, 5);
    }
    Log.d(TAG, "getInstalledPackages: " + packageInfos);

    // 獲取imei
    Log.d(TAG, "getImei: " + DeviceUtils.getImeiValue(this));
    // 獲取電話號碼
    Log.d(TAG, "getLine1Number: " + DeviceUtils.getLine1Number(this));
    // 獲取定位信息
    Log.d(TAG, "getLastKnownLocation: " + DeviceUtils.getLastKnownLocation(this));

    try {
        // 加載一個類
        Log.d(TAG, "loadClass: " + getClassLoader().loadClass("com.cdh.aop.sample.op.BaseOp"));
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    }

    try {
        // 通過反射獲取手機安裝應用信息
        PackageManager pm = getPackageManager();
        Method method = PackageManager.class.getDeclaredMethod("getInstalledApplications", int.class);
        List<ApplicationInfo> list = (List<ApplicationInfo>) method.invoke(pm, 0);
        if (list != null && list.size() > 5) {
            list = list.subList(0, 5);
        }
        Log.d(TAG, "reflect getInstalledApplications: " + list);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

運行後查看logcat輸出日誌:

log6
打印了敏感API調用的警告信息和調用處所在位置。

log7
調用方最終獲取到的都是空值。

尾聲

在集成AspectJX框架打包apk後可能會遇到ClassNotFoundException,反編譯apk發現很多類沒有打進去,甚至包括Application。絕大部分原因是因爲依賴的三方庫中使用了AspectJ框架導致的衝突,或者是自己寫的切入點的語法有錯誤,或織入代碼有問題,例如方法返回值沒有對應上,或者對同一個切入點定義了有衝突的通知。若發生錯誤,會在build中顯示錯誤信息。

如果不用AOP思想和AspectJ框架實現上面的需求,會有很多繁瑣的工作量。這裏通過幾個簡單場景的應用,可以發現若能深入理解AOP思想和掌握AspectJ使用,會對架構設計和開發效率有很大的提升和幫助。

文中示例完整源碼見Efficiency-Toys

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章