18、Document Provider:存储访问框架、DocumentsProvider 实现、文件浏览

说到 Android 的文件操作,很多人的第一反应就是 File API。但说实话,从 Android 4.4 开始,直接操作文件路径的方式就已经被官方「劝退」了。为什么?因为碎片化太严重了——不同厂商的路径规则不一样,权限管理也乱。这时候,存储访问框架(SAF) 就登场了。

我个人习惯把 SAF 理解成「文件界的 Content Provider」。它让 App 可以像查询联系人一样去浏览和操作文件。你不需要知道文件存在哪,也不需要申请 READ_EXTERNAL_STORAGE 这种粗粒度权限。用户自己选文件,你只管读。

存储访问框架的核心思想

SAF 的设计理念其实很简单:用户是文件的主人。App 不能偷偷扫描整个 SD 卡,只能通过系统提供的 UI 让用户主动选择文件或目录。这个 UI 就是大家熟悉的文件选择器——DocumentsUI。

整个流程分三步:

  • 客户端:你的 App 通过 Intent 调起 DocumentsUI
  • 服务端:DocumentsProvider 实现类提供文件数据
  • 桥梁:ContentResolver 负责跨进程传输

你想想看,这其实和 Content Provider 的 C/S 架构一模一样。只不过 Content Provider 操作的是数据库表,而 DocumentsProvider 操作的是文件树。

关键点:SAF 不要求 App 声明任何存储权限。用户授权后,系统会返回一个 content:// URI,你的 App 只能操作这个 URI 指向的文件或目录。

DocumentsProvider 的实现

如果你想让自己 App 的文件也能被其他 App 通过 SAF 浏览,就需要实现一个 DocumentsProvider。嗯,这里要注意,它比普通的 Content Provider 要复杂一些,因为要处理文件系统的层级结构。

我曾在项目中做过一个加密文件管理器,需要把加密后的文件通过 SAF 暴露给其他 App 使用。当时踩了不少坑,下面我把核心步骤拆开讲。

1. 继承 DocumentsProvider

你需要继承 DocumentsProvider 并实现 7 个抽象方法。别慌,大部分方法逻辑都很固定。

public class MyDocumentsProvider extends DocumentsProvider {

    @Override
    public Cursor queryRoots(String[] projection) throws FileNotFoundException {
        // 返回根节点信息
        MatrixCursor result = new MatrixCursor(
            new String[]{Root.COLUMN_ROOT_ID, Root.COLUMN_TITLE,
                         Root.COLUMN_SUMMARY, Root.COLUMN_FLAGS,
                         Root.COLUMN_ICON, Root.COLUMN_DOCUMENT_ID});
        result.addRow(new Object[]{
            "root", "我的文件", "加密存储空间",
            Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH,
            R.mipmap.ic_launcher, "root"
        });
        return result;
    }

    @Override
    public Cursor queryDocument(String documentId, String[] projection)
            throws FileNotFoundException {
        // 根据 documentId 返回单个文件/目录的信息
        return queryChildDocuments(documentId, projection, null);
    }

    @Override
    public Cursor queryChildDocuments(String parentDocumentId,
                                      String[] projection, String sortOrder)
            throws FileNotFoundException {
        // 返回某个目录下的所有子文件
        MatrixCursor result = new MatrixCursor(projection != null ?
            projection : DEFAULT_DOCUMENT_PROJECTION);
        File dir = getFileForId(parentDocumentId);
        for (File child : dir.listFiles()) {
            result.addRow(createDocumentRow(child));
        }
        return result;
    }

    // 其他方法:openDocument、createDocument、deleteDocument、renameDocument
}

个人经验:queryRoots 返回的 Root 信息中,COLUMN_DOCUMENT_ID 指向的是根目录的 documentId。这个 ID 必须和 queryDocument 中的参数对应上,否则 DocumentsUI 会显示空白。我曾经因为拼写错误排查了半天。

2. 注册 Provider

在 AndroidManifest.xml 中注册,注意 authority 的命名规范:

<provider
    android:name=".MyDocumentsProvider"
    android:authorities="com.example.myapp.documents"
    android:exported="true"
    android:grantUriPermissions="true"
    android:permission="android.permission.MANAGE_DOCUMENTS">
    <intent-filter>
        <action android:name="android.content.action.DOCUMENTS_PROVIDER" />
    </intent-filter>
</provider>

