尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Android权限开发实战:从运行时权限到Scoped Storage的避坑指南

Android权限开发实战:从运行时权限到Scoped Storage的避坑指南 1. 从一次“闪退”事故说起权限不只是弹窗那天下午我正在调试一个刚上线的图片编辑应用。测试同事跑过来一脸困惑“哥这个保存到相册的功能在我这台新手机上一点就闪退但在你和我自己的旧手机上又好好的。” 我接过手机打开Logcat一串刺眼的红色日志映入眼帘java.lang.SecurityException: Permission Denial: writing com.android.providers.media.MediaProvider uri content://media/external/images/media from pid10186, uid10354 requires android.permission.WRITE_EXTERNAL_STORAGE, or grantUriPermission()又是它WRITE_EXTERNAL_STORAGE。这个在Android 6.0API 23引入的运行时权限机制已经成了无数开发者的“必修课”但依然时不时会跳出来给你上一课。我检查了代码权限申请逻辑明明写了为什么还会崩溃深入一看发现测试同事用的是Android 13API 33的设备而在这个版本上WRITE_EXTERNAL_STORAGE权限的作用域发生了重大变化它不再提供对共享存储空间中所有文件的广泛写入权限取而代之的是更细粒度的媒体权限如READ_MEDIA_IMAGES或使用系统文件选择器。这个看似简单的“权限申请”问题背后牵扯的是Android系统长达十多年的安全演进史、不同版本间的兼容性陷阱以及开发者在用户体验与系统安全之间的艰难平衡。权限绝不仅仅是弹出一个请求窗口那么简单。它是应用与系统之间的一份契约是用户隐私和数据安全的第一道防线也是我们开发中必须透彻理解的基石。对于Android开发者而言无论是刚入门的新手还是经验丰富的老兵对权限体系的认知深度直接决定了应用的稳定性、安全性和上架成功率。今天我们就抛开那些枯燥的官方文档从一个一线开发者的视角彻底拆解Android权限的方方面面包括那些你必须在实际编码中注意的“坑”以及如何优雅地处理不同版本、不同厂商带来的差异。2. Android权限体系的演进与核心分类要理解现在的权限该怎么用最好先看看它从哪来。Android的权限管理并非一蹴而就而是一个随着系统迭代不断收紧和精细化的过程。2.1 历史脉络从“安装时一刀切”到“运行时动态管控”在Android 5.1API 22及更早的版本权限模型非常简单粗暴属于安装时权限模型。用户在安装应用前系统会弹出一个列表告知该应用声明的所有权限比如访问通讯录、获取位置、读写存储等。用户只有两个选择全部接受然后安装或者全部拒绝放弃安装。这种“要么全有要么全无”的模式对用户极不友好也催生了许多滥用权限的应用。转折点发生在Android 6.0API 23。谷歌引入了运行时权限模型。核心变化在于权限被分成了两类普通权限涉及应用自身沙盒内数据或对系统影响极小的操作如网络访问、蓝牙使用、振动等。这些权限只需要在AndroidManifest.xml中声明系统会在安装时自动授予。危险权限涉及用户隐私数据或可能影响其他应用/系统运行的操作如读取联系人、获取精确位置、读写外部存储、使用相机等。对于这类权限除了在清单文件中声明应用必须在运行时在需要用到该权限的具体场景下主动向用户弹窗申请。用户可以选择“允许”或“拒绝”并且可以随时在系统设置中更改授权状态。这个模型将权限控制的主动权交还给了用户是Android安全史上的一大进步。自Android 6.0之后权限管理的趋势是越来越细、越来越严。Android 10API 29引入了分区存储Scoped Storage的雏形进一步限制应用对外部存储的随意访问鼓励应用使用自身的私有目录和媒体库API。Android 11API 30强化了分区存储并对一些权限的授予方式做了调整例如位置权限的“仅限这一次”选项。Android 13API 33正如我开篇遇到的案例将媒体文件访问权限进一步细化为READ_MEDIA_IMAGES图片、READ_MEDIA_VIDEO视频、READ_MEDIA_AUDIO音频并弱化了WRITE_EXTERNAL_STORAGE的全局作用。2.2 权限的“三六九等”普通、危险、特殊与签名现在我们给Android权限分分类。理解这些分类是正确申请和使用权限的前提。1. 普通权限这类权限风险极低系统认为它们不会直接危及用户隐私或设备操作。你只需要在AndroidManifest.xml中声明即可。示例INTERNET网络、BLUETOOTH蓝牙、VIBRATE振动、WAKE_LOCK保持唤醒。特点安装时自动授予无需运行时申请。2. 危险权限这是运行时权限机制管控的核心对象。它们被分组管理同一个权限组内的权限用户只需授权一次。示例分组CALENDAR日历组READ_CALENDAR,WRITE_CALENDARCAMERA相机组CAMERACONTACTS联系人组READ_CONTACTS,WRITE_CONTACTS,GET_ACCOUNTSLOCATION位置组ACCESS_FINE_LOCATION,ACCESS_COARSE_LOCATIONMICROPHONE麦克风组RECORD_AUDIOPHONE电话组READ_PHONE_STATE,CALL_PHONE,READ_CALL_LOG等SENSORS传感器组BODY_SENSORSSMS短信组SEND_SMS,RECEIVE_SMS,READ_SMS等STORAGE存储组在Android 13之前主要指READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE。Android 13后读取媒体文件被新的媒体权限组替代。特点必须动态申请。如果用户拒绝了某个权限组中的一项同组的其他权限也需要重新申请。3. 特殊权限这类权限的授予方式非常特殊不在标准的运行时权限弹窗流程内。它们通常涉及更深层的系统交互。示例SYSTEM_ALERT_WINDOW悬浮窗权限允许应用在其他应用上层绘制。需要引导用户到系统特殊权限页面开启。WRITE_SETTINGS修改系统设置允许应用修改系统全局设置。同样需要跳转到特殊页面授权。MANAGE_EXTERNAL_STORAGE管理所有文件访问Android 11中允许应用访问共享存储空间中的所有文件包括其他应用的非媒体文件。申请此权限需要上架Google Play时进行声明且审核严格通常只适用于文件管理器、备份还原等特定类型应用。特点申请流程复杂通常需要Intent跳转到系统特定界面且用户感知非常明显滥用会导致应用被商店拒绝或下架。4. 签名权限这类权限主要用于系统应用或由同一密钥签名的应用之间进行受保护的交互。普通应用几乎不会用到。特点如果应用使用相同的证书签名系统会在安装时自动授予这些权限。注意在实际开发中最常打交道的就是危险权限和少数特殊权限。务必在 Android官方文档 中查询目标权限的确切分类和行为因为随着版本更新权限的归属和表现可能会发生变化。3. 实战从声明到检查一行代码都不能错理论说再多不如一行代码。我们来构建一个完整的权限处理流程以在Android 13设备上“从相册选择一张图片”这个常见需求为例。这个需求现在涉及到新的媒体权限。3.1 第一步在 AndroidManifest.xml 中正确声明这是所有权限工作的起点。声明必须准确且要考虑版本兼容。?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android xmlns:toolshttp://schemas.android.com/tools packagecom.example.myapp !-- 对于 Android 13 (API 33) 及以上使用新的媒体权限 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / !-- 为了兼容 Android 12L (API 32) 及以下版本仍需声明旧存储权限。 tools:ignore 属性告诉 Lint 工具我们知道这个权限在低版本是需要的避免警告。 maxSdkVersion 指明此权限最高应用到哪个SDK版本对于新权限旧版本系统会忽略它。-- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 tools:ignoreScopedStorage / !-- 如果需要写入媒体文件如保存编辑后的图片在 Android 10-12 可能需要这个。 注意Android 13WRITE_EXTERNAL_STORAGE 对媒体文件无效应用应使用 MediaStore API。-- !-- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion32 / -- application ... ... /application /manifest关键点解析maxSdkVersion这是处理兼容性的利器。它告诉系统当应用运行在指定API等级或更高的设备上时不要添加此权限。这样我们在Android 13的设备上就只使用READ_MEDIA_IMAGES避免了申请一个已经失效的权限。tools:ignore这是一个给Android Studio的Lint检查工具看的指令。因为我们在高版本目标SDK下声明了READ_EXTERNAL_STORAGELint可能会提示我们使用了“过时”的权限。加上这个属性可以消除警告但前提是你必须清楚自己在做什么。权限分组声明对于危险权限组你只需要声明你具体要用的那个权限如READ_MEDIA_IMAGES不需要声明整个组。3.2 第二步在运行时检查与申请权限声明了权限不代表就有了权限。必须在代码中动态处理。我们通常在Activity或Fragment的onCreate或某个按钮点击事件中触发。// 假设这是在某个 Activity 中 class MainActivity : AppCompatActivity() { // 定义一个权限请求码用于在回调中识别是哪次请求 companion object { private const val REQUEST_CODE_IMAGE_PERMISSION 1001 } private fun pickImageFromGallery() { // 1. 检查权限状态 val permissionToRequest if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { // Android 13 使用新权限 Manifest.permission.READ_MEDIA_IMAGES } else { // Android 12L 及以下使用旧权限 Manifest.permission.READ_EXTERNAL_STORAGE } val permissionStatus ContextCompat.checkSelfPermission(this, permissionToRequest) when (permissionStatus) { PackageManager.PERMISSION_GRANTED - { // 2. 已有权限直接执行操作例如启动图片选择器 launchImagePicker() } PackageManager.PERMISSION_DENIED - { // 3. 没有权限需要申请 // 在申请前可以先判断是否需要向用户展示解释性弹窗 if (ActivityCompat.shouldShowRequestPermissionRationale(this, permissionToRequest)) { // 用户之前拒绝过但没有勾选“不再询问”。此时应该用一个友好的对话框解释为什么需要这个权限。 showPermissionRationaleDialog(permissionToRequest) } else { // 首次申请或者用户之前拒绝并勾选了“不再询问”直接发起请求 requestPermissions(arrayOf(permissionToRequest), REQUEST_CODE_IMAGE_PERMISSION) } } } } private fun showPermissionRationaleDialog(permission: String) { AlertDialog.Builder(this) .setTitle(需要相册权限) .setMessage(此功能需要访问您的相册以选择图片。我们仅会在您使用该功能时访问用于图片编辑。) .setPositiveButton(去授权) { _, _ - requestPermissions(arrayOf(permission), REQUEST_CODE_IMAGE_PERMISSION) } .setNegativeButton(取消, null) .show() } private fun launchImagePicker() { val intent Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) startActivityForResult(intent, REQUEST_CODE_IMAGE_PICK) // 需要另一个请求码 } // 4. 处理权限申请结果回调 override fun onRequestPermissionsResult( requestCode: Int, permissions: Arrayout String, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) when (requestCode) { REQUEST_CODE_IMAGE_PERMISSION - { // 检查结果是否为空以及是否是我们请求的权限 if (grantResults.isNotEmpty() grantResults[0] PackageManager.PERMISSION_GRANTED) { // 用户同意了执行后续操作 launchImagePicker() } else { // 用户拒绝了 // 可以再次判断 shouldShowRequestPermissionRationale // 如果返回 false说明用户勾选了“不再询问”此时应引导用户去应用设置页手动开启 if (!ActivityCompat.shouldShowRequestPermissionRationale(this, permissions[0])) { showGoToSettingsDialog() } else { Toast.makeText(this, 权限被拒绝无法选择图片, Toast.LENGTH_SHORT).show() } } } // 可以处理其他 requestCode... } } private fun showGoToSettingsDialog() { AlertDialog.Builder(this) .setTitle(权限被永久拒绝) .setMessage(您已禁止权限请求并选择了‘不再询问’。如需使用此功能请到应用设置中手动开启相册权限。) .setPositiveButton(去设置) { _, _ - val intent Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data Uri.fromParts(package, packageName, null) } startActivity(intent) } .setNegativeButton(取消, null) .show() } }代码逻辑深度解析版本判断 (Build.VERSION.SDK_INT)这是处理Android碎片化的核心。我们必须根据运行设备的系统版本来决定申请哪个权限字符串。硬编码一个权限字符串是绝对错误的。检查权限状态 (checkSelfPermission)这是第一步永远不要假设权限已被授予。即使上次同意了用户也可能在系统设置中关闭它。shouldShowRequestPermissionRationale的精妙之处这个方法是用户体验的关键。它返回true的情况用户上次拒绝了权限请求但没有勾选“不再询问”的复选框。这意味着用户可能只是不理解为什么需要这个权限此时弹出一个解释性对话框成功率会高很多。它返回false的情况有两种可能a) 第一次申请权限b) 用户上次拒绝并勾选了“不再询问”。在情况b下再次调用requestPermissions系统将不会弹出任何对话框直接回调拒绝。因此当它返回false且我们没有权限时我们需要区分是首次申请还是永久拒绝。一个简单的判断逻辑是如果checkSelfPermission返回DENIED且shouldShowRequestPermissionRationale返回false我们通常可以认为是“永久拒绝”应引导用户去设置。处理回调 (onRequestPermissionsResult)在这里我们必须检查grantResults数组。它对应着permissions数组中每个权限的授予结果。永远不要只检查permissions参数因为系统回调可能会包含其他权限。3.3 使用 Jetpack Activity Result API 进行现代化改造上述方式使用的是传统的requestPermissions和onRequestPermissionsResult代码略显分散。Google推荐使用更现代、更解耦的Activity Result API属于androidx.activity:activity-ktx和androidx.fragment:fragment-ktx库。// 在 Activity/Fragment 中 class ModernPermissionActivity : AppCompatActivity() { // 1. 注册一个权限请求契约 private val requestPermissionLauncher registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean - // 2. 权限申请结果的回调 if (isGranted) { launchImagePicker() } else { // 处理拒绝逻辑可以结合 shouldShowRequestPermissionRationale if (!shouldShowRequestPermissionRationale(Manifest.permission.READ_MEDIA_IMAGES)) { showGoToSettingsDialog() } else { Toast.makeText(this, 权限被拒绝, Toast.LENGTH_SHORT).show() } } } fun pickImage() { val permission if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_IMAGES } else { Manifest.permission.READ_EXTERNAL_STORAGE } when { ContextCompat.checkSelfPermission(this, permission) PackageManager.PERMISSION_GRANTED - { launchImagePicker() } shouldShowRequestPermissionRationale(permission) - { showPermissionRationaleDialog { // 用户看了解释后同意申请 requestPermissionLauncher.launch(permission) } } else - { // 首次申请或永久拒绝 requestPermissionLauncher.launch(permission) } } } // ... showPermissionRationaleDialog 和 launchImagePicker 等方法 ... }优势生命周期安全API自动处理了生命周期问题避免在onSaveInstanceState后调用requestPermissions导致的异常。代码更清晰将权限请求和结果回调绑定在一起逻辑更集中。可测试性更强契约对象更容易进行单元测试。4. 进阶议题与避坑指南掌握了基础流程我们来看看那些容易让人栽跟头的进阶问题。4.1 存储权限的“巨变”Scoped Storage 与权限更迭开篇的闪退案例根源就在这里。Android 10开始的分区存储彻底改变了应用访问外部文件的方式。核心思想应用默认只能访问自身的私有目录 (Context.getExternalFilesDir()) 和公共媒体库通过MediaStoreAPI。不能像以前一样通过FileAPI 随意遍历整个SD卡。权限变化表API 等级读取媒体文件所需权限写入媒体文件所需权限备注 29 (Android 9-)READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE传统模式可广泛访问存储。29-32 (Android 10-12L)READ_EXTERNAL_STORAGE无需权限对媒体文件分区存储启用。应用可通过MediaStore写入自身创建的媒体文件无需WRITE权限。但读取仍需READ权限。33 (Android 13)READ_MEDIA_IMAGES/VIDEO/AUDIO无需权限对媒体文件读取权限按媒体类型细分。WRITE_EXTERNAL_STORAGE权限已废弃对媒体文件不再有效。避坑实践永远使用MediaStoreAPI访问图片、视频、音频文件优先使用MediaStore而不是File(path)。私有文件放私有目录应用产生的非媒体文件如缓存、配置文件、下载的文档应存放在getExternalFilesDir()或getCacheDir()下这些位置无需任何权限。使用ACTION_OPEN_DOCUMENT或ACTION_CREATE_DOCUMENT如果需要让用户选择任意类型的文件如PDF、Word或创建新文件应使用系统文件选择器 Intent。这不需要任何存储权限是最佳实践。谨慎使用MANAGE_EXTERNAL_STORAGE这个权限是“核武器”能访问几乎所有文件。但Google Play对它的使用有严格限制仅适用于真正的文件管理器、备份还原等应用。滥用会导致应用被下架。4.2 后台位置权限的“高门槛”从Android 10开始后台位置权限的申请变得极其困难。ACCESS_BACKGROUND_LOCATION是一个独立的危险权限。申请策略你必须先获得前台位置权限ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION。在已经拥有前台位置权限的前提下才能申请后台位置权限。申请后台权限时系统会弹出一个非常醒目的对话框明确告知用户应用将在后台收集位置信息。用户拒绝的可能性极高。如果你的应用核心功能不需要后台定位如仅需在应用使用期间获取位置绝对不要申请此权限。4.3 权限请求的“用户体验”陷阱频繁、突兀的权限请求是导致用户卸载应用的主要原因之一。最佳实践适时请求不要在应用一启动就请求所有权限。应该在用户即将使用相关功能时再请求即“上下文请求”。例如在用户点击“更换头像”按钮时请求相机/相册权限。解释原因充分利用shouldShowRequestPermissionRationale在用户首次拒绝后用一个非模态的、友好的界面解释“为什么需要这个权限能让你获得更好的体验”。优雅降级如果用户拒绝了核心功能所需的权限应用不应崩溃或完全卡死。应该禁用相关功能并友好地提示用户如何重新开启。例如在相册选择器入口处显示一个灰色的提示条“需要相册权限以选择图片 [去开启]”。处理“不再询问”如前所述当用户永久拒绝后引导用户前往系统设置页面是唯一途径。跳转代码是固定的Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { data Uri.fromParts(package, packageName, null) }。4.4 厂商定制系统的“魔改”国内各安卓厂商小米、华为、OPPO、vivo等都对权限管理进行了深度定制增加了自启动管理、关联启动、电池优化白名单等概念。即使你获得了运行时权限应用在后台也可能被“杀死”或限制网络。应对策略引导用户手动设置对于需要保活的核心服务如即时通讯可能需要检测到功能异常时引导用户去手机的“省电策略”、“权限管理”或“自启动”设置中将你的应用加入白名单。这通常需要跳转到厂商特定的设置页面代码非常繁琐可以考虑使用一些成熟的第三方库来简化流程。遵循最佳后台实践使用WorkManager进行后台任务调度使用ForegroundService并显示前台通知来执行用户可感知的长时间任务。避免滥用后台服务。5. 测试与调试如何模拟各种权限场景开发中我们需要测试应用在不同权限状态下的表现。Android Studio和ADB提供了强大工具。5.1 使用 ADB 命令行管理权限ADB是权限测试的利器可以快速模拟各种状态无需在真机上反复点击。# 授予权限 adb shell pm grant package_name permission_name # 例如adb shell pm grant com.example.myapp android.permission.CAMERA # 撤销权限 adb shell pm revoke package_name permission_name # 例如adb shell pm revoke com.example.myapp android.permission.CAMERA # 重置应用的所有权限恢复到安装初始状态 adb shell pm reset-permissions package_name # 模拟点击“不再询问”将权限置于拒绝状态且shouldShowRequestPermissionRationale返回false # 这需要两步 # 1. 先授予权限 adb shell pm grant package_name permission_name # 2. 再通过adb shell进入设备使用appops命令拒绝并标记为“不再询问” adb shell appops set package_name permission_OP_STR ignore # 例如对于CAMERA权限其对应的OP是CAMERA # appops set com.example.myapp CAMERA ignore # 退出shell exit注意appops命令中的操作字符串 (permission_OP_STR) 与权限名不同。你需要查询映射关系例如android.permission.CAMERA对应的 OP 是CAMERA。这比较繁琐通常用模拟器UI操作更直观。5.2 利用 Android 模拟器进行可视化测试在Android Studio的模拟器中你可以非常方便地管理权限状态运行你的应用到模拟器。在模拟器侧边栏点击“三点”更多按钮-“Settings”-“Apps”- 找到你的应用 -“Permissions”。在这里你可以看到所有危险权限并可以将其设置为Allowed允许、Denied拒绝或Ask every time每次询问。将权限设为Denied就相当于用户拒绝且未勾选“不再询问”。要模拟“永久拒绝”你需要先在应用中触发一次权限请求并拒绝然后在系统设置里找到该权限并关闭这样下次请求时shouldShowRequestPermissionRationale就会返回false。5.3 编写单元测试与集成测试对于权限检查逻辑应编写单元测试。RunWith(AndroidJUnit4::class) class PermissionUtilsTest { Test fun testPermissionCheck_WhenGranted() { // 使用 Mockito 等框架模拟 Context 和 PackageManager val mockContext mock(Context::class.java) val mockPackageManager mock(PackageManager::class.java) when(mockContext.packageManager).thenReturn(mockPackageManager) when(mockPackageManager.checkPermission(anyString(), anyString())) .thenReturn(PackageManager.PERMISSION_GRANTED) val utils PermissionUtils(mockContext) val result utils.checkPermission(Manifest.permission.CAMERA) assertThat(result).isTrue() } Test fun testShouldShowRationale_WhenDeniedFirstTime() { val mockActivity mock(Activity::class.java) // 模拟 shouldShowRequestPermissionRationale 返回 true when(mockActivity.shouldShowRequestPermissionRationale(anyString())).thenReturn(true) // ... 测试你的逻辑 } }对于涉及系统权限弹窗的流程则需要编写使用ActivityScenario或Espresso的集成测试模拟用户点击行为。6. 权限与隐私合规不可逾越的红线随着全球对数据隐私保护的重视如GDPR、CCPA以及国内《个人信息保护法》的实施权限的合规使用不再是技术问题更是法律问题。核心原则最小必要原则只申请业务功能所必需的权限。一个手电筒应用申请通讯录权限是绝对违规的。透明告知原则在隐私政策中清晰、明确地告知用户你收集了哪些信息对应哪些权限、用于什么目的、存储多久、如何保护。用户自主控制原则提供易于操作的入口允许用户随时撤回授权对应权限的关闭并保障应用基本功能在撤回后仍可使用或优雅降级。Google Play 的硬性要求数据安全表单上架Google Play必须填写此表单详细说明应用收集的数据类型、用途、是否共享等。声明的权限必须与此表单一致。权限使用审核对于敏感权限如后台位置、MANAGE_EXTERNAL_STORAGEGoogle会进行人工审核。如果声明的使用范围与实际功能不符应用会被拒绝或下架。目标API级别Google Play要求新应用和更新必须针对较新的Android API级别这迫使开发者必须适配新的、更严格的权限模型如分区存储。国内应用商店各大国内商店也有类似的隐私合规检测通常会集成第三方SDK进行扫描。如果检测到违规收集个人信息、过度索权等问题应用将无法过审。因此在设计和开发阶段开发者、产品经理和法务就需要共同评审权限使用的必要性和合规性从源头杜绝风险。在代码层面则要确保权限申请逻辑与宣称的隐私政策完全吻合。权限是Android开发的基石也是连接应用与用户信任的桥梁。处理得当应用流畅稳定用户安心处理不当轻则功能异常重则审核被拒、法律风险。希望这篇从实战出发的梳理能帮你建立起清晰、稳固的Android权限知识体系在开发中游刃有余。
返回列表