从共享存储空间访问媒体文件

为了提供更丰富的用户体验,许多应用允许用户贡献和访问外部存储卷上的媒体。该框架提供了媒体集合的优化索引,称为 媒体库,使用户更容易检索和更新这些媒体文件。即使您的应用被卸载后,这些文件仍会保留在用户的设备上。

照片选择器

作为使用媒体库的替代方案,Android 照片选择器工具提供了一种安全、内置的方式,让用户无需授予您的应用访问其整个媒体库的权限即可选择媒体文件。这仅在受支持的设备上可用。有关更多信息,请参阅照片选择器指南。

媒体库

要与媒体库抽象进行交互,请使用从应用上下文中检索到的 ContentResolver 对象

Kotlin

val projection = arrayOf(media-database-columns-to-retrieve)
val selection = sql-where-clause-with-placeholder-variables
val selectionArgs = values-of-placeholder-variables
val sortOrder = sql-order-by-clause

applicationContext.contentResolver.query(
    MediaStore.media-type.Media.EXTERNAL_CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    sortOrder
)?.use { cursor ->
    while (cursor.moveToNext()) {
        // Use an ID column from the projection to get
        // a URI representing the media item itself.
    }
}

Java

String[] projection = new String[] {
        media-database-columns-to-retrieve
};
String selection = sql-where-clause-with-placeholder-variables;
String[] selectionArgs = new String[] {
        values-of-placeholder-variables
};
String sortOrder = sql-order-by-clause;

Cursor cursor = getApplicationContext().getContentResolver().query(
    MediaStore.media-type.Media.EXTERNAL_CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    sortOrder
);

while (cursor.moveToNext()) {
    // Use an ID column from the projection to get
    // a URI representing the media item itself.
}

系统会自动扫描外部存储卷,并将媒体文件添加到以下预定义集合中

  • 图片,包括照片和屏幕截图,存储在 DCIM/Pictures/ 目录中。系统会将这些文件添加到 MediaStore.Images 表中。
  • 视频,存储在 DCIM/Movies/Pictures/ 目录中。系统会将这些文件添加到 MediaStore.Video 表中。
  • 音频文件,存储在 Alarms/Audiobooks/Music/Notifications/Podcasts/Ringtones/ 目录中。此外,系统还识别位于 Music/Movies/ 目录中的音频播放列表,以及位于 Recordings/ 目录中的录音。系统会将这些文件添加到 MediaStore.Audio 表中。Recordings/ 目录在 Android 11 (API 级别 30) 及更低版本上不可用。
  • 下载文件,存储在 Download/ 目录中。在运行 Android 10 (API 级别 29) 及更高版本的设备上,这些文件存储在 MediaStore.Downloads 表中。此表在 Android 9 (API 级别 28) 及更低版本上不可用。

媒体库还包含一个名为 MediaStore.Files 的集合。其内容取决于您的应用是否使用分区存储,分区存储适用于以 Android 10 或更高版本为目标平台的应用。

  • 如果启用了分区存储,则该集合仅显示您的应用已创建的照片、视频和音频文件。大多数开发者不需要使用 MediaStore.Files 来查看其他应用的媒体文件,但如果您有特定要求,可以声明 READ_EXTERNAL_STORAGE 权限。但是,我们建议您使用 MediaStore API 来打开您的应用尚未创建的文件。
  • 如果分区存储不可用或未使用,则该集合会显示所有类型的媒体文件。

请求必要权限

在对媒体文件执行操作之前,请确保您的应用已声明访问这些文件所需的权限。但是,请注意不要声明您的应用不需要或不使用的权限。

存储权限

您的应用是否需要权限来访问存储空间,取决于它是否只访问自己的媒体文件或其他应用创建的文件。

访问您自己的媒体文件

在运行 Android 10 或更高版本的设备上,您无需存储相关权限即可访问和修改您应用拥有的媒体文件,包括 MediaStore.Downloads 集合中的文件。例如,如果您正在开发相机应用,则无需请求存储相关权限即可访问它拍摄的照片,因为您的应用拥有您正在写入媒体库的图片。

访问其他应用的媒体文件

要访问其他应用创建的媒体文件,您必须声明相应的存储相关权限,并且文件必须位于以下媒体集合之一中

只要文件可以通过 MediaStore.ImagesMediaStore.VideoMediaStore.Audio 查询来查看,也可以使用 MediaStore.Files 查询来查看。

以下代码段演示了如何声明相应的存储权限

<!-- Required only if your app needs to access images or photos
     that other apps created. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

<!-- Required only if your app needs to access videos
     that other apps created. -->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

