前言
从 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}")
}
}
}