Android 採用AOP方式封裝6.0許可權管理

_小馬快跑_發表於2018-04-01

【一】背景

6.0執行時申請許可權已經是一個老生常談的內容了,最近專案TargetSDKVersion升到23以上,所以我們也需要做許可權管理,我想到的需求是這樣的:

1、支援單個許可權、多個許可權申請 2、執行時申請 3、無侵入式申請,無需關注許可權申請的邏輯 4、除了Activity、Fragment之外,還需要支援Service中申請 5、對國產手機做相容處理

第一、二點,Google都有對應的API;

第三點可以通過自定義註解+AOP切面方式來解決。為什麼採用AOP方式呢?首先看AOP定義: 面向切面程式設計(Aspect-Oriented Programming)。如果說,OOP(物件導向)如果是把問題劃分到單個模組的話,那麼AOP就是把涉及到眾多模組的某一類問題進行統一管理。 因為我們申請許可權的邏輯都是基本一樣的,所以可以把申請許可權的邏輯統一管理。

第四點稍微有點麻煩,因為Google提供的API只支援在Activity和Fragment中去申請許可權,Service中並沒有相應的API,比如專案中的某個Service裡需要拿到當前位置資訊,並且不能確定定位許可權已經給了,所以在定位之前仍然需要判斷有沒有定位許可權,按照常規邏輯好像是行不通了。腫麼辦呢?先說一下我想到的辦法:通過一個透明的Activity去申請許可權,並且把申請結果返回來,最後實踐也是這麼做的,具體思路請往下看。

第五點也比較麻煩,如果都按Google標準來,那就不用考慮相容問題了,但是國產安卓手機碎片化比較嚴重,且基本都修改了ROM,導致申請許可權的API跟期望返回的結果不一致,這種的可能就需要特殊處理了。

調研了一下比較流行的三方庫,如 PermissionsDispatcherRxPermissions,做了一個簡單的總結:

許可權庫 是否使用註解 是否支援鏈式呼叫 是否支援Service 是否適配國產機
RxPermissions No Yes No No
PermissionsDispatcher Yes No No 適配了小米

RxPermissions是基於RX的思想,支援鏈式呼叫,簡單方便,但是他不支援Service呼叫;PermissionsDispatcher使用了編譯時解析註解的方式,通過apt自動生成.class方式幫我們去寫申請許可權的邏輯,很好很強大,並且適配了小米手機,但是它也不支援Service中去申請許可權。考慮到我們專案中的應用場景並且借鑑了PermissionsDispatcher的申請許可權的邏輯,決定基於AOP方式手動擼一個許可權管理庫出來。

【二】效果圖

先上一下最終的效果圖:

執行效果圖.gif

效果圖有點模糊,可以下載原始碼執行一下看效果

【三】整體思路

首先,先定義一個說法,彈出系統許可權彈窗,使用者沒有給許可權,並且選中不再提示,這種情況稱為許可權被拒絕;如果使用者沒有給許可權,但是沒有選中不再提示,這種情況稱為許可權被取消。申請許可權、許可權被取消、許可權被拒絕都是採用註解的形式,分別為@NeedPermission、@PermissionCanceled、@PermissionDenied,註解都是宣告在Method級別上的。在我們的Activity、Fragment及Service中宣告註解,然後在AOP中解析我們的註解,並把申請的許可權傳遞給一個透明的Activity去處理,並把處理結果返回來。這就是整體思路,可能會遇到的問題: 1、 不同型號的手機相容問題(申請許可權、跳設定介面) 2、AOP解析註解以及傳值問題

上面說了很多,其實用一個圖來表示更清晰一些:

UML時序圖.png

OK,通過上面的圖是不是更清晰了呢?其實最關鍵的地方就是AOP解析註解及傳值。AOP面向切面程式設計是一種程式設計思想,而AspectJ是對AOP程式設計思想的一個實踐,本文采用AspectJ來實現切面程式設計,簡單介紹AspectJ的幾個概念:

  • JPoint:程式碼可注入的點,比如一個方法的呼叫處或者方法內部,對於本文來說即是註解作用的方法。
  • Pointcut:用來描述 JPoint 注入點的一段表示式。見下面例子
  • Advice:常見的有 Before、After、Around 等,表示程式碼執行前、執行後、替換目的碼,也就是在 Pointcut 何處編織程式碼。
  • Aspect:切面,Pointcut 和 Advice 合在一起稱作 Aspect。

關於AspectJ的介紹及用法的文章很多,不瞭解的朋友可以去google下,直接列一下AOP切面程式碼:

@Aspect
public class PermissionAspect {

    private static final String PERMISSION_REQUEST_POINTCUT =
            "execution(@com.ninetripods.aopermission.permissionlib.annotation.NeedPermission * *(..))";

