存取共用儲存空間中的文件和其他檔案

在搭載 Android 4.4 (API 級別 19) 及以上版本的裝置上,應用程式可以使用 Storage Accesss Framework 與文件供應器互動,包括存取外部儲存空間磁碟區和雲端儲存空間。Storage Accesss Framework 可讓應用程式使用系統挑選器,以選擇文件供應器,進而選取特定文件及其他檔案,以便建立、開啟或修改。

由於使用者可決定應用程式可存取的檔案或目錄,因此不需要任何系統權限,且能強化使用者控制項與隱私權。此外,在應用程式解除安裝後,這些檔案不會儲存在應用程式專屬目錄與媒體商店中。

如要使用 Storage Accesss Framework,請按照下列步驟操作:

  1. 應用程式會叫用意圖,內含與儲存空間相關的動作。這個動作與 Storage Accesss Framework 提供的特定用途相對應。
  2. 使用者會看到系統顯示挑選器,進而瀏覽文件供應器,並選擇要儲存的位置或文件。
  3. 應用程式會取得 URI 的讀取和寫入權限,其中 URI 代表使用者的選擇的儲存位置或文件。透過使用該 URI,應用程式即可在所選位置執行作業

如果應用程式使用媒體商店,且需存取其他應用程式的媒體檔案,則須要求 READ_EXTERNAL_STORAGE 權限。在搭載 Android 9 (API 級別 28) 或以下版本的裝置上,應用程式必須要求 READ_EXTERNAL_STORAGE 權限才能存取任何媒體檔案,就算是它自己建立的亦然。

本指南說明 Storage Accesss Framework 支援的各種功能,以處理檔案和其他文件。並說明如何在使用者選取的位置執行作業。

用於存取文件和其他檔案的功能

Storage Access Framework 提供存取下列檔案以及其他文件的功能。

建立新檔
使用者透過
ACTION_CREATE_DOCUMENT 意圖動作可將檔案儲存在特定位置。
開啟文件或檔案
使用者透過
ACTION_OPEN_DOCUMENT 意圖動作可選取並開啟特定的文件或檔案。
授予目錄內容的存取權
ACTION_OPEN_DOCUMENT_TREE 意圖動作適用於 Android 5.0 (API 級別 21) 及以上版本,可讓使用者選取特定目錄,授予應用程式權限,以存取該目錄中的所有檔案和子目錄。

如要瞭解各項功能設定,請參閱下列指南。

建立新檔

為了讓使用者選擇寫入檔案內容的位置,請使用 ACTION_CREATE_DOCUMENT 意圖動作載入系統挑選器。這項程序與類似於其他作業系統的「另存新檔」對話方塊。

注意:ACTION_CREATE_DOCUMENT 無法覆寫現有檔案。如果應用程式嘗試儲存同名檔案,系統會在檔名結尾加上含有數字的括號。

舉例來說,如果應用程式嘗試儲存的目錄中,已有名為 confirmation.pdf 的檔案,系統就會以 confirmation(1).pdf 儲存新檔。

設定意圖時,請指定檔案名稱和 MIME 類型,如使用 EXTRA_INITIAL_URI 額外意圖首次載入檔案,則視需要指定挑選器應顯示的檔案或目錄的 URI。

要瞭解如何建立和叫用意圖以建立檔案,請參閱以下程式碼範例:

Kotlin

// Request code for creating a PDF document.
const val CREATE_FILE = 1

private fun createFile(pickerInitialUri: Uri) {
    val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
        addCategory(Intent.CATEGORY_OPENABLE)
        type = "application/pdf"
        putExtra(Intent.EXTRA_TITLE, "invoice.pdf")

        // Optionally, specify a URI for the directory that should be opened in
        // the system file picker before your app creates the document.
        putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
    }
    startActivityForResult(intent, CREATE_FILE)
}

Java

// Request code for creating a PDF document.
private static final int CREATE_FILE = 1;

private void createFile(Uri pickerInitialUri) {
    Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
    intent.addCategory(Intent.CATEGORY_OPENABLE);
    intent.setType("application/pdf");
    intent.putExtra(Intent.EXTRA_TITLE, "invoice.pdf");

    // Optionally, specify a URI for the directory that should be opened in
    // the system file picker when your app creates the document.
    intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri);

    startActivityForResult(intent, CREATE_FILE);
}

