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

前言

从 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 后,必须立即进行权限持久化:

kotlin 复制代码
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()

java 复制代码
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 的成本很高,在扫描大量文件时,我们不能这么写:

kotlin 复制代码
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 中就将所有数据返回。

kotlin 复制代码
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: 处理你的业务逻辑
        }
    }
}

什么时候使用 DocumentFile?

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

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

kotlin 复制代码
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}")
        }
    }
}
相关推荐
阿巴斯甜1 天前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker1 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95271 天前
Andorid Google 登录接入文档
android
黄林晴1 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab2 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android
阿巴斯甜2 天前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇2 天前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_2 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android