<!-- Required only if your app needs to access audio files
     that other apps created. -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                 android:maxSdkVersion="29" />

在旧设备上运行的应用所需的额外权限

如果您的应用在运行 Android 9 或更低版本的设备上使用,或者如果您的应用暂时选择退出分区存储,您必须请求 READ_EXTERNAL_STORAGE 权限才能访问任何媒体文件。如果您想修改媒体文件,还必须请求 WRITE_EXTERNAL_STORAGE 权限。

访问其他应用的下载需要存储访问框架

如果您的应用想访问 MediaStore.Downloads 集合中非您的应用创建的文件,您必须使用存储访问框架。要了解如何使用此框架的更多信息,请参阅从共享存储空间访问文档和其他文件

媒体位置权限

如果您的应用以 Android 10 (API 级别 29) 或更高版本为目标平台,并且需要从照片中检索未编辑的 EXIF 元数据,则需要在应用的清单中声明 ACCESS_MEDIA_LOCATION 权限,然后在运行时请求此权限。

检查媒体库更新

为了更可靠地访问媒体文件,尤其是当您的应用缓存媒体库中的 URI 或数据时,请检查媒体库版本是否与您上次同步媒体数据时相比发生了变化。要执行此更新检查,请调用 getVersion()。返回的版本是一个唯一的字符串,每当媒体库发生重大变化时,该字符串就会改变。如果返回的版本与上次同步的版本不同,请重新扫描并重新同步您的应用媒体缓存。

在应用进程启动时完成此检查。无需在每次查询媒体库时都检查版本。

不要假定关于版本号的任何实现细节。

查询媒体集合

要查找满足特定条件(例如时长为 5 分钟或更长)的媒体,请使用类似于以下代码段中所示的类 SQL 选择语句

Kotlin

// Need the READ_EXTERNAL_STORAGE permission if accessing video files that your
// app didn't create.

// Container for information about each video.
data class Video(val uri: Uri,
    val name: String,
    val duration: Int,
    val size: Int
)
val videoList = mutableListOf<Video>()

val collection =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        MediaStore.Video.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL
        )
    } else {
        MediaStore.Video.Media.EXTERNAL_CONTENT_URI
    }

val projection = arrayOf(
    MediaStore.Video.Media._ID,
    MediaStore.Video.Media.DISPLAY_NAME,
    MediaStore.Video.Media.DURATION,
    MediaStore.Video.Media.SIZE
)

// Show only videos that are at least 5 minutes in duration.
val selection = "${MediaStore.Video.Media.DURATION} >= ?"
val selectionArgs = arrayOf(
    TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES).toString()
)

// Display videos in alphabetical order based on their display name.
val sortOrder = "${MediaStore.Video.Media.DISPLAY_NAME} ASC"

val query = ContentResolver.query(
    collection,
    projection,
    selection,
    selectionArgs,
    sortOrder
)
query?.use { cursor ->
    // Cache column indices.
    val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
    val nameColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
    val durationColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
    val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)

    while (cursor.moveToNext()) {
        // Get values of columns for a given video.
        val id = cursor.getLong(idColumn)
        val name = cursor.getString(nameColumn)
        val duration = cursor.getInt(durationColumn)
        val size = cursor.getInt(sizeColumn)

        val contentUri: Uri = ContentUris.withAppendedId(
            MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
            id
        )

        // Stores column values and the contentUri in a local object
        // that represents the media file.
        videoList += Video(contentUri, name, duration, size)
    }
}

Java

// Need the READ_EXTERNAL_STORAGE permission if accessing video files that your
// app didn't create.

// Container for information about each video.
class Video {
    private final Uri uri;
    private final String name;
    private final int duration;
    private final int size;

    public Video(Uri uri, String name, int duration, int size) {
        this.uri = uri;
        this.name = name;
        this.duration = duration;
        this.size = size;
    }
}
List<Video> videoList = new ArrayList<Video>();

Uri collection;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL);
} else {
    collection = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
}

String[] projection = new String[] {
    MediaStore.Video.Media._ID,
    MediaStore.Video.Media.DISPLAY_NAME,
    MediaStore.Video.Media.DURATION,
    MediaStore.Video.Media.SIZE
};
String selection = MediaStore.Video.Media.DURATION +
        " >= ?";
String[] selectionArgs = new String[] {
    String.valueOf(TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES));
};
String sortOrder = MediaStore.Video.Media.DISPLAY_NAME + " ASC";

