DocumentsContract 的树形文档访问,SAF 的局限

DocumentsContract 的树形文档访问,SAF 的局限

DocumentsContract 的树形文档访问,SAF 的局限


「DocumentsContract 的树形文档访问,SAF 的局限」


去年把文件管理模块重构到纯 SAF(Storage Access Framework)方案时,我在 Android 11 的测试机上遇到一个极其诡异的权限崩溃。用户通过系统文件选择器授权了 Download/Work 目录,应用通过 takePersistableUriPermission 成功持有了持久化权限,但当遍历到第三层子目录 Download/Work/Projects/2023 时,ContentResolver.query 直接抛出了 SecurityException: Permission Denial,堆栈里找不到我方的逻辑错误,也没有常见的 Uri 格式问题。反复排查后才发现,问题根本不在我的代码,而在 DocumentsContract 的树形访问模型本身——它看起来像个文件系统,实际上只是一层脆弱的抽象。


Tree URI 的构造与拆解


SAF 的核心是 DocumentsContract,而树形访问的入口是那个从 Intent.ACTION_OPEN_DOCUMENT_TREE 返回的 Uri。拿到一个典型的返回 Uri 大概是 content://com.android.externalstorage.documents/tree/primary%3ADownload%2FWork,这里面的门道比文档里写的要多。


很多人第一次接触时会直接把整个 Uri 丢进 ContentResolverquery,然后解析 _IDDISPLAY_NAME 这些列。但树形访问的真正玩法在于 DocumentsContract.buildDocumentUriUsingTree。这个静态方法接收两个参数:一个 treeUri,和一个 documentId。它的内部逻辑并不复杂,只是把 documentId 编码后拼进 Uripath 里,生成类似 content://com.android.externalstorage.documents/tree/primary%3ADownload%2FWork/document/primary%3ADownload%2FWork%2FProjects 这样的 Uri


这里立刻出现一个容易混淆的点:getTreeDocumentIdgetDocumentId 返回的字符串可能长得一模一样。比如对于上面这个 treeUriDocumentsContract.getTreeDocumentId(treeUri) 返回 primary:Download/Work。但如果你拿着这个字符串,用 buildDocumentUriUsingTree(treeUri, "primary:Download/Work/Projects") 去构造子目录的 Uri,系统 Provider 能认出来吗?在 AOSP 的 ExternalStorageProvider 里确实能认,因为 Google 的实现把 documentId 设计成了真实的相对路径。可一旦换个第三方 Provider,比如某些 NAS 客户端或者冷门网盘,它们的 documentId 可能是一串完全不透明的 UUID,如 uuid-7a3f-9e2d,这时如果你天真地往字符串后面拼 /Projects,得到的只能是一条 FileNotFoundException


更深一层的问题在于 Uri 的编码。documentId 里的冒号在 Uri 中被编码为 %3A,斜杠编码为 %2F。但如果你手动拼接字符串而不是用 DocumentsContract 提供的工具方法,很容易在 ContentResolver.openFileDescriptor 时吃到 IllegalArgumentException: Invalid URI。我在 Android 10 的三星设备上实测过,某些三星定制系统的 MyFilesProvider 对未完全百分号编码的 documentId 容忍度极低,哪怕只差一个字符也会直接拒绝访问。


还有一个细节很少有人提:DocumentsContract.buildTreeDocumentUri 需要传入 authoritydocumentId。这意味着你的应用必须事先知道目标 Providerauthority 字符串。对于内置存储,你可以硬编码 com.android.externalstorage.documents,但对于外置 SD 卡,不同厂商可能使用不同的 authority,比如 com.android.externalstorage.documents 依然适用,但某些设备会映射到 com.samsung.android.externalstorage。这就导致你无法仅凭一个路径字符串可靠地构造出 Tree Uri,必须依赖用户通过系统 UI 选择。


queryChildren 的性能真相


拿到 Tree Uri 之后,下一步通常是递归列出目录内容。官方文档会告诉你用 ContentResolver.query 配合 DocumentsContract.Document.COLUMN_DOCUMENT_ID 等列名。但实际跑起来,性能表现和文件系统的 File.listFiles() 完全不在一个量级。


先看一下最基础的查询代码。通常我们会这样写:


Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocumentId);
Cursor cursor = getContentResolver().query(childrenUri, new String[]{
    DocumentsContract.Document.COLUMN_DOCUMENT_ID,
    DocumentsContract.Document.COLUMN_DISPLAY_NAME,
    DocumentsContract.Document.COLUMN_MIME_TYPE,
    DocumentsContract.Document.COLUMN_SIZE,
    DocumentsContract.Document.COLUMN_LAST_MODIFIED
}, null, null, null);

