☰
Android存储权限适配指南:版本演进与Scoped Storage实践
2026/10/3 6:02:21 网站建设 项目流程

搞 Android 开发最绕不开的就是“Android各个版本存储权限适配”这道坎。从 Android 6.0 的动态权限,到 7.0 的 FileProvider,再到 10.0 的 Scoped Storage、13.0 的细粒度媒体权限,每一代系统升级本质上都是对数据访问边界的重新划分。这篇文章我打算把这么多年踩过的坑、验证过的方案一次性整理出来,重点说清楚每个版本该用什么方式读写文件、为什么这么改,以及上线前怎么自查。

我见过太多项目在“存储权限”上翻车:有的在 Android 10 上文件写不进去,有的在 Android 11 上 requestLegacyExternalStorage 突然失效,有的在 Android 13 上 READ_EXTERNAL_STORAGE 权限根本不弹窗,还有的因为 FileProvider 路径配错导致拍照闪退。这些问题看起来零散,但背后其实是一条清晰的演进线。把这根线理清楚,后面就很少会慌。

1. 先搞懂存储权限的版本演变脉络

1.1 为什么存储权限适配一直是重灾区

先说结论:存储权限适配难,难在“旧代码太顺手”和“新系统不兼容”之间的矛盾。早期 Android 开发对公共存储区几乎是透明的,直接 new File("/sdcard/xxx") 就能读写,拿到 WRITE_EXTERNAL_STORAGE 权限更是“一条路走到黑”。可随着隐私保护加强,系统开始把“公共区域”和“应用私有区域”分开,从 Android 10 开始逐步推行分区存储(Scoped Storage),意图就是让应用只能访问自己的专属目录和公共媒体文件。

很多老项目一直把 targetSdkVersion 停在低版本,等到应用市场要求必须升 target 时,中断两三年的适配问题集中爆发。Google Play 的 target API 要求逐年提高,国内各应用市场也在跟进,很多团队不得不把“存储权限适配”当成一次专项来做。这个过程中最常见的心态是:我不要懂原理,只要能跑。可如果你不了解版本差异,连报错都看不懂。比如 Android 11 上应用突然看不到 /sdcard/Download 下的文件,不是你代码写错,而是系统把无权限访问外部存储这块口子彻底焊死了。

1.2 一张表理清 Android 各版本关键变化

系统版本关键变化对业务的影响
Android 6.0(API 23)引入运行时权限机制,读写外存从安装时授予改为运行时申请新增权限申请流程,getGrantedPermissions 不再能跳过
Android 7.0(API 24)推出 FileProvider,禁止应用间传递 file:// URI跨应用共享文件必须改用 content:// URI
Android 8.0(API 26)外部存储行为没有大改,但通知渠道等新特性开始分流对存储适配影响较小,主要是兼容包版本要求变化
Android 9(API 28)继续收紧私有目录访问方式,限制隐藏 API 使用依赖反射拿路径的代码开始受影响
Android 10(API 29)引入 Scoped Storage,targetSdk 29 默认启用分区存储;提供 requestLegacyExternalStorage 临时开关公共目录不能直接 File 写,必须用 MediaStore 或 SAF
Android 11(API 30)Scoped Storage 强制开启,requestLegacyExternalStorage 失效;新增 MANAGE_EXTERNAL_STORAGE 所有文件访问权限老方案全部作废,必须迁移到分区存储模式
Android 12(API 31)MANAGE_EXTERNAL_STORAGE 申请流程调整,文件路径读取更严格全文件访问类应用上架审核更难过
Android 13(API 33)READ_EXTERNAL_STORAGE 拆分为 READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO权限声明要按媒体类型细化,否则不会弹对应弹窗
Android 14(API 34)新增 READ_MEDIA_VISUAL_USER_SELECTED,用户可只授权部分照片/视频需要适配“部分授权”场景,否则崩溃或功能异常

这张表不是让你背下来,而是排查问题时先对着系统版本看一遍。我遇到过很多次同事过来说“代码没动,怎么在测试机上又崩了”,一问测试机是 Android 14,后来一查发现是系统版本变了,权限模型变了。实际开发中,我会在项目里保留一份类似的“版本兼容矩阵”,每适配一次新系统就往里补一行,包括权限声明、存储访问方式、FileProvider 配置、SAF 使用入口,这样当新同事接手存储相关需求时,不用从头摸索,直接查表看当前页面需要用哪套方案。

