簡介
對於許可權,每個android開發者應該很熟悉了,對於targetSDK大於23的時候需要對某些敏感許可權進行動態申請,比如獲取通訊錄許可權、相機許可權、定位許可權等。
在android 6.0中也同時新增了許可權組的概念,若使用者同意組內的某一個許可權,那麼系統預設app可以使用組內的所有許可權,無需再次申請。
這裡貼一張許可權組的圖片:
申請許可權API
先介紹一下android 6.0以上動態申請許可權的流程,申請許可權,使用者可以點選拒絕,再次申請的時候可以選擇不再提醒。
下面說介紹一下執行時申請許可權需要用到的API,程式碼示例使用kotlin實現
- 在Manifest中註冊
<uses-permission android:name="android.permission.XXX"/>
複製程式碼
- 檢查使用者是否同意了某個許可權
// (API) int checkSelfPermission (Context context, String permission)
ContextCompat.checkSelfPermission(context, Manifest.permission.XXX) != PackageManager.PERMISSION_GRANTED
複製程式碼
- 申請許可權
// (API) void requestPermissions (Activity activity, String[] permissions, int requestCode)
requestPermissions(arrayOf(Manifest.permission.CALL_PHONE), REQUEST_CODE_CALL_PHONE)
複製程式碼
- 請求結果回撥
// (API) void onRequestPermissionsResult (int requestCode, String[] permissions, int[] grantResults)
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
}
複製程式碼
- 是否需要向使用者解釋請求許可權的目的
// (API) boolean shouldShowRequestPermissionRationale (Activity activity, String permission)
ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CALL_PHONE)
複製程式碼
情況 | 返回值 |
---|---|
第一次開啟App時 | false |
上次彈出許可權點選了禁止(但沒有勾選“下次不在詢問”) | true |
上次選擇禁止並勾選“下次不在詢問 ” | false |
注:如果使用者在過去拒絕了許可權請求,並在許可權請求系統對話方塊中選擇了 Don't ask again 選項,此方法將返回 false。如果裝置規範禁止應用具有該許可權,此方法也會返回 false。
單一許可權申請互動流程
我們做移動端需要直接與使用者互動,需要多考慮如何根使用者互動才能達到最好的體驗。下面我結合google samples中動態申請許可權示例android-RuntimePermissions
github.com/googlesampl…
以及動態申請許可權框架easypermissions
github.com/googlesampl…
來對互動上做一個總結。
首先說明,Android不建議App直接進行撥打電話這種敏感操作,建議跳轉至撥號介面,並將電話號碼傳入撥號介面中,這裡僅作參考案例,下面每中情況都是使用者從使用者第一次申請許可權開始(許可權詢問狀態)
-
直接允許許可權。
-
拒絕之後再次申請允許
-
不再提醒之後引導至設定介面面
話不多說,上程式碼。
/**
* 建立伴生物件,提供靜態變數
*/
companion object {
const val TAG = "MainActivity"
const val REQUEST_CODE_CALL_PHONE = 1
}
...
// 這裡進行呼叫requestPermmission()進行撥號前的許可權請求
...
private fun callPhone() {
val intent = Intent(Intent.ACTION_CALL)
val data = Uri.parse("tel:9898123456789")
intent.data = data
startActivity(intent)
}
/**
* 提示使用者申請許可權說明
*/
@TargetApi(Build.VERSION_CODES.M)
fun showPermissionRationale(rationale: String) {
Snackbar.make(view, rationale,
Snackbar.LENGTH_INDEFINITE)
.setAction("確定") {
requestPermissions(arrayOf(Manifest.permission.CALL_PHONE), REQUEST_PERMISSION_CODE_CALL_PHONE)
}.setDuration(3000)
.show()
}
/**
* 使用者點選撥打電話按鈕,先進行申請許可權
*/
private fun requestPermmission(context: Context) {
// 判斷是否需要執行時申請許可權
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) {
// 判斷是否需要對使用者進行提醒,使用者點選過拒絕&&沒有勾選不再提醒時進行提示
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CALL_PHONE)) {
// 給用於予以許可權解釋, 對於已經拒絕過的情況,先提示申請理由,再進行申請
showPermissionRationale("需要開啟電話許可權直接進行撥打電話,方便您的操作")
} else {
// 無需說明理由的情況下,直接進行申請。如第一次使用該功能(第一次申請許可權),使用者拒絕許可權並勾選了不再提醒
// 將引導跳轉設定操作放在請求結果回撥中處理
requestPermissions(arrayOf(Manifest.permission.CALL_PHONE), REQUEST_PERMISSION_CODE_CALL_PHONE)
}
} else {
// 擁有許可權直接進行功能呼叫
callPhone()
}
}
/**
* 許可權申請回撥
*/
@TargetApi(Build.VERSION_CODES.M)
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
// 根據requestCode判斷是那個許可權請求的回撥
if (requestCode == REQUEST_PERMISSION_CODE_CALL_PHONE) {
// 判斷使用者是否同意了請求
if (grantResults.size == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
callPhone()
} else {
// 未同意的情況
if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CALL_PHONE)) {
// 給用於予以許可權解釋, 對於已經拒絕過的情況,先提示申請理由,再進行申請
showPermissionRationale("需要開啟電話許可權直接進行撥打電話,方便您的操作")
} else {
// 使用者勾選了不再提醒,引導使用者進入設定介面進行開啟許可權
Snackbar.make(view, "需要開啟許可權才能使用該功能,您也可以前往設定->應用。。。開啟許可權",
Snackbar.LENGTH_INDEFINITE)
.setAction("確定") {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:$packageName")
startActivityForResult(intent,REQUEST_SETTINGS_CODE)
}
.show()
}
}
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_SETTINGS_CODE) {
Toast.makeText(this, "再次判斷是否同意了許可權,再進行自定義處理",
Toast.LENGTH_LONG).show()
}
}
}
複製程式碼
EasyPermissions使用及存在問題
上面介紹了單一許可權的申請,簡單的一個申請程式碼量其實已經不小了,對於某一個功能需要多個許可權更是需要複雜的邏輯判斷。google給我們推出了一個許可權申請的開源框架,下面圍繞著EasyPermission進行說明。
使用方法不介紹了,看一下demo就可以了,網上也有很多的文章這裡引用前人的總結。
我在使用的時候發現了有這樣一個問題,使用版本是pub.devrel:easypermissions:2.0.0
,在demo中使用多個許可權申請的時候同意一個,拒絕一個,沒有勾選不在提醒。這個時候,第二次申請許可權,在提示使用者使用許可權時候點選取消,會彈出跳轉到設定手動開啟的彈框。這個做法是不合適的,使用者並沒有點選不在提醒,可以在app內部引導使用者授權,肯定是哪裡的邏輯有問題。先貼圖
從最後的設定介面也可以看出,app並沒有拒絕某些許可權,還處於詢問狀態。
為了瞭解為什麼出現這樣的異常情況,那就跟我一起read the XXXX source code吧。
先說結論,在提示使用者點選取消的時候會進入下面方法
@Override
public void onPermissionsDenied(int requestCode, @NonNull List<String> perms) {
Log.d(TAG, "onPermissionsDenied:" + requestCode + ":" + perms.size());
// (Optional) Check whether the user denied any permissions and checked "NEVER ASK AGAIN."
// This will display a dialog directing them to enable the permission in app settings.
if (EasyPermissions.somePermissionPermanentlyDenied(this, perms)) {
new AppSettingsDialog.Builder(this).build().show();
}
}
複製程式碼
在判斷EasyPermissions.somePermissionPermanentlyDenied()
的時候判斷出了問題,彈出了dialog(這裡的對話方塊使用Activity實現的)
EasyPermissions原始碼分析
這裡我會跟著demo使用的思路,對原始碼進行閱讀。建議下載原始碼,上面有連結
在點選兩個許可權的按鈕之後呼叫如下方法
@AfterPermissionGranted(RC_LOCATION_CONTACTS_PERM)
public void locationAndContactsTask() {
if (hasLocationAndContactsPermissions()) {
// 如果有許可權,toast
Toast.makeText(this, "TODO: Location and Contacts things", Toast.LENGTH_LONG).show();
} else {
// 沒有許可權,進行申請許可權,交由EasyPermission類管理
EasyPermissions.requestPermissions(
this,
getString(R.string.rationale_location_contacts),
RC_LOCATION_CONTACTS_PERM,
LOCATION_AND_CONTACTS);
}
}
複製程式碼
按照使用的思路梳理,先不管註解部分。跟進EasyPermissions.requestPermissions
/**
* 請求多個許可權,如果系統需要就彈出許可權說明
*
* @param host context
* @param rationale 想使用者說明為什麼需要這些許可權
* @param requestCode 請求碼用於onRequestPermissionsResult回撥中確定是哪一次申請
* @param perms 具體需要的許可權
*/
public static void requestPermissions(
@NonNull Activity host, @NonNull String rationale,
int requestCode, @Size(min = 1) @NonNull String... perms) {
requestPermissions(
new PermissionRequest.Builder(host, requestCode, perms)
.setRationale(rationale)
.build());
}
複製程式碼
很明顯,呼叫了內部的requestPermissions()
方法,繼續跟
public static void requestPermissions(
@NonNull Fragment host, @NonNull String rationale,
int requestCode, @Size(min = 1) @NonNull String... perms) {
requestPermissions(
new PermissionRequest.Builder(host, requestCode, perms)
.setRationale(rationale)
.build());
}
複製程式碼
構建者Builder模式建立了一個PermissionRequest.Builder物件,傳入真正的requestPermissions()
方法,跟吧
public static void requestPermissions(PermissionRequest request) {
// 在請求許可權之前檢查是否已經包含了這些許可權
if (hasPermissions(request.getHelper().getContext(), request.getPerms())) {
// 已經存在了許可權,給許可權狀態陣列賦值PERMISSION_GRANTED,並進入請求完成部分。不進行這條處理分支的分析,自己看一下吧
notifyAlreadyHasPermissions(
request.getHelper().getHost(), request.getRequestCode(), request.getPerms());
return;
}
// 通過helper類來輔助呼叫系統api申請許可權
request.getHelper().requestPermissions(
request.getRationale(),
request.getPositiveButtonText(),
request.getNegativeButtonText(),
request.getTheme(),
request.getRequestCode(),
request.getPerms());
}
複製程式碼
跟requestPermissions()
方法
public void requestPermissions(@NonNull String rationale,
@NonNull String positiveButton,
@NonNull String negativeButton,
@StyleRes int theme,
int requestCode,
@NonNull String... perms) {
// 這裡遍歷呼叫系統api ,shouldShowRequestPermissionRationale,是否需要提示使用者申請說明
if (shouldShowRationale(perms)) {
showRequestPermissionRationale(
rationale, positiveButton, negativeButton, theme, requestCode, perms);
} else {
// 抽象方法,其實就是在不同的子類裡呼叫系統api
// ActivityCompat.requestPermissions(getHost(), perms, requestCode);方法
directRequestPermissions(requestCode, perms);
}
}
複製程式碼
到這裡,第一次的請求流程已經結束,與使用者互動,按我們上面gif的演示,對一個許可權允許,一個許可權拒絕。
這時候回到Activity中的回撥onRequestPermissionsResult
方法中
@Override
public void onRequestPermissionsResult(int requestCode,
@NonNull String[] permissions,
@NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
// 交給EasyPermissions類進行處理事件
EasyPermissions.onRequestPermissionsResult(requestCode, permissions, grantResults, this);
}
複製程式碼
跟進去!
public static void onRequestPermissionsResult(int requestCode,
@NonNull String[] permissions,
@NonNull int[] grantResults,
@NonNull Object... receivers) {
// 建立兩個list用於收集請求許可權的結果
List<String> granted = new ArrayList<>();
List<String> denied = new ArrayList<>();
for (int i = 0; i < permissions.length; i++) {
String perm = permissions[i];
if (grantResults[i] == PackageManager.PERMISSION_GRANTED) {
granted.add(perm);
} else {
denied.add(perm);
}
}
// 遍歷
for (Object object : receivers) {
// 如果有某個許可權被同意了,回撥到Activity中的onPermissionsGranted方法
if (!granted.isEmpty()) {
if (object instanceof PermissionCallbacks) {
((PermissionCallbacks) object).onPermissionsGranted(requestCode, granted);
}
}
// 如果有某個許可權被拒絕了,回撥到Activity中的onPermissionsDenied方法
if (!denied.isEmpty()) {
if (object instanceof PermissionCallbacks) {
((PermissionCallbacks) object).onPermissionsDenied(requestCode, denied);
}
}
// 如果請求的許可權都被同意了,進入我們被@AfterPermissionGranted註解的方法,這裡對註解的使用不進行詳細分析了。
if (!granted.isEmpty() && denied.isEmpty()) {
runAnnotatedMethods(object, requestCode);
}
}
}
複製程式碼
我們對許可權一個允許一個拒絕,所以會回撥onPermissionsGranted
和onPermissionsDenied
。在demo中的onPermissionsDenied
方法進行了處理
@Override
public void onPermissionsDenied(int requestCode, @NonNull List<String> perms) {
Log.d(TAG, "onPermissionsDenied:" + requestCode + ":" + perms.size());
// (Optional) Check whether the user denied any permissions and checked "NEVER ASK AGAIN."
// This will display a dialog directing them to enable the permission in app settings.
if (EasyPermissions.somePermissionPermanentlyDenied(this, perms)) {
new AppSettingsDialog.Builder(this).build().show();
}
}
複製程式碼
做了一個判斷,`EasyPermissions.somePermissionPermanentlyDenied,這裡回撥傳入的是一個list,我們來繼續分析。跟進去,一直跟!
public static boolean somePermissionPermanentlyDenied(@NonNull Activity host,
@NonNull List<String> deniedPermissions) {
return PermissionHelper.newInstance(host)
.somePermissionPermanentlyDenied(deniedPermissions);
}
複製程式碼
又進入了helper輔助類
public boolean somePermissionPermanentlyDenied(@NonNull List<String> perms) {
for (String deniedPermission : perms) {
if (permissionPermanentlyDenied(deniedPermission)) {
return true;
}
}
return false;
}
複製程式碼
迴圈遍歷了每一許可權。有一個是true就返回true。繼續跟!
public boolean permissionPermanentlyDenied(@NonNull String perms) {
// 返回了shouldShowRequestPermissionRationale的非值,就是系統API shouldShowRequestPermissionRationale的非值
return !shouldShowRequestPermissionRationale(perms);
}
複製程式碼
這裡並沒有過濾掉使用者已經同意的許可權,正常的互動不會進入new AppSettingsDialog.Builder(this).build().show();
,但是在Rationale彈框點選取消的時候會出問題,我們看一下關於許可權說明的rationale彈框的具體實現。
從demo申請許可權requestPermissions
方法中,呼叫的showRequestPermissionRationale
方法。在ActivityPermissionHelper
類中找到具體的實現
@Override
public void showRequestPermissionRationale(@NonNull String rationale,
@NonNull String positiveButton,
@NonNull String negativeButton,
@StyleRes int theme,
int requestCode,
@NonNull String... perms) {
FragmentManager fm = getHost().getFragmentManager();
// Check if fragment is already showing
Fragment fragment = fm.findFragmentByTag(RationaleDialogFragment.TAG);
if (fragment instanceof RationaleDialogFragment) {
Log.d(TAG, "Found existing fragment, not showing rationale.");
return;
}
// 建立了一個DialogFragment並顯示出來
RationaleDialogFragment
.newInstance(positiveButton, negativeButton, rationale, theme, requestCode, perms)
.showAllowingStateLoss(fm, RationaleDialogFragment.TAG);
}
複製程式碼
檢視RationaleDialogFragment
類,裡面程式碼不多,找到取消按鈕的實現。
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
// Rationale dialog should not be cancelable
setCancelable(false);
// 建立listener
RationaleDialogConfig config = new RationaleDialogConfig(getArguments());
RationaleDialogClickListener clickListener =
new RationaleDialogClickListener(this, config, mPermissionCallbacks, mRationaleCallbacks);
// 將listener傳入dialog中
return config.createFrameworkDialog(getActivity(), clickListener);
}
複製程式碼
檢視RationaleDialogClickListener
程式碼
@Override
public void onClick(DialogInterface dialog, int which) {
int requestCode = mConfig.requestCode;
if (which == Dialog.BUTTON_POSITIVE) { // 點選確定
String[] permissions = mConfig.permissions;
if (mRationaleCallbacks != null) {
mRationaleCallbacks.onRationaleAccepted(requestCode);
}
if (mHost instanceof Fragment) {
PermissionHelper.newInstance((Fragment) mHost).directRequestPermissions(requestCode, permissions);
} else if (mHost instanceof Activity) {
PermissionHelper.newInstance((Activity) mHost).directRequestPermissions(requestCode, permissions);
} else {
throw new RuntimeException("Host must be an Activity or Fragment!");
}
} else { // 點選取消
if (mRationaleCallbacks != null) {
mRationaleCallbacks.onRationaleDenied(requestCode);
}
// 呼叫下面方法
notifyPermissionDenied();
}
}
private void notifyPermissionDenied() {
if (mCallbacks != null) {
// 這裡回撥了Activity的onPermissionsDenied()方法,傳入兩個許可權
// 不同與使用者點選拒絕,使用者點選拒絕的時候,此處僅傳遞了一個拒絕的許可權,而這裡將用於已經允許的許可權和拒絕的許可權都傳入到裡面去。
mCallbacks.onPermissionsDenied(mConfig.requestCode, Arrays.asList(mConfig.permissions));
}
}
複製程式碼
接下來在執行somePermissionPermanentlyDenied()
判斷的時候,已經被允許的許可權在內部呼叫系統APIshouldShowRequestPermissionRationale
是否需要說明的時候返回的是false,在easyPermission中被認為是使用者勾選了不再提醒,所以導致出了問題。
至此,問題找到了,我們該如何處理呢?我們可以在onPermissionsDenied
方法先對已經擁有的許可權做一個篩選,將沒有通過使用者同意的許可權塞入somePermissionPermanentlyDenied
中,即可解決問題。當然,也可以改內部程式碼,重新編譯打包放到工程內。
EasyPermissions中的巧妙設計
既然程式碼都分析到這裡了,就繼續說說EasyPermissions中設計比較巧妙的點吧。如果細心看程式碼,會發現在工程裡rationale的彈框是用DialogFragment
實現的,而AppsettingDialog是在AppSettingsDialogHolderActivity
(一個空的Activity)上通過AppSettingsDialog
類中內部完成的AlertDialog的建立和顯示(AppSettingsDialog並不是一個dialog,只是一個輔助類)。
public class RationaleDialogFragmentCompat extends AppCompatDialogFragment {
...
}
複製程式碼
public class AppSettingsDialog implements Parcelable {
...
}
複製程式碼
public class AppSettingsDialogHolderActivity extends AppCompatActivity implements DialogInterface.OnClickListener {
...
}
複製程式碼
真正的去往設定的dialog是在AppSettingsDialog中建立的
AlertDialog showDialog(DialogInterface.OnClickListener positiveListener,
DialogInterface.OnClickListener negativeListener) {
AlertDialog.Builder builder;
if (mThemeResId > 0) {
builder = new AlertDialog.Builder(mContext, mThemeResId);
} else {
builder = new AlertDialog.Builder(mContext);
}
return builder
.setCancelable(false)
.setTitle(mTitle)
.setMessage(mRationale)
.setPositiveButton(mPositiveButtonText, positiveListener)
.setNegativeButton(mNegativeButtonText, negativeListener)
.show();
}
複製程式碼
為什麼要建立一個單獨的Activity來承載dialog呢?我的理解是這樣來處理,可以統一了我們自己工程中onActivityResult
方法,在跳轉設定的dialog上無論點選確定和取消,都會涉及到Activity的跳轉,都會回撥到onActivityResult ()
方法,執行統一的使用者給予許可權或拒絕許可權的處理。
總結
參考google samples,個人認為最友好的申請許可權流程應該是
- 使用者點選功能按鈕(如掃一掃),直接申請需要許可權(攝像頭許可權),呼叫系統彈框進行與使用者互動。
- 使用者拒絕,那麼彈框提示使用者我們需要許可權的理由,使用者點選同意,再次呼叫系統彈框申請許可權。
- 使用者再次拒絕(已經點選了不再提醒),提示使用者使用該功能必須獲取許可權,引導使用者去設定介面手動開啟。