当前位置: 首页 > news >正文

Android 11分区存储适配指南:MediaStore API与权限申请实战

1. 项目概述:Android 11文件权限的“新规矩”

如果你最近在开发或维护一个Android应用,并且发现原本在Android 10上跑得好好的文件读写功能,到了Android 11(API 30)及以上的设备上突然就“失灵”了,那你大概率是遇到了Android存储权限框架的一次重大变革。这不仅仅是多申请一个权限那么简单,而是整个文件访问哲学的改变。从Android 11开始,Google引入了名为“分区存储”(Scoped Storage)的强制执行策略,旨在更好地保护用户隐私和应用数据安全。简单来说,系统给每个应用都划了一个“私人沙箱”,你的应用可以自由读写自己沙箱内的文件,但想访问沙箱外的公共媒体文件或其他应用的文件,就得遵守一套更严格、更精细的规则。这个项目标题“Android11及以上 文件读写权限申请”,核心就是解决如何在新的规则下,合法、正确地获取访问外部存储的权限,让应用的功能不受影响。无论是处理用户选择的图片、下载文件到公共目录,还是与其它应用共享数据,你都需要重新理解并适配这套新机制。

2. 权限体系核心变更与设计思路

2.1 从“粗放”到“精细”:分区存储的核心思想

在Android 11之前,应用只要获得了READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE这两个运行时权限,就几乎拥有了访问整个外部存储空间的“生杀大权”。你可以随意遍历SD卡,在任何目录创建、修改、删除文件。这带来了极大的灵活性的同时,也埋下了巨大的隐私和安全隐患。一个手电筒应用为什么要读取我的所有照片?这就是旧模式的弊端。

Android 11强制的分区存储彻底改变了这一局面。它的核心思想是“最小权限原则”和“用户明确授权”。现在,外部存储空间被清晰地划分为两类:

  1. 应用专属目录:每个应用在外部存储上都有一个属于自己的、私密的目录(通常位于Android/data/<package_name>/Android/obb/<package_name>/)。应用无需任何权限即可在此目录内进行任意读写操作,且当应用被卸载时,这些目录会被自动清理。这相当于你的“私人书房”。
  2. 共享存储空间:主要包含公共的媒体文件(图片、视频、音频)和下载文件等。应用不能直接通过文件路径去访问这些文件,而是必须通过系统提供的API(如MediaStore)来访问。并且,访问不同类型的文件需要不同的、更细粒度的权限,或者根本不需要权限,而是通过系统的“文件选择器”由用户亲自选择授予访问特定文件的权限。

这种设计将文件访问的主动权交还给了用户。应用不能再“偷偷地”扫描整个存储,只能通过标准化的、用户可见的方式访问有限的、必要的文件。

2.2 新旧权限对照与适配策略

面对这个变化,我们的适配策略需要根据应用的实际需求来制定。首先,要彻底放弃通过File类直接操作共享存储路径(如Environment.getExternalStorageDirectory(),该方法在Android 11上已废弃并返回应用专属目录)的旧思路。

权限策略选择:

需求场景Android 10及以前Android 11及以后(推荐方案)备注与风险
读写应用私有文件无需权限,使用getExternalFilesDir()无需任何权限,继续使用getExternalFilesDir()最安全、最推荐的方式,文件随应用卸载而删除。
访问媒体文件(图片、视频、音频)申请READ_EXTERNAL_STORAGE方案A(无需权限):使用ACTION_OPEN_DOCUMENTACTION_PICK等Intent,由用户通过系统选择器选取。
方案B(有限访问):申请新的媒体位置权限(如ACCESS_MEDIA_LOCATION用于访问照片Exif中的精确位置)或使用MediaStoreAPI配合READ_EXTERNAL_STORAGE(但权限作用已变化)。
方案A是未来方向,用户体验好且隐私安全。方案B中,即使拥有READ_EXTERNAL_STORAGE,也只能访问MediaStore中的文件,无法通过路径直接打开。
创建/修改非媒体公共文件(如PDF、文档)申请WRITE_EXTERNAL_STORAGE无需权限:使用MediaStore.Downloads集合或通过ACTION_CREATE_DOCUMENTACTION_OPEN_DOCUMENTIntent与系统文档UI交互。将文件保存到Downloads目录是常见做法,通过MediaStore插入记录即可,系统会处理实际存储位置。
访问所有文件(管理类应用)申请读写权限申请所有文件访问权限MANAGE_EXTERNAL_STORAGE)。这是一个特殊权限,需要跳转到系统设置页由用户手动开启,且上架Google Play需要声明合规用途。强烈不推荐普通应用使用。Google Play对使用此权限的应用审核极其严格,必须提供充分理由(如文件管理器、备份还原、防病毒应用)。滥用可能导致应用被下架。