2. 权限声明与运行时权限申请的正确姿势

2.1 Manifest 里那些权限到底怎么配

Manifest 声明是适配的第一步,但也最容易写错。常见误区是不管系统版本一口气全写上,Android 13 设备会弹一堆权限,Android 8 设备反而因为多声明了未识别权限遇到底层问题。更规范的做法是按 sdkVersion 限制权限生效范围。

<!-- Android 10 及以下需要读外存 --> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <!-- 写权限在 Android 10 以下还有实际作用 --> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" /> <!-- Android 13 及以上细粒度媒体权限 --> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <!-- 仅文件管理类应用才考虑,普通应用不建议声明 --> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />

为什么 READ_EXTERNAL_STORAGE 的 maxSdkVersion 写 32?因为 Android 13 上即使声明了 READ_EXTERNAL_STORAGE,系统也不会再把它当作有效权限授予,弹窗也不会出现。保留这个声明只是为了让 targetSdk 较低的旧设备能兼容,到 Android 12L 及以下才生效。而 WRITE_EXTERNAL_STORAGE 在 Android 10 以上,系统会把写权限降级成与读权限等价,到了 Android 11 后再没有任何增益,所以 maxSdkVersion 限制在 29 就够了。

提示:不要一时图省事把 MANAGE_EXTERNAL_STORAGE 也加上。这个权限对应“所有文件访问”,普通应用申请后,应用市场会以“权限滥用”为由拒绝上架,即使通过了,用户看到也会担心。

2.2 动态申请权限兼容 6.0 到 14.0

动态申请这块,我不建议在 Activity 里到处写 permission 判断。封装成一个工具方法,统一传回调,后面维护起来会轻松非常多。下面是兼容到 Android 14 的申请逻辑。

fun requestStoragePermission(activity: Activity, callback: (Boolean) -> Unit) { val permissions = mutableListOf<String>() when { Build.VERSION.SDK_INT >= 33 -> { permissions.add(Manifest.permission.READ_MEDIA_IMAGES) permissions.add(Manifest.permission.READ_MEDIA_VIDEO) permissions.add(Manifest.permission.READ_MEDIA_AUDIO) } else -> { permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE) permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) } } val unGranted = permissions.filter { ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED } if (unGranted.isEmpty()) { callback(true) return } ActivityCompat.requestPermissions(activity, unGranted.toTypedArray(), REQUEST_CODE_STORAGE) }

回调里要注意 Android 14 的“部分访问”模式。用户在 Android 14 上第一次请求图片权限时,系统弹窗里会出现“选择照片和视频”,用户可能只给了部分媒体文件的访问权。此时 READ_MEDIA_IMAGES 和 READ_MEDIA_VIDEO 可能都返回已授予,但真正能访问的集合是受限制的,所以还要额外判断是否有 READ_MEDIA_VISUAL_USER_SELECTED 权限。

private fun hasPartialMediaAccess(context: Context): Boolean { return Build.VERSION.SDK_INT >= 34 && ContextCompat.checkSelfPermission( context, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED ) == PackageManager.PERMISSION_GRANTED }

如果发现应用只拿到部分访问权,界面不要直接崩,应该引导用户到系统设置把权限改成“允许访问所有照片和视频”。官方也建议对这种场景做降级处理,比如在相册页显示“仅可访问部分内容”的提示条,同时提供“去设置”按钮。这个细节在真机适配中非常关键,我在开发时曾因没处理部分授权,用户选完照片后返回列表直接空指针,后来加了权限状态监听和重新加载逻辑才好。

3. 分区存储(Scoped Storage)适配实操

3.1 选对目录:应用专属目录、公共目录的读写差异

分区存储最核心的变化是“不能再用绝对路径访问别人的目录”。在 Android 10 之前,new File("/storage/emulated/0/Download/xxx.pdf") 随手可写;Android 10 之后,这个路径在 targetSdk 29 及以上基本就废了。但你并不需要所有文件都走 MediaStore,因为系统给了你一块“自留地”。