開啟檔案

請使用文件來做為應用程式的儲存空間單位,以便使用者輸入要與其他同類應用程式共享或匯入其他文件的資料。幾個常見例子包括使用者開啟生產力文件,或是以 EPUB 檔案格式儲存的書籍。

在這些情況下,使用者可叫用 ACTION_OPEN_DOCUMENT 意圖開啟系統挑選器,藉此選擇要開啟的檔案。如有指定 MIME 類型,則應用程式只顯示其支援的檔案類型。此外,使用 EXTRA_INITIAL_URI 額外意圖進行首次載入時,開發人員可指定挑選器應顯示的檔案或目錄的 URI。

進一步瞭解如何建立和叫用意圖以開啟 PDF 文件,請參閱下列程式碼範例:

Kotlin

// Request code for selecting a PDF document.
const val PICK_PDF_FILE = 2

fun openFile(pickerInitialUri: Uri) {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        addCategory(Intent.CATEGORY_OPENABLE)
        type = "application/pdf"

        // Optionally, specify a URI for the file that should appear in the
        // system file picker when it loads.
        putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
    }

    startActivityForResult(intent, PICK_PDF_FILE)
}

Java

// Request code for selecting a PDF document.
private static final int PICK_PDF_FILE = 2;

private void openFile(Uri pickerInitialUri) {
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
    intent.addCategory(Intent.CATEGORY_OPENABLE);
    intent.setType("application/pdf");

    // Optionally, specify a URI for the file that should appear in the
    // system file picker when it loads.
    intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri);

    startActivityForResult(intent, PICK_PDF_FILE);
}

存取權限制

在 Android 11 (API 級別 30) 及以上版本中,應用程式無法使用 ACTION_OPEN_DOCUMENT 意圖動作,要求使用者選取下列目錄中的個別檔案:

  • Android/data/ 目錄和所有子目錄。
  • Android/obb/ 目錄和所有子目錄。

授予目錄內容的存取權

一般而言,檔案管理和建立媒體的應用程式負責管理目錄階層中的檔案群組。使用 ACTION_OPEN_DOCUMENT_TREE 意圖動作,即可讓使用者授予應用程式權限,以存取將整個樹狀目錄,但 Android 11 (API 級別 30) 以上版本會有例外情形。之後,應用程式便可存取所選目錄及其子目錄中的任何檔案。

使用 ACTION_OPEN_DOCUMENT_TREE 時,應用程式只會存取使用者所選目錄中的檔案,不能存取位於使用者所選目錄之外的其他應用程式檔案。存取權由使用者控管,讓他們選擇願意與應用程式共用的內容。

或者,開發人員也可以使用 EXTRA_INITIAL_URI 額外意圖,指定檔案首次載入時要顯示的目錄 URI。

進一步瞭解如何建立和叫用意圖以開啟目錄,請參閱下列程式碼範例:

Kotlin

fun openDirectory(pickerInitialUri: Uri) {
    // Choose a directory using the system's file picker.
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
        // Optionally, specify a URI for the directory that should be opened in
        // the system file picker when it loads.
        putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
    }

    startActivityForResult(intent, your-request-code)
}

Java

public void openDirectory(Uri uriToLoad) {
    // Choose a directory using the system's file picker.
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);

    // Optionally, specify a URI for the directory that should be opened in
    // the system file picker when it loads.
    intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uriToLoad);

    startActivityForResult(intent, your-request-code);
}

存取權限制

在 Android 11 (API 級別 30) 及以上版本中,開發人員無法使用 ACTION_OPEN_DOCUMENT_TREE 意圖動作來存取下列目錄:

  • 內部儲存磁碟區的根目錄。
  • 無論是模擬 SD 卡還是可移除的 SD 卡的根目錄,裝置製造商都視為穩定目錄。只有穩定的磁碟區,才能讓應用程式成功存取檔案。
  • Download 目錄

此外,在 Android 11 (API 級別 30) 及以上版本中,無法使用 ACTION_OPEN_DOCUMENT_TREE 意圖動作要求使用者選取下列目錄中的個別檔案:

  • Android/data/ 目錄和所有子目錄。
  • Android/obb/ 目錄和所有子目錄。

