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

为了提供更丰富的用户体验,许多应用允许用户提供和访问位于外部存储卷上的媒体。该框架提供经过优化的媒体集合索引,称为媒体库,使您可以更轻松地检索和更新这些媒体文件。即使您的应用已卸载,这些文件仍会保留在用户的设备上。

照片选择器

作为使用媒体库的替代方案,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. 通过调用 setRequireOriginal(),从 MediaStore 对象获取照片的确切字节,并传入照片的 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 provider 创建指南中的建议使用 content:// URI。

媒体文件的应用归因

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

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

添加项目

如需将媒体项添加到现有集合,请使用类似于以下内容的代码。此代码段可在搭载 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 或基于 Koltin 的代码传入原生代码。

以下代码段演示了如何将媒体对象的文件描述符传入应用的原生代码:

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 对象。应用调用此 intent 后,用户会看到一个对话框,请求用户同意应用更新或删除指定的媒体文件。

例如,以下是构建 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. 在您的应用中,向用户显示一个界面,说明为什么他们可能需要向您的应用授予媒体管理访问权限。

  3. 调用 ACTION_REQUEST_MANAGE_MEDIA intent 操作。这样会将用户引导至系统设置中的媒体管理应用屏幕。在此处,用户可以授予特殊应用访问权限。

要求使用媒体库替代方案的用例

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

使用其他类型的文件

如果您的应用使用的文档和文件并非专门包含媒体内容,例如使用 EPUB 或 PDF 文件扩展名的文件,请按照有关如何存储和访问文档及其他文件的指南中所述,使用 ACTION_OPEN_DOCUMENT intent 操作。

在配套应用中共享文件

如果您提供了一组配套应用(例如即时通讯应用和个人资料应用),请使用 content:// URI 来设置文件共享。我们还建议将此工作流程作为一项安全最佳实践

其他资源

如需详细了解如何存储和访问媒体,请参阅以下资源。

示例

视频