在搭載 Android 4.4 (API 級別 19) 及以上版本的裝置上,應用程式可以使用 Storage Accesss Framework 與文件供應器互動,包括存取外部儲存空間磁碟區和雲端儲存空間。Storage Accesss Framework 可讓應用程式使用系統挑選器,以選擇文件供應器,進而選取特定文件及其他檔案,以便建立、開啟或修改。
由於使用者可決定應用程式可存取的檔案或目錄,因此不需要任何系統權限,且能強化使用者控制項與隱私權。此外,在應用程式解除安裝後,這些檔案不會儲存在應用程式專屬目錄與媒體商店中。
如要使用 Storage Accesss Framework,請按照下列步驟操作:
- 應用程式會叫用意圖,內含與儲存空間相關的動作。這個動作與 Storage Accesss Framework 提供的特定用途相對應。
- 使用者會看到系統顯示挑選器,進而瀏覽文件供應器,並選擇要儲存的位置或文件。
- 應用程式會取得 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(); }
其他資源
如要進一步瞭解如何儲存及存取文件和其他檔案,請參閱下列資源。
範例
- GitHub 上的 ActionOpenDocument。
- GitHub 上的 ActionOpenDocumentTree。