Android动态权限详解
1. 什么是动态权限
去年底,上级主管部门为加强国内Android应用隐私管理,出台了一系列规定,我们的App也做了相应的修改。主要一条修改为,隐私提示与权限获取顺序。修改测试过程中,发觉部分同学对Android权限相关知识和历史并不了解,就此疫情期间忙里偷闲,整理些东西供参阅。
首先,从一张图开始此文。
时间回到2013年,苹果公司发布IOS7系统。其中一项令开发者头疼的修改点:隐私中增加相册、录音等权限,App如需使用相应权限,需要申请并由用户同意(IOS7以前,可以直接访问相册)。
针对此点,很多App在首次启动时一通弹窗,申请各式各样的权限。后来苹果为改善用户体验,在App Store审核时要求App必须在使用前一刻才能申请权限,有效改善了此类问题。比如一款直播App,当你启动App时并不需要相机、录音权限,等到你开播时才需要申请这两个权限。这一场景,其实就类似今天要提到的Android动态授权。
谷歌于2015年推出Android 6.0 Marshmallow,其中一个主要特点便是加入了危险权限管理。这里的“危险权限管理”就带来了“运行时权限”这个新特性。
“危险权限管理”即在进行一些涉及到用户隐私的操作时,需要获取用户的授权才能使用。如通讯录、短信、相机、定位等隐私权限。获取用户权限,谷歌提倡在应用运行时向其授权,简称,运行时权限(也被叫做“动态权限/动态授权”,后文称“动态权限”)。
那,在这之前,Android权限管理是怎样的呢?自己杜撰了下国内Android权限管理经历的大概四个阶段。
Android权限管理简史
第一阶段:没遮拦
早期Android系统(Android 6.0以前),在安装App前,会罗列出App申请的所有权限。如果继续安装,视为用户同意赋予App所需权限。
例如:sony L36h Android 4.2.2系统。在尝试安装App时,弹窗罗列了App申请的全部权限。只能对所需权限进行查看,无法拒绝授权,可选择取消安装或继续安装。
这种方式,对于开发者极为友好,仅需在Manifest中配置App所需权限即可,代码就可以直接调用了。但是对于用户来说,这种方法存在极大的安全隐患。
例:获取手机IMEI,需要PHONE_STATE权限;访问网络,需要INIERNET权限。只许在Manifest文件中添加权限即可。
<!-- PHONE_STATE权限-->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<!-- 网络权限-->
<uses-permission android:name="android.permission.INTERNET" />
第二阶段:第三方安全App
基于以上背景,为解决部分敏感权限被不合理使用,国内部分公司的安全类App,开始监控应用获取手机敏感权限并做出提示。如360手机卫士、腾讯手机管家等产品,当监测到有App尝试使用短信权限、定位等敏感权限,会告知用户,并可以拒绝赋予权限。刚开始,还比较顺利。但随着手机厂商逐渐开始修改ROM,第三方安全App的兼容、性能问题逐步爆发。
例:HTC T328 Android 4.0.2系统。浏览器扫码功能触发相机调用时,360手机卫士会弹出权限提示窗,用户可以允许或拒绝授权。注意,此窗由第三方安全软件弹出,非系统级弹窗,跟后面要说的两种弹窗有所区别。
第三阶段:手机厂商介入
随着时间的推移,手机厂商开始发力,纷纷将第三方软件的权限提示功能直接做入ROM。
例:小米4,基于Android 4.4.4的MIUI7;oppo R9,基于Android 5.1的ColorOS 3.0,浏览器扫码功能触发相机调用时,会弹出权限提示窗。此窗,由ROM也就是系统自己弹出,为系统级权限弹窗。
第四阶段:谷歌升级权限管理
以上3个时期,App在申请权限时都不需做改变,只需配置Manifest。2015年推出的Android 6.0,加入了危险权限管理。因手机厂商对ROM的修改,部分6.0以上机器并不支持此项特性。
到了第四阶段,App需要在对权限代码进行修改后,才能正常使用对应权限。简单理解为3步:1、判断是否授权;2、如果未授权需申请权限,根据授权结果继续执行;3、已授权可以继续操作。
例:Pixel2,原生Android 10;华为mate8,基于Android 8.0的EMUI8。浏览器扫码功能触发相机调用时,会弹出权限提示窗。此窗,由App通知系统弹出,为系统级权限弹窗。
第三阶段与第四阶段,同为系统弹出授权弹窗。二者有什么区别吗?
首先,从UI上很难判断所弹授权窗为第三阶段或第四阶段。第三阶段弹的系统授权窗大都带有一个倒计时自动拒绝逻辑;第四阶段弹的系统授权窗基本不带自动拒绝逻辑。此点可以粗略判断系统使用的哪种机制。
其次,从原理上。第三阶段的弹窗,为系统监测到App在使用危险权限行为自动弹出弹窗。第四阶段的弹窗,为App发觉自己没有权限,让系统弹出的弹窗。粗俗的理解,第三阶段,你去朋友家串门,到门口看到大门敞开就直接往里走,触发了红外线报警器,报警器通知了你朋友;第四阶段,你去朋友家串门,到门口发觉门关着,就按下门铃呼叫朋友给你开门。
目前,国内主要处于第三阶段(涵盖Android4.0~7.1)和第四阶段(涵盖Android6.0~10),此点将在后文用到。
如何应对动态权限特性
方案一:逃避
因为动态权限特性,仅从Android 6.0开始拥有,所以,可以简单粗暴的通过不提升targetSDK(targetSDK<23)的方式,便可不触发此特性。
如果不改变任何代码,直接将targetSDK提升到26,然后运行App,做同样操作时会发生异常甚至崩溃,崩溃举例如下:
产生这个崩溃的原因,是在Android 6.0及以上,未获取权限的情况下直接执行了需要权限的操作。那么如何解决呢,就涉及到了真正的修改方案。
方案二:实现动态权限
1. 在使用权限前,检测权限。
首先,我们需要判断自己是否拥有权限。判断时间点为执行需要权限的对应操作前。如我们在获取IMEI前,需要判断是否拥有PHONE_STATE权限。
我们可以调用ContextCompat.checkSelfPermission()方法检测授权状态,返回的结果为PackageManager中的两个常量:PERMISSION_GRANTED(已授权)和PERMISSION_DENIED(未授权)。
2. 已授权的情况下,执行你的原有操作。
当已授权时,就可以执行你原有的操作了。代码如下:
// 检测PHONE_STATE 如果已授权
if (ContextCompat.checkSelfPermission(this,Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) {
//做你想做的
}
那么如果未授权怎么办?
3. 未授权的情况下,申请权限。
如果App未获得授权,我们就需要向用户申请授权。可以调用requestPermissions()方法来请求授权。代码如下:
// 检测PHONE_STATE 如果未授权
if (ContextCompat.checkSelfPermission(this,Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
//申请权限
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_PHONE_STATE), PERMISSIONS_REQUEST_PHONE_STATE)
}
requestPermissions()中的第三个参数是一个int型请求码,方便回调处理。
调用申请授权方法后,ROM会调起一个系统级弹窗(如下图),这个dialog你无法定制。当用户点击同意后,系统会记录,下次再判断权限时就会返回已授权状态;当App卸载时,记录会被清除。
以上,就完成了最朴素版的授权逻辑。整体代码如下:
// 检测PHONE_STATE 如果未授权
if (ContextCompat.checkSelfPermission(this,Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
//申请权限
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_PHONE_STATE), PERMISSIONS_REQUEST_PHONE_STATE)
}else {
//如果已授权做你想做的
}
那么弹出申请弹窗之后呢?上面说道,弹出的dialog为系统的,我们无法在dialog中加代码,但当弹窗被用户点击后,会触发回调,我们在指定函数中处理回调即可。
4. 重写函数,处理授权弹窗的点击结果。
直接在Activity或Fragment中重写onRequestPermissionsResult()函数,来处理权限申请结果。requestPermissions()的第三个参数,将在这里被用到。代码如下:
// 处理授权弹窗回调
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
when(requestCode){
// 识别刚刚用到的请求码,根据请求码识别不同弹窗回调并处理
PERMISSIONS_REQUEST_PHONE_STATE ->{
// 如果用户点击“允许”
if (grantResults.size > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED){
Toast.makeText(this, "用户允许权限!",Toast.LENGTH_SHORT).show()
// 可以继续执行你原来想做的事情了
}else{
Toast.makeText(this, "用户拒绝权限!",Toast.LENGTH_SHORT).show()
// 用户拒绝了,你想咋办?
}
return;
}
// 可以识别其他请求码并处理
}
}
这样,就完成了授权流程。然后,为提升授权概率,对流程进行优化。
5. 优化授权流程,提高授权几率。
首先,系统授权窗我们无法定制,但是我们可以在这之前做个引导。在触发系统弹窗之前,弹出一个引导UI,来告知用户将要申请权限,并说明所需权限可带来哪些更好体验。尤其当你申请的权限看似与主要功能并无关系时,比如一个相机App如果需要申请定位权限的时候。
其次,谷歌官方还提供了个函数shouldShowRequestPermissionRationale(),这个函数可以用来判断,用户上次是否拒绝了且未选则不再询问。可以在授权前,通过此判断,来决定给用户展示首次授权引导或非首次授权引导。
最后,当用户还是选择了拒绝授权时,如果是必要权限(比如导航软件申请定位权限),我们可以通过处理授权回调,在用户点击拒绝时弹出引导,告知用户功能不可用,并引导用户重新授权或到设置中手动开启权限。
以上3部分大体流程如下:
综上,动态权限主要实现步骤
在AndroidManifest明确我们需要哪些权限。(非动态权限也需要此步) 在执行操作前检是否获得对应授权 -> checkSelfPermission()。 如果已授权可以继续操作;如果未授权,判断之前是否授权被拒 -> shouldShowRequestPermissionRationale() (非必须操作)
a) 判断如果没有被拒过,弹出首次授权引导。
b) 判断如果被据过,弹出非首次授权引导。引导后,申请权限-> requestPermissions()。 处理申请的结果信息-> 回调函数onRequestPermissionsResult()。
系统一共提供如下4个函数完成动态权限相关操作。
/**
* 检查指定的权限是否授权(Context对象调用)
*/
public static int checkSelfPermission (Context context,
String permission)
/**
* 在没有授权的情况下,有些时候可能需要提示给用户为什么需要改权限,就通过该函数来实现。
* 关于shouldShowRequestPermissionRationale的返回值问题,我们分三种情况
* 1. 第一次打开App时 -> false
* 2. 上次弹出权限点击了禁止(但没有勾选“下次不在询问”) -> true
* 3. 上次选择禁止并勾选:下次不在询问 -> false
*/
public static boolean shouldShowRequestPermissionRationale (Activity activity,
String permission)
/**
* 申请指定的权限(Activity或者Fragment对象调用)
* @param permissions 权限列表,可以同时申请多个权限
* @param requestCode 该次权限申请对应的requestCode。和 onRequestPermissionsResult()回调函数里面的requestCode对应
*/
public static void requestPermissions (Activity activity,
String[] permissions,
int requestCode)
/**
* 处理请求权限的响应,当用户对请求权限的dialog做出响应之后,系统会回调该函数(Activity或者Fragment中重写)
* @param requestCode 申请权限对应的requestCode
* @param permissions 权限列表
* @param grantResults 权限列表对应的返回值,判断permissions里面的每个权限是否申请成功
*/
public abstract void onRequestPermissionsResult (int requestCode,
String[] permissions,
int[] grantResults)
写到这里,动态授权实现demo部分均已完成,实际业务场景肯定比以上流程复杂的多。
系统版本兼容
动态权限为Android 6.0新特性,那低于6.0的系统,该如何写适配代码呢?
首先想到的,是判断系统版本,针对6.0以上使用动态权限代码,针对低版本,使用老代码。
fun test(){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
// 走动态授权
return
else
// 走非动态授权
return
}
其实,可以不必如此麻烦。对于低版本,可以不必单独写代码适配。在不支持动态授权的系统上,Manifest中申请过的权限,checkSelfPermission()方法,会直接返回PERMISSION_GRANTED。
另外,根据系统版本区分是否支持动态权限,实际是不靠谱的。前文有提到,部分手机厂商在ROM提升到Android 6.0以后,阉割了动态权限特性。目前没有找到准确的API判断当前系统是否支持动态权限。这会带来什么问题呢?
举一个前不久遇到的实例。App的某一功能,是对别人显示我所在城市(地理位置属于敏感数据),用户反馈关闭系统定位权限后,仍会显示他所在城市。我们需要考虑如何解决用户的问题,所以增加个需求,如果用户关闭了定位权限,则不获取城市。那么问题来了,怎么判断用户是否关闭了定位权限呢?为了避开不支持动态权限的ROM,需求只能退一步,6.0及以上系统做以上逻辑,6.0以下直接不获取地理位置。但是根据测试经验6.0以上系统仍不一定支持动态权限, 7.0及以上系统,绝大部分ROM支持动态权限。所以妥协决定7.0以下全部不获取,7.0以上调checkSelfPermission()判断是否授权,少数不支持动态权限的设备会误认为已授权,需要增加设置项关闭功能。(提升到Android8.0应该是绝对安全的,不过覆盖量太少)
以下为目前主流国内厂商对动态权限支持情况。(测试方法:在全新安装未进行过授权操作的情况下,使用checkSelfPermission()检查PHONE_STATE、定位、相机权限,返回如果是PERMISSION_GRANTED,则认为不支持动态权限)
基于Android6.0的ROM | 基于Android7.0的ROM | |
---|---|---|
小米 | 支持 | |
华为 | 支持 | 支持 |
OPPO | 不支持 | 支持 |
VIVO | 不支持 | 7.1.1不支持 7.1.2支持部分权限 |
魅族 | 支持 | |
锤子 | 不支持 | |
360 | 不支持 | 不支持 |
中兴 | 支持 |
关于权限弹窗
1. 授权弹窗元素
权限组icon App名称 申请的权限 允许、拒绝 操作 不再询问选项 多弹窗索引
2. 是否存在不再询问选项
关于权限弹窗,针对同一个App的同一个权限,有时弹窗不带“拒绝&不再询问”选项,有时带此选项。如下图是谷歌原生系统、小米MIUI系统的两种弹窗对比。这是什么原因呢?Android原生实现:App全新安装后首次申请权限,弹窗不带此选项,即图左效果。当用户拒绝授权后,App下次再申请该权限时,则带此选项,即图右效果。但是,国内部分手机厂商并未遵循此标准,比如华为的Android 10之前的系统、OPPO/VIVO的部分权限,授权弹窗不管是否首次,都带此选项。此为系统行为,App无法决定。
3. 弹窗选项与App设置中权限选项对应关系
系统的授权弹窗,实际具有3项(允许、拒绝、 拒绝不再询问)。但设置中的App权限选项,有的系统有2项(允许、拒绝),有的有3项(允许、询问、拒绝)。授权弹窗选项与设置中的选项对应关系如下。
以原生Android 10系统为例:
弹窗 允许 -> 设置 允许; 弹窗 拒绝 -> 设置 拒绝; 弹窗 拒绝&不再询问 -> 设置 拒绝 (跟上一项UI一致,本质有区别)。
以基于Android 9.0的MIUI10.4.8为例:
弹窗 允许 -> 设置 允许; 弹窗 拒绝 -> 设置 询问; 弹窗 拒绝&不再询问 -> 设置 拒绝。
4. 弹窗选项对四个函数的影响。
弹窗弹出,用户操作指定选项后,下次再调用四个函数会有如下现象:
权限分类
Android 6.0系统开始,权限被分为Normal permissions、Signature permissions、Dangerous permissions,其中Signature permissions比较超纲,仅介绍普通权限和危险权限。
其中普通权限使用方法跟低版本一样,只用在Manifest里申请就可使用。大部分低风险权限,不需要通过确认框这种形式让用户显示的同意。比如访问网络、检查WiFi状态等权限。
另一种危险权限,也就是本文介绍的对象,它的产生主要为了保护用户隐私,换言之,涉及到用户隐私的一些权限,属于危险权限。例如:相机权限、定位权限、PHONE_STATE(可读取手机IMEI等识别码)权限等。
危险权限和权限组。(不同系统危险权限可能不同)
关于权限,还有一个权限组的概念。例如,读取外置存储权限(READ_EXTERNAL_STORAGE)和写入外置存储权限(WRITE_EXTERNAL_STORAGE),同属存储权限组(STORAGE)。
权限组有什么作用呢?在Android O之前,同一权限组的权限,只要用户授权一个,则整个权限组都被授权。
例如:
步骤一:Manifest中加入了READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE
步骤二:在程序中只申请了READ_EXTERNAL_STORAGE权限,用户同意后
步骤三:在程序中未申请WRITE_EXTERNAL_STORAGE权限,并尝试直接使用
结果:可以直接使用,同组权限不需再申请。
而Android O对此进行了修改。同一权限组不同权限,必须都要动态申请权限。但是如果第一个被用户同意了,后面的同组权限再申请时,就不会再弹窗而是被直接同意了。
例如:
步骤一:Manifest中加入了READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE
步骤二:在程序中只申请了READ_EXTERNAL_STORAGE权限,用户同意后
步骤三:在程序中未申请WRITE_EXTERNAL_STORAGE权限,并尝试直接使用
结果:崩溃。
修改步骤三:在程序中申请WRITE_EXTERNAL_STORAGE权限
结果:不会弹出授权弹窗,同一权限组直接被自动授权
But,部分ROM修改了此逻辑。比如,华为9.0以下系统,遵循的是原生系统Android 8.0之前的逻辑。但是,华为9.0以后系统和小米6.0以后系统,都用的比原生系统Android 8.0更严格的逻辑。每个权限都需要单独申请权限,而且会单独弹窗要求用户确认。
例如:
步骤一:Manifest中加入了READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE
步骤二:在程序中只申请了READ_EXTERNAL_STORAGE权限,用户同意后
步骤三:在程序中申请WRITE_EXTERNAL_STORAGE权限
结果:会弹出授权弹窗,需要用户再次授权
带来问题:相同权限组不同权限的授权弹窗是一毛一样的。这就导致用户很懵逼,明明刚刚授权过了,为什么又要问我一次。
所以,部分手机上,你会发觉有些App,先后弹出两个访问文件存储的权限弹窗。那是因为写App的时候,先后申请了READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE权限导致。如何解决?
查看requestPermissions()方法的第二个参数,为一个数组。也就是说,可以传入一个权限列表。
/**
* 申请指定的权限(Activity或者Fragment对象调用)
* @param permissions 权限列表,可以同时申请多个权限
* @param requestCode 该次权限申请对应的requestCode。和 onRequestPermissionsResult()回调函数里面的requestCode对应
*/
public static void requestPermissions (Activity activity,
String[] permissions,
int requestCode)
经测试,如果直接调该方法同时传入READ_EXTERNAL_STORAGE、WRITE_EXTERNAL_STORAGE只会弹出一个授权窗,而且用户同意后可以同时获得两个权限。如果传入不同组权限,则按先后每组弹出一个弹窗。而且,这种单次传入多组权限的情况,弹窗中大都会出现一个m/n的编号,以标识弹到第几个,还剩几个。如下图分别是MIUI10(基于android9)和EMUI10(基于android10)的弹窗样式:
写在最后
后期的一些权限策略变化,仅列部分户感知较大的。
IOS 8(2014年),定位权限选项分为“使用期间”(新增项)、“始终允许”、“不允许”。(减少App后台定位) IOS 10(2016年),App访问网络需要授权。 Android 8.0(2017年), 安装未知来源应用需要申请权限。(App自升级、三方应用市场、广告App安装其他App需申请权限) 权限组授权问题修复,上文有提及。 Android 10(2019年), 定位权限选项分为“使用期间”(新增项)、“始终允许”、“拒绝”。(减少App后台定位) 部分电话、蓝牙、WLAN的API,需要申请精确位置权限。 无法再获取手机IMEI IOS 13(2019年),定位权限选项分为“使用App时允许”、“允许一次”(新增选项)、“不允许”,去除了“始终允许”。(“允许一次”相当于试用权限或临时权限,重启App后需要重新申请权限) Android 11 预览版(2020年), 分区存储强制执行。Download目录、SD卡目录访问受限。 对位置、麦克风、相机增加一次性权限许可,见IOS 13定位权限(即,如果用户选了一次性许可,重启App后需要重新申请权限)。 自动阻止App重复的权限请求。也就是说如果用户点击2次拒绝授权,那么系统会自动停止询问授权,当然了,用户也可以前往设置中手动调整。
两大平台,都在多个版本中对用户隐私进行了优化,仅定位权限的优化就多次提及。
可见,在手机逐渐转化为人体器官之一的今天,IOS和Android两大移动平台对于权限、隐私的管理越发严苛,而且趋同的速度约来越快。估计以后Android App想访问网络也需申请授权。但手机厂商自行定制修改ROM,仍是开发者最头疼的问题。
参考文献:谷歌官方文档:https://developer.android.com/training/permissions/requesting.html