在所選位置執行作業

使用者透過系統挑選器選取檔案或目錄後,開發人員可以使用下列範例程式碼中的 onActivityResult() 擷取所選項目的 URI:

Kotlin

override fun onActivityResult(
        requestCode: Int, resultCode: Int, resultData: Intent?) {
    if (requestCode == your-request-code
            && resultCode == Activity.RESULT_OK) {
        // The result data contains a URI for the document or directory that
        // the user selected.
        resultData?.data?.also { uri ->
            // Perform operations on the document using its URI.
        }
    }
}

Java

@Override
public void onActivityResult(int requestCode, int resultCode,
        Intent resultData) {
    if (requestCode == your-request-code
            && resultCode == Activity.RESULT_OK) {
        // The result data contains a URI for the document or directory that
        // the user selected.
        Uri uri = null;
        if (resultData != null) {
            uri = resultData.getData();
            // Perform operations on the document using its URI.
        }
    }
}

一旦取得所選項目的 URI,應用程式就能對該項目執行作業。例如,存取項目的中繼資料、編輯以及刪除項目。

要瞭解如何針對使用者選取的檔案完成操作,請參閱下列各節說明。

判斷供應器提供的作業方式

不同的內容供應器可對文件進行不同作業,例如複製文件或查看文件縮圖。請查看 Document.COLUMN_FLAGS 的值,即可得知特定供應器可執行的作業。接著,應用程式的 UI 只會顯示供應器提供的選項。

保留權限

當應用程式為了讀取或寫入而開啟檔案時,系統會提供該檔案的 URI 權限,直到使用者的裝置重新啟動為止。舉例來說,如果是圖片編輯應用程式,且開發人員希望使用者直接從應用程式存取最近編輯的 5 張圖片。如果使用者的裝置已重新啟動,就必須顯示系統挑選器以尋找檔案。

如要在使用者重新啟動裝置後,還能保留檔案存取權,以建立更優質的使用者體驗,開發人員可以「採用」系統提供的永久 URI 權限,如以下程式碼範例所示:

Kotlin

val contentResolver = applicationContext.contentResolver

val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
        Intent.FLAG_GRANT_WRITE_URI_PERMISSION
// Check for the freshest data.
contentResolver.takePersistableUriPermission(uri, takeFlags)

Java