    @Pointcut(PERMISSION_REQUEST_POINTCUT + " && @annotation(needPermission)")
    public void requestPermissionMethod(NeedPermission needPermission) {
    }

    @Around("requestPermissionMethod(needPermission)")
    public void AroundJoinPoint(final ProceedingJoinPoint joinPoint, NeedPermission needPermission) {

        Context context = null;
        final Object object = joinPoint.getThis();
        if (object instanceof Context) {
            context = (Context) object;
        } else if (object instanceof Fragment) {
            context = ((Fragment) object).getActivity();
        } else if (object instanceof android.support.v4.app.Fragment) {
            context = ((android.support.v4.app.Fragment) object).getActivity();
        }
        if (context == null || needPermission == null) return;

        PermissionRequestActivity.PermissionRequest(context, needPermission.value(),
                needPermission.requestCode(), new IPermission() {
                    @Override
                    public void PermissionGranted() {
                        try {
                            joinPoint.proceed();
                        } catch (Throwable throwable) {
                            throwable.printStackTrace();
                        }
                    }

                    @Override
                    public void PermissionDenied(int requestCode, List<String> denyList) {
                        Class<?> cls = object.getClass();
                        Method[] methods = cls.getDeclaredMethods();
                        if (methods == null || methods.length == 0) return;
                        for (Method method : methods) {
                            //過濾不含自定義註解PermissionDenied的方法
                            boolean isHasAnnotation = method.isAnnotationPresent(PermissionDenied.class);
                            if (isHasAnnotation) {
                                method.setAccessible(true);
                                //獲取方法型別
                                Class<?>[] types = method.getParameterTypes();
                                if (types == null || types.length != 1) return;
                                //獲取方法上的註解
                                PermissionDenied aInfo = method.getAnnotation(PermissionDenied.class);
                                if (aInfo == null) return;
                                //解析註解上對應的資訊
                                DenyBean bean = new DenyBean();
                                bean.setRequestCode(requestCode);
                                bean.setDenyList(denyList);
                                try {
                                    method.invoke(object, bean);
                                } catch (IllegalAccessException e) {
                                    e.printStackTrace();
                                } catch (InvocationTargetException e) {
                                    e.printStackTrace();
                                }
                            }
                        }
                    }

                    @Override
                    public void PermissionCanceled(int requestCode) {
                        Class<?> cls = object.getClass();
                        Method[] methods = cls.getDeclaredMethods();
                        if (methods == null || methods.length == 0) return;
                        for (Method method : methods) {
                            //過濾不含自定義註解PermissionCanceled的方法
                            boolean isHasAnnotation = method.isAnnotationPresent(PermissionCanceled.class);
                            if (isHasAnnotation) {
                                method.setAccessible(true);
                                //獲取方法型別
                                Class<?>[] types = method.getParameterTypes();
                                if (types == null || types.length != 1) return;
                                //獲取方法上的註解
                                PermissionCanceled aInfo = method.getAnnotation(PermissionCanceled.class);
                                if (aInfo == null) return;
                                //解析註解上對應的資訊
                                CancelBean bean = new CancelBean();
                                bean.setRequestCode(requestCode);
                                try {
                                    method.invoke(object, bean);
                                } catch (IllegalAccessException e) {
                                    e.printStackTrace();
                                } catch (InvocationTargetException e) {
                                    e.printStackTrace();
                                }
                            }
                        }
                    }
                });
    }
}
複製程式碼

程式碼有點多,但是思路還是挺清晰的,首先定義@Pointcut(描述的是我們的註解@NeedPermission),接著由Advice(@Around)及Pointcut構成我們的切面Aspect, 在切面Aspect中,通過joinPoint.getThis()根據不同來源來獲得Context,接著跳轉到一個透明Activity申請許可權並通過介面回撥拿到許可權申請結果,最後在不同的回撥方法裡通過反射把回撥結果回傳給呼叫方。

【四】使用舉例

為了簡化AspectJ的各種配置,這裡用了一個三方的gradle外掛: https://github.com/HujiangTechnology/gradle_plugin_android_aspectjx

1、許可權庫引入方式,在app模組的build.gradle中引入如下:

apply plugin: 'android-aspectjx'

dependencies {
     compile 'com.ninetripods:aop-permission:1.0.1'
     ..........其他............
}
複製程式碼

2、在整個工程的build.gradle裡面配置如下:

dependencies {
    classpath 'com.android.tools.build:gradle:2.3.3'
    classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:1.0.8'
    ................其他................
}
複製程式碼

說明:aspectjx:1.0.8不是最新版本,最高支援gradle的版本到2.3.3,如果你的工程裡gradle版本是3.0.0以上,請使用aspectjx:1.1.0以上版本,aspectjx歷史版本檢視地址: https://github.com/HujiangTechnology/gradle_plugin_android_aspectjx/blob/master/CHANGELOG.md

