优雅地申请运行时权限

前言

从android 6.0(API 级别 23)开始,android引入了运行时权限,用户开始在应用运行时向其授予权限,而不是在应用安装时向其授予权限,如果应用的某项功能需要使用到受运行时权限保护的资源(例如相机、位置、麦克风等),但在运行该功能前没有动态地申请相应的权限,那么在调用该功能时就会抛出SecurityException异常, android 6.0已经推出了很多年了,相信大家对于运行时权限的申请过程已经非常的熟悉,但是android的运行时权限的申请过程一直都是非常的繁琐的,主要有两步:

1、在需要申请权限的地方检查该权限是否被同意,如果同意了就直接执行,如果不同意就动态申请权限;

2、重写Activity或Fragment的onRequestPermissionsResult方法,在里面根据grantResults数组判断权限是否被同意,如果同意就直接执行,如果不同意就要进行相应的提示,如果用户勾选了“don't ask again”,还要引导用户去“Settings”界面打开权限,这时还要重写onActivityResult判断权限是否被同意.

就是这简单的两步,却夹杂了大量的if else语句,不但不优雅,而且每次都要写重复的样板代码,可能android的开发者也意识到了这一点,在最新androidx中引入了activity result api,通过activity result api你可以不需要自己管理requestCode,只需要提供需要请求的权限和处理结果的回调就行,让权限请求简单了一点,但是如果在权限请求的过程中,用户点击拒绝或者拒绝并不再询问,那么我们还是需要自己处理这些情况,但是这些处理流程都是一样的,完全可以封装起来,所以我就把以前的一个使用无界面fragment代理权限申请的库重构了一下,让权限的请求流程更加简单,本文会先复习一下权限的分类,然后再介绍PermissionHelper申请权限时的设计,最后记录一下从android 6.0后随着系统的迭代跟权限申请相关的重要行为变更。

权限的分类

android中所有的预定义权限(不包括厂商自定义的)都可以在Manifest.permission这个静态类中找到定义,android把权限分为四类:普通权限、签名权限、危险权限和特殊权限,每一种类型的权限都分配一个对应的Protection Level,分别为:normal、signature、dangerous和appop,下面简单介绍一下这四种类型的权限

1、普通权限

普通权限也叫正常权限,Protection Level为normal,它不需要动态申请,你只需要在AndroidManifest.xml中静态地声明,然后系统在应用安装时就会自动的授予该应用相应的权限,当应用获得授权时,它就可以访问应用沙盒外受该普通权限保护地数据或操作,这些数据或操作不会泄漏或篡改用户的隐私,对用户或其他应用几乎没有风险。

2、签名权限

这类权限我们用得比较少,它只对拥有相同签名的应用开放,Protection Level为signature,它也不需要动态申请,例如应用A在AndroidManifest.xml中自定义了一个permission且在权限标签中加入android:protectionLevel=”signature”,表示应用A声明了一个签名权限,那么应用B想要访问应用A受该权限保护的数据时,必须要在AndroidManifest.xml中声明该权限,同时要用与应用A相同的签名打包,这样系统在应用B安装时才会自动地授予应用B该权限,应用B在获得授权后就可以访问该权限控制的数据,其他应用即使知道这个权限,也在AndroidManifest.xml中声明了该权限,但由于应用签名不同,安装时系统不会授予它该权限,这样其他应用就无法访问受该权限保护的数据。

还有一些签名权限不会供第三方应用程序使用,只会供系统预装应用使用,这种签名权限的Protection Level为signature和privileged。

3、危险权限

危险权限也叫运行时权限,Protection Level为dangerous,跟普通权限相反,一旦应用获取了该类权限,用户的隐私数据就会面临被泄露或篡改的风险,所以如果你想使用该权限保护的数据或操作,就必须在AndroidManifest.xml中静态地声明需要用到的危险权限,并在访问这些数据或操作前动态的申请权限,系统就会弹出一个权限请求弹窗征求用户的同意,除非用户同意该权限,否则你不能使用该权限保护的数据或操作。