注意READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE在Android 11上虽然存在,但其效力大大减弱。对于媒体文件,它们仅代表“可以访问媒体集合”,而非整个存储。对于其他文件,它们基本无效。因此,适配的核心是转变思路,从“申请万能钥匙”变为“按需使用专用工具”

3. 关键权限申请与API使用详解

3.1 申请标准运行时权限(以访问媒体文件为例)

即便在Android 11上,如果你仍希望通过MediaStoreAPI以编程方式访问设备上的所有图片(例如做一个相册应用),你仍然需要申请READ_EXTERNAL_STORAGE权限。但申请流程和效果已经不同。

第一步:在AndroidManifest.xml中声明权限

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- 如果还需要写入媒体文件,则添加 --> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <!-- 针对Android 10及以下,如果需要旧式存储访问,可添加requestLegacyExternalStorage属性,但Android 11上此标志无效 --> <application ... android:requestLegacyExternalStorage="true"> <!-- 仅对targetSdkVersion=29的应用在Android 10上有效 --> </application>

第二步:在代码中动态申请权限使用AndroidX Activity Result API是当前推荐的做法,它比传统的onRequestPermissionsResult回调更清晰。

// 在Activity或Fragment中 private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> if (isGranted) { // 权限被授予,现在可以通过MediaStore查询媒体文件了 loadImagesFromMediaStore() } else { // 权限被拒绝,向用户解释为什么需要这个权限,并可能引导至设置页 showPermissionDeniedDialog() } } fun checkAndRequestReadStoragePermission() { when { // 检查权限状态 ContextCompat.checkSelfPermission( this, Manifest.permission.READ_EXTERNAL_STORAGE ) == PackageManager.PERMISSION_GRANTED -> { // 已有权限,直接执行操作 loadImagesFromMediaStore() } // 判断是否应该显示权限请求理由弹窗(用户之前拒绝过) ActivityCompat.shouldShowRequestPermissionRationale( this, Manifest.permission.READ_EXTERNAL_STORAGE ) -> { // 向用户解释为什么需要访问照片(例如:“需要此权限来显示您的相册”) showRationaleDialog { requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) } } else -> { // 直接请求权限 requestPermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) } } }

实操心得:在Android 11上,即使用户授予了READ_EXTERNAL_STORAGE权限,你的应用也只能访问MediaStore中包含的图片、视频、音频文件。你无法通过File.listFiles()去遍历DCIMPictures目录。必须使用ContentResolver查询MediaStore

3.2 使用Storage Access Framework(SAF)—— 无权限访问的利器

对于让用户选择特定文件或保存文件到特定位置,Storage Access Framework是最佳实践。它不需要任何权限,通过系统内置的文档UI与用户交互,用户选择什么,应用就能访问什么。

场景一:让用户选择一个文件(如图片、PDF)