try (Cursor cursor = getApplicationContext().getContentResolver().query(
    collection,
    projection,
    selection,
    selectionArgs,
    sortOrder
)) {
    // Cache column indices.
    int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID);
    int nameColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME);
    int durationColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION);
    int sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE);

    while (cursor.moveToNext()) {
        // Get values of columns for a given video.
        long id = cursor.getLong(idColumn);
        String name = cursor.getString(nameColumn);
        int duration = cursor.getInt(durationColumn);
        int size = cursor.getInt(sizeColumn);

        Uri contentUri = ContentUris.withAppendedId(
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);

        // Stores column values and the contentUri in a local object
        // that represents the media file.
        videoList.add(new Video(contentUri, name, duration, size));
    }
}

在应用中执行此类查询时,请注意以下几点

  • 在工作线程中调用 query() 方法。
  • 缓存列索引,这样您就不需要在每次处理查询结果中的行时都调用 getColumnIndexOrThrow()
  • 按照此示例所示,将 ID 附加到内容 URI。
  • 运行 Android 10 及更高版本的设备需要 MediaStore API 中定义的列名。如果您的应用中的某个依赖库期望一个 API 中未定义的列名(例如 "MimeType"),请使用 CursorWrapper 在应用进程中动态转换列名。

加载文件缩略图

如果您的应用显示多个媒体文件并要求用户选择其中一个文件,那么加载文件的预览版本(或称缩略图)比加载文件本身更有效率。

要加载给定媒体文件的缩略图,请使用 loadThumbnail() 并传入要加载缩略图的大小,如以下代码段所示

Kotlin

// Load thumbnail of a specific media item.
val thumbnail: Bitmap =
        applicationContext.contentResolver.loadThumbnail(
        content-uri, Size(640, 480), null)

Java

// Load thumbnail of a specific media item.
Bitmap thumbnail =
        getApplicationContext().getContentResolver().loadThumbnail(
        content-uri, new Size(640, 480), null);

打开媒体文件

用于打开媒体文件的特定逻辑取决于媒体内容是最佳表示为文件描述符、文件流还是直接文件路径。

文件描述符

要使用文件描述符打开媒体文件,请使用类似于以下代码段所示的逻辑

Kotlin

// Open a specific media item using ParcelFileDescriptor.
val resolver = applicationContext.contentResolver

// "rw" for read-and-write.
// "rwt" for truncating or overwriting existing file contents.
val readOnlyMode = "r"
resolver.openFileDescriptor(content-uri, readOnlyMode).use { pfd ->
    // Perform operations on "pfd".
}

Java

// Open a specific media item using ParcelFileDescriptor.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();

// "rw" for read-and-write.
// "rwt" for truncating or overwriting existing file contents.
String readOnlyMode = "r";
try (ParcelFileDescriptor pfd =
        resolver.openFileDescriptor(content-uri, readOnlyMode)) {
    // Perform operations on "pfd".
} catch (IOException e) {
    e.printStackTrace();
}

文件流

要使用文件流打开媒体文件,请使用类似于以下代码段所示的逻辑

Kotlin

// Open a specific media item using InputStream.
val resolver = applicationContext.contentResolver
resolver.openInputStream(content-uri).use { stream ->
    // Perform operations on "stream".
}

Java

// Open a specific media item using InputStream.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();
try (InputStream stream = resolver.openInputStream(content-uri)) {
    // Perform operations on "stream".
}

直接文件路径

为了帮助您的应用与第三方媒体库更顺畅地协作,Android 11 (API 级别 30) 及更高版本允许您使用 MediaStore API 之外的 API 从共享存储空间访问媒体文件。您可以直接使用以下任一 API 访问媒体文件

  • File API
  • 原生库,例如 fopen()

如果您没有任何存储相关权限,可以使用 File API 访问应用专属目录中的文件以及归属于您应用的媒体文件

如果您的应用尝试使用 File API 访问文件但没有必要的权限,会发生 FileNotFoundException 异常。

要在运行 Android 10 (API 级别 29) 的设备上访问共享存储空间中的其他文件,我们建议您在应用的清单文件中将 requestLegacyExternalStorage 设置为 true,以暂时退出分区存储。要在 Android 10 上使用原生文件方法访问媒体文件,您还必须请求 READ_EXTERNAL_STORAGE 权限。

访问媒体内容时的注意事项

访问媒体内容时,请记住以下部分讨论的注意事项。

缓存数据

如果您的应用缓存媒体库中的 URI 或数据,请定期检查媒体库更新。此检查可使您的应用侧缓存数据与系统侧提供程序数据保持同步。

性能

当您使用直接文件路径对媒体文件执行顺序读取时,其性能与 MediaStore API 的性能相当。

