深入理解 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}")
        }
    }
}
相关推荐
inputA2 小时前
【LwIP源码学习8】netbuf源码分析
android·c语言·笔记·嵌入式硬件·学习
CHINAHEAO2 小时前
FlyEnv+Bagisto安装遇到的一些问题
android
毕设源码-郭学长2 小时前
【开题答辩全过程】以 基于Android的自习室座位预订系统为例,包含答辩的问题和答案
android
一 乐3 小时前
健康打卡|健康管理|基于java+vue+的学生健康打卡系统设计与实现(源码+数据库+文档)
android·java·数据库·vue.js·spring boot·微信小程序
天平4 小时前
开发了几个app后,我在React Native用到的几个库的推荐
android·前端·react native
如果'\'真能转义说4 小时前
Android | 资源类型详解
android
用户69371750013845 小时前
4.Kotlin 流程控制:强大的 when 表达式:取代 Switch
android·后端·kotlin
用户69371750013845 小时前
5.Kotlin 流程控制:循环的艺术:for 循环与区间 (Range)
android·后端·kotlin
Android系统攻城狮5 小时前
Android ALSA驱动进阶之获取周期帧数snd_pcm_lib_period_frames:用法实例(九十五)
android·pcm·android内核·音频进阶·周期帧数