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
作为客户端,你不需要实现任何 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