所有的危险权限都有对应的权限组,android预定义了11个权限组(根据android 11总结),这11个权限组中包含了30个危险权限和几个普通权限,当我们动态的申请某个危险权限时,都是按权限组申请的,当用户一旦同意授权该危险权限,那么该权限所对应的权限组中的其他在AndroidManifest.xml中注册的权限也会同时被授权,android预定义的11个权限组包含的危险权限如下:

Permission Group Dangerous Permissions
CALENDAR (日历) READ_CALENDAR
WRITE_CALENDAR
CALL_LOG (通话记录,Added in android 29) READ_CALL_LOG
WRITE_CALL_LOG
PROCESS_OUTGOING_CALLS
CAMERA (相机) CAMERA
CONTACTS (通讯录) READ_CONTACTS
WRITE_CONTACTS
GET_ACCOUNTS
LOCATION (位置信息) ACCESS_COARSE_LOCATION
ACCESS_FINE_LOCATION
ACCESS_BACKGROUND_LOCATION (Added in android 10)
MICROPHONE (麦克风) RECORD_AUDIO
PHONE (电话) READ_PHONE_NUMBERS
READ_PHONE_STATE
CALL_PHONE
ANSWER_PHONE_CALLS
ADD_VOICEMAIL
USE_SIP
ACCEPT_HANDOVER (Added in android 9)
SENSORS (身体传感器) BODY_SENSORS
SMS (短信) READ_SMS
RECEIVE_WAP_PUSH
RECEIVE_SMS
RECEIVE_MMS
SEND_SMS
STORAGE (存储空间) READ_EXTERNAL_STORAGE
WRITE_EXTERNAL_STORAGE
ACCESS_MEDIA_LOCATION (Added in android 10)
ACTIVITY_RECOGNITION (身体活动,Added in android 10) ACTIVITY_RECOGNITION (Added in android 10)

4、特殊权限

特殊权限用于保护一些特定的应用程序操作,Protection Level为appop,使用前也需要在AndroidManifest.xml中静态地声明,也需要动态的申请,但是它不同于危险权限的申请,危险权限的申请会弹出一个对话框询问你是否同意,而特殊权限的申请需要跳转到指定的设置界面,让你手动点击toggle按钮确认是否同意,截止到android 11,我了解到的常用的5个特殊权限为:

  • SYSTEM_ALERT_WINDOW:允许应用在其他应用的顶部绘制悬浮窗,当你创建的悬浮窗是TYPE_APPLICATION_OVERLAY类型时需要申请这个权限;
  • WRITE_SETTINGS:允许应用修改系统设置,当你需要修改系统参数Settings.System时需要申请该权限,例如修改系统屏幕亮度等;
  • REQUEST_INSTALL_PACKAGES: 允许应用安装未知来源应用,android 8.0以后当你在应用中安装第三方应用时需要申请这个权限,否则不会跳转到安装界面;
  • PACKAGE_USAGE_STATS:允许应用收集其他应用的使用信息,当你使用UsageStatsManager相关Api获取其他应用的信息时需要申请这个权限;
  • MANAGE_EXTERNAL_STORAGE(Added in android 11):允许应用访问作用域存储(scoped storage)中的外部存储,android 11以后强制新安装的应用使用作用域存储,但是对于文件管理器这一类的应用它们需要管理整个SD卡上的文件,所以针对这些特殊应用可以申请这个权限来获得对整个SD卡的读写权限,当应用授予这个权限后,它就可以访问文件的真实路径,注意这个权限是很危险的,声明这个权限上架应用时可能需要进行审核.

除了特殊权限,LOCATION权限组中的位置权限也有点特殊,需要注意一下,位置信息的获取不仅依赖位置权限的动态申请还依赖系统定位开关,如果你没有打开定位开关就申请了位置权限,那么就算用户同意授权位置权限,应用通过Location相关Api也无法获取到位置信息,所以申请位置权限前,最好先通过LocationManager#isProviderEnabled方法判断是否打开定位开关后再进行位置权限的申请,如果没有打开定位开关需要先跳转到设置界面打开定位开关,伪代码如下:

val locationManager = this.getSystemService(Context.LOCATION_SERVICE) as LocationManager
if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) or locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
    //请求位置权限
} else {
    //跳转到开启定位的地方
    Toast.makeText(this, "检测到未开启定位服务,请开启", Toast.LENGTH_SHORT).show()
    val intent = Intent().apply {
        action = Settings.ACTION_LOCATION_SOURCE_SETTINGS
    }
    startActivityForResult(intent, REQUEST_CODE_LOCATION_PROVIDER)
}

当然,上面危险权限和特殊权限的判断与申请,PermissionHelper都已经替你做好了封装,你只需要像平常一样在AndroidManifest.xml中静态地声明权限,然后在代码中动态地申请就行,下面我把危险权限和特殊权限都统称为动态权限,因为它们都是需要动态申请的。

动态权限申请设计

动态权限的申请依据不同的android版本和应用targetSdkVersion有着不同的行为,主要有两种处理,如下:

  • android版本 <= 5.1 或者 应用的targetSdkVersion <= 22:当用户同意安装应用时,系统会要求用户授权应用声明的所有权限,包括动态权限,如果用户不同意授权,只能拒绝安装应用,如果用户同意全部授权,他们撤销权限的唯一方式就是卸载应用;
  • android版本 >= 6.0 且 应用的targetSdkVersion >= 23:当用户同意安装应用时,系统不再强制用户必须授权动态权限,系统只会授权应用除动态权限之外的普通权限,而动态权限需要应用使用到相关功能时才动态申请,当申请动态权限时,用户可以选择授权或拒绝每项权限,即使用户同意授权权限,用户也可以随时进入应用的“Settings”中调整应用的动态权限授权,所以你每次使用到该权限的功能时,都要动态申请,因为用户有可能在“Settings”界面中把它再次关闭掉.

在android版本 <= 5.1 或者 应用的targetSdkVersion <= 22时,系统使用的是AppOps来进行权限管理,这是android在4.4推出的一套应用程序操作权限管理,AppOps所管理的是所有可能涉及用户隐私和安全的操作,例如access notification、keep weak lock、display toast 等等,而运行时权限管理是android 6.0才出现,是基于AppOps的实现,进一步做了动态请求封装和明确的规范,同时当targetSdkVersion <= 22的应用运行在 >= 6.0的android系统上时,动态权限可以在“Settings”界面中关闭,应用运行过程中使用到相关功能时就会由于没有权限而出现崩溃,这时只能使用AppOps的 checkOp方法来检测对应的权限是否已经授权,没有权限就跳转到“Settings”界面,考虑到目前android 6.0已经推出了很久,应用商店也不允许targetSdkVersion < 23的应用上架,所以为了减少框架的复杂度,动态权限申请设计就没有考虑兼容AppOps的权限管理操作,所以当你使用PermissionHelper时应用的targetSdkVersion要 >= 23

PermissionHelper支持危险权限和特殊权限的申请,只需要一行代码就可以发起权限请求,具有生命周期感应能力,只在界面可见时才发起请求和回调结果,同时当系统配置更改例如屏幕旋转后能够恢复之前权限申请流程,不会中断权限申请流程,灵活性高,可以设置请求前、拒绝后回调,在回调发生时暂停权限申请流程,然后根据用户意愿再决定是否继续权限申请流程,整个申请过程如图:

PermissionHelper可以通过设置回调在权限申请开始前和权限被拒绝后把要请求的权限和被拒绝的权限回调出去,在回调中你可以通过弹窗向用户解释要申请的权限对应用的必要性,引导用户继续授权或再次授权,PermissionHelper不定制弹窗UI,弹窗的UI由开发者自定义,开发者只需要在用户同意或拒绝后调用回调中的Process实例的相应方法就能让被暂停的权限申请流程恢复,然后在最终的结果回调中处理结果就行,整个过程都是链式的,关于向用户解释权限申请原因的弹窗,弹窗内容建议包含下面的3点:

1、包含需要授权的权限列表的描述;

2、包含确认按钮,用户可以点击确认按钮再次授权或跳转到”Settings“;

3、包含取消按钮,用户可以点击取消按钮放弃授权.

如果用户不授权这个权限,就会导致应用无法继续运行下去,可以考虑取消第3步的取消按钮,即无法取消这个弹窗,一定要用户再次授权或跳转到”Settings“去授权。

PermissionHelper整个框架的设计参考了okhttp的拦截器模式,通过责任链模式的形式把危险权限申请、特殊权限申请、申请前处理和申请后处理划分为一个个节点,然后通过Chain串联起各个节点,每个节点只负责对应的内容,如下:

val originalRequest = Request()    
val interceptors = listOf(
    StartRequestNode(),
    RequestLocationNode(),
    RequestNormalNode(),
    RequestSpecialNode(),
    PostRequestNode(),
    FinishRequestNode()
)
DefaultChain(originalRequest, interceptors).process(originalRequest)

通过这样的形式PermissionHelper就可以很灵活的控制权限申请流程,对于生命周期感应能力的实现PermissionHelper使用了Lifecycle+LiveData组件,这两个都是官方支持的用于实现需要响应生命周期感应的操作,可以编写更轻量级和更易于维护的代码,避免界面销毁后的内存泄漏,对于系统配置更改后的数据恢复则使用到了ViewModel组件,这是官方支持的用于保存需要在配置更改后恢复的数据,例如一些UI相关的数据,通过这三件套 + 责任链模式实现了一个简单易用的权限申请框架,更多详细使用和实现细节可以查看代码仓库

权限申请相关变更

自android 6.0推出动态权限申请之后,有一些申请行为也随着系统的迭代发生变化,目的都是更好的保护用户的隐私权,使得权限申请对用户感知:

android 8.0以后并且应用的targetSdkVersion >= 28时,应用申请某个危险权限授权,用户同意后,系统不再错误地把该危险权限对应的权限组中的其他在AndroidManifest.xml中注册的权限一并授予给应用,系统只会授予应用明确请求的权限,然而,一旦用户应用同意授权某个危险权限,则后续对该危险权限的权限组中的其他权限请求都会被自动批准,而不会提示用户,例如某个应用在AndroidManifest.xml中注册READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限,应用申请READ_EXTERNAL_STORAGE权限并且用户同意,在android 8.0之前,系统在用户同意后还会一并授予WRITE_EXTERNAL_STORAGE权限,因为它和READ_EXTERNAL_STORAGE权限是同一个权限组并且也在AndroidManifest.xml中注册,但在android 8.0之后并且应用的targetSdkVersion >= 28,系统在用户同意后只会授予READ_EXTERNAL_STORAGE权限,但是如果后来应用又申请WRITE_EXTERNAL_STORAGE权限,系统会立即授予该权限,而不会提示用户,换句话说,如果只申请了外部存储空间读取权限,在低版本下(android < 8.0)对外部存储空间使用写入操作是没有问题的,但是在高版本(android >= 8.0 && targetSdkVersion >= 28)下是会出现问题的,解决方案是将两个读和写的权限一起申请。

android 9.0增加了CALL_LOG(通话记录)权限组,并把READ_CALL_LOG、WRITE_CALL_LOG]、PROCESS_OUTGOING_CALLS权限从PHONE(电话)权限组移动到了CALL_LOG权限组,CALL_LOG权限组使得用户能够更好地控制需要访问电话通话记录敏感信息的应用程序,例如读取通话记录和识别电话号码。

