ContentProvider 与跨进程数据访问-《Android深水区(八)》

ContentProvider 与跨进程数据访问

前言

很多 Android 开发者第一次接触 ContentProvider,是因为读取联系人、媒体库、日历,或者给相机、分享面板传一个 content:// 文件地址。它看起来像一个"数据库接口",但真正的价值不是数据库,而是 把数据访问做成系统可识别、可授权、可跨进程调用的组件边界

这篇文章用一个工程化视角拆开它:什么时候该写 ContentProviderContentResolver 如何找到远端 Provider,Binder 在哪里出现,权限检查发生在哪一层,以及为什么主线程查询、宽泛导出、裸文件路径分享都会让项目在性能或安全上出问题。

前置知识

阅读本文前,最好先理解:

  • Android 进程、组件和系统服务的基本关系。
  • Intent 和临时 URI 权限的作用。
  • SQLite 或 Room 的基本查询模型。
  • Binder 是 Android 跨进程调用的基础机制。

本文属于 Android 基础阶段的第 8 篇,上一篇是 A025 BroadcastReceiver,下一篇是 A027 Context。

核心概念

ContentProvider 常被误解成"把 SQLite 暴露出去的类"。更准确的模型是:

角色 关注点 常见类
调用方 我想读写某个 content:// 数据 ContentResolver
系统解析层 根据 authority 找到对应 Provider,并维护引用 ActivityThread、AMS 相关路径
数据提供方 校验 URI、权限、参数,然后访问真实存储 ContentProvider
真实存储 Room、SQLite、文件、内存、远端同步缓存 RoomDatabase、DAO、文件系统

所以判断是否需要自定义 Provider 的关键问题不是"我有没有数据库",而是:

  1. 是否需要给其他 App 或系统组件提供结构化数据?
  2. 是否需要通过 URI 授权让外部临时访问某个文件或记录?
  3. 是否需要与系统能力集成,例如搜索建议、媒体库、文档提供器、同步适配等?
  4. 是否需要一个稳定的跨进程数据契约?

如果只是 App 内部模块访问数据库,直接使用 Repository + Room/SQLite 更清晰;没有跨进程边界时,Provider 反而会增加 URI 设计、权限和 Cursor 生命周期成本。

Content URI:跨进程数据的地址

官方文档把 Content URI 定义为定位 Provider 中数据的 URI。它通常由三段组成:

text 复制代码
content://com.example.notes.provider/notes/42
| scheme | authority                  | path + id |
  • content://:表示这是内容 URI,不是 file://http://
  • authority:Provider 的全局符号名,系统用它解析到具体组件。
  • path:Provider 自己定义的数据集合,例如 notes
  • id:可选,常用于定位单行记录。

调用方不应该猜测 Provider 背后是不是 SQLite。URI 是契约,Cursor 列名、MIME 类型、权限和排序参数才是对外 API。

整体架构

flowchart TD Client["Client App\nContentResolver"] Framework["Framework\nContentResolver / ActivityThread"] System["System Server\nprovider resolve + permission"] Remote["Provider Process\nContentProvider.Transport"] Store["Storage\nRoom / SQLite / File / Cache"] Client -->|"query/insert/update/delete(content://...)"| Framework Framework -->|"authority -> provider holder"| System Framework -->|"IContentProvider Binder"| Remote Remote -->|"check permission + dispatch URI"| Store Store -->|"Cursor / Uri / count / stream"| Remote Remote -->|"Binder result"| Client

这张图的重点是:ContentResolver 不是直接 new 一个 Provider。它先根据 authority 获取 IContentProvider,远端 Provider 进程通过 Binder 接收请求,再由 Provider 内部访问真实存储。

API 使用:读取一个 Provider

客户端通常只依赖 ContentResolver。查询要放到后台线程,并显式关闭 Cursor。

kotlin 复制代码
data class SharedNote(
    val id: Long,
    val title: String,
    val updatedAt: Long,
)

suspend fun ContentResolver.loadRecentNotes(): List<SharedNote> =
    withContext(Dispatchers.IO) {
        val uri = Uri.parse("content://com.example.notes.provider/notes")
        val projection = arrayOf("_id", "title", "updated_at")
        val sortOrder = "updated_at DESC"

        query(
            uri,
            projection,
            null,
            null,
            sortOrder,
        )?.use { cursor ->
            val idIndex = cursor.getColumnIndexOrThrow("_id")
            val titleIndex = cursor.getColumnIndexOrThrow("title")
            val updatedAtIndex = cursor.getColumnIndexOrThrow("updated_at")

            buildList {
                while (cursor.moveToNext()) {
                    add(
                        SharedNote(
                            id = cursor.getLong(idIndex),
                            title = cursor.getString(titleIndex),
                            updatedAt = cursor.getLong(updatedAtIndex),
                        ),
                    )
                }
            }
        }.orEmpty()
    }