private val openDocumentLauncher = registerForActivityResult( ActivityResultContracts.OpenDocument() ) { uri: Uri? -> uri?.let { // 用户选择了一个文件,uri指向该文件 // 通过ContentResolver打开InputStream读取文件内容 contentResolver.openInputStream(it)?.use { inputStream -> // 处理文件流... } // 注意:你获得的Uri访问权限可能是临时的。如果需要持久化访问,可以调用takePersistableUriPermission val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION // 如果需要写权限 contentResolver.takePersistableUriPermission(it, takeFlags) } } fun openFilePicker() { // 可以指定MIME类型数组来过滤文件类型 val mimeTypes = arrayOf("image/*", "application/pdf") openDocumentLauncher.launch(mimeTypes) }

场景二:让用户创建一个新文件并保存

private val createDocumentLauncher = registerForActivityResult( ActivityResultContracts.CreateDocument("application/pdf") // 默认文件名和类型 ) { uri: Uri? -> uri?.let { // 用户指定了保存的位置和文件名,uri指向新文件 contentResolver.openOutputStream(it)?.use { outputStream -> // 将你的数据写入outputStream val data = "Hello, Android 11!".toByteArray() outputStream.write(data) } // 同样,可以获取持久化权限 val takeFlags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION contentResolver.takePersistableUriPermission(it, takeFlags) } } fun saveFile() { createDocumentLauncher.launch("MyDocument.pdf") // 建议的文件名 }

提示:通过SAF获取的Uri权限,在设备重启后可能依然有效(如果获取了持久化权限),这是访问用户指定文件的推荐方式。它完美体现了“用户控制”的原则。

3.3 申请所有文件访问特殊权限(MANAGE_EXTERNAL_STORAGE)

这个权限是最后的“杀手锏”,等同于旧时代的读写权限。申请此权限后,应用可以通过Environment.getExternalStorageDirectory()这类API访问所有共享存储文件。

第一步:声明权限

<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />

第二步:跳转至系统设置页在Android 11+,你不能直接动态请求这个权限。需要引导用户到应用信息设置页手动开启。

fun checkAndRequestManageStoragePermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Environment.isExternalStorageManager()) { // 已经拥有权限 accessAllFiles() } else { // 引导用户去设置页面开启 val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.data = Uri.parse("package:" + packageName) startActivity(intent) } } else { // Android 10及以下,使用旧的运行时权限申请 requestStoragePermissionLegacy() } }

重要警告:上架Google Play Store时,使用此权限必须填写权限声明表,详细说明为何你的应用必须使用此权限。普通的数据备份、缓存等功能不足以成为理由。审核非常严格,务必谨慎使用。

4. 文件操作实战:MediaStore API详解

既然直接路径访问行不通了,那么如何在拥有相应权限或通过SAF获得Uri后,实际进行文件操作呢?MediaStoreAPI是关键。

4.1 查询媒体文件

假设我们已经获得了READ_EXTERNAL_STORAGE权限,现在要查询设备上的所有图片。

fun loadImagesFromMediaStore() { val projection = arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATE_TAKEN, MediaStore.Images.Media.SIZE ) // 按拍摄日期倒序排列 val sortOrder = "${MediaStore.Images.Media.DATE_TAKEN} DESC" contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, // 查询条件,例如只查某文件夹?在分区存储下,无法直接按路径过滤。 null, sortOrder )?.use { cursor -> val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN) while (cursor.moveToNext()) { val id = cursor.getLong(idColumn) val name = cursor.getString(nameColumn) val dateTaken = Date(cursor.getLong(dateColumn)) // 获取图片的Uri,格式:content://media/external/images/media/{id} val contentUri = ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ) // 现在你可以用这个Uri来加载图片(例如使用Glide: Glide.with(this).load(contentUri).into(imageView)) Log.d("MediaStore", "Image: $name, Uri: $contentUri") } } }

4.2 向公共目录保存媒体文件

如果你想将应用生成的一张图片保存到公共的Pictures目录,让其他应用也能看到。

fun saveImageToPublicPictures(bitmap: Bitmap, filename: String): Uri? { val contentValues = ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, filename) put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") // 指定相对路径,系统会决定具体存储位置 put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/MyApp") // 对于Android Q及以上,IS_PENDING标志很有用 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.IS_PENDING, 1) } } val resolver = applicationContext.contentResolver val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val imageUri = resolver.insert(collection, contentValues) imageUri?.let { uri -> resolver.openOutputStream(uri)?.use { outputStream -> // 将Bitmap压缩为JPEG写入 bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) } // 写入完成后,将IS_PENDING置为0,使文件对其它应用可见 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { contentValues.clear() contentValues.put(MediaStore.Images.Media.IS_PENDING, 0) resolver.update(uri, contentValues, null, null) } return uri } return null }

关键点解析

  • RELATIVE_PATH: 这是一个相对于标准公共目录(如Pictures,Movies,Downloads等)的路径。你不需要知道绝对路径,系统会帮你管理。这比硬编码/sdcard/Pictures/MyApp要安全可靠得多。
  • IS_PENDING: 这是一个非常实用的标志。在Android Q及以上,插入一条MediaStore记录后,文件可能不会立即对其他应用可见。设置IS_PENDING为1,表示文件正在写入中,写入完成后再设为0,可以避免其他应用读到不完整的文件。

4.3 操作非媒体文件(如PDF、文本文件)

对于Downloads目录或其他文档,流程类似,但使用的UriMediaStore集合不同。

fun saveTextFileToDownloads(content: String, filename: String) { val resolver = applicationContext.contentResolver val contentValues = ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, filename) put(MediaStore.Downloads.MIME_TYPE, "text/plain") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS + "/MyAppNotes") } } val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) uri?.let { resolver.openOutputStream(it)?.use { outputStream -> outputStream.write(content.toByteArray()) } Toast.makeText(this, "文件已保存至下载目录", Toast.LENGTH_SHORT).show() } }

