4. MediaStore 图片文件读写:操作 Images 表、获取缩略图、写入 Exif 元数据
4.1 本章核心原理
Android 10+ 的分区存储将媒体文件统一交由 MediaStore 管理。对于图片文件,MediaStore 提供了 Images 表(实际为 MediaStore.Images.Media 外部内容 URI)作为访问入口。应用不再直接使用文件路径,而是通过 ContentResolver 在 URI 层面执行增删改查,并利用 MediaStore 内置的缩略图机制和 Exif 读写接口完成高级操作。
关键原理分解:
- Images 表结构:每条记录对应一张图片,包含
_ID、TITLE、DATE_ADDED、MIME_TYPE、ORIENTATION等列,图片二进制数据存储在DATA列(Android 10+ 该列可能为空,应使用 URI 读取流)。 - 缩略图生成:MediaStore 自动为图片生成两种尺寸的缩略图(
MINI_KIND512px、MICRO_KIND96px),通过ContentResolver.loadThumbnail()或查询Thumbnails表获取。 - Exif 元数据:Exif(Exchangeable image file format)存储在图片文件头部,包含拍摄时间、GPS、设备型号、旋转方向等。Android 提供
ExifInterface类读取/写入,但写入时需通过 URI 获取FileDescriptor,不能直接写文件。
注意:写入 Exif 时,若目标文件位于应用专属目录之外(如 DCIM),需要先通过 MediaStore 插入记录得到 URI,再打开输出流写入数据,最后使用 ExifInterface 的 saveAttributes() 更新元数据。
核心差异对比表:传统路径访问 vs MediaStore 分区存储模式
| 操作 | 传统方式(Android 9-) | 分区存储方式(Android 10+) |
|---|---|---|
| 查找图片 | 遍历 /sdcard/DCIM | query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, ...) |
| 读取二进制 | FileInputStream | ContentResolver.openInputStream(uri) |
| 获取缩略图 | BitmapFactory.decodeFile | ContentResolver.loadThumbnail(uri, Size) |
| 写入 Exif | ExifInterface(filePath) | ExifInterface(descriptor) 通过 URI 获取 |
| 删除图片 | file.delete() | ContentResolver.delete(uri, null, null) |
4.2 实现流程
图片文件读写的典型流程分为四步,下面使用内联 SVG 刻画整体数据流:
4.3 代码实现与 API 详解
4.3.1 查询 Images 表(读取图片列表)
// 定义投影列
String[] projection = {
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_ADDED,
MediaStore.Images.Media.MIME_TYPE
};
// 查询外部存储图片
Uri collectionUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
Cursor cursor = getContentResolver().query(
collectionUri,
projection,
null, // selection
null, // selectionArgs
MediaStore.Images.Media.DATE_ADDED + " DESC" // 按时间倒序
);
List imageUris = new ArrayList<>();
if (cursor != null) {
int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID);
while (cursor.moveToNext()) {
long id = cursor.getLong(idColumn);
Uri contentUri = Uri.withAppendedPath(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
String.valueOf(id)
);
imageUris.add(contentUri);
}
cursor.close();
}
4.3.2 读取图片二进制数据
// 通过 URI 打开输入流
InputStream is = getContentResolver().openInputStream(uri);
if (is != null) {
Bitmap bitmap = BitmapFactory.decodeStream(is);
is.close();
// 或直接读取字节: ByteArrayOutputStream baos = new ByteArrayOutputStream();
// 使用 IOUtils.copy(is, baos);
}
4.3.3 获取缩略图
// 方法一:loadThumbnail (Android 10+ 推荐)
Size size = new Size(512, 512); // MINI_KIND 的大小
Bitmap thumbnail = getContentResolver().loadThumbnail(uri, size, null);
// 方法二:查询 MediaStore.Images.Thumbnails 表
Uri thumbUri = MediaStore.Images.Thumbnails.EXTERNAL_CONTENT_URI;
String[] thumbProjection = {
MediaStore.Images.Thumbnails._ID,
MediaStore.Images.Thumbnails.DATA
};
String selection = MediaStore.Images.Thumbnails.IMAGE_ID + " = ?";
String[] selArgs = new String[]{String.valueOf(imageId)};
Cursor thumbCursor = getContentResolver().query(thumbUri, thumbProjection,
selection, selArgs, null);
if (thumbCursor != null && thumbCursor.moveToFirst()) {
String thumbPath = thumbCursor.getString(
thumbCursor.getColumnIndexOrThrow(MediaStore.Images.Thumbnails.DATA));
Bitmap bitmap = BitmapFactory.decodeFile(thumbPath);
thumbCursor.close();
}
4.3.4 写入 Exif 元数据
// 假设已经通过 ContentValues 插入图片得到 uri(参考第3章写入流程)
// 此处用已有 uri 演示 Exif 写入
try {
// 获取文件描述符(写入模式)
ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(uri, "rw");
if (pfd != null) {
FileDescriptor fd = pfd.getFileDescriptor();
ExifInterface exif = new ExifInterface(fd);
// 设置 Exif 标签
exif.setAttribute(ExifInterface.TAG_ORIENTATION,
String.valueOf(ExifInterface.ORIENTATION_ROTATE_90));
exif.setAttribute(ExifInterface.TAG_DATETIME, "2025:03:15 10:30:00");
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, "39/1,54/1,15/1");
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, "N");
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, "116/1,23/1,30/1");
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, "E");
// 保存属性
exif.saveAttributes();
pfd.close();
}
} catch (IOException e) {
e.printStackTrace();
}
⚠ 常见误区: 写入 Exif 时,许多开发者尝试直接通过
uri.getPath() 获取文件路径再 new ExifInterface(path),这在 Android 10+ 上会失败,因为外部存储路径对应用不可见。必须使用 openFileDescriptor(uri, "rw") 或 openInputStream/OutputStream 配合 ExifInterface(FileDescriptor) 才能成功写入。
4.4 调试与验证
在开发过程中,可以使用以下方法验证读写正确性:
- Log 输出:在查询 Images 表后打印每个 URI、ID 和名称,确认数据返回正常。
- 缩略图加载测试:将缩略图 Bitmap 绑定到 ImageView 显示,观察是否出现空白或缩略图尺寸错误。
- Exif 验证:使用 Android Studio 的 Device File Explorer 导出图片,在电脑上右键属性查看 Exif 信息,或使用第三方 App(如 Exif Viewer)对比。
- 权限检查:确保在 Android 10+ 中
READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE已不再需要;如果目标 SDK 29+ 仍声明这两个权限,系统会忽略。建议使用ACCESS_MEDIA_LOCATION获取精准 Exif 定位。
4.5 常见错误与解决方案
| 错误现象 | 原因 | 解决方案 |
|---|---|---|
| 查询 Images 表返回空 | 未授予媒体权限;或目标 SDK 29+ 但请求了过时权限 | 使用 READ_EXTERNAL_STORAGE 仅用于 Android 9-;Android 10+ 无需权限即可读取自己创建的文件 |
| loadThumbnail 抛出 SecurityException | URI 对应的图片不属于当前应用且未获取 READ_EXTERNAL_STORAGE 权限 | Android 10+ 读取其他应用图片仍需 READ_EXTERNAL_STORAGE 权限(仅在 Manifest 中声明,用户授予) |
| ExifInterface 保存后无变化 | 保存前未调用 saveAttributes();或者写入模式使用了 "r" 而非 "rw" | 检查 openFileDescriptor(uri, "rw");确保调用 exif.saveAttributes() |
| 缩略图模糊变形 | 使用了 MICRO_KIND (96px) 放大到高分辨率 ImageView | 使用 MINI_KIND (512px) 或配合 Matrix 缩放 |
| 写入 Exif 后图片损坏 | 同时写入了多个 Exif 标签导致文件结构混乱;或写入过程中崩溃 | 在 try-finally 中确保关闭资源;建议先备份原文件再测试写入 |
4.6 性能与稳定性优化建议
- 批量查询:避免逐条查询缩略图,使用
IN查询一次性获取多个缩略图 URI。 - 缩略图缓存:使用 LruCache 缓存加载过的 Bitmap,避免频繁 I/O。
- 异步加载:所有 MediaStore 操作(query、loadThumbnail、openFileDescriptor)应放在后台线程,使用
AsyncTask/Coroutine或HandlerThread。 - Exif 写入频率:每张图片只写入一次 Exif,避免频繁重写;若需批量修改,可先收集所有修改再统一写入。
- 文件描述符复用:如果需要对同一 URI 同时读写 Exif 和数据,先读后写,避免同时持有多个 FD。
- 兼容性:对于 Android 9-,仍可使用《旧路径+MediaStore并存》方式,但建议尽早适配分区存储。
💡 最佳实践: 获取缩略图时优先使用
ContentResolver.loadThumbnail(),因为它内部返回的是 MediaStore 缓存的缩略图,无需二次解码,性能远高于查询 Thumbnails 表再 decodeFile。此外,loadThumbnail 支持 Android 10+ 的 BitmapFactory.Options 参数,可有效控制内存占用。