1. 从相册扫描说起:MediaStore 与 ContentResolver 到底能做什么
Android 获取手机中所有图片的绝对路径,这件事在相册类、图片压缩、上传、去重、备份类应用里几乎是绕不开的基础能力。核心工具就两个:MediaStore负责描述系统媒体库里的图片、视频、音频等条目,ContentResolver负责跨进程去查询这些条目。你不需要遍历/sdcard/DCIM这种物理目录,而是向系统媒体库发起一次查询,拿到游标(Cursor),再逐行读出每张图片的路径、名称、尺寸、日期等信息。
这套机制的好处是:系统已经帮你索引好了所有被媒体扫描器识别到的图片,查询速度快、结果全,而且能适配不同厂商的目录结构。坏处是:Android 10(API 29)引入分区存储后,DATA列的行为发生了变化,很多老代码在新系统上要么拿不到路径,要么直接抛异常。这也是为什么很多人照着旧教程写完,在模拟器上跑得好好的,一到真机 Android 13、14 就翻车。
这篇文章面向的是需要落地「扫描相册、拿到每张图片绝对路径」的 Android 开发者,尤其是还在用 Java 或 Kotlin 写原生查询、暂时不想引入 Glide/Coil 这类图片库的场景。我会给出可直接复制的查询列配置、游标遍历代码、权限声明,以及真机验证步骤,并把分区存储和权限差异这两块单独拆开讲清楚。你跟着做,基本能在一个新工程里半小时内跑通。
先明确一个概念:所谓「绝对路径」,在 Android 10 之前就是/storage/emulated/0/DCIM/Camera/xxx.jpg这种文件系统路径;Android 10 之后,媒体库更推荐你用Content Uri(形如content://media/external/images/media/12345)来访问文件,而不是直接拼路径。但很多业务(比如把图片交给第三方 SDK、写日志、做本地缓存命名)仍然需要真实路径,所以DATA列在可读的情况下依然有价值。下面分步骤讲。
2. 前置准备:权限、TaoToken 与工程配置怎么摆
在写查询代码之前,先把权限和工程配置理顺,否则你会遇到「查询返回空游标」这种最难查的问题。Android 的媒体读取权限经历了几个阶段:Android 9 及以下只需要READ_EXTERNAL_STORAGE;Android 10 到 12 引入了READ_EXTERNAL_STORAGE配合分区存储;Android 13(API 33)开始,读取图片要用READ_MEDIA_IMAGES,读取视频用READ_MEDIA_VIDEO,旧的READ_EXTERNAL_STORAGE在 33 以上对媒体文件不再生效。
所以你的AndroidManifest.xml里应该这样声明,用maxSdkVersion做版本区分:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />注意READ_MEDIA_IMAGES不需要maxSdkVersion,它在 33 及以上生效,在低版本会被忽略,不会报错。如果你还要兼容 Android 14 的部分照片访问(用户只授权选中的几张图),那属于READ_MEDIA_VISUAL_USER_SELECTED的范畴,本文先聚焦全量扫描。
运行时权限请求这块,用ActivityResultContracts.RequestPermission或RequestMultiplePermissions都行。关键点是:先请求权限,拿到授权后再执行查询,不要在onCreate里直接查,否则第一次启动必然空结果。
这里插一句关于开发工具链的选择。如果你在本地调试接口、验证模型返回的 JSON 结构,或者需要临时生成一些测试用的图片元数据,我平时会用 TaoToken 的模型对话来做辅助验证,它的 API 地址是https://taotoken.net/api,控制台在https://taotoken.net/console,申请 Key 的入口是https://taotoken.net/api-keys。对于需要长期跑编码 Agent 的场景,可以看下 Coding Plan:https://taotoken.net/coding-plan。这些和 Android 查询本身没有强绑定,只是我在做联调时顺手用的工具,你按需取用即可。
工程配置上还有一个容易忽略的点:targetSdkVersion。如果你的targetSdk是 33 及以上,系统会强制走新的媒体权限模型,DATA列在部分机型上可能返回null。所以查询代码里必须对DATA为空做兜底,用Content Uri代替。这一点在后面的排错章节会详细展开。
3. 可复制配置:查询列、游标遍历与分区存储适配
现在进入核心代码。查询图片的基本流程是:构造ContentResolver,指定MediaStore.Images.Media.EXTERNAL_CONTENT_URI作为查询目标,传入投影列(projection),执行query拿到Cursor,然后moveToNext逐行读取。
先看投影列的配置。不要图省事传null,虽然传null会返回所有列,但列多了性能差,而且不同 Android 版本列名有差异,显式指定更稳:
String[] projection = new String[]{ MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATA, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_ADDED, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT };_ID是媒体库的主键,用来拼Content Uri;DATA是绝对路径(可能为空);DISPLAY_NAME是文件名;SIZE是字节大小;DATE_ADDED是加入媒体库的时间戳(秒);WIDTH/HEIGHT在 API 16 以上可用。
然后是查询和遍历。下面这段是 Java 版本,可以直接贴进你的工具类:
public List<ImageItem> queryAllImages(Context context) { List<ImageItem> result = new ArrayList<>(); ContentResolver resolver = context.getContentResolver(); Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; String[] projection = new String[]{ MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATA, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_ADDED, MediaStore.Images.Media.MIME_TYPE }; String sortOrder = MediaStore.Images.Media.DATE_ADDED + " DESC"; try (Cursor cursor = resolver.query(uri, projection, null, null, sortOrder)) { if (cursor == null) { return result; } int idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID); int nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME); int dataCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); int sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE); int dateCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED); int mimeCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE); while (cursor.moveToNext()) { long id = cursor.getLong(idCol); String name = cursor.getString(nameCol); String path = cursor.getString(dataCol); long size = cursor.getLong(sizeCol); long dateAdded = cursor.getLong(dateCol); String mime = cursor.getString(mimeCol); Uri contentUri = ContentUris.withAppendedId(uri, id); if (path == null || path.isEmpty()) { path = contentUri.toString(); } result.add(new ImageItem(id, name, path, size, dateAdded, mime, contentUri)); } } catch (Exception e) { Log.e("MediaQuery", "query failed", e); } return result; }几个关键点解释一下。第一,用getColumnIndexOrThrow而不是getColumnIndex,这样列名写错会立刻抛异常,而不是静默返回 -1 导致getString(-1)崩溃。第二,DATA为空时用ContentUris.withAppendedId拼出content://地址兜底,这是分区存储下的标准做法。第三,用 try-with-resources 自动关闭 Cursor,避免内存泄漏。第四,排序用DATE_ADDED DESC,让最新的图片排在前面,符合相册习惯。
如果你用 Kotlin,逻辑一样,只是语法更简洁:
val projection = arrayOf( MediaStore.Images.Media._ID, MediaStore.Images.Media.DISPLAY_NAME, MediaStore.Images.Media.DATA, MediaStore.Images.Media.SIZE, MediaStore.Images.Media.DATE_ADDED ) context.contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, projection, null, null, "${MediaStore.Images.Media.DATE_ADDED} DESC" )?.use { cursor -> val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID) val dataCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA) while (cursor.moveToNext()) { val id = cursor.getLong(idCol) val path = cursor.getString(dataCol) // 处理 path } }关于分区存储的适配,核心判断是Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q。在 Q 及以上,DATA列虽然还能读,但官方不保证它一定返回真实路径,尤其是应用自己没权限访问的目录。稳妥策略是:优先用DATA,为空则回退到Content Uri,需要读文件内容时用resolver.openInputStream(contentUri),而不是new File(path)。
4. 真机验证:从空游标到拿到完整路径列表
代码写完了,怎么确认它真的能拿到所有图片?我建议按下面的步骤在真机上验证,而不是只信模拟器。
第一步,确认权限已授予。在查询前打印一行日志:
int granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES); Log.d("MediaQuery", "permission state = " + granted);如果返回PackageManager.PERMISSION_DENIED,说明权限没拿到,查询必然空。Android 13 以上要动态请求READ_MEDIA_IMAGES,别只声明不请求。
第二步,打印游标总数。在query之后加:
Log.d("MediaQuery", "cursor count = " + (cursor != null ? cursor.getCount() : -1));如果count是 0,先别怀疑代码,去系统相册 App 里看看有没有图片。有些测试机是干净的,一张图都没有。你可以先用相机拍两张,或者用adb push推几张图到/sdcard/Pictures/,然后触发媒体扫描:
adb push test1.jpg /sdcard/Pictures/ adb shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d file:///sdcard/Pictures/test1.jpg第三步,逐条打印路径。在while循环里输出:
Log.d("MediaQuery", "name=" + name + ", path=" + path + ", size=" + size);在 Android Studio 的 Logcat 里过滤MediaQuery标签,你应该能看到类似这样的输出:
name=IMG_20240115_103022.jpg, path=/storage/emulated/0/DCIM/Camera/IMG_20240115_103022.jpg, size=2456789 name=Screenshot_20240114.png, path=/storage/emulated/0/Pictures/Screenshots/Screenshot_20240114.png, size=345678如果path全是null,但name和size有值,说明你跑在 Android 10 以上的设备上,DATA列被限制了。这时候检查你的targetSdkVersion,并确认兜底逻辑生效——把contentUri打出来看看是不是content://media/external/images/media/xxx格式。
第四步,验证路径可读。拿到路径后,用new File(path).exists()判断文件是否真实存在。在分区存储下,即使DATA有值,你的应用也可能没有读该文件的权限(比如图片属于其他应用)。这时候要用resolver.openInputStream(contentUri)来读,而不是直接读文件。
实测下来,在 Android 13 的 Pixel 和几台国产 ROM 上,DATA列对相册图片基本都能返回真实路径,但截图目录、微信保存目录的图片偶尔返回null,所以兜底逻辑不能省。
5. 常见报错排查:401、空游标、DATA 为 null 怎么破
这一节把我在真机上踩过的坑列出来,对照着查能省不少时间。
问题一:查询返回空游标,count 为 0。最常见原因是权限没授予。Android 13 以上如果只声明了READ_EXTERNAL_STORAGE而没声明READ_MEDIA_IMAGES,查询会静默返回空,不报错。解决方法是补上READ_MEDIA_IMAGES并在运行时请求。另一个原因是媒体库还没扫描到你的图片,用上面的MEDIA_SCANNER_SCAN_FILE广播触发一次。
问题二:DATA列返回 null。这是分区存储的预期行为,不是 bug。Android 10 及以上,应用对媒体文件的访问应该走Content Uri。解决方法是保留_ID,用ContentUris.withAppendedId拼出content://地址,读文件时用openInputStream。如果你确实需要真实路径(比如传给只接受路径的第三方库),可以考虑把文件复制到应用私有目录再操作。
问题三:IllegalArgumentException: column '_data' does not exist。这种报错通常出现在你手动拼了错误的列名,或者在某些定制 ROM 上DATA列被移除。用MediaStore.Images.Media.DATA常量而不是硬编码字符串"_data",能避免大部分拼写问题。如果常量在某个版本不存在,用getColumnIndex而不是getColumnIndexOrThrow做兼容。
问题四:SecurityException: Permission Denial。说明你访问了没有权限的 Uri。检查是不是用了EXTERNAL_CONTENT_URI却只申请了内部存储权限,或者查询了其他应用的私有媒体。分区存储下,跨应用访问媒体需要READ_MEDIA_IMAGES或通过MediaStore的createWriteRequest等 API。
问题五:Cursor 泄漏导致StaleDataException。忘记cursor.close()会导致游标失效后继续访问抛异常。用 try-with-resources 或finally里关闭。另外,查询不要在 UI 线程做,图片多的时候会卡顿,放到子线程或协程里。
问题六:接口联调时遇到 401。如果你在用 TaoToken 的 API 做辅助验证,遇到 401 通常是 Key 没带对或过期了。检查请求头里的Authorization: Bearer <你的Key>,Key 在https://taotoken.net/api-keys管理。如果是本地代理相关的报错(比如local proxy failed),检查你的网络配置和 Base URL 是否写成了https://taotoken.net/api。模型返回里如果出现reading choices之类的解析错误,多半是响应体结构没对上,用模型对话页面https://taotoken.net/chat先手动发一次请求看看原始返回。
问题七:Android 14 部分授权导致列表不全。Android 14 引入了「选择部分照片」的授权方式,用户可能只给了你几张图的访问权。这时候查询返回的只是被选中的图片,不是全部。如果你需要全量,得引导用户授予完整权限,或者用READ_MEDIA_VISUAL_USER_SELECTED配合PhotoPicker让用户主动选。
排查顺序建议是:先看权限日志,再看游标 count,再看单条 path 是否为 null,最后看文件是否可读。一层层往下,基本能定位到问题。
6. 把查询能力接进你的工程:下一步怎么做
到这里,你已经有了一个能在真机上跑通的图片扫描方案:权限声明、投影列配置、游标遍历、分区存储兜底、常见报错排查。接下来就是把它接进你的业务代码。
我的建议是封装成一个MediaImageRepository,对外暴露suspend fun loadAllImages(): List<ImageItem>,内部用Dispatchers.IO执行查询,避免阻塞主线程。ImageItem里同时保留absolutePath和contentUri两个字段,调用方按需取用。如果业务只需要展示缩略图,直接用contentUri配合 Glide/Coil 加载,性能更好,也不用担心路径权限问题。
如果你在做的项目涉及大量图片处理、需要跑编码 Agent 来生成或重构这部分代码,可以了解下 Coding Plan:https://taotoken.net/coding-plan。接入文档在https://taotoken.net/doc,API Key 在https://taotoken.net/api-keys,模型对话验证在https://taotoken.net/chat。这些入口按你的实际需要取用,不必全用。
最后提醒一个实践细节:查询大量图片时,Cursor的getCount()在部分 ROM 上会触发全表扫描,比较慢。如果你只需要数量,用query配合null投影和COUNT(*)更高效。另外,图片列表变化频繁的场景(比如用户边拍边看),可以注册ContentObserver监听MediaStore.Images.Media.EXTERNAL_CONTENT_URI的变化,收到通知后重新查询,而不是轮询。这套组合下来,你的相册扫描能力就相当扎实了。