android 10引入了很多隐私变更,新增了ACTIVITY_RECOGNITION(身体活动)权限和权限组,允许应用检测用户的步数或分类用户的身体活动如步行、骑自行车等;同时android 10引入了作用域存储,当应用启用作用域存储时,WRITE_EXTERNAL_STORAGE权限会失效,应用对WRITE_EXTERNAL_STORAGE权限的申请不会对应用的存储访问权限产生任何影响,并且WRITE_EXTERNAL_STORAGE会在未来被废弃,因为作用域存储的目的就是不让应用随意的修改应用沙盒外的外部存储;同时新增了ACCESS_BACKGROUND_LOCATION权限,归属于LOCATION权限组,用于后台运行的应用访问用户定位时申请,与ACCESS_FINE_LOCATION和ACCESS_COARSE_LOCATION这些前台定位权限区分开,当你的应用targetSdkVersion >= 29并且运行在android 10以上时,应用在后台访问定位时需要动态的申请后台定位权限,当你把后台定位权限和前台定位权限一起申请时,弹窗授权框会有2个允许选项:始终允许仅在应用使用过程中允许,点击始终允许表示同时授权后台定位权限和前台定位权限,点击仅在应用使用过程中允许表示仅授权前台定位权限,然后下次再次申请时只会单独申请后台定位权限,并且也会有2个允许选项,并且要点击始终允许才会让后台定位权限申请通过,当你的应用targetSdkVersion < 29运行在android 10以上时,应用在申请前台定位权限时系统会把后台定位权限一并授予给应用;android 10还新增了ACCESS_MEDIA_LOCATION权限,归属于STORAGE (存储空间) 权限组,android 10以后,因为隐私问题,默认不再提供图片的地理位置信息,要获取该信息需要向用户申请ACCESS_MEDIA_LOCATION权限,并使用MediaStore.setRequireOriginal()接口更新文件Uri。

android 11也引入了很多隐私变更,android 11强制新安装的应用(targetSdkVersion >= 30)启用作用域存储,新增MANAGE_EXTERNAL_STORAGE用于代替WRITE_EXTERNAL_STORAGE权限,提供给手机管家、文件管理器这类需要管理整个SD卡上的文件的应用申请;android 11中当用户开启“安装未知来源应用”权限后返回应用,应用会被杀死重启,该行为与强制分区存储有关;从android 11后,如果应用对某个权限连续点击多次拒绝,那么下一次请求该权限时系统会直接拒绝连授权弹窗都不会弹出,该行为等同于android 11之前勾选了don‘t ask again;android 11后还新增了一次性权限(One-time permissions)和权限自动重置功能(Permissions auto-reset),这些变更只要你正确的进行运行时权限请求就不需要做额外适配;同时android 11后当targetSdkVersion < 30的应用把后台定位权限和前台定位权限一起申请时,弹窗授权框的允许选项中不再会显示始终允许选项,只有本次允许仅在应用使用过程中允许,也就说点击允许时只会授予你前台定位权限不再默认授予你后台定位权限,而android 11后targetSdkVersion >= 30的应用的ACCESS_BACKGROUND_LOCATION权限需要独立申请,不能与前台权限一起申请,如果与前台权限一起申请,系统会直接拒绝连授权弹窗都不会弹出,系统推荐增量请求权限,这样对用户更友好,同时用户必须先同意前台权限后才能进入后台定位权限的申请。

可以看到从android 10引入ACCESS_BACKGROUND_LOCATION权限以来,后台定位权限的申请一直都非常特殊,它在android 10可以和前台定位权限一起申请,而在android 11又不可以一起申请还有先后申请顺序,针对这种特殊情况,申请后台定位权限时要做到:

  • 1、先请求前台定位权限,再请求后台定位权限;
  • 2、单独请求后台定位权限,不要与其他权限一同请求.

上面这些PermissionHelper都已经做好了处理,申请时只需要把后台定位权限和前台定位权限一起传进去就行。

结语

本文主要让让大家对权限的申请流程有进一步的认识,然后可以通过对动态权限的封装,将检测动态权限,请求动态权限,权限设置跳转,监听权限设置结果等处理和业务功能隔离开来,业务以后可以非常快速的接入动态权限支持,提高开发效率。

以上就是本文的全部内容!

参考资料:

Request app permissions

安卓系统权限,你真的了解吗?

Android 6.0 运行权限解析

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容