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_KIND 512px、MICRO_KIND 96px),通过 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/DCIMquery(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, ...)
读取二进制FileInputStreamContentResolver.openInputStream(uri)
获取缩略图BitmapFactory.decodeFileContentResolver.loadThumbnail(uri, Size)
写入 ExifExifInterface(filePath)ExifInterface(descriptor) 通过 URI 获取
删除图片file.delete()ContentResolver.delete(uri, null, null)

4.2 实现流程

图片文件读写的典型流程分为四步,下面使用内联 SVG 刻画整体数据流:

MediaStore Images 表操作流程 应用层 ContentResolver MediaStore Provider Images 表 缩略图引擎 Thumbnails 表 文件系统 实际文件 Exif 写入 ExifInterface 流程说明: ① 应用通过 ContentResolver 操作 MediaStore.Images.Media.EXTERNAL_CONTENT_URI。 ② Provider 查询 Images 表,返回 URI 列表;实际文件由文件系统管理。 ③ 缩略图通过 Thumbnails 表或 loadThumbnail() 生成,不暴露直接路径。 ④ Exif 写入须使用 FileDescriptor 方式,写入后调用 saveAttributes()。

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 抛出 SecurityExceptionURI 对应的图片不属于当前应用且未获取 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 参数,可有效控制内存占用。