final int takeFlags = intent.getFlags()
            & (Intent.FLAG_GRANT_READ_URI_PERMISSION
            | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
// Check for the freshest data.
getContentResolver().takePersistableUriPermission(uri, takeFlags);

檢查文件中繼資料

取得文件的 URI 後,應用程式就可以存取其中繼資料。進一步瞭解如何擷取 URI 指定的文件中繼資料,並且將過程記錄下來,請參閱以下程式碼範例:

Kotlin

val contentResolver = applicationContext.contentResolver

fun dumpImageMetaData(uri: Uri) {

    // The query, because it only applies to a single document, returns only
    // one row. There's no need to filter, sort, or select fields,
    // because we want all fields for one document.
    val cursor: Cursor? = contentResolver.query(
            uri, null, null, null, null, null)

    cursor?.use {
        // moveToFirst() returns false if the cursor has 0 rows. Very handy for
        // "if there's anything to look at, look at it" conditionals.
        if (it.moveToFirst()) {

            // Note it's called "Display Name". This is
            // provider-specific, and might not necessarily be the file name.
            val displayName: String =
                    it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
            Log.i(TAG, "Display Name: $displayName")

            val sizeIndex: Int = it.getColumnIndex(OpenableColumns.SIZE)
            // If the size is unknown, the value stored is null. But because an
            // int can't be null, the behavior is implementation-specific,
            // and unpredictable. So as
            // a rule, check if it's null before assigning to an int. This will
            // happen often: The storage API allows for remote files, whose
            // size might not be locally known.
            val size: String = if (!it.isNull(sizeIndex)) {
                // Technically the column stores an int, but cursor.getString()
                // will do the conversion automatically.
                it.getString(sizeIndex)
            } else {
                "Unknown"
            }
            Log.i(TAG, "Size: $size")
        }
    }
}

Java

public void dumpImageMetaData(Uri uri) {

    // The query, because it only applies to a single document, returns only
    // one row. There's no need to filter, sort, or select fields,
    // because we want all fields for one document.
    Cursor cursor = getActivity().getContentResolver()
            .query(uri, null, null, null, null, null);

    try {
        // moveToFirst() returns false if the cursor has 0 rows. Very handy for
        // "if there's anything to look at, look at it" conditionals.
        if (cursor != null && cursor.moveToFirst()) {

            // Note it's called "Display Name". This is
            // provider-specific, and might not necessarily be the file name.
            String displayName = cursor.getString(
                    cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
            Log.i(TAG, "Display Name: " + displayName);

            int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE);
            // If the size is unknown, the value stored is null. But because an
            // int can't be null, the behavior is implementation-specific,
            // and unpredictable. So as
            // a rule, check if it's null before assigning to an int. This will
            // happen often: The storage API allows for remote files, whose
            // size might not be locally known.
            String size = null;
            if (!cursor.isNull(sizeIndex)) {
                // Technically the column stores an int, but cursor.getString()
                // will do the conversion automatically.
                size = cursor.getString(sizeIndex);
            } else {
                size = "Unknown";
            }
            Log.i(TAG, "Size: " + size);
        }
    } finally {
        cursor.close();
    }
}

開啟文件

只要參照文件的 URI,就可以開啟文件進行後續處理。本節範例將說明如何開啟點陣圖和輸入串流。

點陣圖

要瞭解如何根據其 URI 開啟 Bitmap 檔案,請見下列程式碼範例:

Kotlin

val contentResolver = applicationContext.contentResolver

@Throws(IOException::class)
private fun getBitmapFromUri(uri: Uri): Bitmap {
    val parcelFileDescriptor: ParcelFileDescriptor =
            contentResolver.openFileDescriptor(uri, "r")
    val fileDescriptor: FileDescriptor = parcelFileDescriptor.fileDescriptor
    val image: Bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
    parcelFileDescriptor.close()
    return image
}

Java

private Bitmap getBitmapFromUri(Uri uri) throws IOException {
    ParcelFileDescriptor parcelFileDescriptor =
            getContentResolver().openFileDescriptor(uri, "r");
    FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
    Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
    parcelFileDescriptor.close();
    return image;
}

開啟點陣圖後,就可以在 ImageView 中顯示該圖。

輸入串流

如要瞭解如何根據 URI 開啟輸入串流物件,以及如何將檔案中的行讀取成字串,請參閱以下程式碼範例:

Kotlin

val contentResolver = applicationContext.contentResolver

@Throws(IOException::class)
private fun readTextFromUri(uri: Uri): String {
    val stringBuilder = StringBuilder()
    contentResolver.openInputStream(uri)?.use { inputStream ->
        BufferedReader(InputStreamReader(inputStream)).use { reader ->
            var line: String? = reader.readLine()
            while (line != null) {
                stringBuilder.append(line)
                line = reader.readLine()
            }
        }
    }
    return stringBuilder.toString()
}

Java

private String readTextFromUri(Uri uri) throws IOException {
    StringBuilder stringBuilder = new StringBuilder();
    try (InputStream inputStream =
            getContentResolver().openInputStream(uri);
            BufferedReader reader = new BufferedReader(
            new InputStreamReader(Objects.requireNonNull(inputStream)))) {
        String line;
        while ((line = reader.readLine()) != null) {
            stringBuilder.append(line);
        }
    }
    return stringBuilder.toString();
}

編輯文件

使用 Storage Access Framework 可以編輯既有的文字文件。

要瞭解如何覆寫指定 URI 代表的文件內容,請見以下程式碼範例:

Kotlin

val contentResolver = applicationContext.contentResolver

private fun alterDocument(uri: Uri) {
    try {
        contentResolver.openFileDescriptor(uri, "w")?.use {
            FileOutputStream(it.fileDescriptor).use {
                it.write(
                    ("Overwritten at ${System.currentTimeMillis()}\n")
                        .toByteArray()
                )
            }
        }
    } catch (e: FileNotFoundException) {
        e.printStackTrace()
    } catch (e: IOException) {
        e.printStackTrace()
    }
}

Java

private void alterDocument(Uri uri) {
    try {
        ParcelFileDescriptor pfd = getActivity().getContentResolver().
                openFileDescriptor(uri, "w");
        FileOutputStream fileOutputStream =
                new FileOutputStream(pfd.getFileDescriptor());
        fileOutputStream.write(("Overwritten at " + System.currentTimeMillis() +
                "\n").getBytes());
        // Let the document provider know you're done by closing the stream.
        fileOutputStream.close();
        pfd.close();
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

刪除文件

如果應用程式有文件的 URI,且文件的 Document.COLUMN_FLAGS 含有 SUPPORTS_DELETE,即可刪除文件。例如:

Kotlin

DocumentsContract.deleteDocument(applicationContext.contentResolver, uri)

Java

DocumentsContract.deleteDocument(applicationContext.contentResolver, uri);

擷取對等媒體 URI

getMediaUri() 方法提供媒體商店的 URI,等同於指定文件供應器的 URI。兩個 URI 皆參照相同基礎項目。使用媒體商店的 URI,就能更輕鬆地存取共用儲存空間中的媒體檔案

getMediaUri() 方法提供 ExternalStorageProvider URI。在 Android 12 (API 級別 31) 及以上版本中,這個方法也提供 MediaDocumentsProvider URI。

開啟虛擬檔案

在 Android 7.0 (API 級別 25) 及以上版本中,應用程式可以使用 Storage Access Framework 提供的虛擬檔案。雖然虛擬檔案沒有二進位表示法,但應用程式仍可將其強制轉換成其他類型的檔案,或是使用 ACTION_VIEW 意圖動作查看這些檔案。

如果用戶端應用程式,則必須具備特殊邏輯,才能開啟虛擬檔案。如要取得檔案的位元組表示法,比如預覽檔案,應用程式必須請求文件供應器提供替代的 MIME 類型。

使用者選擇要開啟的檔案後,請使用結果資料中的 URI,以判斷檔案是否為虛擬檔案,詳情請參閱以下程式碼範例:

Kotlin

private fun isVirtualFile(uri: Uri): Boolean {
    if (!DocumentsContract.isDocumentUri(this, uri)) {
        return false
    }

    val cursor: Cursor? = contentResolver.query(
            uri,
            arrayOf(DocumentsContract.Document.COLUMN_FLAGS),
            null,
            null,
            null
    )

    val flags: Int = cursor?.use {
        if (cursor.moveToFirst()) {
            cursor.getInt(0)
        } else {
            0
        }
    } ?: 0

    return flags and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT != 0
}

Java

private boolean isVirtualFile(Uri uri) {
    if (!DocumentsContract.isDocumentUri(this, uri)) {
        return false;
    }

    Cursor cursor = getContentResolver().query(
        uri,
        new String[] { DocumentsContract.Document.COLUMN_FLAGS },
        null, null, null);

    int flags = 0;
    if (cursor.moveToFirst()) {
        flags = cursor.getInt(0);
    }
    cursor.close();

    return (flags & DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0;
}

應用程式確認文件是虛擬檔案後,即可將檔案轉換成替代 MIME 類型,例如 "image/png"。如要進一步瞭解如何檢查虛擬檔案是否可用圖片形式呈現,以及如果可以的話,即從虛擬檔案取得輸入串流,請參閱以下程式碼範例:

Kotlin

@Throws(IOException::class)
private fun getInputStreamForVirtualFile(
        uri: Uri, mimeTypeFilter: String): InputStream {

    val openableMimeTypes: Array<String>? =
            contentResolver.getStreamTypes(uri, mimeTypeFilter)

    return if (openableMimeTypes?.isNotEmpty() == true) {
        contentResolver
                .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null)
                .createInputStream()
    } else {
        throw FileNotFoundException()
    }
}

Java

private InputStream getInputStreamForVirtualFile(Uri uri, String mimeTypeFilter)
    throws IOException {

    ContentResolver resolver = getContentResolver();

    String[] openableMimeTypes = resolver.getStreamTypes(uri, mimeTypeFilter);

    if (openableMimeTypes == null ||
        openableMimeTypes.length < 1) {
        throw new FileNotFoundException();
    }

    return resolver
        .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null)
        .createInputStream();
}

其他資源

如要進一步瞭解如何儲存及存取文件和其他檔案,請參閱下列資源。

範例

影片