在Android上優雅的申請許可權
對於許可權,每個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
ofollow,noindex">github.com/googlesampl…
以及動態申請許可權框架easypermissions
來對互動上做一個總結。
首先說明,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 hostcontext * @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()
判斷的時候,已經被允許的許可權在內部呼叫系統API shouldShowRequestPermissionRationale
是否需要說明的時候返回的是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,個人認為最友好的申請許可權流程應該是
- 使用者點選功能按鈕(如掃一掃),直接申請需要許可權(攝像頭許可權),呼叫系統彈框進行與使用者互動。
- 使用者拒絕,那麼彈框提示使用者我們需要許可權的理由,使用者點選同意,再次呼叫系統彈框申請許可權。
- 使用者再次拒絕(已經點選了不再提醒),提示使用者使用該功能必須獲取許可權,引導使用者去設定介面手動開啟。