谈谈Android的应用权限申请
为了提高对用户隐私的保护水平,Android每次更新迭代中大都会包含对应用权限控制的变更。本文主要谈谈Android应用如何实现权限申请。
Android各版本关于应用权限的变更
下面是Android 6到15各个版本涉及到的应用权限控制的变更如下表。
| 版本号 | 主要更新内容 | 关键点 |
|---|---|---|
| Android 6.0 (API 23) | 运行时权限模型 | 对于被认为是“危险”的权限(例如访问相机、位置信息、联系人等),应用不仅需要在清单文件中声明,还必须在运行时动态向用户请求授权。 |
| Android 7.0 (API 24-25) | 对私有目录的访问权限受到限制 | MODE_WORLD_READABLE 和MODE_WORLD_WRITEABLE 被废弃 |
| Android 8.0 (API 26-27) | 系统只会授予应用明确请求的权限 | 某个应用在其清单中列出 READ_EXTERNAL_STORAGE 和 WRITE_EXTERNAL_STORAGE。应用请求 READ_EXTERNAL_STORAGE,并且用户授予了该权限,系统此时仅会授予 READ_EXTERNAL_STORAGE。如果该应用后来又请求 WRITE_EXTERNAL_STORAGE,系统会立即授予该权限,而不会提示用户。 在 Android 8.0(API 级别 26)之前,如果应用在运行时请求权限并且被授予该权限,系统会错误地将属于同一权限组并且在清单中注册的其他权限也一起授予应用。 |
| Android 9.0 (API 28) | 前台服务权限 | 如果应用以 Android 9 或更高版本为目标平台并使用前台服务,则必须请求 FOREGROUND_SERVICE 权限。这是一般权限, 以便系统自动将其授权给发出请求的应用。 |
| Android 10.0 (API 29) | 后台位置访问权限 存储权限变更(Scoped Storage) 后台 Activity 启动限制 | 新增ACCESS_BACKGROUND_LOCATION;为了限制应用对外部存储的广泛访问,引入了分区存储;限制了应用在后台启动 Activity 的能力,以减少对用户的干扰 |
| Android 11.0 (API 30) | 单次授权 权限自动重置 分区存储强制执行 | 用户现在可以选择“仅限这一次”授予权限,当应用下次需要该权限时,必须再次请求;如果用户几个月未使用某个应用,系统会自动撤销该应用已获得的敏感权限;对于目标 API 级别为 30 或更高的应用,分区存储成为强制要求,requestLegacyExternalStorage 属性不再有效(有特定豁免情况,如文件管理器应用)。 |
| Android 12.0 (API 31-32) | 近似位置权限 蓝牙权限细分 隐私仪表板 麦克风和摄像头指示器 剪贴板访问通知 | 用户可选择大致位置;新增BLUETOOTH_SCAN等权限;系统提供了一个仪表板,显示哪些应用在过去 24 小时内访问了敏感权限(如位置、麦克风、相机)的时间线;当应用使用麦克风或相机时,图标会出现在状态栏中;当某个应用首次调用 getPrimaryClip() 以从另一个应用访问剪辑数据时,会弹出一个消息框消息,通知用户对剪贴板的访问。 |
| Android 13.0 (API 33) | 通知的运行时权限 可以关闭前台服务通知 从剪贴板中隐藏敏感内容 | 新增POST_NOTIFICATIONS;用户将敏感内容复制到剪贴板时支持添加可阻止敏感内容出现在内容预览中的标志;用户可以默认关闭与前台服务相关联的通知 |
| Android 14.0 (API 34) | 允许用户关闭前台通知 应用只能终止自己的后台进程 授予对照片和视频的部分访问权限 | 设置FLAG_ONGOING_EVENT无法阻止用户关闭前台;调用 killBackgroundProcesses() 时,该 API 只能终止自己应用的后台进程;Android 14 引入了“已选照片访问权限”,让用户可以向应用授予对其媒体库中特定图片和视频的访问权限,而不是授予对给定类型的所有媒体的访问权限。 |
| Android 15.0 (API 35) | 私密空间 后台网络访问权限限制 | 私密空间是 Android 15 中推出的一项新功能,可让用户在设备上创建一个单独的空间,在额外的身份验证层保护下,防止敏感应用遭到窥探;在 Android 15 中,如果应用在有效的进程生命周期之外启动网络请求,则会收到异常,通常是 UnknownHostException 或其他与套接字相关的 IOException。 |
申请权限的一般流程
Android 将权限分为不同的类型,包括安装时权限、运行时权限和特殊权限。本文主要谈的是运行时权限。运行时权限又名危险权限,是我们需要在应用运行过程中申请的涉及到访问受限数据或者进行受限操作的权限。申请运行时权限需要先在应用的清单文件(AndroidManifest.xml)中声明应用可能需要请求的权限。如下。
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
下面是应用申请运行时权限(以写文件权限为例)的示例代码:
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted) {
//用户授予权限了
} else {
//用户选择禁止权限
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
//已经授予权限了
} else {
//未授予权限,检查是否需要展示原因
if (shouldShowRequestPermissionRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
// 展示原因
} else {
// 直接申请权限
requestPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
1.定义一个requestPermissionLauncher,它是抽象类ActivityResultLauncher的一个实例,来自调用registerForActivityResult(...)的返回值,同时我们需要将 ActivityResultCallback 的实现作为参数传入registerForActivityResult(...)。ActivityResultCallback 中的内容是应用如何处理用户对权限请求的响应,在示例代码中它对应尾随registerForActivityResult(...) 的大括号里面的代码。
2.在 activity 或 fragment 的初始化逻辑中,示例代码是在 activity 的 onCreate 方法中,检查权限并根据需要向用户请求权限。实际开发中根据需要去检查和申请权限,不一定要在刚进入应用的时候申请权限,例如用户需要的功能和服务必需某个权限再检查和申请该权限。
使用checkSelfPermission检查权限是否授予。如果未授予需要调用shouldShowRequestPermissionRationale检查是否需要向用户解释原因。如果权限未授予且不需要说明原因,可以直接调用requestPermissionLauncher.launch尝试申请权限。shouldShowRequestPermissionRationale在用户首次拒绝权限申请后调用会返回true,后面用户再次拒绝申请后调用只会返回false。
一般情况下,requestPermissionLauncher.launch在第二次执行的时候(首次申请时用户选择拒绝)会多出一个"拒绝且不再询问"的选项,用户选择了这个选项后,之后再调用requestPermissionLauncher.launch不会有弹窗出现,而是直接跳到回调函数ActivityResultCallback中,响应结果isGranted始终为false。
也就是说用户拒绝两次后,Android就认为不应该继续打扰用户了。
3.在定义requestPermissionLauncher时的回调函数ActivityResultCallback中处理用户选择的结果,isGranted表示用户是否选择授予权限。
申请权限注意事项
不要陷入重复申请权限的死循环
应用申请权限的一般逻辑是检查权限是否授予,已授予则执行下一步代码,未授予则申请权限。千万注意不要写出申请权限后,如果用户选择未授予,又跳回第一步检查权限是否授予的代码。这样用户在拒绝授予权限的情况下会一直卡在申请权限的界面中。
由于调用requestPermissionLauncher.launch后,activity会再次执行 onResume 方法。所以如果 检查权限并根据需要向用户请求权限 的代码一开始是写在 onResume 中的,那很可能会出现 用户拒绝权限后反复执行 onResume 中向用户请求权限的代码。
在实际开发中,我们应该根据多个条件判断是否需要向用户请求权限,例如上次用户已经拒绝了申请,我们就应该存储用户上次选择的结果,不要再重复执行申请权限的代码了。
onRequestPermissionsResult被弃用
我们上面使用ActivityResultLauncher来完成申请权限和响应用户选择。在网上搜索Android申请权限的教程和文章,许多都是使用requestPermissions/onRequestPermissionsResult来实现向用户请求权限的全过程,谷歌的Android开发者官网也有相应的教程和示例代码。不过在Android Studio中编写onRequestPermissionsResult相关的代码,会发现onRequestPermissionsResult已经被弃用。\
目前谷歌推荐使用ActivityResultLauncher来实现权限申请的功能。
1.ActivityResultLauncher的生命周期会更好管理,它的生命周期可以与特定组件(Activity或Fragment)相关联,确保了结果始终传递给正确的活动实例,简化了生命周期管理并减少了潜在的错误。onRequestPermissionsResult则必须和某个Activity绑定在一起。
2.一个应用可能会申请多组权限,使用ActivityResultLauncher可以为每组权限注册一个特定启动器用于申请权限和响应结果。使用onRequestPermissionsResult则一般需要在一块代码里处理多个权限请求的结果,可能会降低代码可读性。
3.据说ActivityResultLauncher在 Android Instrumentation Test (集成测试) 或 Unit Test 中更好用(我没试过)。
导航到系统设置界面设置权限
我们除了可以使用ActivityResultLauncher申请权限外,还可以使用Intent跳转到应用的系统设置详情界面设置应用权限,请求特殊权限就必须通过这种途径。下面是使用该方法申请通知权限的示例代码。
val intent = Intent().apply {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> {
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS
putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName)
}
// For older versions, you might need to guide them to the general app info screen
// from where they can access notification settings.
Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP -> {
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
data = Uri.fromParts("package", activity.packageName, null)
}
else -> {
action =
Settings.ACTION_SETTINGS // Fallback to general settings
}
}
}
startActivity(activity, intent, null)
执行上面的代码,我们会跳转到本应用通知权限的设置界面。
使用导航到系统设置界面的方法申请权限需要我们去自己实现请求权限的对话框,好处是可以自定义对话框的样式,坏处是比较麻烦。我们需要挑选合适action精准导航到权限授予的设置界面,避免给用户带来困扰。如果导航到的设置界面还需要用户点击界面上选项进入新的界面才能授予权限,可能会让用户感到繁琐,有的用户可能根本不知道点什么。“傻瓜”式的“授予/禁止”按钮或选项才是最佳实践方案。例如在Android 10上如果想导航到系统设置界面授予文件读写权限,可选的action只有 Settings.ACTION_APPLICATION_DETAILS_SETTINGS,这个设置界面不是最精准的。如下
val packageName = activity.packageName
val intent = Intent()
//跳转到本应用设置界面
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
intent.data = Uri.fromParts("package", packageName, null)
startActivity(activity, intent, null)
上面的代码会导航到应用设置界面。到了该界面,用户还需要点击“权限”,再点击“存储”,才能设置存储权限。\
从方便省事的角度考虑,能使用ActivityResultLauncher申请权限就使用ActivityResultLauncher会比较好。