几个细节值得保留成团队规范:

  • projection 不传 null。AOSP ContentResolver.query() 注释也明确提醒,传 null 会返回所有列,效率差且容易让契约失控。
  • 查询不放主线程。官方示例也强调真实代码应异步执行。
  • selectionArgs 或结构化参数,不拼接外部输入。
  • Cursor 必须 useclose,否则远端 Provider 引用和本地资源可能滞留。

创建一个最小 Provider

Provider 对外暴露的是 URI 和列,不是 DAO 本身。下面是一个简化的只读 Provider:内部用 Room,外部只允许按 URI 查询。

kotlin 复制代码
class NotesProvider : ContentProvider() {
    private lateinit var database: NotesDatabase

    override fun onCreate(): Boolean {
        val appContext = requireNotNull(context).applicationContext
        database = Room.databaseBuilder(
            appContext,
            NotesDatabase::class.java,
            "shared-notes.db",
        ).build()
        return true
    }

    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?,
    ): Cursor? {
        return when (matcher.match(uri)) {
            MATCH_NOTES -> database.query(
                SimpleSQLiteQuery(
                    buildNotesSql(projection, selection, sortOrder),
                    selectionArgs ?: emptyArray(),
                ),
            ).also { cursor ->
                cursor.setNotificationUri(requireNotNull(context).contentResolver, uri)
            }
            MATCH_NOTE_ID -> {
                val id = ContentUris.parseId(uri)
                database.query(
                    SimpleSQLiteQuery(
                        "SELECT _id, title, updated_at FROM notes WHERE _id = ?",
                        arrayOf(id),
                    ),
                )
            }
            else -> throw IllegalArgumentException("Unsupported uri: $uri")
        }
    }

    override fun getType(uri: Uri): String = when (matcher.match(uri)) {
        MATCH_NOTES -> "vnd.android.cursor.dir/vnd.com.example.note"
        MATCH_NOTE_ID -> "vnd.android.cursor.item/vnd.com.example.note"
        else -> throw IllegalArgumentException("Unsupported uri: $uri")
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        throw UnsupportedOperationException("Read only provider")
    }

    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?,
    ): Int = throw UnsupportedOperationException("Read only provider")

    override fun delete(
        uri: Uri,
        selection: String?,
        selectionArgs: Array<out String>?,
    ): Int = throw UnsupportedOperationException("Read only provider")

    private companion object {
        const val AUTHORITY = "com.example.notes.provider"
        const val MATCH_NOTES = 1
        const val MATCH_NOTE_ID = 2

        val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
            addURI(AUTHORITY, "notes", MATCH_NOTES)
            addURI(AUTHORITY, "notes/#", MATCH_NOTE_ID)
        }
    }
}

实际项目中,buildNotesSql() 必须限制列名和排序字段白名单。不要把调用方传入的 projectionselectionsortOrder 原样拼成 SQL。Provider 是跨进程边界,所有入参都应该按"不可信输入"处理。

Manifest 侧至少要明确 authoritiesexported 和权限:

xml 复制代码
<permission
    android:name="com.example.notes.permission.READ_NOTES"
    android:protectionLevel="signature" />

<provider
    android:name=".provider.NotesProvider"
    android:authorities="com.example.notes.provider"
    android:exported="true"
    android:readPermission="com.example.notes.permission.READ_NOTES"
    android:writePermission="com.example.notes.permission.WRITE_NOTES" />

如果只是给同一个 App 的其他进程访问,可以考虑 android:exported="false" 并让组件运行在同一包名/签名边界内;如果给第三方 App 访问,必须把权限、列契约、异常语义和版本兼容写清楚。

工作流程

一次跨进程查询可以拆成四步:客户端构造 content:// URI,ContentResolver 根据 authority 获取 Provider 引用,Provider 侧 Binder 接口完成权限检查与分发,最后真实存储返回 Cursor 或文件描述符。下面结合 AOSP 源码展开。

源码分析:一次 query 如何跨进程

本文源码基于 2026-07-03 访问的 AOSP master 分支做分析。不同 Android 版本在细节上可能变化,但主干模型稳定:ContentResolver 获取 IContentProvider,通过 Binder 调用 Provider 侧的 Transport,Provider 再分发到应用实现。

1. ContentResolver 先拿 IContentProvider

