查看原文
其他

Android动态权限详解

花椒测试 花椒技术 2023-02-19

1. 什么是动态权限

  去年底,上级主管部门为加强国内Android应用隐私管理,出台了一系列规定,我们的App也做了相应的修改。主要一条修改为,隐私提示与权限获取顺序。修改测试过程中,发觉部分同学对Android权限相关知识和历史并不了解,就此疫情期间忙里偷闲,整理些东西供参阅。

  首先,从一张图开始此文。

IOS 12定位权限

  时间回到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申请的全部权限。只能对所需权限进行查看,无法拒绝授权,可选择取消安装或继续安装。

Sony L36h安装提示

  这种方式,对于开发者极为友好,仅需在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手机卫士会弹出权限提示窗,用户可以允许或拒绝授权。注意,此窗由第三方安全软件弹出,非系统级弹窗,跟后面要说的两种弹窗有所区别。

360手机卫士 弹窗

第三阶段:手机厂商介入

  随着时间的推移,手机厂商开始发力,纷纷将第三方软件的权限提示功能直接做入ROM。
  例:小米4,基于Android 4.4.4的MIUI7;oppo R9,基于Android 5.1的ColorOS 3.0,浏览器扫码功能触发相机调用时,会弹出权限提示窗。此窗,由ROM也就是系统自己弹出,为系统级权限弹窗。

小米4授权
OPPO R9授权

第四阶段:谷歌升级权限管理

  以上3个时期,App在申请权限时都不需做改变,只需配置Manifest。2015年推出的Android 6.0,加入了危险权限管理。因手机厂商对ROM的修改,部分6.0以上机器并不支持此项特性。
  到了第四阶段,App需要在对权限代码进行修改后,才能正常使用对应权限。简单理解为3步:1、判断是否授权;2、如果未授权需申请权限,根据授权结果继续执行;3、已授权可以继续操作。
  例:Pixel2,原生Android 10;华为mate8,基于Android 8.0的EMUI8。浏览器扫码功能触发相机调用时,会弹出权限提示窗。此窗,由App通知系统弹出,为系统级权限弹窗。

pixel2授权弹窗
华为mate8授权弹窗

  第三阶段与第四阶段,同为系统弹出授权弹窗。二者有什么区别吗?
  首先,从UI上很难判断所弹授权窗为第三阶段或第四阶段。第三阶段弹的系统授权窗大都带有一个倒计时自动拒绝逻辑;第四阶段弹的系统授权窗基本不带自动拒绝逻辑。此点可以粗略判断系统使用的哪种机制。
  其次,从原理上。第三阶段的弹窗,为系统监测到App在使用危险权限行为自动弹出弹窗。第四阶段的弹窗,为App发觉自己没有权限,让系统弹出的弹窗。粗俗的理解,第三阶段,你去朋友家串门,到门口看到大门敞开就直接往里走,触发了红外线报警器,报警器通知了你朋友;第四阶段,你去朋友家串门,到门口发觉门关着,就按下门铃呼叫朋友给你开门。
  目前,国内主要处于第三阶段(涵盖Android4.0~7.1)和第四阶段(涵盖Android6.0~10),此点将在后文用到。

如何应对动态权限特性

方案一:逃避

  因为动态权限特性,仅从Android 6.0开始拥有,所以,可以简单粗暴的通过不提升targetSDK(targetSDK<23)的方式,便可不触发此特性。

targetSDK18正常获取IMEI
仅提升targetSDK到26直接运行崩溃

  如果不改变任何代码,直接将targetSDK提升到26,然后运行App,做同样操作时会发生异常甚至崩溃,崩溃举例如下:

无PHONE_STATE获取IMEI崩溃

  产生这个崩溃的原因,是在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卸载时,记录会被清除。

Android 10授权弹窗

  以上,就完成了最朴素版的授权逻辑。整体代码如下:

 // 检测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部分大体流程如下:

引导授权流程

综上,动态权限主要实现步骤

  1. 在AndroidManifest明确我们需要哪些权限。(非动态权限也需要此步)
  2. 在执行操作前检是否获得对应授权 -> checkSelfPermission()。
  3. 如果已授权可以继续操作;如果未授权,判断之前是否授权被拒 -> shouldShowRequestPermissionRationale() (非必须操作)
    a) 判断如果没有被拒过,弹出首次授权引导。
    b) 判断如果被据过,弹出非首次授权引导。
  4. 引导后,申请权限-> requestPermissions()。
  5. 处理申请的结果信息-> 回调函数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. 授权弹窗元素

Android 8.0授权弹窗
  1. 权限组icon
  2. App名称
  3. 申请的权限
  4. 允许、拒绝 操作
  5. 不再询问选项
  6. 多弹窗索引

2. 是否存在不再询问选项

  关于权限弹窗,针对同一个App的同一个权限,有时弹窗不带“拒绝&不再询问”选项,有时带此选项。如下图是谷歌原生系统、小米MIUI系统的两种弹窗对比。这是什么原因呢?Android原生实现:App全新安装后首次申请权限,弹窗不带此选项,即图左效果。当用户拒绝授权后,App下次再申请该权限时,则带此选项,即图右效果。但是,国内部分手机厂商并未遵循此标准,比如华为的Android 10之前的系统、OPPO/VIVO的部分权限,授权弹窗不管是否首次,都带此选项。此为系统行为,App无法决定。

pixel2不再询问
MIUI不再询问

3. 弹窗选项与App设置中权限选项对应关系

  系统的授权弹窗,实际具有3项(允许、拒绝、 拒绝不再询问)。但设置中的App权限选项,有的系统有2项(允许、拒绝),有的有3项(允许、询问、拒绝)。授权弹窗选项与设置中的选项对应关系如下。

以原生Android 10系统为例:

  • 弹窗 允许 -> 设置 允许;
  • 弹窗 拒绝 -> 设置 拒绝;
  • 弹窗 拒绝&不再询问 -> 设置 拒绝 (跟上一项UI一致,本质有区别)。
pixel2弹窗对应设置

以基于Android 9.0的MIUI10.4.8为例:

  • 弹窗 允许 -> 设置 允许;
  • 弹窗 拒绝 -> 设置 询问;
  • 弹窗 拒绝&不再询问 -> 设置 拒绝。
MIUI弹窗对应设置

4. 弹窗选项对四个函数的影响。

  弹窗弹出,用户操作指定选项后,下次再调用四个函数会有如下现象:

UI选项与函数调用结果

权限分类

  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权限
结果:会弹出授权弹窗,需要用户再次授权
带来问题:相同权限组不同权限的授权弹窗是一毛一样的。这就导致用户很懵逼,明明刚刚授权过了,为什么又要问我一次。

不同ROM权限组内影响

  所以,部分手机上,你会发觉有些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)的弹窗样式:

红米Note8Pro连续授权窗
华为Mate20连续授权窗

写在最后

后期的一些权限策略变化,仅列部分户感知较大的。

  • 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

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存