前言
這篇文章我想了很久不太知道該怎麼去寫,因為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(面向切面程式設計)產生了一點點興趣的話,不妨再去網上找一些“正式”一點的教程學習一下,對其中的一些概念有個認知吧!?
參考
- HujiangTechnology/gradle_plugin_android_aspectjx
- 看AspectJ在Android中的強勢插入
- 深入理解Android之AOP 這篇強烈推薦
- 使用AspectJ在Android中實現Aop
- 跟我學aspectj之一 ----- 簡介
最後是demo的地址,demo就不求star了,覺得文章還行的話在掘金上點個喜歡就好了? AOPDemo專案地址