Skip to content

ContentProvider:发布时机、查询链路与跨进程游标 ​

本页导读:ContentProvider 是四大组件里最「隐形」的一个——它比你最早醒来,也在你最意想不到的线程上被调用。本页讲它为什么在 Application.onCreate 之前就发布、一次 query() 从 Binder 到 CursorWindow 的完整链路、跨进程游标的共享内存真相,以及文件共享的 FileProvider/DocumentsProvider 规范。前置:Activity、架构篇 Binder 页。

一、发布时机:比你更早的组件 ​

ContentProvider 的发布嵌在应用绑定的关键路径里,ActivityThread.handleBindApplication 的骨架(简化):

text
handleBindApplication
 ├ makeApplication()                    // 反射 new Application
 ├ application.attachBaseContext()      // ← 基类挂钩点
 ├ installContentProviders()            // ← 所有 provider:new + attachInfo + onCreate()
 ├ application.onCreate()               // ← 业务初始化才开始
 └ 开始处理 Activity/Service/Broadcast 消息

结论与推论:

  • provider 的 onCreate() 先于 Application.onCreate()——launcher 冷启动你的 provider 时,你的全局初始化还没跑,依赖注入框架(Hilt/手工单例)的 DAO 会直接 NPE。provider 内要做极简初始化或自建依赖图。
  • 发布是「同步阻塞」在 bindApplication 里的:AMS 等所有 provider publish 回执后才认为进程 ready,慢 provider 直接拖慢整个进程启动(冷启动优化必查项)。
  • 系统还会为 provider 单独拉进程:你没打开过某 App,但短信/联系人查询可能把它进程拽起来——这就是「没点过的 App 也在后台」的主要合法来源。
bash
adb shell dumpsys activity providers | head -30   # 看已注册 provider、authority 归属、进程
adb shell dumpsys package providers               # authority 全局路由表(同 authority 抢占规则)

二、URI 与路由:authority 是门牌号 ​

一个 content URI 的三要素:content://<authority>/<path>#<fragment>。authority 全局唯一路由(同 authority 冲突时系统安装期拒绝/隐藏,以 AOSP PackageManager 行为为准);path 由 provider 自己解释,AOSP 惯用 UriMatcher 建表匹配:

常见系统 authority 顺手记几个(命名格式即最佳示范):

authority提供方用途
contactsAOSP 通讯录联系人/电话档案
media媒体库照片/音视频元数据
downloads下载管理下载任务状态
settings设置库Settings.System/Secure/Global
java
// 经典模式(AOSP 通讯录/媒体等 provider 同款思路)
static final UriMatcher sMatcher = new UriMatcher()
    .match("content://com.example.provider/notes", NOTES)          // 集合
    .match("content://com.example.provider/notes/#", NOTE_ID)      // 单条
    .match("content://com.example.provider/notes/*/comments", COMMENTS); // 子资源
// query() 第一行 match uri,落到哪个码走哪条分支;不认识的直接抛
// IllegalArgumentException——不要返回 null,调用方无从处理