注意 android:permission 必须声明为 MANAGE_DOCUMENTS,这是系统级权限,但不用担心——系统会自动授予 DocumentsUI 这个权限。

3. 处理文件操作

openDocument 方法需要返回一个 ParcelFileDescriptor。这里有个坑:如果你直接返回 FileDescriptor,其他 App 可能无法正确读取。我建议用 ContentResolver.openFileDescriptor 的底层逻辑来包装。

@Override
public ParcelFileDescriptor openDocument(String documentId, String mode,
                                         CancellationSignal signal)
        throws FileNotFoundException {
    File file = getFileForId(documentId);
    int accessMode = ParcelFileDescriptor.parseMode(mode);
    return ParcelFileDescriptor.open(file, accessMode);
}

避坑指南:我曾经在 openDocument 中直接返回了 FileInputStream 的 FD,结果第三方 App 读取时出现数据截断。后来发现必须用 ParcelFileDescriptor.open() 并指定正确的访问模式。另外,如果文件是加密的,记得在 openDocument 中做解密处理。

文件浏览的完整流程

当用户点击「打开文件」时,系统会调起 DocumentsUI。DocumentsUI 会查询所有注册了 DOCUMENTS_PROVIDER 的 Provider,然后展示它们的根节点。

下面这张图展示了整个调用链路:

SAF 文件浏览调用链路 客户端 App Intent(ACTION_OPEN_DOCUMENT) 系统 DocumentsUI 查询所有 DocumentsProvider 你的 DocumentsProvider queryRoots / queryChildDocuments 文件存储 加密/普通文件 返回 content:// URI 发起请求 系统 UI 你的 Provider 存储层

客户端如何使用 SAF

作为客户端,你不需要实现任何 Provider。只需要发一个 Intent:

// 打开单个文件
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("image/*");
startActivityForResult(intent, REQUEST_CODE);

// 打开目录
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, REQUEST_CODE_TREE);

在 onActivityResult 中拿到 URI 后,通过 ContentResolver 读取:

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (resultCode == RESULT_OK && data != null) {
        Uri uri = data.getData();
        try (InputStream is = getContentResolver().openInputStream(uri)) {
            // 读取文件内容
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

个人习惯:拿到 URI 后,我会立即通过 takePersistableUriPermission 获取持久化权限。这样即使 App 重启,也能继续访问这个文件。否则用户下次打开 App 还得重新选一次。

常见问题与避坑

问题 原因 解决方案
DocumentsUI 不显示我的 Provider AndroidManifest 中缺少 intent-filter 或权限 检查是否声明了 DOCUMENTS_PROVIDER action 和 MANAGE_DOCUMENTS 权限
打开文件时崩溃 openDocument 返回了错误的 ParcelFileDescriptor 确保文件存在且访问模式正确,不要直接返回 FileInputStream
文件列表为空 queryChildDocuments 返回的 Cursor 没有数据 检查 documentId 是否与 queryRoots 中的根 ID 一致
权限被拒绝 没有调用 takePersistableUriPermission 在 onActivityResult 中立即申请持久化权限

我记得有一次,用户反馈说在第三方文件管理器里看不到我们 App 的文件。排查了半天,发现是 queryRoots 返回的 COLUMN_ICON 资源 ID 写错了,导致 DocumentsUI 加载图标时崩溃,整个 Provider 被系统标记为不可用。所以,Provider 中的任何异常都会导致它被系统屏蔽,一定要做好异常处理。

性能优化建议

如果你的文件系统层级很深(比如超过 5 层),queryChildDocuments 的响应速度会直接影响用户体验。我建议:

  • 懒加载:只在用户展开目录时才查询子文件,不要一次性加载全部
  • 缓存:对频繁访问的目录做内存缓存,避免重复 IO
  • 分页:如果目录下有上千个文件,考虑实现分页查询

说白了,DocumentsProvider 就是一个文件系统的适配器。你只要把文件树映射成 Cursor 返回给系统,剩下的 UI 交互、权限管理都由 SAF 帮你搞定。这也是为什么我强烈推荐新项目都用 SAF 替代传统 File API——省心,而且更安全。

总结:SAF 是 Android 官方推荐的存储访问方案。实现 DocumentsProvider 时,重点关注 queryRoots、queryChildDocuments 和 openDocument 三个方法。客户端使用 Intent 调起系统 UI,通过 ContentResolver 读写文件。记住申请持久化权限,做好异常处理。


公众号:蓝海资料掘金营,微信deep3321