简介
对于权限, 每个 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
以及动态申请权限框架 easypermissions
https://github.com/googlesamples/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 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, 个人认为最友好的申请权限流程应该是
用户点击功能按钮 (如扫一扫), 直接申请需要权限 (摄像头权限), 调用系统弹框进行与用户交互.
用户拒绝, 那么弹框提示用户我们需要权限的理由, 用户点击同意, 再次调用系统弹框申请权限.
用户再次拒绝 (已经点击了不再提醒), 提示用户使用该功能必须获取权限, 引导用户去设置界面手动开启.
来源: https://juejin.im/post/5bf4b776518825170d1ab40b