【一】背景
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跟期望返回的結果不一致,這種的可能就需要特殊處理了。
調研了一下比較流行的三方庫,如 PermissionsDispatcher、RxPermissions,做了一個簡單的總結:
許可權庫 | 是否使用註解 | 是否支援鏈式呼叫 | 是否支援Service | 是否適配國產機 |
---|---|---|---|---|
RxPermissions | No | Yes | No | No |
PermissionsDispatcher | Yes | No | No | 適配了小米 |
RxPermissions是基於RX的思想,支援鏈式呼叫,簡單方便,但是他不支援Service呼叫;PermissionsDispatcher使用了編譯時解析註解的方式,通過apt自動生成.class方式幫我們去寫申請許可權的邏輯,很好很強大,並且適配了小米手機,但是它也不支援Service中去申請許可權。考慮到我們專案中的應用場景並且借鑑了PermissionsDispatcher的申請許可權的邏輯,決定基於AOP方式手動擼一個許可權管理庫出來。
【二】效果圖
先上一下最終的效果圖:
效果圖有點模糊,可以下載原始碼執行一下看效果
【三】整體思路
首先,先定義一個說法,彈出系統許可權彈窗,使用者沒有給許可權,並且選中不再提示,這種情況稱為許可權被拒絕;如果使用者沒有給許可權,但是沒有選中不再提示,這種情況稱為許可權被取消。申請許可權、許可權被取消、許可權被拒絕都是採用註解的形式,分別為@NeedPermission、@PermissionCanceled、@PermissionDenied,註解都是宣告在Method級別上的。在我們的Activity、Fragment及Service中宣告註解,然後在AOP中解析我們的註解,並把申請的許可權傳遞給一個透明的Activity去處理,並把處理結果返回來。這就是整體思路,可能會遇到的問題: 1、 不同型號的手機相容問題(申請許可權、跳設定介面) 2、AOP解析註解以及傳值問題
上面說了很多,其實用一個圖來表示更清晰一些:
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、終於配好了,都閃開,我要開始舉栗子了:
下面以Activity中申請許可權為例,Fragment、Service中使用是一樣的,就不一一寫了,原始碼中也有相應使用的Demo4.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設定介面,這裡採用了策略模式(下圖所示)
如需做相容,只需要在庫裡修改,呼叫方是不需要處理的,呼叫方如需跳轉到設定介面,只需像下面這樣呼叫就OK了:
SettingUtil.go2Setting(this);
複製程式碼
【五】總結
回看一下我們的需求,基本上都實現了:
1、首先通過@NeedPermission、@PermissionCanceled、@PermissionDenied三個註解來分別定義許可權申請、被取消、被拒絕三種情況,如果不想處理被取消的邏輯就不用使用@PermissionCanceled註解,其他許可權申請的邏輯呼叫方不用關心,是完全解耦的;
2、同時支援在Activity、Fragment、Service中去申請許可權;
3、最後關於申請許可權、跳設定介面的相容問題,因為身邊的手機有限,不能測試出所有相容問題,需要後續優化。
關於在AOP中通過反射方式把許可權申請結果返回給呼叫方,是參考了EventBus的方式,感覺這樣用起來更方便一些;之前的做法是在AOP對應的Java類中宣告介面,呼叫方實現該介面,然後通過介面回撥的方式將許可權申請結果回傳,也能實現同樣效果,但是感覺沒有反射方式更方便。以上就是全部內容,後面會貼出原始碼,如有使用不當之處,歡迎批評指正!
【六】原始碼
原始碼地址:https://github.com/crazyqiang/Aopermission