深入理解 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 为了方便开发者,提供的一个兼容包装类。

它的本质是对 DocumentsContract 和 ContentResolver 操作的封装,让我们能用类似 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 的方法,底层都是一次 ContentProvider 的 IPC(跨进程通信)查询。

性能陷阱

由于 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 小时前
Zabbix 7.4 Alpine 版 Docker Compose 部署
android·运维·docker·容器·zabbix
素师良码2 小时前
第9篇:Android驱动踩坑实录:DSI RX FIFO前置脏0、数据错位复盘
android
夜雪一千2 小时前
如何在MySQL中实现字段级加密
android·数据库·mysql
夜雪一千2 小时前
MySQL 如何存储经纬度坐标:选型、建表、空间查询实战
android·mysql·adb
JMchen3 小时前
集成第三方 C/C++ 库到 Android NDK 项目:OpenCV 与 FFmpeg 实战指南
android·opencv·cmake
有毒的教程4 小时前
Dev-C++ 使用扩展库两种方式:DevPak一键安装 / 手动配置(重点)
android·c++·adb
mmsx5 小时前
Android 测绘数值基础:测量单位系统与七四参数、高程拟合
android
郑州光合科技余经理5 小时前
出海外卖平台:支付对接为什么要和下单收款分开
android·java·开发语言·前端·后端·php·ai编程
夜雪一千5 小时前
MySQL 如何提高查询速度:从索引、SQL、表结构到实战调优
android·sql·mysql
终端安全笔记5 小时前
配置锁状态那一栏查的是什么:报告里几项各自指向什么风险
android·前端·安全·ios