【Android 文件管理】分区存储下 MediaStore 图片文件创建与查询实战 1. Android 10 分区存储下 MediaStore 图片创建与查询到底难在哪Android 10API 29开始Google 正式把分区存储Scoped Storage推上台面到 Android 11 强制启用后很多做文件管理、相册、图片编辑的同学都会撞上同一堵墙以前new File(/sdcard/Pictures/xxx.jpg)直接写文件的路子在公共目录上基本走不通了。你写进去要么抛EACCES (Permission denied)要么文件写成功了但相册里死活刷不出来要么查询时cursor是空的。核心原因就是公共目录不再允许应用用绝对路径直接操作必须通过 MediaStore 这个“中间人”去跟external.db数据库打交道。MediaStore 本质上是一个内容提供者ContentProvider它维护着external.db里的files表。你在公共目录创建一张图片实际流程是两步第一步先往files表插一条索引记录拿到系统返回的content://Uri第二步用这个 Uri 打开输出流把真正的图片字节写进去。只插索引不写数据文件是“空壳”相册里看不到只写数据不插索引系统根本不知道有这个文件。查询同理你查的不是磁盘目录而是files表查出来的DATA绝对路径在 Android 11 已经不能拿来做读写操作只能用来展示或做兼容判断。这篇就聚焦一件事在 Android 10 分区存储场景下用 MediaStore 在公共目录Pictures/DCIM 这类创建图片并查询回读。我会给出可直接复制的 Kotlin 代码片段、AndroidManifest 权限声明、ContentValues各字段含义重点讲清RELATIVE_PATH和IS_PENDING这两个最容易踩坑的字段最后带你在真机或模拟器上验证图片是否真的落盘、查询结果对不对。适合正在做相册、文件管理、图片导出功能的 Android 开发者也适合被分区存储折腾过、想一次性搞明白 MediaStore 图片读写的人。2. 动手前的前置准备权限、依赖与 TaoToken 辅助排查在写代码之前先把环境和权限理清楚不然你会在“为什么 insert 返回 null”这种问题上耗掉半天。分区存储下往公共目录写图片Android 10 到 Android 13 的权限模型是有差异的这里按版本拆开说。Android 10API 29如果targetSdkVersion是 29可以在 manifest 里加android:requestLegacyExternalStoragetrue临时退回旧存储模型但这是过渡方案不建议新项目用。正规做法是直接用 MediaStore写自己应用创建的媒体文件不需要额外存储权限。Android 11API 30往公共媒体目录写自己创建的文件同样不需要WRITE_EXTERNAL_STORAGE。但如果你要读取其他应用创建的图片Android 13API 33起需要READ_MEDIA_IMAGESAndroid 12 及以下用READ_EXTERNAL_STORAGE。查询自己刚插入的图片用ContentResolver.query配合自己的 Uri 即可不涉及跨应用读取。AndroidManifest 里按需声明别一股脑全加上manifest xmlns:androidhttp://schemas.android.com/apk/res/android !-- Android 13 读取媒体图片 -- uses-permission android:nameandroid.permission.READ_MEDIA_IMAGES / !-- Android 12 及以下读取外部存储查询他人图片时需要 -- uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE android:maxSdkVersion32 / !-- 仅当你要写非自己创建的媒体文件时才需要一般用不到 -- uses-permission android:nameandroid.permission.WRITE_EXTERNAL_STORAGE android:maxSdkVersion28 / application android:requestLegacyExternalStoragefalse ... /application /manifest注意requestLegacyExternalStorage设成false是明确告诉系统走分区存储避免在 Android 10 上行为不一致。运行时权限方面Android 13 读图片要动态申请READ_MEDIA_IMAGES写自己创建的文件不用申请。如果你只是创建并查询自己插入的图片其实连读权限都可以不申请直接用 insert 返回的 Uri 就能读回。这里插一句关于调试效率的事。分区存储的报错信息经常很含糊比如insert返回null、openOutputStream抛FileNotFoundException光看堆栈很难定位是字段没设对还是权限没给。我平时排查这类问题时会把关键日志和异常信息整理出来丢给模型对话工具做一轮分析让它帮我对照 MediaStore 的字段约束逐条核对。TaoToken 的模型对话入口https://taotoken.net/api 对应的对话能力在这种“报错信息 代码片段”联合分析的场景下挺顺手尤其是它能记住上下文你可以把ContentValues的构造代码和报错一起贴进去让它指出哪个字段类型不对。当然这只是辅助手段核心还是得自己理解字段含义下面正式进入代码。3. 可复制的 MediaStore 插入与查询配置ContentValues 字段逐个拆这一节是全文的核心给出完整的插入和查询代码并把ContentValues里每个字段讲透。先看插入图片的完整函数。import android.content.ContentValues import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore import java.io.OutputStream /** * 在公共 Pictures 目录下创建一张图片 * 返回创建成功后的 content:// Uri失败返回 null */ fun createImageInPictures(context: Context, bitmap: Bitmap): Uri? { val resolver context.contentResolver // 1. 指定操作 external.db 中 images 表的 Uri val collection: Uri if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) } else { MediaStore.Images.Media.EXTERNAL_CONTENT_URI } // 2. 构造要插入 files 表的字段 val fileName tt_demo_${System.currentTimeMillis()}.jpg val values ContentValues().apply { // 相对路径必须以 / 结尾系统会自动拼到公共目录下 put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES /TaoTokenDemo) // 显示名称带后缀 put(MediaStore.Images.Media.DISPLAY_NAME, fileName) // 标题一般去掉后缀可不设 put(MediaStore.Images.Media.TITLE, fileName.substringBeforeLast(.)) // MIME 类型图片必须设对否则后续读取会出错 put(MediaStore.Images.Media.MIME_TYPE, image/jpeg) // Android 10 建议设置 IS_PENDING写入完成后再置 0 if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { put(MediaStore.Images.Media.IS_PENDING, 1) } } // 3. 插入索引拿到 Uri val itemUri: Uri resolver.insert(collection, values) ?: return null // 4. 通过 Uri 打开输出流写入真实图片数据 try { resolver.openOutputStream(itemUri)?.use { os: OutputStream - bitmap.compress(Bitmap.CompressFormat.JPEG, 95, os) os.flush() } } catch (e: Exception) { // 写入失败要把索引删掉避免留下空壳记录 resolver.delete(itemUri, null, null) return null } // 5. 写入完成清除 IS_PENDING让文件对系统和其他应用可见 if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { val done ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) } resolver.update(itemUri, done, null, null) } return itemUri }几个字段必须说清楚。RELATIVE_PATH是相对公共目录的路径比如Pictures/TaoTokenDemo系统最终会拼成/storage/emulated/0/Pictures/TaoTokenDemo/。这个字段的值必须以斜杠结尾否则部分机型会把它当成文件名的一部分导致路径错乱。DISPLAY_NAME是完整文件名带后缀系统据此推断文件类型。MIME_TYPE一定要和实际图片格式一致写 JPEG 就填image/jpeg写 PNG 就填image/png填错了 insert 不会报错但后续openInputStream或相册加载时会出问题。IS_PENDING是 Android 10 引入的关键字段置 1 表示“文件正在写入别对外暴露”写完置 0 才正式可见。不设这个字段也能写成功但在写入过程中如果被其他应用扫描到可能读到半截文件。再看查询回读的代码/** * 查询指定名称的图片返回 Uri 和路径信息 */ fun queryImageByName(context: Context, displayName: String): Uri? { val resolver context.contentResolver val collection MediaStore.Images.Media.EXTERNAL_CONTENT_URI // 指定要查询的列null 表示查所有列但显式指定更高效 val projection arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.RELATIVE_PATH, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.SIZE ) val selection ${MediaStore.Images.Media.DISPLAY_NAME} ? val selectionArgs arrayOf(displayName) val sortOrder ${MediaStore.Images.Media.DATE_ADDED} DESC resolver.query(collection, projection, selection, selectionArgs, sortOrder)?.use { cursor - if (cursor.moveToFirst()) { val idCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) val nameCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME) val pathCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media.RELATIVE_PATH) val sizeCol cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE) val id cursor.getLong(idCol) val name cursor.getString(nameCol) val path cursor.getString(pathCol) val size cursor.getLong(sizeCol) // 用 _id 拼出该图片的 Uri val uri Uri.withAppendedPath(collection, id.toString()) android.util.Log.i(MediaStore, 查询到 Uri$uri, name$name, path$path, size$size) return uri } } return null }查询的关键是projection里显式列出需要的列别用null查全部列多了性能差。_ID是files表的主键拿到它就能用Uri.withAppendedPath拼出content://media/external/images/media/42这种 Uri。RELATIVE_PATH返回的是相对路径SIZE能帮你确认文件是不是真的写进去了如果 size 是 0说明只插了索引没写数据。注意 Android 11 不要再依赖DATA列拿绝对路径去做读写那个路径已经不可靠只能用于展示。4. 真机与模拟器验证图片落盘与查询结果怎么确认代码写完了怎么确认它真的生效分三步验证模拟器和真机都适用。第一步跑完createImageInPictures后先看返回值。如果返回null说明 insert 失败大概率是RELATIVE_PATH格式不对或 MIME 类型有问题。如果返回了content://media/external/images/media/xxx说明索引插入成功。接着调用queryImageByName传入刚才的文件名看日志里打印的size是不是大于 0。size 大于 0 才代表图片字节真的写进去了。第二步用 Android Studio 的 Device Explorer 看文件。打开 View Tool Windows Device Explorer路径展开到/storage/emulated/0/Pictures/TaoTokenDemo/应该能看到你创建的那张 jpg。如果目录存在但文件是 0 字节说明openOutputStream那步没写成功检查 bitmap 是否为 null、compress 是否返回 false。如果目录都不存在说明RELATIVE_PATH没生效回去检查字段值是不是以斜杠结尾。第三步用系统相册验证可见性。打开手机自带相册或 Google Photos正常情况下应该能看到这张图。如果相册里没有但 Device Explorer 里文件存在八成是IS_PENDING没置回 0。这是最常见的坑写入完成后忘了 updateIS_PENDING文件对系统不可见相册自然扫不到。你可以手动在代码里补上 update或者用下面的命令通过 adb 触发媒体扫描验证# 触发媒体扫描让系统重新索引指定文件 adb shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE \ -d file:///storage/emulated/0/Pictures/TaoTokenDemo/tt_demo_xxx.jpg如果扫描后相册出现了基本可以确认是IS_PENDING的问题。另外查询时如果cursor.moveToFirst()返回 false先确认查询的DISPLAY_NAME和插入时完全一致包括后缀再确认查询用的 collection Uri 和插入时是同一个 volume。Android 10 有VOLUME_EXTERNAL_PRIMARY和VOLUME_EXTERNAL的区别混用会导致查不到。实测下来模拟器上 Android 13 的镜像对IS_PENDING处理比较严格不置 0 就是不可见部分国产真机在 Android 11 上即使不置 0 也能被自家相册扫到但这属于厂商行为不能依赖。统一按标准流程走写完就置 0最稳。5. 本篇常见报错排查insert 返回 null、cursor 为空、FileNotFoundException这一节把分区存储下 MediaStore 图片操作最常见的几个报错列出来对照着排查。insert返回null日志里可能有IllegalArgumentException: Volume external_primary not found或Unknown URL。原因通常是 collection Uri 用错了。Android 10 推荐用MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)而不是老的EXTERNAL_CONTENT_URI。另外RELATIVE_PATH如果传了绝对路径比如/storage/emulated/0/Pictures系统会拒绝必须传相对路径Pictures/xxx。openOutputStream抛FileNotFoundException: open failed: EACCES。这是权限或路径问题。先确认RELATIVE_PATH指向的是公共媒体目录Pictures、DCIM、Movies 等别指向Android/data这种应用专属目录那类目录要用getExternalFilesDir。再确认没有在 Android 11 上试图用DATA绝对路径去new FileOutputStream那条路已经封死。cursor查询结果为空moveToFirst返回 false。先确认查询的DISPLAY_NAME和插入时一致注意大小写和后缀。再确认查询的 collection 和插入的 collection 是同一个 volume。如果跨应用查询Android 13 要确认READ_MEDIA_IMAGES权限已授予。还有一种情况是插入后立刻查询媒体数据库还没刷新可以加个短暂延迟或直接用 insert 返回的 Uri 去读别急着 query。SecurityException: Permission Denial。检查 manifest 权限声明和运行时权限。Android 13 读图片要READ_MEDIA_IMAGES别再用READ_EXTERNAL_STORAGE。写自己创建的文件不需要写权限如果你申请了WRITE_EXTERNAL_STORAGE反而可能因为maxSdkVersion限制导致行为异常。BitmapFactory.decodeStream返回 null。图片字节写坏了或者 MIME 类型和实际格式不符。用queryImageByName查一下SIZE如果是 0 就是没写进去如果 size 正常但 decode 失败检查compress的格式和MIME_TYPE是否匹配。排查这类问题时把完整的异常堆栈、ContentValues的构造代码、manifest 权限片段一起整理出来用模型对话做一轮交叉核对会快很多。TaoToken 的对话入口支持长上下文你可以把整个函数贴进去让它逐行检查字段比自己在文档里翻半天效率高。不过记住工具只是辅助字段语义还是得自己吃透。6. 从创建到查询跑通后下一步可以做什么把上面的插入和查询跑通你其实已经掌握了分区存储下 MediaStore 图片操作的核心链路插索引拿 Uri、写数据、置 IS_PENDING、按列查询、拼 Uri 回读。这套模式换成视频、音频、下载文件字段名换一下就能复用MediaStore.Video、MediaStore.Audio、MediaStore.Downloads的用法基本一致。如果你在做图片导出、相册备份、文件管理这类功能接下来可以往几个方向深入批量插入时用ContentResolver.applyBatch减少 IO 次数删除图片用resolver.delete(uri, null, null)注意 Android 11 删除他人文件需要MediaStore.createDeleteRequest走用户确认更新图片元数据比如重命名用resolver.update但DISPLAY_NAME的修改在部分机型上有限制建议先查文档。调试阶段如果遇到跨应用读取、批量操作、权限适配这些更复杂的问题可以把关键代码和报错整理好用模型对话做一轮分析或者把长期要维护的 Android 项目接入 Coding Plan 做代码审查和重构建议。核心还是那句话分区存储不是限制而是把文件操作的入口统一到了 MediaStore理解external.db的字段模型后面所有媒体文件操作都是同一套逻辑。