存取共用儲存空間中的媒體檔案

為使用者能享受更豐富的體驗,許多應用程式可讓使用者提供及存取外部儲存磁碟區中的媒體。該架構為媒體集合提供了稱為「媒體儲存區」的最佳化索引,讓您可以更輕鬆地擷取及更新媒體檔案。即便在解除安裝應用程式後,這些檔案仍會保留在使用者的裝置上。

如要與媒體儲存區抽象層互動,請使用從應用程式環境擷取的 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 資料表。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 或以上的應用程式才提供此功能)。

已啟用限定範圍儲存空間

如果裝置搭載 Android 9 (API 級別 28) 或以下的版本,而應用程式需要使用限定範圍儲存空間,則須要求儲存空間的相關權限。如要套用此條件,請在應用程式資訊清單檔案的權限宣告中加入 android:maxSdkVersion 屬性:

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

如果裝置搭載 Android 10 或以上的版本,請勿要求不必要的儲存空間權限。不要求任何儲存空間權限的應用程式,有助改善定義完善的媒體集合,包括 MediaStore.Downloads 集合。以開發相機應用程式為例,由於應用程式已具備要寫入媒體儲存區的圖片的擁有權,因此無須要求儲存空間權限。

如要存取其他應用程式所建立的檔案,必須滿足以下條件:

如果應用程式想存取未建立的 MediaStore.Downloads 集合中的檔案,則必須使用 Storage 存取架構。如要進一步瞭解如何使用這個架構,請參閱這項指南,瞭解如何存取文件和其他檔案。

無法使用限定範圍儲存空間

如果應用程式是安裝於搭載 Android 9 或以下版本的裝置上,或者應用程式暫時停用限定範圍儲存空間,則必須要求 READ_EXTERNAL_STORAGE 權限以存取媒體檔案。如要修改媒體檔案,則須同時要求 WRITE_EXTERNAL_STORAGE 權限。

媒體位置存取權

如果應用程式為了從相片中擷取未遮蓋的 Exif 中繼資料,因而以 Android 10 (API 級別 29) 或以上為目標版本,則必須宣告 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();

擷取媒體的位置

部分相片和影片的位置資訊可以在中繼資料中找到,內容包含拍照或錄影的位置。

如要在應用程式中存取這項資訊,需使用不同 API 獲取相片位置資訊影片位置資訊

相片

如果應用程式啟用限定範圍儲存空間,位置資訊將預設為隱藏。如要存取這項資訊,請完成下列步驟:

  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 should use 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 should use a standardized format.
    String locationMetadata = retriever.extractMetadata(
            MediaMetadataRetriever.METADATA_KEY_LOCATION);
}

共用

有些應用程式可讓使用者與他人分享媒體檔案。例如,社交媒體應用程式可讓使用者與朋友分享相片和影片。

按照建立內容供應器指南的建議,使用 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")
}

// Keeps a handle to the new song's URI in case we 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");

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

切換媒體檔案的待處理狀態

如果應用程式執行的作業可能非常耗時 (例如寫入媒體檔案),建議您在處理檔案時取得檔案的專屬存取權。在搭載 Android 10 以上版本的裝置上,將 IS_PENDING 標記的值設為 1 即可讓應用程式取得專屬存取權。除非 IS_PENDING 的值變更回 0,否則只有應用程式可以查看檔案。

下列程式碼範例接續了先前的程式碼。要瞭解如何使用 IS_PENDING 標記,並根據目錄,將一首長曲儲存至相對應的目錄 MediaStore.Audio,請參閱下列程式碼範例:

Kotlin

// Add a media item that other apps shouldn'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)

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

// Now that we're finished, release the "pending" status, and allow other apps
// to 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 shouldn'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);

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

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

提供檔案位置提示

如使用搭載 Android 10 的裝置上儲存媒體,系統會根據預設以分類媒體。比方說,如果是對應 MediaStore.Images 集合的新圖片檔,就會根據預設存放於 Environment.DIRECTORY_PICTURES 目錄。

如應用程式知道儲存檔案的確切位置 (例如「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 we 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 we 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 {
    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 {
    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()。只要媒體儲存區是同一版本,此方法的傳回值會持續單調遞增。

請特別注意,與 DATE_ADDEDDATE_MODIFIED 這些媒體資料欄相比,getGeneration() 方法取得的日期更為可靠。這是因為這些媒體資料欄值,在應用程式呼叫 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 權限。

    只要一併宣告 ACCESS_MEDIA_LOCATION 權限,即可呼叫 createWriteRequest(),且不顯示確認對話方塊。

  2. 請向使用者顯示使用者介面,並說明為何需要使用者授權應用程式存取媒體。

  3. 請叫用 ACTION_REQUEST_MANAGE_MEDIA 意圖動作,將使用者導向至系統設定中的「媒體管理應用程式」畫面。應用程式要在這裡取得使用者授權特殊存取權限。

需要媒體儲存區替代方案的功能

如果應用程式主要作業包含下列任一功能,則需考慮改用 MediaStore API。

使用其他類型檔案

如果應用程式使用的文件和檔案不僅限於媒體內容 (例如副檔名為 EPUB 或 PDF 的檔案),請使用 ACTION_OPEN_DOCUMENT 意圖動作;詳情請參閱這篇指南,進一步瞭解如何儲存及存取文件和其他檔案。

與隨附應用程式共用檔案

如果開發人員提供了隨附應用程式套件,例如訊息應用程式與設定檔應用程式,請使用 content:// URI 設定檔案共用區。作為安全性最佳做法,建議開發人員採用這個工作流程。

其他資源

如要進一步瞭解如何儲存及存取媒體,請參考下列資源。

範例

影片