但是,当您使用直接文件路径对媒体文件执行随机读取和写入时,该过程可能会慢达两倍。在这种情况下,我们建议改用 MediaStore API。

DATA 列

访问现有媒体文件时,您可以在逻辑中使用 DATA 列的值。这是因为该值具有有效的文件路径。但是,不要假定文件始终可用。请准备好处理可能发生的任何基于文件的 I/O 错误。

另一方面,要创建或更新媒体文件,请不要使用 DATA 列的值。而是使用 DISPLAY_NAMERELATIVE_PATH 列的值。

存储卷

以 Android 10 或更高版本为目标平台的应用可以访问系统分配给每个外部存储卷的唯一名称。此命名系统可帮助您高效地组织和索引内容,并让您控制新媒体文件的存储位置。

需要特别注意以下卷

  • VOLUME_EXTERNAL 卷提供设备上所有共享存储卷的视图。您可以读取此合成卷的内容,但无法修改其内容。
  • VOLUME_EXTERNAL_PRIMARY 卷代表设备上的主要共享存储卷。您可以读取和修改此卷的内容。

您可以通过调用 MediaStore.getExternalVolumeNames() 来发现其他卷

Kotlin

val volumeNames: Set<String> = MediaStore.getExternalVolumeNames(context)
val firstVolumeName = volumeNames.iterator().next()

Java

Set<String> volumeNames = MediaStore.getExternalVolumeNames(context);
String firstVolumeName = volumeNames.iterator().next();

媒体捕获位置

某些照片和视频在其元数据中包含位置信息,这些信息显示了拍摄照片或录制视频的地点。

您在应用中如何访问此位置信息取决于您是需要访问照片还是视频的位置信息。

照片

如果您的应用使用分区存储,系统默认会隐藏位置信息。要访问此信息,请完成以下步骤

  1. 在您的应用清单中请求 ACCESS_MEDIA_LOCATION 权限。
  2. 从您的 MediaStore 对象中,通过调用 setRequireOriginal() 并传入照片的 URI 来获取照片的精确字节,如以下代码段所示

    Kotlin

    val photoUri: Uri = Uri.withAppendedPath(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            cursor.getString(idColumnIndex)
    )
    
    // Get location data using the Exifinterface library.
    // Exception occurs if ACCESS_MEDIA_LOCATION permission isn't granted.
    photoUri = MediaStore.setRequireOriginal(photoUri)
    contentResolver.openInputStream(photoUri)?.use { stream ->
        ExifInterface(stream).run {
            // If lat/long is null, fall back to the coordinates (0, 0).
            val latLong = latLong ?: doubleArrayOf(0.0, 0.0)
        }
    }

    Java

    Uri photoUri = Uri.withAppendedPath(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            cursor.getString(idColumnIndex));
    
    final double[] latLong;
    
    // Get location data using the Exifinterface library.
    // Exception occurs if ACCESS_MEDIA_LOCATION permission isn't granted.
    photoUri = MediaStore.setRequireOriginal(photoUri);
    InputStream stream = getContentResolver().openInputStream(photoUri);
    if (stream != null) {
        ExifInterface exifInterface = new ExifInterface(stream);
        double[] returnedLatLong = exifInterface.getLatLong();
    
        // If lat/long is null, fall back to the coordinates (0, 0).
        latLong = returnedLatLong != null ? returnedLatLong : new double[2];
    
        // Don't reuse the stream associated with
        // the instance of "ExifInterface".
        stream.close();
    } else {
        // Failed to load the stream, so return the coordinates (0, 0).
        latLong = new double[2];
    }

视频

要访问视频元数据中的位置信息,请使用 MediaMetadataRetriever 类,如以下代码段所示。您的应用不需要请求任何额外权限即可使用此类。

Kotlin

val retriever = MediaMetadataRetriever()
val context = applicationContext

// Find the videos that are stored on a device by querying the video collection.
val query = ContentResolver.query(
    collection,
    projection,
    selection,
    selectionArgs,
    sortOrder
)
query?.use { cursor ->
    val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
    while (cursor.moveToNext()) {
        val id = cursor.getLong(idColumn)
        val videoUri: Uri = ContentUris.withAppendedId(
            MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
            id
        )
        extractVideoLocationInfo(videoUri)
    }
}

private fun extractVideoLocationInfo(videoUri: Uri) {
    try {
        retriever.setDataSource(context, videoUri)
    } catch (e: RuntimeException) {
        Log.e(APP_TAG, "Cannot retrieve video file", e)
    }
    // Metadata uses a standardized format.
    val locationMetadata: String? =
            retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
}

