深入理解 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}")
        }
    }
}
相关推荐
赏金术士6 分钟前
第六章:UI组件与Material3主题
android·ui·kotlin·compose
TechMerger1 小时前
Android 17 重磅重构!服役 20 年的 MessageQueue 迎来无锁改造,卡顿大幅优化!
android·性能优化
yuhuofei20214 小时前
【Python入门】Python中字符串相关拓展
android·java·python
dalancon4 小时前
Android Input Spy Window
android
dalancon6 小时前
InputDispatcher派发事件,查找目标窗口
android
我命由我123456 小时前
Android Framework P3 - MediaServer 进程、认识 ServiceManager 进程
android·c语言·开发语言·c++·visualstudio·visual studio·android runtime
天才少年曾牛7 小时前
Android14 新增系统服务后,应用调用出现 “hidden api” 警告的原因与解决方案
android·frameworks
赏金术士7 小时前
Jetpack Compose 底部导航实战教程(完整版)
android·kotlin·compose
随遇丿而安7 小时前
第5周:XML 资源、样式和主题,真正解决的是“页面以后还改不改得动”
android
zh_xuan8 小时前
Android 获取系统内存页大小:sysconf(_SC_PAGESIZE) 与 JNI 实现
android·jni·ndk·内存页大小