权限模型挂在 authority 或 path 粒度上:

  • android:permission / android:readPermission / android:writePermission:authority 级;
  • pathPermissions(API 19+):pathPattern 级,例如 /notes/* 要求 NOTE_OWNER 权限;
  • 临时授权:grantUriPermission/FLAG_GRANT_READ_URI_PERMISSION——跨进程给单个 URI 开临时门,intent 生命周期结束自动回收。
  • exported=false 的 provider 只同包可用(跨进程拿不到),这是「隐形组件」也必须是显式安全决策的原因(target 31+ 无 filter 组件 exported 必须显式声明)。

三、一次 query 的完整链路 ​

text
ContentResolver.query(uri, projection, ...)
 ├ getContentProvider(uri) ── Binder ──▶ AMS/ProviderHelper:authority 路由 + 权限/导出校验
 │                                     └ 目标进程未起则先拉进程(等 publish)
 ├ IContentProvider.query(...)(ContentProviderNative 的 Binder 代理)
 │      └ 目标进程 ContentProvider.Transport 直接收 binder 线程调用
 │         └ 回到你的 query()——注意:跑在 Binder 线程池,不是主线程!
 └ 返回 Cursor:实际是 CursorWindow + 继续查询句柄 的窗口化游标

四个工程要点:

  1. 线程安全是硬要求:Transport 收 binder 线程调用,多个客户端并发打你时 query 并发执行(除非你覆写让回调转投主线程的旧写法——性能灾难,勿念)。
  2. CursorWindow 是共享内存(frameworks/base/core/java/android/database/CursorWindow.java,native 实现在 frameworks/base/core/jni/android_database_CursorWindow.cpp):query 只回传第一窗口(默认典型约 2MB,可配置,以 AOSP 为准)+ 一个 binder 句柄,后续 moveToPosition 越窗时经 IContentProvider.transactContinueQuery 续拉(Android 12 起为显式的续查协议,接口细节以 AOSP 为准)。
  3. stable vs unstable:acquireUnstableProvider 拿到的 binder 死亡你自理(游标变空),acquireProvider/stable 则系统代管死信;自己写客户端时用 ContentProviderClient 并正确 release()。
  4. 超时:客户端等 provider 就绪有超时(历史 10s 量级,publishContentProviders/getContentProvider 路径,以 AOSP 为准),慢 init 的 provider 会以「query 超时/ANR」形式报复回来。

四、跨进程游标的真面目:窗口化、非快照 ​

直觉里 Cursor 是「一次查询的结果集」,实际是按需分页的活物:

  • 客户端游标 = CursorWindow(本地映射的共享内存)+ 远端游标句柄;窗口装不下的行在遍历时才续拉。
  • 因此 cursor.getCount() 可能触发远端往返;遍历时持有 Cursor 跨越 UI 动画/协程挂起,远端 provider 进程可能已死 → StaleDataException。

现代客户端正确用法(拿到的是「租约」,记得还):

kotlin
val client = context.contentResolver
    .acquireUnstableProviderForMediaType(uri, MediaTypes.APP_MEDIA)?  // API 30+
    ?: return

client.use { c->  // Closeable,自动 release
    c.query(uri, arrayOf("title", "created"), "size>?",
            arrayOf("1048576"), null)?.use { cur ->
        while (cur.moveToNext()) {
            val title = cur.getString(0)   // 行数据来自共享内存窗口
        }
    }
}
  • 大结果集的正确姿势:限定 projection、加 selection、分页自己控(LIMIT/OFFSET 由你传进 selectionArgs 语义);绝不要把整表拉进内存。
  • 写操作同理:insert/update/delete 直接走 binder 调用到 Binder 线程,notifyChange 再经 ContentObserver 链回到注册方(观察者注册要记得 registerContentObserver/unregister 成对)。

Binder 传输的微观层面可以一句话讲死:游标数据本体走共享内存(CursorWindow 的 ashmem/memfd),Binder 只传句柄、窗口描述与续查请求——这是「跨进程大结果」不炸 Binder 缓冲区的根本原因,也是为什么它不能跨「provider 数据实时变更」给你一致性保证。

text
窗口化游标时序(极简版):
客户端 query ──▶ 服务端 open:先 COUNT + 定位器
       ◀── CursorWindow#1(前 N 行)+ token
遍历到窗口外 ──▶ transactContinueQuery(token, startPos, 2)
       ◀── CursorWindow#2(下一段)
中途服务端数据变更/进程死亡 ──▶ 客户端读到 StaleDataException/空游标

五、文件共享:FileProvider 与 DocumentsProvider ​

文件不能用 file:// URI 传(target 24+ 直接 FileUriExposureException/StrictMode 击杀),规范通道是 content URI + 临时读权限:

  • FileProvider(AndroidX androidx.core.content.FileProvider):manifest 配 <paths> 白名单目录,把文件映射成 content://authority/子目录/文件名,发 intent 时带 FLAG_GRANT_READ_URI_PERMISSION;跨重启长期访问用 takePersistableUriPermission。
  • MediaStore(Android 10+ 分区存储):媒体文件优先走 MediaStore 的 content URI,配合 MediaColumns 查询;MediaStore.Downloads 还允许「带来源标记的下载」。
  • DocumentsProvider(framework 基类 frameworks/base/core/java/android/provider/DocumentsProvider.java):系统级文件提供接口,云盘/SD 卡以 DocumentsContract 文档树形式暴露;App 想「当网盘」就实现它,用户经 SAF(系统文件选择器)授权后,通过 takePersistableUriPermission 拿到跨重启的长期读权。

AOSP 自己的文件类 provider(下载管理、媒体库、设置导出等)都遵循同一套「content URI + grantUriPermissions + path 白名单」的纪律,写新 provider 时先看它们抄纪律,而不是自己发明。

xml
<!-- FileProvider 的 paths 白名单(res/xml/file_paths.xml) -->
<paths>
    <files-path name="docs" path="reports/"/>
    <cache-path name="tmp" path="share/"/>
    <external-path name="ext" path="Download/"/>
</paths>

六、写一个生产级 Provider 的检查单 ​

  • 冷启动:onCreate 里只做本 provider 必需初始化;数据库 migration 别挡 publish 路径(放首次 query 或懒建)。
  • 并发:以 Binder 线程视角审查每一行代码;SQLite 用 WAL + 单连接管理;绝不在 query 里做网络 IO。
  • 契约:暴露 CONTENT_URI 常量与列名(契约类),selectionArgs 用占位符防注入;不认识的 URI 抛异常。
  • 性能:notifyChange 别高频广播全表失效;大表提供「增量查询」参数(since= 时间戳类)。
  • 安全:exported + 权限 + URI 授权三件套按需组合;查询列白名单(别人传什么都接 = 列注入风险)。
  • 测试:用 adb shell content query/read/write 直测 authority:
bash
adb shell content query --uri content://com.example.provider/notes --projection title
adb shell content insert --uri content://com.example.provider/notes \
    --bind title:s:"hello" --bind body:s:"from adb"
adb shell content read --uri content://com.example.provider/notes/1

七、常见误区问答 ​

Q1:provider 能跑后台任务吗? 它只是数据入口。进程可能被 provider 调用拉活,但 Android 8 后它不给你起后台服务的豁免;把「provider 唤醒」当保活通道是给自己埋雷。

Q2:为什么我的 Cursor 在子线程崩了? 游标不是线程安全的:跨线程使用要加锁,或者读完立刻关,把需要的行物化进局部变量。

Q3:同 authority 的两个 App 会怎样? 后安装的拿不到该 authority 的路由(同签名共享 android:sharedUserId 的老玩法已被时代淘汰),表现为 Unknown URL——全局唯一命名空间,authority 必须带包名。

Q4:query 里能做业务校验/限流吗? 可以且应该——query/update 就是你的服务端入口,越权、超频、超大结果都在这里挡;返回空集合不是万能回答,权限不足该抛 SecurityException。

Q5:provider 之间能互相查询吗? 能,但按普通跨进程客户端对待;环形依赖(A 查 B、B 查 A)会在冷启动期互相等 publish,表现为超时。跨 provider 聚合应该放到应用层或系统服务层做。

版本注记:基于 Android 16 / API 36;Android 7 差异:file:// 暴露开始被杀;Android 10 差异:分区存储重塑媒体 provider 使用面;Android 11 差异:QUERY_ALL_PACKAGES/包可见性影响 provider 探测;Android 12 差异:续查窗口化协议调整(以 AOSP 为准);Android 13~16 差异:媒体权限细分(READ_MEDIA_*、部分照片访问)叠加在查询语义之上。

本页小结:ContentProvider 的三件本质事——它是最早发布的组件(先于 Application.onCreate,慢即罪)、Binder 线程上的公开 API(并发与安全都要按服务端标准写)、窗口化的流式游标(共享内存翻页而不是快照拷贝)。文件共享按 FileProvider/MediaStore/DocumentsProvider 三条规范通道走,URI 授权机制贯穿始终。

参考链接 ​

基于 Android 16 / API 36 · 内容为原创整理,转载请注明出处