Java

MediaMetadataRetriever retriever = new MediaMetadataRetriever();
Context context = getApplicationContext();

// Find the videos that are stored on a device by querying the video collection.
try (Cursor cursor = context.getContentResolver().query(
    collection,
    projection,
    selection,
    selectionArgs,
    sortOrder
)) {
    int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID);
    while (cursor.moveToNext()) {
        long id = cursor.getLong(idColumn);
        Uri videoUri = ContentUris.withAppendedId(
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
        extractVideoLocationInfo(videoUri);
    }
}

private void extractVideoLocationInfo(Uri videoUri) {
    try {
        retriever.setDataSource(context, videoUri);
    } catch (RuntimeException e) {
        Log.e(APP_TAG, "Cannot retrieve video file", e);
    }
    // Metadata uses a standardized format.
    String locationMetadata = retriever.extractMetadata(
            MediaMetadataRetriever.METADATA_KEY_LOCATION);
}

共享

某些应用允许用户相互共享媒体文件。例如,社交媒体应用允许用户与朋友共享照片和视频。

要共享媒体文件,请使用 content:// URI,如创建内容提供程序指南中所建议。

媒体文件的应用归属

当以 Android 10 或更高版本为目标平台的应用启用分区存储时,系统会将一个应用归属于每个媒体文件,这决定了当您的应用未请求任何存储权限时可以访问的文件。每个文件只能归属于一个应用。因此,如果您的应用创建了一个存储在照片、视频或音频文件媒体集合中的媒体文件,您的应用就可以访问该文件。

但是,如果用户卸载并重新安装您的应用,您必须请求 READ_EXTERNAL_STORAGE 才能访问您的应用最初创建的文件。需要此权限请求是因为系统认为该文件归属于之前安装的应用版本,而不是新安装的版本。

当应用以 SDK 36 或更高版本为目标平台,并在运行 Android 16 或更高版本的设备上提示照片和视频权限时,选择限制访问特定媒体的用户会在照片选择器中看到应用拥有的照片已预先选中。用户可以取消选择这些预选项目中的任何一项,这将撤销该应用对这些照片和视频的访问权限。

添加项目

要将媒体项目添加到现有集合,请使用类似于以下代码的代码。此代码段在运行 Android 10 或更高版本的设备上访问 VOLUME_EXTERNAL_PRIMARY 卷。这是因为,在这些设备上,只有当卷是主卷时,您才能修改其内容,如存储卷部分所述。

Kotlin

// Add a specific media item.
val resolver = applicationContext.contentResolver

// Find all audio files on the primary external storage device.
val audioCollection =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        MediaStore.Audio.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL_PRIMARY
        )
    } else {
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
    }

// Publish a new song.
val newSongDetails = ContentValues().apply {
    put(MediaStore.Audio.Media.DISPLAY_NAME, "My Song.mp3")
}

// Keep a handle to the new song's URI in case you need to modify it
// later.
val myFavoriteSongUri = resolver
        .insert(audioCollection, newSongDetails)

Java

// Add a specific media item.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();

// Find all audio files on the primary external storage device.
Uri audioCollection;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    audioCollection = MediaStore.Audio.Media
            .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
} else {
    audioCollection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}

// Publish a new song.
ContentValues newSongDetails = new ContentValues();
newSongDetails.put(MediaStore.Audio.Media.DISPLAY_NAME,
        "My Song.mp3");

// Keep a handle to the new song's URI in case you need to modify it
// later.
Uri myFavoriteSongUri = resolver
        .insert(audioCollection, newSongDetails);

切换媒体文件的待处理状态

如果您的应用执行可能耗时的操作,例如写入媒体文件,那么在处理文件时独占访问该文件会很有用。在运行 Android 10 或更高版本的设备上,您的应用可以通过将 IS_PENDING 标志的值设置为 1 来获取此独占访问权限。只有您的应用可以查看该文件,直到您的应用将 IS_PENDING 的值更改回 0。

以下代码段以前一个代码段为基础。此代码段展示了在将一首长歌曲存储到与 MediaStore.Audio 集合对应的目录时如何使用 IS_PENDING 标志

Kotlin

// Add a media item that other apps don't see until the item is
// fully written to the media store.
val resolver = applicationContext.contentResolver

// Find all audio files on the primary external storage device.
val audioCollection =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        MediaStore.Audio.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL_PRIMARY
        )
    } else {
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
    }

val songDetails = ContentValues().apply {
    put(MediaStore.Audio.Media.DISPLAY_NAME, "My Workout Playlist.mp3")
    put(MediaStore.Audio.Media.IS_PENDING, 1)
}

