前言
从 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 为了方便开发者,提供的一个兼容包装类。
它的本质是对 DocumentsContract 和 ContentResolver 操作的封装,让我们能用类似 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 的方法,底层都是一次 ContentProvider 的 IPC(跨进程通信)查询。
性能陷阱
由于 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}")
}
}
}