Android 基础补强 B11|ContentProvider:从一个 URI 读懂跨应用内容访问
发布摘要:读取用户选中文档的名称和大小,区分 ContentResolver、Cursor、内容 URI、临时访问与持久授权。标签:ContentProvider、ContentResolver、URI、Android。
系统文件选择器返回了content://开头的地址,有人第一反应是查“怎样转成真实文件路径”。这常常把问题引向错误方向:内容可能来自云盘、媒体库或另一个应用,根本没有对我们公开的本地路径。正确入口是让提供者按自己的协议交付元数据或字节流。
本篇对应《第一行代码》第 8 章,补强 D19 文件分享背后的基础。D19 负责向外提供文件,这里研究客户端怎样读取已授权内容,以及为什么保存 URI 字符串不代表永久拥有访问权。代码为未编译运行的教学片段。
一、把四个对象放在一次查询里
ContentProvider 是提供数据的组件;ContentResolver 是客户端访问入口;内容 URI 标识某项或某类内容;Cursor 是表格式查询结果。提供者底层可以使用数据库、文件或其他来源,客户端不应该绕过它去猜内部实现。
URI 的 authority 用于定位提供者,path 由该提供者定义含义,不能一律理解成文件夹。查询中的 projection 表示需要哪些列,selection 与参数表达筛选条件,但不是所有提供者都支持任意 SQL 功能。使用公开 Contract 常量比猜列名可靠。ContentProvider 基础
第一步用系统文档选择器拿到用户选择的 URI;第二步只查询名称和大小;第三步按需要打开输入流;第四步明确内容是否需要跨重启再次读取。将授权和读取分步观察,可以避免把 SecurityException 都误判为路径错误。
二、查询元数据并正确关闭 Cursor
以下函数接受已经取得读取授权的可打开文档 URI。导入全部列出,需要协程核心依赖。函数从挂起调用点执行,查询放到 IO 上;返回 null 表示未取得一行元数据,不等于文件内容为空。
importandroid.content.ContentResolverimportandroid.net.Uriimportandroid.provider.OpenableColumnsimportkotlinx.coroutines.Dispatchersimportkotlinx.coroutines.withContextdataclassDocumentMeta(valname:String?,valsizeBytes:Long?)suspendfunreadDocumentMeta(resolver:ContentResolver,uri:Uri):DocumentMeta?=withContext(Dispatchers.IO){resolver.query(uri,arrayOf(OpenableColumns.DISPLAY_NAME,OpenableColumns.SIZE),null,null,null)?.use{cursor->if(!cursor.moveToFirst())return@usenullvalnameColumn=cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)valsizeColumn=cursor.getColumnIndex(OpenableColumns.SIZE)DocumentMeta(name=if(nameColumn>=0&&!cursor.isNull(nameColumn))cursor.getString(nameColumn)elsenull,sizeBytes=if(sizeColumn>=0&&!cursor.isNull(sizeColumn))cursor.getLong(sizeColumn)elsenull)}}大小未知与零字节不同。云端提供者可能尚不能返回长度,UI 应显示“大小未知”,不要凭空显示零。文件名也不能直接用作本地写入路径;若要复制,应生成应用自己的安全文件名,并保持目录边界。
Cursor 使用 use 保证正常和异常路径都关闭。真正读取内容时也应对openInputStream返回的流使用 use,边读边处理,给导入大小设合理上限。不要为了预览名称就把整个大文件读入内存。这里的 withContext 只避免查询阻塞主线程,不承诺底层跨进程查询在协程取消后立即终止;复杂长查询可以进一步使用 CancellationSignal。
三、临时授权与持久授权要分开
ACTION_OPEN_DOCUMENT 适合需要后续继续访问的文档。返回 Intent 中实际授予的读写标志应被检查,只有确实获得允许持久化的授权时,才调用 takePersistableUriPermission,且仅申请需要且已授予的访问模式。文档选择与持久权限
如果使用 StartActivityForResult 合约接收原始结果,可以先从data.flags取出读写位,再检查持久授权位。只读功能不应顺手保留写权限。持久权限取得成功后再保存 URI 字符串;失败则说明当前文件只能按临时访问处理,或者让用户重新选择,不能假装已经永久保存。
即便持久授权成功,用户移动、删除文档、提供者移除或主动撤销授权仍可能导致读取失败。重新打开时要处理无权限与找不到内容,提供重新选择入口。记录授权状态可以帮助排错,但每次实际访问仍是最后判断依据。
ACTION_GET_CONTENT 常用于一次性取得内容,不能照搬所有 OpenDocument 的持久化假设。也不要拿 FileProvider 临时分享 URI 调用持久授权 API,指望把它升级成永久权限。
四、反过来看自定义 Provider 的边界
书中的自定义 Provider 帮助理解 URI 匹配、查询和增删改。练习可以只暴露收藏编号与标题,明确允许哪些路径、哪些列、哪些操作。不要把所有传入字符串原样拼进 SQL;值通过选择参数绑定,排序字段等无法作为值绑定的位置采用白名单。
Provider 可能被并发访问,不能假定所有调用都在同一工作线程。对外暴露还需要设计 exported、读写权限及 URI 临时授权策略。把 exported 改成 true 并不意味着访问已安全,更不意味着可以忽略调用者权限。内部 Repository 没有跨应用访问需求时,也无需为“看起来完整”额外创建 Provider。
五、故障实验与预期
选择一个普通文本文件、一个大小未知的远端文档和一个空文件,比较显示。预期未知与零能区分,查询结束 Cursor 被关闭。远端文档是否可用取决于实际设备安装的提供者,不能虚构测试结果。
第二组保存 URI 字符串但不保留持久授权,重启设备后再次读取,观察结果;然后使用确实支持持久授权的 OpenDocument 流程对照。预期后者在内容仍存在且权限未撤销时可以继续访问,不应把一次成功扩大为永久保证。
第三组选择后删除原文件,预期应用提示重新选择,已有收藏数据保持完整。这样能验证文件入口失败没有破坏业务数据库。
六、原创面试问答与追问
可连接题库四大组件与权限申请。以下为原创练习。
问:content URI 为什么不是普通文件路径?答:它描述提供者的内容标识,真实存储由提供者决定。追问:怎样读取?使用 ContentResolver 和该提供者支持的操作。
问:把 URI 存进数据库就能下次读取吗?答:还取决于权限寿命和内容是否存在。追问:持久授权会保证文件永不消失吗?不会,它只保留允许的访问权限。
问:查询结果为空和查询抛异常一样吗?答:不一样,前者可能只是没有匹配记录,后者可能是权限或提供者故障。追问:UI 为什么要分开?可恢复动作不同,不能都显示“没有文件”。
七、练习与验收
建议在 D19 后增加一次六十到九十分钟基础练习:做文档信息卡,支持选择、名称、未知大小、重新打开与重新选择。书中 Provider 示例读完后,再画客户端、Resolver、Provider 和存储四层关系。
验收时闭卷说明一次 URI 访问经过哪些检查,并提交查询资源释放、授权丢失和文件删除三类记录。只实现了读取元数据,就在博客中写清范围,不宣称已经实现任意文件管理器。