val songContentUri = resolver.insert(audioCollection, songDetails)

// "w" for write.
resolver.openFileDescriptor(songContentUri, "w", null).use { pfd ->
    // Write data into the pending audio file.
}

// Now that you're finished, release the "pending" status and let other apps
// play the audio track.
songDetails.clear()
songDetails.put(MediaStore.Audio.Media.IS_PENDING, 0)
resolver.update(songContentUri, songDetails, null, null)

Java

// Add a media item that other apps don't see until the item is
// fully written to the media store.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();

// Find all audio files on the primary external storage device.
Uri audioCollection;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    audioCollection = MediaStore.Audio.Media
            .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
} else {
    audioCollection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}

ContentValues songDetails = new ContentValues();
songDetails.put(MediaStore.Audio.Media.DISPLAY_NAME,
        "My Workout Playlist.mp3");
songDetails.put(MediaStore.Audio.Media.IS_PENDING, 1);

Uri songContentUri = resolver
        .insert(audioCollection, songDetails);

// "w" for write.
try (ParcelFileDescriptor pfd =
        resolver.openFileDescriptor(songContentUri, "w", null)) {
    // Write data into the pending audio file.
}

// Now that you're finished, release the "pending" status and let other apps
// play the audio track.
songDetails.clear();
songDetails.put(MediaStore.Audio.Media.IS_PENDING, 0);
resolver.update(songContentUri, songDetails, null, null);

为文件位置提供提示

当您的应用在运行 Android 10 的设备上存储媒体时,默认情况下媒体会根据其类型进行组织。例如,默认情况下,新的图像文件会放置在 Environment.DIRECTORY_PICTURES 目录中,该目录对应于 MediaStore.Images 集合。

如果您的应用知道文件可以存储的特定位置,例如一个名为 Pictures/MyVacationPictures 的相册,您可以设置 MediaColumns.RELATIVE_PATH 为系统提供存储新写入文件的位置提示。

更新项目

要更新您的应用拥有的媒体文件,请使用类似于以下代码的代码

Kotlin

// Updates an existing media item.
val mediaId = // MediaStore.Audio.Media._ID of item to update.
val resolver = applicationContext.contentResolver

// When performing a single item update, prefer using the ID.
val selection = "${MediaStore.Audio.Media._ID} = ?"

// By using selection + args you protect against improper escaping of // values.
val selectionArgs = arrayOf(mediaId.toString())

// Update an existing song.
val updatedSongDetails = ContentValues().apply {
    put(MediaStore.Audio.Media.DISPLAY_NAME, "My Favorite Song.mp3")
}

// Use the individual song's URI to represent the collection that's
// updated.
val numSongsUpdated = resolver.update(
        myFavoriteSongUri,
        updatedSongDetails,
        selection,
        selectionArgs)

Java

// Updates an existing media item.
long mediaId = // MediaStore.Audio.Media._ID of item to update.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();

// When performing a single item update, prefer using the ID.
String selection = MediaStore.Audio.Media._ID + " = ?";

// By using selection + args you protect against improper escaping of
// values. Here, "song" is an in-memory object that caches the song's
// information.
String[] selectionArgs = new String[] { getId().toString() };

// Update an existing song.
ContentValues updatedSongDetails = new ContentValues();
updatedSongDetails.put(MediaStore.Audio.Media.DISPLAY_NAME,
        "My Favorite Song.mp3");

// Use the individual song's URI to represent the collection that's
// updated.
int numSongsUpdated = resolver.update(
        myFavoriteSongUri,
        updatedSongDetails,
        selection,
        selectionArgs);

如果分区存储不可用或未启用,前面代码段中所示的过程也适用于非您的应用拥有的文件。

在原生代码中更新

如果您需要使用原生库写入媒体文件,请将文件的关联文件描述符从您的 Java 或 Kotlin 代码传递到您的原生代码中。

以下代码段展示了如何将媒体对象的文件描述符传递到您的应用原生代码中

Kotlin

val contentUri: Uri = ContentUris.withAppendedId(
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
        cursor.getLong(BaseColumns._ID))
val fileOpenMode = "r"
val parcelFd = resolver.openFileDescriptor(contentUri, fileOpenMode)
val fd = parcelFd?.detachFd()
// Pass the integer value "fd" into your native code. Remember to call
// close(2) on the file descriptor when you're done using it.

Java

Uri contentUri = ContentUris.withAppendedId(
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
        cursor.getLong(Integer.parseInt(BaseColumns._ID)));
