在运行 Android 4.4(API 级别 19)及更高版本的设备上,您的应用可以使用存储访问框架与 文档提供程序 进行交互,包括外部存储卷和基于云的存储。此框架允许用户与系统选择器进行交互,以选择文档提供程序并为您的应用选择要创建、打开或修改的特定文档和其他文件。
由于用户参与选择您的应用可以访问的文件或目录,因此此机制不需要任何 系统权限,并且可以增强用户控制和隐私。此外,这些文件存储在应用特定目录和媒体存储之外,在卸载您的应用后仍保留在设备上。
使用此框架涉及以下步骤
- 应用调用包含存储相关操作的 Intent。此操作对应于框架提供的特定 用例。
- 用户会看到系统选择器,允许他们浏览文档提供程序并选择发生存储相关操作的位置或文档。
- 应用获得对表示用户选择的位置或文档的 URI 的读写访问权限。使用此 URI,应用可以 对选择的位置执行操作。
为了支持在运行 Android 9(API 级别 28)或更低版本的设备上访问媒体文件,请声明 READ_EXTERNAL_STORAGE
权限并将 maxSdkVersion
设置为 28
。
本指南介绍了框架支持用于处理文件和其他文档的不同用例。它还解释了如何对用户选择的位置执行操作。
访问文档和其他文件的用例
存储访问框架支持以下用于访问文件和其他文档的用例。
- 创建新文件
ACTION_CREATE_DOCUMENT
Intent 操作允许用户将文件保存到特定位置。- 打开文档或文件
ACTION_OPEN_DOCUMENT
Intent 操作允许用户选择要打开的特定文档或文件。- 授予对目录内容的访问权限
- Android 5.0(API 级别 21)及更高版本提供了一个名为
ACTION_OPEN_DOCUMENT_TREE
的 Intent 操作,允许用户选择一个特定目录,并授予您的应用访问该目录中所有文件和子目录的权限。
以下部分将指导您如何配置每个用例。
创建新文件
使用 ACTION_CREATE_DOCUMENT
Intent 操作加载系统文件选择器,并允许用户选择文件保存位置。此过程类似于其他操作系统中使用的“另存为”对话框。
注意:ACTION_CREATE_DOCUMENT
无法覆盖现有文件。如果您的应用尝试保存同名文件,系统会在文件名末尾添加括号中的数字。
例如,如果您的应用尝试将名为 confirmation.pdf
的文件保存到已存在同名文件的目录中,则系统会将新文件保存为 confirmation(1).pdf
。
在配置 Intent 时,请指定文件名称和 MIME 类型,并可选择使用 EXTRA_INITIAL_URI
Intent 附加信息指定文件选择器首次加载时显示的文件或目录的 URI。
以下代码片段演示了如何创建和调用创建文件的 Intent
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
Intent 来选择要打开的文件,该 Intent 会打开系统的文件选择器应用。要仅显示您的应用支持的文件类型,请指定 MIME 类型。此外,您还可以选择使用 EXTRA_INITIAL_URI
Intent 附加信息指定文件选择器首次加载时显示的文件的 URI。
以下代码片段演示了如何创建和调用打开 PDF 文档的 Intent
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
Intent 操作来请求用户从以下目录中选择单个文件
Android/data/
目录及其所有子目录。Android/obb/
目录及其所有子目录。
授予对目录内容的访问权限
文件管理和媒体创建应用通常会在目录层次结构中管理一组文件。要在您的应用中提供此功能,请使用 ACTION_OPEN_DOCUMENT_TREE
Intent 操作,该操作允许用户授予对整个目录树的访问权限,从 Android 11(API 级别 30)开始存在一些例外情况。然后,您的应用可以访问所选目录及其任何子目录中的任何文件。
使用 ACTION_OPEN_DOCUMENT_TREE
时,您的应用仅能访问用户选择的目录中的文件。您无法访问驻留在此用户选择目录之外的其他应用的文件。这种用户控制的访问权限允许用户准确选择他们愿意与您的应用共享的内容。
您还可以选择使用 EXTRA_INITIAL_URI
Intent 附加信息指定文件选择器首次加载时显示的目录的 URI。
以下代码片段演示了如何创建和调用打开目录的 Intent
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
Intent 操作来请求访问以下目录
- 内部存储卷的根目录。
- 设备制造商认为是可靠的每个 SD 卡卷的根目录,无论该卡是模拟的还是可移动的。可靠的卷是指应用大部分时间都能成功访问的卷。
Download
目录。
此外,在 Android 11(API 级别 30)及更高版本中,您无法使用 ACTION_OPEN_DOCUMENT_TREE
Intent 操作来请求用户从以下目录中选择单个文件
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。这两个 URI 指向相同的底层项目。使用媒体存储 URI,您可以更轻松地 访问共享存储中的媒体文件。
getMediaUri()
方法支持 ExternalStorageProvider
URI。在 Android 12(API 级别 31)及更高版本中,此方法还支持 MediaDocumentsProvider
URI。
打开虚拟文件
在 Android 7.0(API 级别 25)及更高版本中,您的应用可以使用存储访问框架提供的虚拟文件。即使虚拟文件没有二进制表示形式,您的应用也可以通过将其强制转换为不同的文件类型或使用 ACTION_VIEW
Intent 操作查看这些文件来打开其内容。
要打开虚拟文件,您的客户端应用需要包含处理它们的特殊逻辑。如果您想获取文件的字节表示形式(例如,预览文件),则需要从文档提供程序请求备用 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(); }
其他资源
有关如何存储和访问文档和其他文件的更多信息,请参阅以下资源。
示例
- ActionOpenDocument,可在 GitHub 上获取。
- ActionOpenDocumentTree,可在 GitHub 上获取。