在Android上優雅的申請權限

簡介

對於權限,每一個android開發者應該很熟悉了,對於targetSDK大於23的時候須要對某些敏感權限進行動態申請,好比獲取通信錄權限、相機權限、定位權限等。
在android 6.0中也同時添加了權限組的概念,若用戶贊成組內的某一個權限,那麼系統默認app可使用組內的全部權限,無需再次申請。
這裏貼一張權限組的圖片:
java

android權限組

申請權限API

先介紹一下android 6.0以上動態申請權限的流程,申請權限,用戶能夠點擊拒絕,再次申請的時候能夠選擇再也不提醒。
下面說介紹一下運行時申請權限須要用到的API,代碼示例使用kotlin實現
android

  • 在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。
git

單一權限申請交互流程

咱們作移動端須要直接與用戶交互,須要多考慮如何根用戶交互才能達到最好的體驗。下面我結合google samples中動態申請權限示例android-RuntimePermissions
github.com/googlesampl…
以及動態申請權限框架easypermissions
github.com/googlesampl…
來對交互上作一個總結。
github

首先說明,Android不建議App直接進行撥打電話這種敏感操做,建議跳轉至撥號界面,並將電話號碼傳入撥號界面中,這裏僅做參考案例,下面每中狀況都是用戶從用戶第一次申請權限開始(權限詢問狀態)
api

  • 直接容許權限。
    數組

    直接容許權限

  • 拒絕以後再次申請容許
    微信

    拒絕以後再次申請容許

  • 再也不提醒以後引導至設置界面面
    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就能夠了,網上也有不少的文章這裏引用前人的總結。ide

blog.csdn.net/hexingen/ar…

我在使用的時候發現了有這樣一個問題,使用版本是pub.devrel:easypermissions:2.0.0,在demo中使用多個權限申請的時候贊成一個,拒絕一個,沒有勾選不在提醒。這個時候,第二次申請權限,在提示用戶使用權限時候點擊取消,會彈出跳轉到設置手動開啓的彈框。這個作法是不合適的,用戶並無點擊不在提醒,能夠在app內部引導用戶受權,確定是哪裏的邏輯有問題。先貼圖

easypermissions中不合理的交互.gif

從最後的設置界面也能夠看出,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);
            }
        }
    }
複製代碼

咱們對權限一個容許一個拒絕,因此會回調onPermissionsGrantedonPermissionsDenied。在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,我的認爲最友好的申請權限流程應該是

  1. 用戶點擊功能按鈕(如掃一掃),直接申請須要權限(攝像頭權限),調用系統彈框進行與用戶交互。
  2. 用戶拒絕,那麼彈框提示用戶咱們須要權限的理由,用戶點擊贊成,再次調用系統彈框申請權限。
  3. 用戶再次拒絕(已經點擊了再也不提醒),提示用戶使用該功能必須獲取權限,引導用戶去設置界面手動開啓。

關注微信公衆號,最新技術乾貨實時推送

image
相關文章
相關標籤/搜索