应用专属目录包括 getExternalFilesDir() 和 getCacheDir(),它们在外部存储中的实际路径形如 /storage/emulated/0/Android/data/包名/files。这里面的文件不需要申请任何存储权限,可以随意 File 读写。比如无网络时下载的临时文件、日志、加密后的用户资料,都能放在这里。缺点是:应用被卸载后这些文件会被系统清除,用户通过文件管理器也容易找不到。所以“用户主动需要看到”的场景,比如导出报表、下载歌曲、保存图片,才需要放到公共目录。

公共目录在分区存储下大致分成两类。一类是媒体集合,包括图片、视频、音频,通过 MediaStore 访问;另一类是“非媒体文件”,比如 PDF、ZIP、APK,这类文件如果放在 Download 目录,通常要使用系统文件选择器(SAF)或 MediaStore.Downloads 接口去写。很多老代码上来就 new File("/sdcard/Download"),在 Android 10 上一写一个 SecurityException,就是没搞清楚这两类访问方式的差别。

3.2 用 MediaStore 往公共目录写文件

往相册写一张图片,是比较典型的操作。为了避免 Android 10 和 Android 11 的差异,推荐统一走 MediaStore API,不要自己拼路径。核心思路是:先插入一条“元数据记录”,得到一个 content:// URI,再通过 ContentResolver 打开输出流写二进制数据。

val values = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, "export_${System.currentTimeMillis()}.jpg") put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") if (Build.VERSION.SDK_INT >= 29) { put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/MyApp") } else { put(MediaStore.Images.Media.DATA, Environment.getExternalStorageDirectory().absolutePath + "/Pictures/MyApp/export_xxx.jpg") } } val uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) if (uri != null) { contentResolver.openOutputStream(uri)?.use { output -> output.write(bitmapData) } }

这里有个重点:RELATIVE_PATH 是 Android 10 才有的字段,Android 9 及以下还需要用 DATA 字段写绝对路径。如果你在 if (Build.VERSION.SDK_INT >= 29) 分支里漏了 else,老设备会出现图片插入成功但文件不落盘的问题。另外,RELATIVE_PATH 的顶层目录不能乱传,系统只允许 PICTURES、DCIM、DOWNLOAD、MOVIES 等固定目录,子目录可以自己创建。

3.3 读取公共目录媒体文件

读取场景同样不建议拼 File 路径。先从 MediaStore 查询得到 content:// URI,再打开输入流。查询时可以按需要过滤。

val cursor = contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME), MediaStore.Images.Media.MIME_TYPE + "=?", arrayOf("image/jpeg"), MediaStore.Images.Media.DATE_ADDED + " DESC" ) cursor?.use { c -> while (c.moveToNext()) { val id = c.getLong(c.getColumnIndex(MediaStore.Images.Media._ID)) val contentUri = ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ) // 用 contentUri 去加载图片,而不是自己拼 /storage/emulated/0/... } }

有些同学会尝试拿 MediaStore 查询出来的 DATA 字段拼路径去读,这在 Android 10 部分设备上可能能成功,但在 Android 11 上基本不允许,因为 DATA 字段默认不再填充实际路径。稳妥的方案就是始终通过 ContentResolver 打开文件,把“底层路径”当成黑盒。这样既兼容分区存储,也减少 ROM 厂商差异带来的偶发问题。

3.4 旧项目临时保命:requestLegacyExternalStorage

如果你维护的是历史遗留项目,短时间内又改不动 MediaStore 那一大堆代码,Android 10 还有一个临时开关:在 manifest 的 application 节点加 android:requestLegacyExternalStorage="true"。

<application android:requestLegacyExternalStorage="true" ... > </application>

加上之后,targetSdk 29 的应用在 Android 10 上可以继续使用旧的文件读写方式,也就是相当于临时关闭了分区存储。这个开关只在 Android 10 上有效,Android 11 及以上会被系统忽略。所以它不能作为长期方案,只适合给你争取一到两周的迁移时间。

注意:如果应用 targetSdkVersion 已经升到 30,那么即使加上 requestLegacyExternalStorage,也是无效的。Android 11 开始,系统不再允许任何应用退回旧模式。不要再幻想通过这个字段绕过分区存储。

4. 典型场景:文件共享、下载目录与相册访问

4.1 FileProvider 配置与 content:// URI 转换

跨应用共享文件是另一个高频适配点,比如拍照传原图、分享日志、打开下载的APK。自从 Android 7.0 禁止以 file:// URI 形式传给其他应用后,FileProvider 就成了标准方案。但网络搜索框里频繁出现类似 content://com.tencent.wework.fileprovider/external_path/android/data/com... 的内容,这就是不同应用把自己的 FileProvider 配置暴露出来了,提示我们:路径映射规则设计不好,很容易泄漏内部目录结构。

一个比较完整的 FileProvider 配置是这样:

<provider android:name="androidx.core.content.FileProvider" android:authorities="${applicationId}.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>
<!-- res/xml/file_paths.xml --> <paths> <files-path path="files/" name="internal_files" /> <cache-path path="cache/" name="cache_files" /> <external-path path="Download/" name="download_root" /> </paths>

代码里从 File 转 content URI 时,不要 new Uri 自己拼,直接用 FileProvider.getUriForFile。

val file = File(context.getExternalFilesDir(null), "user_report.pdf") val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file )