3、如果你的專案裡使用了混淆,需要在AOP程式碼進行hook的類及方法名不能被混淆,即被註解作用的類及方法不能被混淆,需要在混淆配置裡keep住, 比如:

package com.hujiang.test;

public class A {

    @NeedPermission
    public boolean funcA(String args) {
        ....
    }
}

//如果你在AOP程式碼裡對A#funcA(String)進行hook, 那麼在混淆配置檔案里加上這樣的配置

-keep class com.hujiang.test.A {*;}
複製程式碼

4、終於配好了,都閃開,我要開始舉栗子了:

舉栗子.jpg
下面以Activity中申請許可權為例,Fragment、Service中使用是一樣的,就不一一寫了,原始碼中也有相應使用的Demo

4.1 申請單個許可權

申請單個許可權:

btn_click.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        callMap();
    }
});

/**
 * 申請許可權
 */
@NeedPermission(value = {Manifest.permission.ACCESS_FINE_LOCATION}, requestCode = 0)
private void callMap() {
    Toast.makeText(this, "定位許可權申請通過", Toast.LENGTH_SHORT).show();
}
複製程式碼

@NeedPermission後面的value代表需要申請的許可權,是一個String[]陣列;requestCode是請求碼,是為了區別開同一個Activity中有多個不同的許可權請求,預設是0,如果同一個Activity中只有一個許可權申請,requestCode可以忽略不寫。

/**
 * 許可權被取消
 *
 * @param bean CancelBean
 */
@PermissionCanceled
public void dealCancelPermission(CancelBean bean) {
    Toast.makeText(this, "requestCode:" + bean.getRequestCode(), Toast.LENGTH_SHORT).show();
}
複製程式碼

宣告一個public方法接收許可權被取消的回撥,方法必須有一個CancelBean型別的引數,這點類似於EventBus,CancelBean中有requestCode變數,即是我們請求許可權時的請求碼。

/**
 * 許可權被拒絕
 *
 * @param bean DenyBean
 */
@PermissionDenied
public void dealPermission(DenyBean bean) {
        Toast.makeText(this, 
        "requestCode:" + bean.getRequestCode()+ ",Permissions: " + Arrays.toString(bean.getDenyList().toArray()), Toast.LENGTH_SHORT).show();
  }
複製程式碼

宣告一個public方法接收許可權被取消的回撥,方法必須有一個DenyBean型別的引數,DenyBean中有一個requestCode變數,即是我們請求許可權時的請求碼,另外還可以通過denyBean.getDenyList()來拿到被許可權被拒絕的List。

4.2 申請多個許可權

基本用法同上,區別是@NeedPermission後面宣告的許可權是多個,如下:

/**
 * 申請多個許可權
 */
@NeedPermission(value = {Manifest.permission.CALL_PHONE, Manifest.permission.CAMERA}, requestCode = 10)
public void callPhone() {
    Toast.makeText(this, "電話、相機許可權申請通過", Toast.LENGTH_SHORT).show();
}
複製程式碼

value中宣告瞭兩個許可權,一個電話許可權,一個相機許可權

4.3 跳轉到設定類

當使用者拒絕許可權並選中不再提示後,需要引導使用者去設定介面開啟許可權,因為國產手機各個設定介面不一樣,用通用的API可能會跳轉不到相應的APP設定介面,這裡採用了策略模式(下圖所示)

跳轉到設定類.png

如需做相容,只需要在庫裡修改,呼叫方是不需要處理的,呼叫方如需跳轉到設定介面,只需像下面這樣呼叫就OK了:

SettingUtil.go2Setting(this);
複製程式碼

【五】總結

回看一下我們的需求,基本上都實現了:

1、首先通過@NeedPermission、@PermissionCanceled、@PermissionDenied三個註解來分別定義許可權申請、被取消、被拒絕三種情況,如果不想處理被取消的邏輯就不用使用@PermissionCanceled註解,其他許可權申請的邏輯呼叫方不用關心,是完全解耦的;

2、同時支援在Activity、Fragment、Service中去申請許可權;

3、最後關於申請許可權、跳設定介面的相容問題,因為身邊的手機有限,不能測試出所有相容問題,需要後續優化。

關於在AOP中通過反射方式把許可權申請結果返回給呼叫方,是參考了EventBus的方式,感覺這樣用起來更方便一些;之前的做法是在AOP對應的Java類中宣告介面,呼叫方實現該介面,然後通過介面回撥的方式將許可權申請結果回傳,也能實現同樣效果,但是感覺沒有反射方式更方便。以上就是全部內容,後面會貼出原始碼,如有使用不當之處,歡迎批評指正!

【六】原始碼

原始碼地址:https://github.com/crazyqiang/Aopermission

相關文章