路径:frameworks/base/core/java/android/content/ContentResolver.java

ContentResolver.query() 先检查 URI,然后调用 acquireUnstableProvider(uri) 获取一个不稳定 Provider 引用。源码里可以看到它随后调用:

java 复制代码
qCursor = unstableProvider.query(
    mContext.getAttributionSource(),
    uri,
    projection,
    queryArgs,
    remoteCancellationSignal
);

如果远端进程在查询中死亡,DeadObjectException 会触发一次恢复路径:释放不稳定引用,再尝试 acquireProvider(uri) 获取稳定引用重新查询。这个设计说明 Provider 调用不是普通本地函数,它必须面对远端进程死亡。

2. ActivityThread 负责安装和缓存 Provider

路径:frameworks/base/core/java/android/app/ActivityThread.java

ActivityThread.acquireProvider() 会按 authority 和 userId 查找已缓存的 Provider;没有命中时通过系统服务获取 ContentProviderHolder,再执行 installProvider()installProvider() 做几件关键事情:

  • 创建或复用 Provider 所属包的 Context
  • 通过 AppFactory 实例化 Provider 类。
  • 调用 localProvider.attachInfo(c, info),最终进入 Provider 初始化。
  • 把 Provider 按 authority 注册到本进程缓存中。
  • 对远端 Provider 维护引用计数。

这也是为什么 Provider 的 onCreate() 不能做重活。官方创建 Provider 文档明确建议只做快速初始化,把数据库创建和数据加载延后到真实请求阶段,否则会拖慢 Provider 启动,也会拖慢其他应用的响应。

3. Provider 侧由 Transport 接 Binder 请求

路径:frameworks/base/core/java/android/content/ContentProvider.java

ContentProvider 内部有一个 Transport,它继承 ContentProviderNative,是远端看到的 Binder 接口。Transport.query() 的流程很有代表性:

  1. 校验并规范化传入 URI。
  2. 执行读权限检查。
  3. 设置调用方 attribution source。
  4. 调用应用实现的 mInterface.query(...)
  5. 结束 Trace 并返回 Cursor。

源码里还有一个容易忽略的细节:如果读权限不通过且调用方传了 projection,Framework 会返回一个空的 MatrixCursor,列顺序按 projection 保留。这说明权限失败并不总是简单地表现为"抛异常",调用方应按 Provider 文档处理空结果、空 Cursor、SecurityExceptionnull

4. Cursor 也是跨进程成本

query() 返回的 Cursor 不是"把所有数据一次性复制回来了"这么简单。跨进程 Cursor 常涉及窗口、批量填充、Binder 往返和资源引用。AOSP ContentResolver.query() 里会先 qCursor.getCount() 强制执行查询,再把 Cursor 包装成 CursorWrapperInner,并在 Cursor 关闭时释放 Provider 引用。

这也是性能优化里要反复强调 projection、分页、后台线程和及时关闭 Cursor 的原因。

权限模型:exported 只是第一层

Provider 安全至少有四层:

层级 配置/代码 说明
可见性 android:exported 是否允许其他应用使用该 Provider
整体权限 android:permission 读写共用权限
读写权限 readPermission / writePermission 分别控制查询和修改,优先级高于整体权限
路径权限 <path-permission> 对部分 URI 设置更细粒度权限
临时授权 grantUriPermissions + Intent flag 给某个 URI 临时读写授权

官方 <provider> 文档说明,readPermissionwritePermissiongrantUriPermissions 会优先于通用的 permission 属性。工程上建议显式配置读写权限,不要依赖默认值,也不要让 exported=true 的 Provider 在没有权限约束时暴露敏感数据。

Android 11 之后还有包可见性问题。如果客户端要发现某个第三方 Provider,可能需要在 manifest 的 <queries> 中声明 provider authority;官方 <queries> 文档将 provider authority 作为可声明的可见性入口之一。

FileProvider:共享文件时优先用 content://

文件分享是 Provider 最常见的落地场景。官方安全文件分享文档建议用 AndroidX FileProvider 生成内容 URI,而不是把真实文件路径暴露给外部 App。

典型配置如下:

xml 复制代码
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/filepaths" />
</provider>

分享时通过 Intent 授权:

kotlin 复制代码
val uri = FileProvider.getUriForFile(
    context,
    "${BuildConfig.APPLICATION_ID}.fileprovider",
    reportFile,
)

