从共享存储空间访问文档和其他文件

在搭载 Android 4.4(API 级别 19)及更高版本的设备上,您的应用可以使用存储访问框架与包括外部存储卷和云端存储空间在内的文档提供器互动。此框架支持用户与系统选择器互动,从而选择文档提供器以及供您的应用创建、打开或修改的特定文档和其他文件。

由于用户参与选择您的应用可以访问的文件或目录,因此该机制无需任何系统权限,同时用户控制和隐私保护也得到了增强。此外,这些文件存储在应用专属目录和媒体库之外,在应用卸载后仍会保留在设备上。

使用存储访问框架涉及以下步骤:

  1. 应用调用包含存储相关操作的 intent。此操作对应于框架支持的特定用例
  2. 用户看到一个系统选择器,供其浏览文档提供器并选择将执行存储相关操作的位置或文档。
  3. 应用获得对代表用户所选位置或文档的 URI 的读写访问权限。利用该 URI,应用可以在选择的位置执行操作

如需在搭载 Android 9(API 级别 28)或更低版本的设备上支持媒体文件访问,请声明 READ_EXTERNAL_STORAGE 权限并将 maxSdkVersion 设为 28

本指南介绍了存储访问框架支持的访问文件和其他文档的不同用例。另外,还介绍了如何在用户选择的位置执行操作。

访问文档和其他文件的用例

存储访问框架支持以下访问文件和其他文档的用例。

创建新文件
ACTION_CREATE_DOCUMENT intent 操作支持用户将文件保存在特定位置。
打开文档或文件
ACTION_OPEN_DOCUMENT intent 操作支持用户选择要打开的特定文档或文件。
授予对目录内容的访问权限
ACTION_OPEN_DOCUMENT_TREE intent 操作在 Android 5.0(API 级别 21)及更高版本中提供,支持用户选择特定目录,授予应用对该目录中所有文件和子目录的访问权限。

以下几个部分分别提供了关于每个用例的配置指导。

创建新文件

使用 ACTION_CREATE_DOCUMENT intent 操作加载系统文件选择器,支持用户选择要写入文件内容的位置。此流程类似于其他操作系统使用的“另存为”对话框中使用的流程。

注意ACTION_CREATE_DOCUMENT 无法覆盖现有文件。如果您的应用尝试保存同名文件,系统会在文件名的末尾附加一个数字并将其包含在一对括号中。

例如,如果您的应用尝试将名为 confirmation.pdf 的文件保存到已有同名文件的目录中,系统会以 confirmation(1).pdf 作为名称来保存新文件。

在配置 intent 时,应指定文件的名称和 MIME 类型,并且还可以根据需要使用 EXTRA_INITIAL_URI intent extra 指定文件选择器在首次加载时应显示的文件或目录的 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 extra 指定文件选择器在首次加载时应显示的文件的 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 extra 指定文件选择器在首次加载时应显示的目录的 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 的值。应用的界面只会显示提供器支持的选项。

保留权限

当您的应用打开文件进行读取或写入时,系统会向应用授予对该文件的 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 的引用后,您可以打开文档进行进一步处理。本部分介绍了打开位图和输入流的示例。

位图

以下代码段显示了如何在已获得 Bitmap 文件的 URI 的情况下打开该文件:

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 中显示该位图。

输入流

以下代码段显示了如何在已获得 InputStream 对象的 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();
}

修改文档

您可以使用存储访问框架在原位置修改文本文档。

以下代码段会覆盖给定 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();
}

其他资源

如需详细了解如何存储和访问文档及其他文件,请参阅以下资源。

示例

视频