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

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

這項機制是由使用者選取應用程式可存取的檔案或目錄,因此不需要動用任何系統權限,還能強化使用者控制權與隱私權。此外,這些檔案不會儲存在應用程式專屬目錄和媒體儲存區,所以應用程式解除安裝後,這些檔案仍會保留在裝置上。

儲存空間存取架構的使用流程包含以下步驟:

  1. 應用程式叫用包含儲存空間相關動作的意圖。這種動作可對應至儲存空間存取架構支援的特定用途
  2. 畫面上會顯示系統選擇器,讓使用者瀏覽文件供應器,並選擇要執行儲存空間相關動作的位置或文件。
  3. 應用程式會取得 URI 讀取和寫入權限,而 URI 代表的是使用者選擇的位置或文件。透過該 URI,應用程式即可在所選位置執行作業

如要在搭載 Android 9 (API 級別 28) 以下版本的裝置上支援媒體檔案存取功能,請宣告 READ_EXTERNAL_STORAGE 權限,並將 maxSdkVersion 設為 28

本指南說明儲存空間存取架構支援的各種用途,以及可處理的檔案和其他文件。此外,文中也會說明如何在使用者選取的位置執行作業。

針對文件和其他檔案的存取用途

儲存空間存取架構支援以下針對檔案和其他文件的存取用途。

建立新檔案
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 卡還是可移除的 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 開啟 InputStream 物件。在這段程式碼中,系統會將檔案中的行讀取成字串:

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();
}

編輯文件

您可以使用儲存空間存取架構編輯現有的文字文件。

下列程式碼片段可覆寫指定 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。這 2 個 URI 皆參照同一個基礎項目。如果使用媒體儲存區 URI,您可以更輕鬆存取共用儲存空間中的媒體檔案

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

開啟虛擬檔案

在 Android 7.0 (API 級別 25) 以上版本中,應用程式可以使用儲存空間存取架構提供的虛擬檔案。即使虛擬檔案未以二進位格式呈現,應用程式仍可將其強制轉換成其他檔案類型,藉此開啟檔案內容,也可以使用 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();
}

其他資源

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

範例

影片