val intent = Intent(Intent.ACTION_SEND).apply {
    type = "application/pdf"
    putExtra(Intent.EXTRA_STREAM, uri)
    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

context.startActivity(Intent.createChooser(intent, "分享报告"))

安全边界有三个重点:

  • android:exported="false":FileProvider 本身不直接开放给任意 App。
  • android:grantUriPermissions="true":只通过明确的 URI 授权开放具体文件。
  • filepaths.xml 只暴露必要目录,不要把根目录、整个 external storage 或缓存大目录粗暴暴露出去。

官方文档还提醒,使用 Intent flag 授予临时访问通常比手动 Context.grantUriPermission() 更容易管理,因为后者需要你自己撤销。

实战案例:导出只读报告给合作 App

假设一个企业应用要向合作方 App 暴露巡检报告列表。推荐设计不是"合作方直接连数据库",而是定义稳定 Provider 契约:

text 复制代码
content://com.example.inspection.provider/reports
content://com.example.inspection.provider/reports/{id}
content://com.example.inspection.provider/reports/{id}/attachments/{fileId}

契约示例:

URI 操作 权限 返回
/reports query READ_REPORTS _id, title, status, updated_at
/reports/{id} query READ_REPORTS 单条报告摘要
/reports/{id}/attachments/{fileId} openFile 临时授权 PDF 或图片流

Provider 内部仍然走 Repository/Room:

kotlin 复制代码
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
    require(mode == "r") { "Reports are read only" }
    val reportId = uri.pathSegments.getOrNull(1)?.toLongOrNull()
        ?: throw FileNotFoundException("Missing report id")
    val fileId = uri.pathSegments.getOrNull(3)
        ?: throw FileNotFoundException("Missing file id")

    val file = reportFileStore.findExportedAttachment(reportId, fileId)
        ?: throw FileNotFoundException("Attachment not found: $uri")

    return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
}

这类设计比直接共享文件路径或数据库表更稳,因为外部只依赖 URI 契约,内部可以迁移 Room 表结构、缓存策略或文件目录。

性能优化

1. projection 必须收窄

跨进程 Cursor 每多一列,都可能增加 CursorWindow 填充、序列化和内存压力。列表页只取列表需要的字段,详情页再查详情。

kotlin 复制代码
val projection = arrayOf("_id", "title", "updated_at")

2. 分页和限制要写进契约

不同 Provider 支持的 query 参数不同。你可以在自定义 Provider 中支持 limitoffset 或 Bundle 查询参数,但要写进文档,并在代码中限制最大值。

kotlin 复制代码
val args = bundleOf(
    ContentResolver.QUERY_ARG_LIMIT to 50,
    ContentResolver.QUERY_ARG_SORT_COLUMNS to arrayOf("updated_at"),
    ContentResolver.QUERY_ARG_SORT_DIRECTION to ContentResolver.QUERY_SORT_DIRECTION_DESCENDING,
)

3. Provider.onCreate() 只做轻初始化

onCreate() 里不要同步跑迁移、网络请求、大文件扫描。Provider 可能被外部 App 的一次查询拉起,启动慢会直接拖慢调用方。

4. 不在主线程 query

Provider 跨进程调用可能等待远端进程启动、权限检查、数据库查询和 CursorWindow 填充。客户端和 Provider 内部都要避免主线程重活。

5. close Cursor 才能释放引用

ContentResolver 会围绕 Cursor 包装 Provider 引用。调用方不关闭 Cursor,轻则泄漏窗口和文件描述符,重则让远端 Provider 进程更久地被引用。

6. 观测慢查询

Provider 边界建议记录:

  • authority、path,不记录敏感参数明文。
  • 行数、列数、耗时。
  • 调用来源包名或 attribution source,在合规允许范围内记录。
  • SecurityExceptionDeadObjectExceptionOperationCanceledException 的分类计数。

这些指标比"感觉 ContentProvider 慢"更有用。

常见坑

坑 1:把 Provider 当 App 内 Repository

App 内部数据访问优先 Repository。Provider 适合跨进程、跨 App、系统集成和 URI 授权。没有这些需求时,不要为了"架构完整"强加 Provider。

坑 2:exported=true 但没有权限

这是高风险配置。只要 Provider 对外可见,就要明确哪些 URI 能被谁读写。敏感数据优先使用 signature 级权限或不导出。

坑 3:直接拼 SQL

selectionsortOrder、URI path 都来自进程边界外。列名、排序字段和 path segment 必须白名单校验。

坑 4:返回内部表的所有列

外部契约一旦发布,删除列、改名、调整含义都会变成兼容性问题。只暴露稳定列,内部字段不要出现在 Cursor 中。

坑 5:用 file:// 分享文件

跨 App 文件分享应使用 content:// + URI permission。FileProvider 是默认优先方案。

