在搭載 Android 4.4 (API 級別 19) 以上版本的裝置上,應用程式可使用儲存空間存取架構與文件供應器互動,包括存取外部儲存空間磁碟區和雲端式儲存空間。有了儲存空間存取架構,使用者可使用系統選擇器挑選文件供應器,並選取要讓應用程式建立、開啟或修改的特定文件和其他檔案。
這項機制是由使用者選取應用程式可存取的檔案或目錄,因此不需要動用任何系統權限,還能強化使用者控制權與隱私權。此外,這些檔案不會儲存在應用程式專屬目錄和媒體儲存區,所以應用程式解除安裝後,這些檔案仍會保留在裝置上。
儲存空間存取架構的使用流程包含以下步驟:
- 應用程式叫用包含儲存空間相關動作的意圖。這種動作可對應至儲存空間存取架構支援的特定用途。
- 畫面上會顯示系統選擇器,讓使用者瀏覽文件供應器,並選擇要執行儲存空間相關動作的位置或文件。
- 應用程式會取得 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(); }
其他資源
如要進一步瞭解如何儲存及存取文件和其他檔案,請參考下列資源。
範例
- GitHub 上的「ActionOpenDocument」。
- GitHub 上的「ActionOpenDocumentTree」。