注意 file_paths.xml 里配置的 path 必须能匹配你实际要共享的文件目录。如果 File 在 getExternalFilesDir 下,而 paths 里只配了 external-path 的 Download,那么 getUriForFile 会抛 IllegalArgumentException:Failed to find configured root。排查时别看代码半天,先看 XML 路径是不是漏了层级。

4.2 通过 SAF 访问任意目录

如果业务需要用户主动选择“把文件保存到任意目录”,比如文件管理器、备份工具,MediaStore 不够用,系统文件选择器(SAF)才是正解。SAF 的核心是让用户授权你访问某个目录或文件,而不是你盲目申请所有文件权限。

访问目录的代码很简单。

val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) startActivityForResult(intent, REQUEST_CODE_OPEN_TREE)

在 onActivityResult 里拿到 Uri 后,如果希望以后还能继续访问,需要申请持久权限。

contentResolver.takePersistableUriPermission( data.data!!, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION )

SAF 模式下,你可以通过 DocumentFile 去遍历目录、创建子文件,操作体验和 File API 很像。比如往用户选择的目录下新建 PDF:

val docFile = DocumentFile.fromTreeUri(context, treeUri) val newFile = docFile?.createFile("application/pdf", "report.pdf") newFile?.uri?.let { uri -> contentResolver.openOutputStream(uri)?.use { output -> output.write(pdfBytes) } }

SAF 在 Android 4.4 就有了,兼容范围极广,很适合作为“文件下载到任意目录”的统一方案。缺点是交互多了一步,用户必须手动选择目录,体验上比直接写 /sdcard/Download 要繁琐。所以产品设计时要想清楚:是不是真的需要让用户选择路径,还是固定在 App 内部目录即可。

4.3 特殊场景:管理所有文件(MANAGE_EXTERNAL_STORAGE)

有的需求确实绕不开“读取所有文件”,比如文件管理器、杀毒、备份、系统清理类应用。Android 11 引入的 MANAGE_EXTERNAL_STORAGE 权限,就是给这类应用用的。申请方式比较特殊,不能直接 requestPermissions,需要跳到系统设置页。

if (Build.VERSION.SDK_INT >= 30) { val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.data = Uri.parse("package:${context.packageName}") context.startActivity(intent) }

判断是否有权限可以用 Environment.isExternalStorageManager()。这个权限一旦获得,应用就能访问外部存储中几乎所有文件,分区存储在它面前相当于被绕开。但代价也很大:应用市场上架审核非常严格,Google Play 要求必须证明应用核心功能与文件管理强相关,国内主流市场同样如此。普通应用如果仅仅为了下载一个 PDF 就申请这个权限,基本就是自找麻烦。

提示:还有一种情况是 targetSdk 29 时申请 MANAGE_EXTERNAL_STORAGE 需要 manifest 里声明,并且部分厂商可能限制跳转。我建议普通应用能不用就不用,优先考虑 MediaStore + SAF,既能满足用户需求,也不会被审核卡脖子。

5. 常见坑与排查技巧实录