坑 6:忘记 Android 11 包可见性

客户端如果要发现外部 Provider,需要考虑 <queries> 中声明 provider authority。否则在目标 SDK 较新时可能出现"明明安装了对方 App,却解析不到"的问题。

常见问题

Q1:ContentProvider 一定运行在独立进程吗?

不一定。默认运行在声明它的应用进程中,也可以通过 manifest 的 android:process 放到指定进程。即使 Provider 和调用方在同一进程,仍建议通过公开契约访问,不要依赖隐藏实现。

Q2:ContentResolver 是系统服务吗?

不是传统意义上的系统服务对象。它是客户端侧 API 门面,负责根据 URI 获取 Provider 引用并发起调用。真正的 Provider 解析、进程管理和权限协调会经过 Framework 与系统服务路径。

Q3:Provider 返回 null Cursor 合法吗?

API 允许底层 Provider 返回 null,调用方必须处理。工程上更推荐 Provider 对可识别 URI 返回空 Cursor 或明确异常,让调用方更容易区分"无数据"和"调用失败"。

Q4:Room 能直接作为 ContentProvider 的返回值吗?

Room DAO 通常返回实体或 Flow,不等于 Cursor 契约。Provider 可以内部使用 Room,但对外要返回 Cursor、Uri、行数或文件描述符。需要 Cursor 时可以使用 RoomDatabase 的 query 能力或 SQLiteQuery。

Q5:什么时候用 FileProvider,什么时候自定义 Provider?

只分享文件时优先用 AndroidX FileProvider。需要结构化数据查询、写入、复杂权限、MIME 类型或自定义 openFile() 行为时,再实现自定义 Provider。

面试考点

基础题

  1. ContentProviderContentResolverContent URI 分别是什么?
  2. authority 的作用是什么?为什么需要全局唯一?
  3. query()insert()update()delete() 分别返回什么?
  4. 为什么客户端查询 Provider 不应该放在主线程?

进阶题

  1. 一次 ContentResolver.query() 大致经过哪些 Framework 路径?
  2. Provider 权限校验发生在哪一层?readPermissionwritePermissionpermission 的优先级关系是什么?
  3. 为什么 Provider 的 onCreate() 不应该做耗时初始化?
  4. Cursor 跨进程返回时有哪些资源和性能成本?

实战题

  1. 设计一个给合作 App 读取订单状态的只读 Provider,你会如何设计 URI、权限和列契约?
  2. 如果 Provider 查询偶发返回 null 或空 Cursor,你会如何定位是权限问题、远端崩溃还是数据问题?
  3. 如何防止外部调用方通过 sortOrder 或 URI path 注入非法 SQL?
  4. 为什么 FileProvider 通常要配置 exported=falsegrantUriPermissions=true

总结

理解 ContentProvider 的核心,不是背 API,而是建立一个边界模型:

  • content:// 是稳定地址。
  • authority 是系统解析 Provider 的入口。
  • ContentResolver 是客户端门面。
  • IContentProviderTransport 承接 Binder 跨进程调用。
  • Provider 内部负责权限、URI 分发、数据访问和 Cursor/文件返回。
  • Manifest 权限、临时 URI 权限和包可见性共同决定外部能否访问。

工程上,Provider 应该被当成跨进程 API 设计:契约要小,权限要明确,参数要校验,查询要可取消、可观测、可分页。只有这样,它才是可靠的数据边界,而不是一个难维护的"数据库转发类"。

扩展阅读

相关推荐
萝卜er1 小时前
Service、前台服务与后台限制-《Android深水区(六)》
android
萝卜er1 小时前
Android 存储体系与分区存储-《Android深水区(十二)》
android
会编程的吕洞宾1 小时前
DeepAgents In Action学习(Second)
android·java·学习
太子釢2 小时前
用 AI 完整实现 Github 客户端:基于 KMP + SwiftUI + Compose
android·人工智能·ios
汪海游龙2 小时前
本地源码还是 Maven 版本?Gradle composite build 双轨依赖的正确接法
android·ci/cd·kotlin
k3x1n2 小时前
三星安卓系统BUG排查记录:云闪付、铁路12306、个人所得税、中国移动等App,长期闪退的根本原因分析
android·逆向
网安蟹佬霸4 小时前
CTF Web方向解题全攻略:从信息收集到getshell(附实战payload与脚本)
android·前端·网络·安全·web安全·网络安全·网安
又见情义4 小时前
Android 13 Settings 设置界面选项裁剪(RK3568平台)
android
人才瘾大5 小时前
Android录屏内部音频录制完全指南:原理、限制与方案选型
android·音视频