在Android上優雅的申請許可權

aTaller發表於2018-11-21

簡介

對於許可權,每個android開發者應該很熟悉了,對於targetSDK大於23的時候需要對某些敏感許可權進行動態申請,比如獲取通訊錄許可權、相機許可權、定位許可權等。
在android 6.0中也同時新增了許可權組的概念,若使用者同意組內的某一個許可權,那麼系統預設app可以使用組內的所有許可權,無需再次申請。
這裡貼一張許可權組的圖片:

android許可權組

申請許可權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就可以了,網上也有很多的文章這裡引用前人的總結。

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

相關文章