String fileOpenMode = "r";
ParcelFileDescriptor parcelFd =
        resolver.openFileDescriptor(contentUri, fileOpenMode);
if (parcelFd != null) {
    int fd = parcelFd.detachFd();
    // Pass the integer value "fd" into your native code. Remember to call
    // close(2) on the file descriptor when you're done using it.
}

更新其他应用的媒体文件

如果您的应用使用分区存储,通常无法更新由其他应用贡献到媒体库的媒体文件。

但是,您可以通过捕获平台抛出的 RecoverableSecurityException 来获取用户同意修改该文件。然后,您可以请求用户授予您的应用对该特定项目的写入访问权限,如以下代码段所示

Kotlin

// Apply a grayscale filter to the image at the given content URI.
try {
    // "w" for write.
    contentResolver.openFileDescriptor(image-content-uri, "w")?.use {
        setGrayscaleFilter(it)
    }
} catch (securityException: SecurityException) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        val recoverableSecurityException = securityException as?
            RecoverableSecurityException ?:
            throw RuntimeException(securityException.message, securityException)

        val intentSender =
            recoverableSecurityException.userAction.actionIntent.intentSender
        intentSender?.let {
            startIntentSenderForResult(intentSender, image-request-code,
                    null, 0, 0, 0, null)
        }
    } else {
        throw RuntimeException(securityException.message, securityException)
    }
}

Java

try {
    // "w" for write.
    ParcelFileDescriptor imageFd = getContentResolver()
            .openFileDescriptor(image-content-uri, "w");
    setGrayscaleFilter(imageFd);
} catch (SecurityException securityException) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        RecoverableSecurityException recoverableSecurityException;
        if (securityException instanceof RecoverableSecurityException) {
            recoverableSecurityException =
                    (RecoverableSecurityException)securityException;
        } else {
            throw new RuntimeException(
                    securityException.getMessage(), securityException);
        }
        IntentSender intentSender =recoverableSecurityException.getUserAction()
                .getActionIntent().getIntentSender();
        startIntentSenderForResult(intentSender, image-request-code,
                null, 0, 0, 0, null);
    } else {
        throw new RuntimeException(
                securityException.getMessage(), securityException);
    }
}

每次您的应用需要修改非其创建的媒体文件时,请完成此过程。

或者,如果您的应用在 Android 11 或更高版本上运行,您可以让用户授予您的应用对一组媒体文件的写入访问权限。使用 createWriteRequest() 方法,如关于如何管理媒体文件组的章节所述。

如果您的应用还有其他未涵盖在分区存储中的用例,请提交功能请求暂时退出分区存储

移除项目

要移除您的应用在媒体库中不再需要的项目,请使用类似于以下代码段中所示的逻辑

Kotlin

// Remove a specific media item.
val resolver = applicationContext.contentResolver

// URI of the image to remove.
val imageUri = "..."

// WHERE clause.
val selection = "..."
val selectionArgs = "..."

// Perform the actual removal.
val numImagesRemoved = resolver.delete(
        imageUri,
        selection,
        selectionArgs)

Java

// Remove a specific media item.
ContentResolver resolver = getApplicationContext()
        getContentResolver();

// URI of the image to remove.
Uri imageUri = "...";

// WHERE clause.
String selection = "...";
String[] selectionArgs = "...";

// Perform the actual removal.
int numImagesRemoved = resolver.delete(
        imageUri,
        selection,
        selectionArgs);

如果分区存储不可用或未启用,您可以使用前面的代码段来移除其他应用拥有的文件。但是,如果启用了分区存储,则对于您的应用想要移除的每个文件,都需要捕获一个 RecoverableSecurityException,如关于更新媒体项目的章节所述。

如果您的应用在 Android 11 或更高版本上运行,您可以让用户选择一组要移除的媒体文件。使用 createTrashRequest() 方法或 createDeleteRequest() 方法,如关于如何管理媒体文件组的章节所述。

如果您的应用还有其他未涵盖在分区存储中的用例,请提交功能请求暂时退出分区存储

检测媒体文件的更新

您的应用可能需要识别包含应用添加或修改的媒体文件的存储卷,并与之前的时间点进行比较。要最可靠地检测这些更改,请将感兴趣的存储卷传递给 getGeneration()。只要媒体库版本不改变,此方法的返回值就会随着时间的推移单调增加。

特别地,getGeneration() 比媒体列中的日期(例如 DATE_ADDEDDATE_MODIFIED)更可靠。这是因为当应用调用 setLastModified() 或用户更改系统时钟时,这些媒体列值可能会改变。

管理媒体文件组

