深入理解 Android DocumentFile:性能陷阱与最佳实践

667 阅读4分钟

前言

从 Android 10 开始,分区存储(Scoped Storage)登场了;到了 Android 11,它开始被强制执行。

这改变了我们操作文件的方式:我们从使用文件的绝对路径(如 /sdcard/DCIM/Camera/IMG_001.jpg),变为了使用存储权限机制(Storage Access Framework,简称 SAF)

具体点说,我们不再操作 Path,而是操作 Uri(例如 content://com.android.providers.media.documents/document/image%3A123)。文件被记录在了数据库中,而 Uri 就是记录的索引。

  • Uri 不是路径,它是数据库键值。
  • 在 SAF 中,我们通过 MIME Type 识别文件类型(如 image/*application/pdf)。

注意:vnd.android.document/directory 是用来标识文件夹的特殊 MIME 类型。

当我们读取文件时,实际上是通过系统提供的 ContentResolver 在查询数据库。

关于 ContentProvider 的底层原理,可以看我之前的这两篇博客:

核心机制

三大核心 Intent

SAF 与系统的交互主要依赖以下三个 Action:

  • ACTION_OPEN_DOCUMENT:打开单个文件。
  • ACTION_OPEN_DOCUMENT_TREE:授权访问整个目录。
  • ACTION_CREATE_DOCUMENT:请求创建一个新文件并写入数据。

权限持久化 (Persist Permissions)

默认情况下,SAF 返回的 Uri 权限是临时的。当应用重启后,再次访问之前授权的文件夹,就会抛出 SecurityException

解决方法:获取到 Uri 后,必须立即进行权限持久化:

val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
// 获取持久的权限
application.contentResolver.takePersistableUriPermission(uri, takeFlags)

什么是 DocumentFile?

DocumentFile 是 Google 为了方便开发者,提供的一个兼容包装类

它的本质是对 DocumentsContractContentResolver 操作的封装,让我们能用类似 java.io.File 的风格(如 listFiles()getName())来操作 Uri。

源码解析

TreeDocumentFile.getName() 方法为例,我们来看看源码。

它最终调用了 DocumentsContractApi19.queryForString()

final ContentResolver resolver = context.getContentResolver();
Cursor c = null;
try {
    // 发起 ContentResolver 查询
    c = resolver.query(uri, new String[]{DocumentsContract.Document.COLUMN_DISPLAY_NAME}, null, null, null);
    if (c.moveToFirst() && !c.isNull(0)) {
        return c.getString(0);
    } else {
        return defaultValue;
    }
} catch (Exception e) {
    Log.w(TAG, "Failed query: " + e);
    return defaultValue;
} finally {
    closeQuietly(c);
}

可以看到:调用的每一个 DocumentFile 的方法,底层都是一次 ContentProviderIPC(跨进程通信)查询

性能陷阱

由于 IPC 的成本很高,在扫描大量文件时,我们不能这么写:

val rootDir = DocumentFile.fromTreeUri(application.applicationContext, uri)
rootDir?.let { dir ->
    val files = dir.listFiles() // 1次 IPC
    for (file in files) { // 2N 次 IPC
        val name = file.name
        val size = file.length()
    }
}

如果你扫描 1000 个文件,这将触发 2000+ 次跨进程通信,导致界面卡顿。

正确做法

对于大量文件,我们应该直接使用 ContentResolver 进行查询。

将所需的所有列告诉系统,让系统在一次 IPC 中就将所有数据返回。

fun scanMassiveDirectory(context: Context, treeUri: Uri) {
    // 构建子文档的 Uri
    val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
        treeUri,
        DocumentsContract.getTreeDocumentId(treeUri)
    )

    // 定义数据列 (Projection)
    val projection = arrayOf(
        DocumentsContract.Document.COLUMN_DOCUMENT_ID,
        DocumentsContract.Document.COLUMN_DISPLAY_NAME,
        DocumentsContract.Document.COLUMN_SIZE,
        DocumentsContract.Document.COLUMN_MIME_TYPE
    )

    // 一次性查询
    val cursor = context.contentResolver.query(
        childrenUri,
        projection,
        null, null, null
    )

    cursor?.use { c ->
        val nameCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
        val sizeCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
        val typeCol = c.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)

        while (c.moveToNext()) {
            val name = c.getString(nameCol)
            val size = c.getLong(sizeCol)
            val type = c.getString(typeCol)

            // TODO: 处理你的业务逻辑
        }
    }
}

递归扫描与对象封装

在实际场景中,我们常常会扫描整个文件夹(包括子文件夹)的文件,并将数据封装为业务对象。

首先定义数据模型:

data class FileItem(
    val name: String,
    val uri: Uri,
    val size: Long,
    val lastModified: Long,
    val parentPath: String // 手动拼接的逻辑路径
)

核心递归逻辑:

/**
 * 递归扫描入口
 * 注意:此操作耗时,必须在后台线程调用
 */
@WorkerThread
fun recursiveScan(context: Context, rootUri: Uri): List<FileItem> {
    val results = mutableListOf<FileItem>()
    // 获取当前被授权访问的目录树的根节点在数据库中的唯一标识符
    val rootDocId = DocumentsContract.getTreeDocumentId(rootUri)

    // 初始路径为 "/"
    scanDirectory(context, rootUri, rootDocId, "/", results)

    return results
}

/**
 * 递归执行函数
 */
private fun scanDirectory(
    context: Context,
    treeUri: Uri, // 根 Tree Uri,用于构建其他 Uri
    documentId: String, // 当前要扫描的文件夹 ID
    currentPath: String, // 当前逻辑路径
    resultList: MutableList<FileItem>
) {
    // 构建查询子文件的 Uri
    val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)

    val projection = arrayOf(
        DocumentsContract.Document.COLUMN_DOCUMENT_ID,
        DocumentsContract.Document.COLUMN_DISPLAY_NAME,
        DocumentsContract.Document.COLUMN_SIZE,
        DocumentsContract.Document.COLUMN_LAST_MODIFIED,
        DocumentsContract.Document.COLUMN_MIME_TYPE
    )

    try {
        context.contentResolver.query(
            childrenUri,
            projection,
            null, null, null
        )?.use { cursor ->
            val idCol = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
            val nameCol = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
            val sizeCol = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_SIZE)
            val dateCol = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
            val mimeCol = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE)

            while (cursor.moveToNext()) {
                val docId = cursor.getString(idCol)
                val name = cursor.getString(nameCol) ?: "Unknown"
                val size = cursor.getLong(sizeCol)
                val date = cursor.getLong(dateCol)
                val mimeType = cursor.getString(mimeCol)

                // 构建操作单个文件的 Uri
                val fileUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, docId)

                // 判断是否为文件夹
                val isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR

                // 如果是文件夹,进行递归
                if (isDirectory) {
                    val nextPath =
                        if (currentPath.endsWith("/")) "$currentPath$name" else "$currentPath/$name"
                    scanDirectory(context, treeUri, docId, nextPath, resultList)
                } else {
                    // 如果是文件,封装并添加到列表
                    val item = FileItem(
                        name = name,
                        uri = fileUri,
                        size = size,
                        lastModified = date,
                        parentPath = currentPath
                    )

                    resultList.add(item)
                }
            }
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

注意 Uri 的构建区别:

  • 查询目录内容,必须使用 buildChildDocumentsUriUsingTree(treeUri, parentDocId)
  • 操作具体文件,必须使用 buildDocumentUriUsingTree(treeUri, docId)

getTreeDocumentId() 有个类似的方法是 getDocumentId(),它的作用是获取当前操作的文档节点在数据库中的唯一标识符。

前者是容器的 ID,通常是为了构建目录内容 Uri 来查询子文件;后者,则是数据实体的 ID,是为了构建访问具体文件的 Uri,从而获取文件元数据或是读取文件流。

当然,文件夹也可以通过 getDocumentId() 解析。此时,我们通常用它来操作文件夹,比如重命名文件夹。

什么时候使用 DocumentFile?

虽然 DocumentFile 有着性能损耗,但它简单易用,并且兼容性很好。

在文件数量较少或 USB OTG 设备场景下,完全可以使用它:

suspend fun scanDirectorySafe(context: Context, treeUri: Uri) = withContext(Dispatchers.IO) {
    val rootDocument = DocumentFile.fromTreeUri(context, treeUri)

    // 防御性检查
    if (rootDocument == null || !rootDocument.exists() || !rootDocument.isDirectory) {
        Log.e("SAF", "Invalid directory uri")
        return@withContext
    }

    // 获取列表 (耗时操作)
    val files = rootDocument.listFiles()
    Log.d("SAF", "Found ${files.size} items")

    // 遍历
    for (doc in files) {
        if (doc.isDirectory) {
            Log.d("SAF", "Dir: ${doc.name}")
        } else {
            // 注意:如果列表很大,doc.type 依然会触发 IPC
            Log.d("SAF", "File: ${doc.name}, Type: ${doc.type}")
        }
    }
}