1. 项目概述Android存储访问的“分水岭”如果你是从Android 9Pie或更早版本开发过来的“老鸟”最近在适配Android 10Q及以上版本时大概率被存储权限问题搞得焦头烂额。没错从Android 10开始谷歌引入的“分区存储”Scoped Storage机制彻底改变了应用访问外部存储特别是公有目录如DCIM、Downloads、Pictures等的规则。这不再是一个简单的权限声明问题而是一次开发范式的转变。简单来说以前你申请个READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限就能在SD卡上“为所欲为”现在这条路被堵死了应用默认只能访问自己沙箱内的私有目录和通过特定API如MediaStore、SAF申请到的公共媒体文件。这个变化的核心目的是增强用户隐私和数据安全防止应用随意扫描、读取用户的所有文件。但对于我们开发者而言它意味着必须重新学习一套新的“交通规则”。很多原有的文件操作逻辑比如直接使用File对象路径去访问相册图片、下载文件夹里的文档在Android 10及以上设备上如果不做适配就会直接抛出FileNotFoundException。因此“Android10及以上访问公有目录”这个主题是当前Android开发中一个无法绕开、且必须熟练掌握的实战技能点。无论你是要开发一个图片选择器、文档管理器还是仅仅需要让用户保存一个配置文件到公共下载目录都需要理解并应用这套新规则。2. 核心机制解析分区存储与MediaStore要解决访问问题必须先理解背后的机制。Android 10的分区存储将外部存储空间划分为两类应用私有目录和共享存储空间。2.1 应用私有目录你的“自留地”每个应用在外部存储上都有一个专属的目录路径通常为/storage/emulated/0/Android/data/package_name/。在这个目录下应用拥有完整的读写权限无需任何特殊权限申请其他应用也无法直接访问除非拥有MANAGE_EXTERNAL_STORAGE权限或系统应用。这里适合存放应用的缓存、临时文件、用户私有数据等。在Android 10上访问这里和以前没有区别。2.2 共享存储空间需要“通行证”的公共区域我们常说的“公有目录”如DCIM、Pictures、Downloads、Documents、Movies、Music等就位于共享存储空间中。Android 10及以后应用默认无法通过直接文件路径FileAPI访问这些目录下的文件。取而代之的是必须通过内容提供者MediaStoreAPI来访问。MediaStore是一个系统级的内容提供者它像一个中央数据库索引了共享存储空间中的所有媒体文件图片、视频、音频和下载文件。应用通过ContentResolver向MediaStore发起查询query、插入insert、更新update、删除delete操作来间接地访问文件。你获取的不再是一个直接的file://路径而是一个Uri如content://media/external/images/media/12345。通过这个Uri你可以用ContentResolver.openInputStream或openOutputStream来读写文件内容。关键区别以前你操作的是File对象和路径字符串现在你操作的是Uri和ContentResolver。这是一个根本性的思维转变。例如你想获取相册里所有的JPEG图片以前你可能遍历/storage/emulated/0/DCIM/目录现在你需要查询MediaStore.Images.Media.EXTERNAL_CONTENT_URI并设置MIME_TYPE为image/jpeg。2.3 权限模型的演变伴随着存储模型的改变权限也同步升级READ_EXTERNAL_STORAGE在Android 10及以上这个权限的作用范围被大大缩小。它仅允许应用读取媒体文件图片、视频、音频而不包括其他类型的文件如PDF、TXT等。并且即使拥有此权限也必须通过MediaStoreAPI来访问。WRITE_EXTERNAL_STORAGE在Android 10及以上对于媒体文件应用在自身创建的媒体文件集合如图片集内进行写入时可能不再需要此权限取决于targetSdkVersion。但对于非媒体文件或修改其他应用创建的媒体文件通常仍需要。但最佳实践是优先使用MediaStoreAPI系统会在背后处理权限。MANAGE_EXTERNAL_STORAGE这是一个“核弹级”的权限在Android 11API 30中引入。它允许应用绕过分区存储限制访问共享存储空间中的几乎所有文件有一些例外如Android/data等目录。但谷歌对这项权限的使用有极其严格的限制仅适用于文件管理器、备份恢复、防病毒等“真正需要广泛文件访问”的核心应用。普通应用如果申请此权限在Google Play上架时会面临审查甚至被拒绝。对于大多数应用应极力避免使用此权限。注意targetSdkVersion的设置至关重要。如果你的应用targetSdkVersion 29Android 10系统会以“兼容模式”运行你仍然可以使用旧的文件路径方式但这不是长久之计。谷歌Play商店早已要求新应用必须target到更高版本。因此主动适配是唯一正确的选择。3. 实战适配四种访问公有目录的路径理解了理论我们进入实战。根据不同的场景访问公有目录有几种主流方案。3.1 方案一使用MediaStore API推荐首选这是谷歌官方主推的、最符合分区存储设计理念的方案。适用于访问和操作媒体文件图片、视频、音频以及Downloads目录中的文件。场景示例从相册选择一张图片并显示。声明权限如果需要在AndroidManifest.xml中声明READ_EXTERNAL_STORAGE。注意从Android 13API 33开始读取媒体文件被细分为READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_MEDIA_AUDIO三个权限需要根据实际访问的文件类型声明。!-- For Android 12L (API 32) and lower -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / !-- For Android 13 (API 33) and higher -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES /在运行时需要向用户申请这些权限。查询媒体文件使用ContentResolver.query()方法。val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATE_TAKEN ) val sortOrder ${MediaStore.Images.Media.DATE_TAKEN} DESC val cursor contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, sortOrder ) cursor?.use { val idColumn it.getColumnIndexOrThrow(MediaStore.Images.Media._ID) val nameColumn it.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) while (it.moveToNext()) { val id it.getLong(idColumn) val name it.getString(nameColumn) val contentUri ContentUris.withAppendedId( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id ) // 现在你有了 contentUri可以用来加载图片 // 例如使用 Glide: Glide.with(this).load(contentUri).into(imageView) } }创建新媒体文件使用ContentResolver.insert()。val values ContentValues().apply { put(MediaStore.Images.Media.DISPLAY_NAME, MyNewImage_${System.currentTimeMillis()}.jpg) put(MediaStore.Images.Media.MIME_TYPE, image/jpeg) // 指定文件保存的目录RELATIVE_PATH在API 29可用 if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /MyApp) } } val uri contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) uri?.let { contentResolver.openOutputStream(it)?.use { outputStream - // 将你的图片字节数据写入 outputStream // bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) } }实操心得使用RELATIVE_PATH可以让你将文件保存到公共目录下的子文件夹中非常方便。系统会自动创建不存在的目录。3.2 方案二使用存储访问框架SAFStorage Access Framework (SAF) 适用于需要用户明确选择文件或目录的场景特别是处理非媒体文件如PDF、DOCX等或让用户指定一个持久化的目录访问权限。它的核心是Intent.ACTION_OPEN_DOCUMENT,Intent.ACTION_CREATE_DOCUMENT, 和Intent.ACTION_OPEN_DOCUMENT_TREE。场景示例让用户选择一个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。你可以直接使用这个Uri来读取文件内容。更重要的是系统会授予你的应用对该Uri的持久化访问权限直到设备重启或你调用ContentResolver.releasePersistableUriPermission。下次应用启动你仍然可以通过这个Uri访问文件无需再次请求用户选择。场景示例请求长期访问某个目录如Downloads的所有文件。val intent Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) startActivityForResult(intent, REQUEST_CODE_OPEN_DIRECTORY)用户授权后你可以通过DocumentFile.fromTreeUri获取DocumentFile对象然后遍历、创建、删除该目录下的文件。这对于文件管理器类应用是必备技能。注意事项SAF的UI由系统提供不同厂商的设备界面可能略有差异。它的优点是用户控制力强符合隐私规范缺点是流程相对冗长无法在后台静默访问特定文件。3.3 方案三使用应用专属外部存储目录在Android 10上你仍然可以在外部存储的“根目录”下创建属于你应用的目录但方式变了。你需要使用Context.getExternalFilesDir()或Context.getExternalCacheDir()。这些目录位于Android/data/package_name/下是应用私有的不需要任何权限即可读写。当应用被卸载时这些目录会被自动清理。虽然这不是“公有目录”但有时你可以利用它作为中转站。例如你可以将网络下载的图片先保存到私有目录然后通过MediaStoreAPI将其“发布”到公共相册。3.4 方案四申请MANAGE_EXTERNAL_STORAGE权限最后的选择如前所述这是下下策。如果你的应用确实需要广泛的文件访问例如开发一个本地文件搜索工具可以尝试申请。在AndroidManifest.xml中声明权限uses-permission android:nameandroid.permission.MANAGE_EXTERNAL_STORAGE /在代码中检查并请求权限if (Build.VERSION.SDK_INT Build.VERSION_CODES.R) { if (!Environment.isExternalStorageManager()) { val intent Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.data Uri.parse(package:${packageName}) startActivity(intent) } }用户会被引导到系统设置页手动开启“允许管理所有文件”的开关。这个开关非常显眼会降低用户信任度。强烈建议在提交Google Play审核前务必在AndroidManifest.xml中为使用此权限提供合理的理由声明queries或权限用途说明否则极有可能被拒。4. 兼容性处理与降级策略一个成熟的应用必须考虑Android 10以下版本的兼容性。我们的策略通常是高版本用新APIMediaStore/SAF低版本用老APIFile路径传统权限。4.1 版本判断与分支逻辑我们可以通过Build.VERSION.SDK_INT来执行条件代码。fun saveImageToPublicGallery(bitmap: Bitmap, context: Context): Boolean { return if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { // Android 10 使用 MediaStore API saveImageViaMediaStore(bitmap, context) } else { // Android 9- 使用传统文件路径方式 // 1. 确保有 WRITE_EXTERNAL_STORAGE 权限 // 2. 使用 Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) saveImageViaFile(bitmap, context) } }4.2 权限申请的兼容性权限申请代码也需要区分fun requestStoragePermission(activity: Activity) { val permissionsToRequest mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.P) { // Android 9及以下申请读写权限 permissionsToRequest.add(Manifest.permission.WRITE_EXTERNAL_STORAGE) // 如果需要读也加上 READ permissionsToRequest.add(Manifest.permission.READ_EXTERNAL_STORAGE) } else if (Build.VERSION.SDK_INT Build.VERSION_CODES.S_V2) { // Android 12L // Android 10 ~ 12L申请读权限用于MediaStore permissionsToRequest.add(Manifest.permission.READ_EXTERNAL_STORAGE) } else { // Android 13申请细分的媒体权限 permissionsToRequest.add(Manifest.permission.READ_MEDIA_IMAGES) // 根据需求添加 READ_MEDIA_VIDEO, READ_MEDIA_AUDIO } if (permissionsToRequest.isNotEmpty()) { activity.requestPermissions(permissionsToRequest.toTypedArray(), REQUEST_CODE_PERMISSION) } }实操心得将文件访问逻辑封装到一个独立的工具类如StorageAccessHelper中内部根据SDK版本进行分发。这样业务代码只需调用统一接口如helper.saveImage(bitmap)无需关心底层实现细节大大降低了代码的复杂度和维护成本。5. 常见问题排查与实战技巧在实际开发中你会遇到各种“坑”。下面是我总结的一些典型问题及其解决方案。5.1 问题一获取到的Uri无法直接转换成File路径这是最常见的问题。通过MediaStore或SAF获取的content://Uri不能直接通过Uri.getPath()或File(String)构造函数来操作。尝试这样做会得到类似/document/image:12345的路径根本不是有效的文件系统路径。解决方案放弃使用File类。对于MediaStoreUri使用ContentResolver.openInputStream(uri)或openOutputStream(uri)来获取流进行读写。对于通过SAF获取的Uri使用DocumentFile相关API或者同样使用ContentResolver打开流。特殊情况如果你必须得到一个绝对路径例如某些第三方原生库只接受文件路径字符串对于MediaStoreUri可以尝试查询_data字段在Android Q及以上此字段可能为空或不可靠不推荐。更可靠的方法是将Uri对应的文件内容读取出来临时写入到你的应用私有目录然后使用这个私有文件的路径。记得在使用后清理临时文件。5.2 问题二通过MediaStore插入的图片/视频不在系统相册中立即显示你通过MediaStore.insert成功保存了一张图片但在系统的“相册”或“图库”App里却找不到需要等待几秒甚至重启媒体扫描才会出现。解决方案在文件写入完成后手动触发媒体扫描。fun notifyMediaScanner(context: Context, fileUri: Uri) { val mediaScanIntent Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) mediaScanIntent.data fileUri context.sendBroadcast(mediaScanIntent) }注意这个广播在Android Q及以上可能受限。更现代、更可靠的做法是使用MediaScannerConnectionMediaScannerConnection.scanFile(context, arrayOf(fileAbsolutePath), null) { path, uri - // 扫描完成回调 }但这里又需要文件路径。一个折中的办法是如果你通过MediaStoreAPI并指定了RELATIVE_PATH系统通常会自动索引延迟较小。如果延迟不可接受可以尝试在保存文件后也将其写入到Pictures目录下的一个已知子目录然后扫描该目录。5.3 问题三处理“Downloads”目录下的非媒体文件访问下载目录中的PDF、ZIP等文件不能使用MediaStore.Images或MediaStore.Video。Android Q及以上为下载内容提供了专门的集合MediaStore.Downloads。但请注意MediaStore.Downloads.EXTERNAL_CONTENT_URI在Android Q及以上才可用。对于更通用的文档访问尤其是让用户选择SAF (ACTION_OPEN_DOCUMENT)是更标准、兼容性更好的选择。5.4 问题四删除或修改通过MediaStore插入的文件你不能直接通过File.delete()删除。需要通过ContentResolver.delete(uri, null, null)来进行。同样更新文件元数据如重命名使用ContentResolver.update。重要提示从Android 11开始应用无法直接修改或删除其他应用创建的媒体文件除非用户通过SAF明确授权。对于自己应用创建的文件则可以自由操作。5.5 性能优化技巧批量操作当需要插入或更新多条MediaStore记录时考虑使用ContentResolver.bulkInsert或applyBatch这比循环单条插入要高效得多。查询优化只查询需要的列projection并使用selection和sortOrder来缩小结果集。避免使用cursor.getString(cursor.getColumnIndex(“column”))在循环中获取列索引应在循环外获取。异步处理所有文件IO操作包括ContentResolver的查询和流操作都必须在后台线程进行避免阻塞主线程导致界面卡顿。6. 架构设计建议构建可维护的存储访问层面对复杂的版本分支和不同的API一个好的架构设计能让你事半功倍。我建议采用“接口隔离”和“策略模式”的思想。定义统一接口创建一个接口如IStorageAccessor定义你的应用需要的所有存储操作saveImage,loadImage,pickDocument,listFilesInDirectory等。实现不同版本策略创建两个实现类例如LegacyStorageAccessor用于Android 9及以下基于File和传统权限和ScopedStorageAccessor用于Android 10及以上基于MediaStore和SAF。工厂或依赖注入根据Build.VERSION.SDK_INT在运行时决定实例化哪个策略类并通过依赖注入框架如Hilt或简单工厂提供给业务模块使用。这样业务逻辑代码完全与具体的存储API解耦。当未来Android 15又推出新的存储API时你只需要增加一个新的策略实现类而无需修改大量业务代码。最后一点体会适配分区存储的过程初期会觉得繁琐和反直觉但一旦你习惯了通过Uri和ContentResolver来思考文件访问你会发现这套模型其实更清晰、更安全。它强制开发者明确声明意图尊重用户的数据主权。从长远看这对整个Android生态的健康度是有益的。作为开发者拥抱变化深入理解其设计初衷才能写出更健壮、更持久的代码。