在 Android 11 及更高版本上,您可以要求用户选择一组媒体文件,然后通过单个操作更新这些媒体文件。这些方法在设备之间提供更好的一致性,并使用户更容易管理其媒体集合。

提供此“批量更新”功能的包括以下方法

createWriteRequest()
请求用户授予您的应用对指定媒体文件组的写入访问权限。
createFavoriteRequest()
请求用户将指定的媒体文件标记为设备上的“收藏”媒体。任何对该文件具有读取访问权限的应用都可以看到用户已将该文件标记为“收藏”。
createTrashRequest()

请求用户将指定的媒体文件放入设备的回收站。回收站中的项目将在系统定义的时间段后永久删除。

createDeleteRequest()

请求用户立即永久删除指定的媒体文件,而无需事先将其放入回收站。

调用这些方法中的任何一个后,系统会构建一个 PendingIntent 对象。您的应用调用此意图后,用户会看到一个对话框,请求他们同意您的应用更新或删除指定的媒体文件。

例如,以下是构建对 createWriteRequest() 的调用的方法

Kotlin

val urisToModify = /* A collection of content URIs to modify. */
val editPendingIntent = MediaStore.createWriteRequest(contentResolver,
        urisToModify)

// Launch a system prompt requesting user permission for the operation.
startIntentSenderForResult(editPendingIntent.intentSender, EDIT_REQUEST_CODE,
    null, 0, 0, 0)

Java

List<Uri> urisToModify = /* A collection of content URIs to modify. */
PendingIntent editPendingIntent = MediaStore.createWriteRequest(contentResolver,
                  urisToModify);

// Launch a system prompt requesting user permission for the operation.
startIntentSenderForResult(editPendingIntent.getIntentSender(),
    EDIT_REQUEST_CODE, null, 0, 0, 0);

评估用户的响应。如果用户同意,则继续媒体操作。否则,向用户解释您的应用为何需要该权限

Kotlin

override fun onActivityResult(requestCode: Int, resultCode: Int,
                 data: Intent?) {
    ...
    when (requestCode) {
        EDIT_REQUEST_CODE ->
            if (resultCode == Activity.RESULT_OK) {
                /* Edit request granted; proceed. */
            } else {
                /* Edit request not granted; explain to the user. */
            }
    }
}

Java

@Override
protected void onActivityResult(int requestCode, int resultCode,
                   @Nullable Intent data) {
    ...
    if (requestCode == EDIT_REQUEST_CODE) {
        if (resultCode == Activity.RESULT_OK) {
            /* Edit request granted; proceed. */
        } else {
            /* Edit request not granted; explain to the user. */
        }
    }
}

您可以将此通用模式用于 createFavoriteRequest()createTrashRequest()createDeleteRequest()

媒体管理权限

用户可能信任特定应用来执行媒体管理,例如频繁编辑媒体文件。如果您的应用以 Android 11 或更高版本为目标平台,且不是设备默认的图库应用,那么每次您的应用尝试修改或删除文件时,都必须向用户显示确认对话框。

如果您的应用以 Android 12 (API 级别 31) 或更高版本为目标平台,您可以请求用户授予您的应用访问媒体管理特殊权限。此权限允许您的应用执行以下各项操作,而无需针对每个文件操作提示用户

为此,请完成以下步骤

  1. 在您的应用清单文件中声明 MANAGE_MEDIA 权限和 READ_EXTERNAL_STORAGE 权限。

    要在不显示确认对话框的情况下调用 createWriteRequest(),还需要声明 ACCESS_MEDIA_LOCATION 权限。

  2. 在您的应用中,向用户展示一个 UI,解释他们可能希望授予您的应用媒体管理访问权限的原因。

  3. 调用 ACTION_REQUEST_MANAGE_MEDIA 意图操作。这将把用户带到系统设置中的媒体管理应用屏幕。在此处,用户可以授予特殊的应用访问权限。

需要媒体库替代方案的用例

如果您的应用主要扮演以下角色之一,请考虑使用 MediaStore API 的替代方案。

处理其他类型的文件

如果您的应用处理不完全包含媒体内容的文档和文件,例如使用 EPUB 或 PDF 文件扩展名的文件,请使用 ACTION_OPEN_DOCUMENT 意图操作,如存储和访问文档及其他文件指南中所述。

伴侣应用中的文件共享

在提供一套伴侣应用(例如消息应用和个人资料应用)的情况下,请使用 content:// URI设置文件共享。我们还建议将此工作流程作为安全最佳实践

其他资源

有关如何存储和访问媒体的更多信息,请查阅以下资源。

示例

视频