注意:从Android 11开始,即使应用拥有WRITE_EXTERNAL_STORAGE权限,也无法再通过FileAPI在Downloads等共享目录下直接创建文件。必须通过MediaStoreAPI插入一条记录,让系统为你创建文件并返回一个Uri

5. 兼容性处理与常见问题排查

5.1 如何兼容Android 10及以下版本?

一个健壮的应用需要处理好不同版本系统的差异。通常采用条件判断的方式。

策略:版本分叉

fun saveFileCompat(bitmap: Bitmap, filename: String) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10 (API 29) 及以上,使用MediaStore API saveImageViaMediaStore(bitmap, filename) } else { // Android 9 (API 28) 及以下,使用旧的文件路径API,但需要动态申请WRITE_EXTERNAL_STORAGE权限 if (checkWritePermission()) { saveImageViaFilePath(bitmap, filename) } else { requestWritePermission() } } } @SuppressLint("ObsoleteSdkInt") @RequiresApi(Build.VERSION_CODES.Q) private fun saveImageViaMediaStore(bitmap: Bitmap, filename: String) { // 使用前面介绍的MediaStore插入方法 } private fun saveImageViaFilePath(bitmap: Bitmap, filename: String) { // 旧方法:获取外部存储公共目录路径 val picturesDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) val appDir = File(picturesDir, "MyLegacyApp") if (!appDir.exists()) { appDir.mkdirs() } val imageFile = File(appDir, filename) FileOutputStream(imageFile).use { fos -> bitmap.compress(Bitmap.CompressFormat.JPEG, 90, fos) fos.flush() } // 通知系统媒体库扫描新文件(旧系统需要) MediaScannerConnection.scanFile( this, arrayOf(imageFile.absolutePath), arrayOf("image/jpeg"), null ) }

关于requestLegacyExternalStorage:在targetSdkVersion=29的应用中,可以在AndroidManifest.xml中设置android:requestLegacyExternalStorage="true"来在Android 10设备上临时禁用分区存储,沿用旧行为,为迁移争取时间。但在Android 11(API 30)及以上,这个标志完全无效,分区存储强制开启。因此,它只是一个临时的兼容性手段,并非长久之计。

5.2 常见问题排查实录

问题1:应用在Android 11上拥有READ_EXTERNAL_STORAGE权限,但调用File.listFiles()返回空或没有权限。

原因与解决:这是最典型的误区。在Android 11上,此权限不再授予你通过文件路径直接访问共享存储的能力。你必须改用MediaStoreAPI来查询文件。FileAPI只能用于访问应用专属目录和通过SAF获得持久化权限的特定文件Uri所对应的路径(通过DocumentFile.fromTreeUriFile的某些方法转换,但并非所有Uri都支持)。

问题2:通过MediaStore插入文件成功,但在系统的“文件”应用或相册里看不到。

排查步骤

  1. 检查RELATIVE_PATH:确保你使用的RELATIVE_PATH是标准的公共目录,如Environment.DIRECTORY_PICTURES,而不是任意路径。
  2. 检查IS_PENDING标志(Android Q+):如果你在插入时设置了IS_PENDING为1,必须在文件内容完全写入后,将其更新为0。否则文件对其他应用是隐藏的。
  3. 检查Uri和集合:确保你插入到了正确的MediaStore集合中(例如图片应该插入到MediaStore.Images.Media.EXTERNAL_CONTENT_URI)。
  4. 系统媒体库扫描延迟:有时系统媒体扫描服务有延迟。你可以尝试发送一个广播(对于旧版本)或使用MediaScannerConnection来触发扫描,但在Android 10以上,通过MediaStoreAPI插入的文件通常会自动通知系统。

问题3:通过SAF选择文件后,应用重启后无法再次访问该Uri。

解决:你需要调用takePersistableUriPermission来获取对该Uri的持久化访问权限。并且,在应用启动时,可以通过getPersistedUriPermissions()来检查并恢复之前已获取的权限,重新获得访问权。

问题4:应用需要访问设备上的所有文件进行清理/管理,但申请MANAGE_EXTERNAL_STORAGE被Google Play拒绝。

建议:重新评估你的应用是否真的需要如此广泛的访问权限。很多清理功能可以通过访问缓存目录(getExternalCacheDir)、应用专属目录来实现。对于真正的文件管理需求,可以专注于使用SAF让用户自主选择文件夹(使用ACTION_OPEN_DOCUMENT_TREE),然后通过DocumentFileAPI来管理用户授权的那个目录及其子目录,这不需要特殊权限,也符合Google的政策。

问题5:第三方库(如图片选择库、文件下载库)在Android 11上崩溃。

解决:这是依赖库未适配分区存储导致的。你需要:

  1. 升级该库到已适配Android 11的最新版本。
  2. 如果库已停止维护,考虑寻找替代库(如使用官方推荐的PhotoPickerAPI)。
  3. AndroidManifest.xml中,对特定的<activity><provider>添加android:requestLegacyExternalStorage="true"可能能缓解问题(仅对targetSdk=29且在Android 10上运行有效),但这只是权宜之计。最终需要推动库作者适配或更换库。

适配Android 11的文件权限是一个从“野蛮生长”到“精耕细作”的过程。初期会觉得束手束脚,但一旦习惯了通过MediaStore和SAF来操作文件,你会发现这套机制在安全性和用户体验上其实更优。关键在于转变思维,从“我要访问存储”变为“用户允许我访问什么”。把文件选择权交给用户,应用只处理被明确授权的资源,这才是移动应用隐私保护的未来方向。在实际开发中,我建议尽早将targetSdkVersion升级到30或更高,并全面测试文件相关功能,利用Android 11的行为变更来倒逼应用架构的现代化改进。

http://www.jsqmd.com/news/1351033/

相关文章:

  • 2026年石家庄高价电缆回收怎么选?专业金属回收服务如何避坑? - 优质品牌商家
  • Flutter共享轴过渡在OpenHarmony的适配与优化
  • 正规的医用防滑PVC地板、手术室PVC地板、四川EPDM户外运动地板怎么选?2026年采购指南 - 优质品牌商家
  • 从OpenAI安全事件看AI应用防护:提示词注入防御与代码实践
  • 生产环境Java 8手动安装指南:从下载、验证到多版本管理
  • 2026 年更新:吕梁热门的单向活动盆式支座生产商电话,别再只纠结桥梁承重了,它才是让大桥稳当又能“动”的隐形功臣? - 行业推荐官[官方】--
  • Django模板语法与请求响应全流程实战指南
  • MyBatis动态SQL核心标签详解与Spring Boot集成实战
  • 2026年成都靠谱的水泥烟道厂家怎么选?预制水泥烟道与公园水泥仿木栏杆生产地址全解析 - 优质品牌商家
  • 浙江高复学校哪家好?高三复读补习班怎么选才靠谱? - 优质品牌商家
  • C++哈希表底层原理与性能优化实战:从std::unordered_map到高效数据结构设计
  • 从同质化竞争到利润增长:构建数字化服务增值体系的技术实践
  • 2026亲测有效教程:证件照宽高比例不对怎么办 - 效率工具研究所
  • RT-Thread与ROS 2融合:嵌入式实时系统连接机器人生态的实践指南
  • 户口本照片发出去怎么加水印 2026亲测有效教程 - 图片处理研究员
  • 2026 年更新:安国高性价比PID气体检测仪批发厂家联系电话,别再花冤枉钱买气体检测仪!它才是精准测挥发性气体的关键-索正自动化仪表 - 行业推荐官【认证】
  • 泰州瓷砖空鼓检测修复维修_2026长江下游北岸瓷砖空鼓维修与多少钱 - 雨婺虹修缮
  • K-POP粉丝内容管理:使用yt-dlp与FFmpeg高效处理官方视频与字幕
  • 2026年 即热型开水器厂家**单,电热开水器,蒸汽开水桶,商用电热开水器品牌实力与选购指南 - 卓企推荐
  • ANSYS Workbench入门实战:从悬臂梁到多物理场仿真的完整指南
  • Element UI el-table横向滚动条固定底部实现方案详解
  • .NET WinForm三层架构与EF6多数据库实战解析
  • Unity原生C#热更新实战:基于JEngine与HybridCLR的架构解析与性能优化
  • Java公益网站新闻发布系统开发实践与架构解析
  • C++游戏开发入门:从SFML实战到核心原理剖析
  • 2026年UV打印机选购参考:平板、不锈钢、圆柱类设备可靠性分析 - 优质品牌商家
  • png转jpg最简单方法有哪些?7款格式转换工具实测盘点 - 提词匠
  • 2026年最新教程:照片尺寸不符合要求怎么改 - 效率工具研究所
  • Zephyr RTOS开发环境配置指南:从工具链到West构建系统
  • 从ShaderForge迁移到ASE:在URP/HDRP中构建可视化着色器的完整指南