AOP:利用Aspectj注入程式碼,無侵入實現各種功能,比如一個註解請求許可權

asAnotherJack發表於2018-01-08

前言

這篇文章我想了很久不太知道該怎麼去寫,因為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專案地址

相關文章