5.1 SecurityException 和 Permission Denial 的排查套路

存储适配最常见的报错是 SecurityException,常见文案有 Permission Denial、open failed: EACCES 等。遇到这类问题,先不要盲目加权限,按下面几步排查。

第一,确认 targetSdkVersion 是多少。把 build.gradle 里的 targetSdk 提上来后,系统行为会立刻切换,这是最大的变量。第二,确认当前 Android 版本是几,Android 10 和 Android 11 的同一段代码结果可能完全不同。第三,先看日志里的“访问路径”,如果是 /storage/emulated/0/Android/data/包名/files 还存在,说明应用还没完全迁移到分区存储;如果是 /storage/emulated/0/Download,要确认你用的是 MediaStore 还是直接 File。第四,检查是不是真的拿到了权限,动态权限弹窗被用户拒绝后,应用第二次申请会直接走 shouldShowRequestPermissionRationale 流程,不是代码报错。

我在实际项目里还遇到过一种很隐蔽的情况:同一个 APK 在模拟器上运行正常,在部分国产手机上却读不到图片。原因不是权限没申请,而是 MIUI、Flyme 等 ROM 在“拍照”“相册”权限之外还加了“后台弹出界面”“访问媒体收藏”等附加开关。这类问题靠常规代码无法完全规避,只能在厂商 ROM 上多做真机测试,并在设置页做好引导提示。

5.2 厂商 ROM 带来的额外差异

国内 Android 生态的碎片化比国外严重得多。同样的 targetSdk 29,华为 EMUI 对分区存储的处理可能和小米 MIUI 有区别;Android 13 的媒体权限,在 ColorOS 某些版本上可能不会弹出“选择照片和视频”的选项,而是直接给全部或拒绝。遇到这类问题,先不要怀疑代码,先在原生 Android 模拟器上跑一遍,如果原生没问题,那就是厂商适配差异。

处理厂商差异,我一般会建一个“权限状态检查工具类”,把各种 ROM 的特殊权限判断集中管理。比如在华为上判断是否信任应用、在小米上判断“显示在顶部”权限,等等。存储权限这块不需要过度设计,但“相册访问失败时给出一个明显的跳设置入口”是非常必要的兜底策略。另外,建议收集线上异常时把设备型号、ROM 版本、targetSdkVersion 一起上报,否则问题定位会非常痛苦。

5.3 上线前自查清单

适配做完之后,强烈建议在发布前用“真机 + 模拟器”结合的方式过一遍自查。下面是我整理的自查表。

检查项预期结果
targetSdkVersion 是否符合目标版本要求升级后行为符合该版本预期
Manifest 权限声明是否按 API 分级Android 13 设备能看到媒体权限弹窗
动态申请是否处理了拒绝/不再询问有引导去设置页的入口
Android 14 是否处理部分授权用户选择部分照片后,功能不崩溃
保存相册功能在 Android 10/11/13 上是否正常图片能写入并出现在相册
跨应用 FileProvider 分享是否正常分享不闪退,目标应用能打开
应用卸载后专属目录是否清理干净无残留文件
厂商 ROM 和原生模拟器行为是否一致有差异时有兜底引导

这张表看起来简单,每一条都对应着上线后的线上事故。我在一个项目里就是因为漏了 Android 14 的部分授权判断,用户反馈“选完照片后列表空白”,最后排了一个通宵才能确认问题。从那以后,我把存储适配单独列成了一个模块,每次系统版本更替都会提前看 release note 排查。这里也想提醒一点:不要等到应用市场强制要求才升级 targetSdk,那时候业务压力会非常大,不如每个季度留一个固定版本做系统兼容性回归。

存储权限适配这件事,说到底就是“理解系统边界 + 按版本调整策略”。没有一劳永逸的适配,只有不断迭代的心态。希望这篇文章能帮你把 Android 6.0 到 Android 14 这条线串起来,少踩几个我踩过的坑。最后再分享一个小技巧:如果团队有多个应用,建议把存储访问相关代码抽成独立 Module,统一封装 MediaStore、SAF、权限请求和 FileProvider,不要在每个 App 里复制粘贴。真遇到新版本发布,只需要改一个地方,就能同步升级到所有项目,省下的时间远比你想象得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询