ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android 13+存储权限适配指南:解决权限开关消失与MediaStore API迁移

Android 13+存储权限适配指南:解决权限开关消失与MediaStore API迁移 1. 问题现象与背景当存储权限开关“消失”时最近在适配一个Android应用时遇到了一个让我和测试同学都懵了几秒的问题。场景很简单应用需要访问用户的相册或下载目录来保存或读取文件。按照常规思路我们在代码里请求了READ_EXTERNAL_STORAGE或WRITE_EXTERNAL_STORAGE权限弹窗也正常出现了。但问题出在用户拒绝后当他们想再次开启权限时按照系统引导进入“应用信息” - “权限”页面却怎么也找不到那个熟悉的“存储”或“文件和媒体”权限开关。取而代之的可能是一个“照片和视频”的选项或者干脆什么都没有用户只能对着空白的权限列表干瞪眼然后回来反馈说“你们的应用有bug我没法给权限”。这绝对不是应用代码写错了而是从 Android 13API 33开始Google 对存储权限模型进行的一次重大、且有点“静默”的变革。对于 targetSdkVersion 为 33 及以上的应用READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE这两个我们用了很多年的“粗粒度”存储权限其行为发生了根本性变化。简单来说它们不再直接控制对整个共享存储空间的访问而是被更细粒度的媒体权限所取代。如果你没有适配新的权限模型系统就会“隐藏”掉那个旧的、已经不再适用的存储权限开关导致用户无法管理这就是“找不到开关”的根本原因。这个问题在 Android 14API 34上依然存在并且规则更加明确。所以如果你正在开发或维护一个 targetSdkVersion 33 的应用并且涉及文件访问那么这篇文章就是为你准备的避坑指南。2. 权限模型变革从“粗放”到“精细”的十年之路要理解为什么开关会消失我们必须回顾一下 Android 存储权限的演变史。这有助于我们理解 Google 的设计意图而不仅仅是记住几个 API 调用。在 Android 4.4KitKat之前应用只要声明了WRITE_EXTERNAL_STORAGE权限就可以几乎无限制地读写 SD 卡外部存储上的任何文件包括其他应用的数据。这带来了巨大的安全和隐私风险。从 Android 6.0Marshmallow开始引入了运行时权限存储权限需要动态申请这是一个进步但权限本身依然是“全有或全无”的粗粒度模式。真正的转折点是 Android 10API 29。Google 推出了“分区存储”Scoped Storage的概念。其核心思想是应用应该主要访问自己的私有目录Context.getExternalFilesDir()对于共享存储空间如 DCIM, Downloads, Movies 等公共目录访问应该受到严格限制。在 Android 10 上可以通过在AndroidManifest.xml中设置requestLegacyExternalStoragetrue来暂时“豁免”继续使用旧模式。到了 Android 11API 30分区存储被强制启用针对 targetSdkVersion 30 的新应用但依然为媒体文件图片、视频、音频留了一个“后门”应用可以通过申请READ_EXTERNAL_STORAGE权限来访问这些特定类型的媒体文件而无需申请所有文件访问权限MANAGE_EXTERNAL_STORAGE。此时存储权限开关在系统设置里还是可见的。关键的 Android 13API 33Google 决定将“后门”也规范化、精细化。READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限被重新定义。现在它们不再是一个“开关”而是一组权限的“别名”或“快捷方式”。具体来说当你的 targetSdkVersion 33 时在AndroidManifest.xml中声明READ_EXTERNAL_STORAGE系统会自动将其映射为新的、细粒度的媒体权限。在运行时如果你请求READ_EXTERNAL_STORAGE系统实际上会向用户请求的是访问媒体文件的权限并且会弹出一个选择器让用户选择是授予“所有照片和视频的访问权限”还是“仅选中的照片和视频”。那么系统设置里的开关去哪了因为旧的“存储”权限作为一个独立实体已经不存在了它被分解了。所以系统设置里显示的是新的、具体的媒体权限项比如“照片和视频”。如果你只声明了旧的存储权限而没有正确适配新的媒体权限系统可能就无法正确归类导致在权限列表里什么都不显示或者显示一个用户无法操作的项。注意这里有一个巨大的认知陷阱。很多开发者以为把 targetSdkVersion 升到 33然后继续用老代码请求READ_EXTERNAL_STORAGE就行了系统会“帮我们”处理好一切。实际上系统确实会处理请求但应用内部的逻辑必须配合改变否则就会遇到文件路径访问失败、File对象 API 返回空或异常等问题。开关“消失”只是这个深层兼容性问题在用户界面的一个表现。3. 精准适配针对不同文件类型的权限策略既然旧的“一刀切”权限行不通了我们就必须根据要访问的文件类型采取不同的策略。Android 13 将共享存储空间的文件访问分为三大类每一类都有对应的权限和API。3.1 访问图片、视频、音频文件媒体文件这是最常见、也是新权限模型主要规范的场景。你需要使用新的媒体权限而不是旧的存储权限。1. 在AndroidManifest.xml中声明权限你需要根据需求声明一个或多个以下权限!-- 访问图片和照片 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / !-- 访问视频 -- uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO / !-- 访问音频文件 (Android 13) -- uses-permission android:nameandroid.permission.READ_MEDIA_AUDIO / !-- 如果要写入/修改/删除媒体文件需要申请对应的 WRITE 权限 (Android 14, API 34) -- uses-permission android:nameandroid.permission.WRITE_MEDIA_IMAGES / uses-permission android:nameandroid.permission.WRITE_MEDIA_VIDEO / uses-permission android:nameandroid.permission.WRITE_MEDIA_AUDIO /关键点READ_MEDIA_IMAGES和READ_MEDIA_VIDEO在 Android 13 引入READ_MEDIA_AUDIO在 Android 13 引入但最初有bug建议关注。写入权限则是 Android 14 才引入的。如果你声明了旧的READ_EXTERNAL_STORAGE系统在构建时会自动帮你添加对应的新媒体权限对于图片和视频但显式声明新的权限是官方推荐的最佳实践代码意图更清晰也便于未来维护。2. 在运行时请求权限在代码中你应该直接请求这些新的媒体权限。// 检查权限 val hasImagePermission ContextCompat.checkSelfPermission( this, Manifest.permission.READ_MEDIA_IMAGES ) PackageManager.PERMISSION_GRANTED if (!hasImagePermission) { // 请求权限 requestPermissions.launch(arrayOf(Manifest.permission.READ_MEDIA_IMAGES)) }3. 使用 MediaStore API 访问文件获得权限后不能再简单地使用File路径如/storage/emulated/0/DCIM/xxx.jpg来访问媒体文件。必须通过ContentResolver查询MediaStore。val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATE_TAKEN ) val selection ${MediaStore.Images.Media.DATE_TAKEN} ? val selectionArgs arrayOf(“${someTimestamp}“) val sortOrder ${MediaStore.Images.Media.DATE_TAKEN} DESC” contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, selection, selectionArgs, sortOrder )?.use { cursor - while (cursor.moveToNext()) { val id cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) val name cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)) // 通过 Content URI 访问文件内容例如content://media/external/images/media/$id val contentUri ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) // 使用 contentResolver.openInputStream(contentUri) 来读取文件 } }为什么必须用 MediaStore因为即使有了权限应用对共享存储的访问也是通过系统管理的“视图”进行的。MediaStore就是这个视图的入口。直接文件路径访问在 Android 10 上对于共享存储是不可靠的经常会因为路径映射或权限问题失败。3.2 访问下载目录等非媒体文件对于Download,Documents等目录下的非媒体文件如PDF、ZIP、APK情况又不一样。Android 引入了一个叫做“存储访问框架”的机制。你不需要也无法申请一个永久性的运行时权限来访问这些目录。你需要做的是启动一个系统级的文件选择器 Intent让用户亲自选择一个或一组文件授予你的应用访问权。这是一种一次性的、基于用户操作的授权。// 启动文件选择器例如选择 PDF 文件 val intent Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type “application/pdf“ // 指定MIME类型 } startActivityForResult(intent, REQUEST_CODE_PICK_PDF) // 在 onActivityResult 中处理返回的 URI override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode REQUEST_CODE_PICK_PDF resultCode Activity.RESULT_OK) { data?.data?.let { uri - // 你获得了这个特定 URI 的读取权限可能是持久化的 contentResolver.openInputStream(uri)?.use { inputStream - // 处理文件 } } } }通过ACTION_OPEN_DOCUMENT或ACTION_CREATE_DOCUMENT获取的 URI应用会获得长期甚至永久的访问权限系统会自动管理。这比申请一个宽泛的存储权限要安全得多。3.3 需要“全部文件访问”的特殊情况如果你的应用是文件管理器、备份工具、杀毒软件等确实需要访问共享存储中的所有文件包括其他应用的数据目录那么你需要申请最特殊的MANAGE_EXTERNAL_STORAGE权限。这是一个“核选项”使用流程非常严格在AndroidManifest.xml中声明权限uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE /。在运行时引导用户跳转到系统设置中专门的应用页面去手动开启此权限。你不能直接通过弹窗请求。val intent Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.data Uri.parse(“package:${packageName}“) startActivity(intent)上架 Google Play 时你必须声明其合理用途并可能面临更严格的人工审核。滥用此权限的应用会被拒绝上架。重要提示99% 的普通应用如社交、购物、新闻、工具类都不应该申请此权限。请优先使用媒体权限和存储访问框架。4. 兼容性处理与降级方案现实情况是我们的应用可能需要支持从 Android 8.0 到 Android 14 的各种设备。如何写一套代码优雅地处理不同系统版本上的权限问题这里有一个清晰的策略。1. 权限声明兼容在AndroidManifest.xml中我们需要声明所有可能用到的权限并使用android:maxSdkVersion属性来限制旧权限在新系统上的生效。!-- 旧的存储权限仅在 Android 12L (API 32) 及以下版本生效 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion“32“ / !-- 注意WRITE_EXTERNAL_STORAGE 在 Android 10 对于共享存储已基本失效但针对旧版本或特定场景可保留 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion“28“ / !-- 通常可以更早移除 -- !-- 新的媒体权限从 Android 13 (API 33) 开始需要 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / uses-permission android:nameandroid.permission.READ_MEDIA_VIDEO / !-- 如果应用需要安装到 Android 13 以下但声明了新权限低版本系统会自动忽略所以可以放心添加 --2. 运行时逻辑兼容在代码中我们需要根据设备的 SDK 版本动态决定请求哪些权限。fun requestNecessaryPermissions() { val permissionsToRequest mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { // Android 13 使用新的媒体权限 permissionsToRequest.add(Manifest.permission.READ_MEDIA_IMAGES) // 根据需求添加 VIDEO 和 AUDIO } else { // Android 12 及以下使用旧的存储权限 permissionsToRequest.add(Manifest.permission.READ_EXTERNAL_STORAGE) // 如果需要写入在 Android 10 以下可能还需要 WRITE_EXTERNAL_STORAGE if (Build.VERSION.SDK_INT Build.VERSION_CODES.P) { permissionsToRequest.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) } } if (permissionsToRequest.isNotEmpty()) { requestPermissions.launch(permissionsToRequest.toTypedArray()) } }3. 文件访问逻辑兼容这是最复杂的一环。即使你在 Android 13 的设备上通过请求READ_MEDIA_IMAGES获得了权限你也不能用FileAPI 去访问/storage/emulated/0/DCIM。你必须统一使用MediaStoreAPI 来查询和访问媒体文件。好消息是MediaStoreAPI 在 Android 4.4 之后就存在了所以你可以在所有支持的版本上都使用同一套MediaStore查询代码。这实际上简化了兼容性处理无论新老系统都坚持使用ContentResolverMediaStore。对于非媒体文件在 Android 11 上FileAPI 访问 Downloads 等目录也会受限。更健壮的做法是对于已知的公共目录使用Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)获取路径但要注意此方法在 Android 10 上已废弃且可能返回空。最通用的方案仍然是如果需要用户选择任意文件就用存储访问框架SAF如果应用自己创建文件供自己使用就存到私有目录getExternalFilesDir()。5. 实战排查当问题已经发生如何定位与修复假设你已经遇到了用户反馈“存储权限开关找不到”或者测试发现文件功能异常可以按照以下步骤进行排查这比盲目修改代码更有效。第一步确认环境与配置检查build.gradle: 确认targetSdkVersion是否已经 33。这是触发新权限行为的必要条件。检查AndroidManifest.xml: 打开合并后的清单文件在build/intermediates/merged_manifests/目录下找到最终产物查看关于存储权限的部分。确认是否只声明了旧的READ_EXTERNAL_STORAGE而没有声明新的READ_MEDIA_*权限。这是导致开关消失的最常见原因。测试设备系统版本: 确认测试设备的 Android 版本是否为 13 或更高。第二步动态权限请求检查在请求权限的代码处打断点或加日志打印出你实际请求的权限数组。看看在 Android 13 的设备上你请求的是READ_EXTERNAL_STORAGE还是READ_MEDIA_IMAGES。观察权限请求弹窗。在 Android 13 上如果请求的是媒体权限弹窗的文案会是“允许 [应用名] 访问您设备上的照片和视频吗”并且可能会有“选择照片和视频”的选项。如果弹窗文案还是“允许 [应用名] 访问您设备上的照片、媒体内容和文件吗”说明你请求的仍然是旧权限或者系统做了向后兼容的映射但这可能意味着你的清单配置有问题。第三步系统权限页面验证手动进入系统设置 - 应用 - 你的应用 - 权限。观察列表。你应该能看到“照片和视频”这一项并且旁边有开关。如果看不到或者看到一项名为“文件与媒体”但无法操作说明系统没有正确识别你的应用所需的权限类型。一个关键技巧在 Android 的“开发者选项”中开启“权限管理器”日志或使用adb shell dumpsys package your.package.name命令。你可以查看系统记录下来的应用权限请求历史这能帮你确认系统到底收到了什么权限请求。第四步代码修复与测试根据排查结果进行修复清单文件补充新权限在AndroidManifest.xml中显式添加READ_MEDIA_IMAGES等权限。更新运行时请求逻辑修改代码根据Build.VERSION.SDK_INT判断在 Android 13 上请求新的媒体权限。重构文件访问代码将任何直接使用File路径访问共享存储媒体文件如图片、视频的代码改为通过MediaStoreAPI 进行查询和访问。使用ContentResolver.openInputStream(uri)或openOutputStream(uri)来读写内容。彻底测试在 Android 13/14 设备上安装新版本应用。首次启动触发权限请求观察弹窗是否正确。拒绝权限后进入系统设置确认“照片和视频”权限开关是否存在且可操作。授予权限后测试核心的文件访问功能如图片选择、保存是否正常。在 Android 12 或更低版本的设备上进行回归测试确保原有功能不受影响。踩坑心得我遇到过最隐蔽的一个坑是项目依赖的某个第三方 SDK 内部也声明了旧的存储权限并且没有设置maxSdkVersion。这导致合并后的清单文件包含了旧权限干扰了系统的判断。解决方法是通过tools:noderemove在合并清单时移除冲突的权限声明或者联系 SDK 提供商更新。所以检查依赖库的权限声明也是一个重要的排查点。6. 进阶考量Android 14 的新规则与未来方向适配好 Android 13 只是第一步。Android 14 在存储权限上又增加了新的限制了解它们可以让我们提前规避问题。1. 更细粒度的媒体写入权限 (Android 14, API 34)在 Android 13你只需要READ_MEDIA_IMAGES就能读和写修改、删除图片吗是的但这是一个临时状态。Android 14 明确将写入权限分离。如果你需要修改或删除用户授予你访问的媒体文件你必须额外申请对应的WRITE_MEDIA_IMAGES、WRITE_MEDIA_VIDEO或WRITE_MEDIA_AUDIO权限。否则尝试写入操作会抛出SecurityException。行动项如果你的应用有编辑或删除媒体文件的功能在 targetSdkVersion 升级到 34 之前就应该开始评估和适配在清单中声明并请求这些写入权限。2. 部分媒体访问权限 (Android 14, API 34)Android 14 进一步强化了用户控制。应用可以请求一个名为READ_MEDIA_VISUAL_USER_SELECTED的特殊权限。当用户授予此权限时你的应用只能访问用户通过系统选择器明确选中的那些照片和视频而不是整个媒体库。这给了用户前所未有的控制力。如果你的应用场景只是让用户挑选一两张图片上传那么请求这个权限可能是更友好、更容易获得用户同意的选择。3. 对MANAGE_EXTERNAL_STORAGE的限制加强Google Play 对申请此权限的审核越来越严格。从 Android 14 开始即使用户授予了此权限应用也无法访问某些特别敏感的目录如Android/data和Android/obb。这意味着即使拥有“全部文件访问”权限也无法随意浏览其他应用的私有数据。这进一步缩小了该权限的适用场景。未来方向Google 的意图非常清晰持续收紧对共享存储的随意访问推动应用使用更安全、更用户友好的方式MediaStore、SAF、私有目录来处理文件。作为开发者我们的最佳策略就是拥抱这个变化尽早放弃基于File路径的旧模式将代码迁移到基于Uri和ContentResolver的现代 API 上来。这不仅是为了通过审核更是为了提供更符合现代 Android 安全与隐私标准的产品体验。
返回列表