注意这里的 childrenUri 不是直接用 treeUri,而是 DocumentsContract.buildChildDocumentsUriUsingTree。这在 API 21 到 API 23 之间是唯一的标准做法。到了 API 24(Android 7.0),Google 封装了一个 DocumentsContract.query 重载,可以直接传 Bundle 做分页和排序,但底层仍然是 ContentProviderquery 调用。


性能灾难出现在遍历深层目录时。本地内置存储的 ExternalStorageProvider 性能尚可,query 一个包含 500 个文件的目录,冷查询大概在 50ms 到 150ms 之间。但如果是外置 SD 卡,或者更糟——某个云存储 Provider(如 Google Drive、Dropbox),这个延迟会飙升到数百毫秒甚至数秒。因为 SAF 的每次 query 都是一次跨进程 Binder 调用,目标 Provider 可能要访问网络或遍历 FUSE 层,而 Cursor 只是一个管道,真正的 IO 发生在 Provider 进程里。


更隐蔽的是游标遍历的开销。如果你用 while(cursor.moveToNext()) 逐个读取,每调一次 getString(index),都会触发 CursorWindow 的边界检查。对于大目录,CursorWindow 默认只有 2MB 的共享内存上限。Google Drive 的 Provider 在面对一个包含上千文件的目录时,可能会直接截断结果集,或者抛出 TransactionTooLargeException。我在 Pixel 4 的 Android 13 上测试过一个极端场景:通过 Google Drive Provider 访问一个共享文件夹,里面有 3000 多个文档,query 直接 ANR,因为 Drive Provider 在主线程执行了网络同步,没有正确实现异步加载。


Android 7.0 引入的 DocumentsContract.isChildDocument(parent, child) 看起来能帮上忙,意图是让你快速判断一个 document 是否属于某个 tree,但实际使用中发现大量第三方 Provider 直接返回 false 或者抛出 UnsupportedOperationException。AOSP 源码里 ExternalStorageProvider 的实现需要遍历路径前缀做字符串比较,这个成本在深层嵌套目录下并不低。


权限的边界:persistable 不等于 recursive


回到开头那个崩溃。用户授权了 Download/Work,为什么访问不到 Download/Work/Projects/2023


ContentResolver.takePersistableUriPermission 的机制是:系统在 Intent 返回时,把 Uri 和对应的 flags(读、写)写进 UriGrantsManager 的数据库。你的应用拿到的是一个对该 Uri 的持久化授权令牌。但这个授权在语义上是否包含所有子目录?Android 框架文档里说是“tree access”,暗示是递归的。然而实际行为取决于 Provider 的实现。


AOSP 的 ExternalStorageProvider 在授权一个 Tree Uri 时,确实会把权限递归到所有子节点,因为它内部用的是路径前缀匹配。但如果你授权的是一个虚拟 Provider,比如某个笔记应用导出的 DocumentsProvider,它的内部数据结构可能是一棵真正的树,每个节点有独立的 rowId。这时用户授权根节点,并不自动意味着框架会把权限下推到子节点——有些 Provider 根本没实现这个语义。


另一个坑是持久化权限的生命周期。用户在系统设置里 revoke 了权限,或者卸载了应用,权限自然消失。但在 Android 11(API 30)上,即使权限还在,Provider 的进程可能被系统回收,下次访问时重启的 Provider 可能会重新校验权限,而此时如果 Uri 里的 documentId 因为云端同步发生了变化(比如被其他设备重命名了),你的 Uri 就会瞬间失效。我遇到过 Nextcloud 客户端的 Provider 在后台同步后改变了 documentId 的编码规则,导致之前持久化的 Tree Uri 全部变成死链,只能重新让用户授权。


还有一个 API 层面的限制:takePersistableUriPermission 只能作用于 Intent 里显式设置了 FLAG_GRANT_PERSISTABLE_URI_PERMISSIONUri。有些 OEM 定制的文件选择器(特别是某些国产厂商在 Android 10 上的实现)会忽略这个 flag,或者返回的 Uri 根本不支持持久化。你的代码在 onActivityResult 里调用 takePersistableUriPermission 时,会直接收到 SecurityException。这种情况下没有可靠的 fallback,你只能告诉用户“系统不支持保存此访问权限”。

Android 开发从 Java 8 到 17 的迁移,还有多少老项目没动 2026-08-03
KtLint 和 Detekt 的代码规范配置 2026-08-03

评论区