从读取联系人到设计数据接口:小进的 Android Content Provider 学习路线
大家好,我是小进。
Android 四大组件里,ContentProvider 往往是最不像“页面”的一个。第一次接触它时,你会看到 Uri、Cursor、ContentResolver、权限和 Manifest,很容易觉得这些东西彼此没有关系。
其实它解决的问题很明确:让一个应用以统一、安全的方式向其他组件或应用提供数据。 你可以先把它理解成一个“数据服务台”:提供方不必暴露数据库表结构,访问方也不必知道数据究竟存在哪里,只通过约定好的 URI 和接口进行增删改查。
这篇文章会先从“会使用别人提供的 Provider”开始,再学习如何自己实现一个 Provider。文章较长,但我建议不要一口气背完,跟着示例一步步运行,理解会更牢。
一、先建立准确的概念模型
1. ContentProvider 是数据接口,不只是数据库
ContentProvider 可以把 SQLite、Room、文件、网络缓存,甚至其他数据源包装成统一接口。它常见的操作包括:
- query():查询数据;
- insert():新增数据;
- update():修改数据;
- delete():删除数据;
- getType():返回 URI 对应的数据 MIME 类型。
Provider 不要求底层一定是 SQLite。它的价值在于定义了一套跨组件、跨应用的数据访问边界。
2. ContentResolver 是访问入口
访问方通常不直接创建 Provider,而是通过 ContentResolver 调用系统已经注册的 Provider:
应用 A
↓ ContentResolver + Uri
系统调度
↓
应用 B 的 ContentProvider
↓
数据库、文件或其他数据源
这让访问方不必依赖提供方的具体实现,也能由系统统一处理权限、进程和 URI 路由。
3. URI 是数据地址
Content Provider 常见 URI 形式如下:
content://authority/path
content://authority/path/42
例如:
content://com.example.notes.provider/notes
content://com.example.notes.provider/notes/42
其中:
- content://:表示这是 Content Provider URI;
- authority:Provider 的唯一标识,通常使用包名加后缀;
- path:资源集合,例如 notes;
- 42:某一条具体记录的 ID。
可以把集合 URI 理解成“所有笔记”,把带 ID 的 URI 理解成“编号为 42 的那条笔记”。
二、先学会使用系统 Provider
新手先做客户端最容易建立直觉,因为联系人、图片和日历等系统数据都已经有现成 Provider。
1. 申请联系人权限
读取联系人需要在清单声明权限:
<uses-permission android:name="android.permission.READ_CONTACTS" />
Android 6.0(API 23)及以上还必须运行时申请:
private val requestContactsPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
loadContacts()
} else {
Toast.makeText(this, "没有联系人权限", Toast.LENGTH_SHORT).show()
}
}
fun ensureContactsPermission() {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.READ_CONTACTS
) == PackageManager.PERMISSION_GRANTED
) {
loadContacts()
} else {
requestContactsPermission.launch(Manifest.permission.READ_CONTACTS)
}
}
权限被拒绝时要提供合理的空状态,不要把拒绝直接当成应用崩溃条件。联系人属于敏感数据,申请前最好向用户解释为什么需要它。
2. 使用 ContentResolver 查询联系人
private fun loadContacts() {
val uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER
)
contentResolver.query(
uri,
projection,
null,
null,
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC"
)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME
)
val numberIndex = cursor.getColumnIndex(
ContactsContract.CommonDataKinds.Phone.NUMBER
)
while (cursor.moveToNext()) {
val name = cursor.getString(nameIndex)
val number = cursor.getString(numberIndex)
Log.d("Contacts", "Name: $name, Number: $number")
}
}
}
query() 的五个常用参数分别是:
| uri | 查询哪一种资源 |
| projection | 需要返回哪些列 |
| selection | 类似 SQL 的过滤条件,不要直接拼接用户输入 |
| selectionArgs | 替换 ? 占位符的参数 |
| sortOrder | 排序规则 |
3. 为什么必须关闭 Cursor
Cursor 可能持有文件描述符、数据库连接或跨进程资源。Kotlin 里使用 .use {},即使查询过程中抛异常,也会自动关闭:
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
// 读取当前行
}
}
不要在主线程查询大量联系人、媒体或文件。可以使用 Dispatchers.IO,然后回到主线程更新界面:
lifecycleScope.launch {
val contacts = withContext(Dispatchers.IO) {
queryContacts()
}
renderContacts(contacts)
}
三、查询时最容易写错的细节
1. 永远使用 selectionArgs
不推荐这样拼接 SQL 条件:
val selection = "name = '$userInput'"
推荐使用占位符:
val selection = "name = ?"
val selectionArgs = arrayOf(userInput)
contentResolver.query(uri, projection, selection, selectionArgs, null)
这样能减少转义错误,也能降低注入风险。Provider 实现方同样不要直接拼接外部输入。
2. 检查列是否存在
不同设备、系统版本和 Provider 实现可能返回不同列。使用 getColumnIndex() 后要判断结果是否为 -1,不要假定列一定存在:
val index = cursor.getColumnIndex("optional_column")
if (index >= 0 && !cursor.isNull(index)) {
val value = cursor.getString(index)
}
3. 只查询真正需要的列
projection 越小,跨进程传输的数据越少,查询通常也越快。不要为了方便传 null 获取所有列,尤其是联系人和媒体库这种数据量较大的 Provider。
四、自己实现一个 ContentProvider
下面设计一个简单的笔记 Provider。假设数据表有三列:
_id INTEGER PRIMARY KEY
title TEXT
body TEXT
1. 定义 Contract
不要把 authority、列名和 URI 字符串散落在各个文件里,建议集中定义:
object NoteContract {
const val AUTHORITY = "com.example.notes.provider"
const val TABLE_NAME = "notes"
val CONTENT_URI: Uri =
Uri.parse("content://$AUTHORITY/$TABLE_NAME")
const val ID = "_id"
const val TITLE = "title"
const val BODY = "body"
val DEFAULT_PROJECTION = arrayOf(ID, TITLE, BODY)
}
_id 很重要。很多 Android 组件和适配器都约定结果集包含名为 _id 的唯一列;自定义 Provider 最好遵守这个约定。
2. 使用 UriMatcher 做路由
Provider 需要知道当前 URI 是查询全部笔记,还是查询某一条笔记:
private const val NOTES = 1
private const val NOTE_ID = 2
private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
addURI(NoteContract.AUTHORITY, "notes", NOTES)
addURI(NoteContract.AUTHORITY, "notes/#", NOTE_ID)
}
# 表示数字 ID。UriMatcher 就像 Provider 内部的路由器,把 URI 映射为不同的处理分支。
3. 实现 Provider 基本结构
class NoteProvider : ContentProvider() {
private lateinit var database: SQLiteDatabase
override fun onCreate(): Boolean {
val context = context ?: return false
database = NoteDatabaseHelper(context).writableDatabase
return true
}
override fun query(
uri: Uri,
projection: Array<String>?,
selection: String?,
selectionArgs: Array<String>?,
sortOrder: String?
): Cursor {
val builder = SQLiteQueryBuilder().apply {
tables = NoteContract.TABLE_NAME
}
when (uriMatcher.match(uri)) {
NOTES -> Unit
NOTE_ID -> builder.appendWhere(
"${NoteContract.ID} = ${uri.lastPathSegment}"
)
else -> throw IllegalArgumentException("Unknown URI: $uri")
}
return builder.query(
database,
projection ?: NoteContract.DEFAULT_PROJECTION,
selection,
selectionArgs,
null,
null,
sortOrder
).apply {
setNotificationUri(context!!.contentResolver, uri)
}
}
override fun getType(uri: Uri): String = when (uriMatcher.match(uri)) {
NOTES -> "vnd.android.cursor.dir/vnd.com.example.note"
NOTE_ID -> "vnd.android.cursor.item/vnd.com.example.note"
else -> throw IllegalArgumentException("Unknown URI: $uri")
}
override fun insert(uri: Uri, values: ContentValues?): Uri {
require(uriMatcher.match(uri) == NOTES) { "Invalid insert URI: $uri" }
val id = database.insertOrThrow(NoteContract.TABLE_NAME, null, values)
val result = ContentUris.withAppendedId(NoteContract.CONTENT_URI, id)
context!!.contentResolver.notifyChange(result, null)
return result
}
override fun delete(
uri: Uri,
selection: String?,
selectionArgs: Array<String>?
): Int = TODO("根据 URI 追加 ID 条件,并在删除后 notifyChange")
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<String>?
): Int = TODO("根据 URI 更新记录,并在修改后 notifyChange")
}
示例重点是结构,不建议把 TODO 直接用于生产代码。真实实现还需要校验 values、处理非法 URI、限制可写列,并在修改后通知观察者。
五、在 Manifest 中注册 Provider
<provider
android:name=".NoteProvider"
android:authorities="com.example.notes.provider"
android:exported="false" />
android:exported="false" 表示其他应用不能直接访问,适合只在本应用内部使用的 Provider。如果要对外提供数据,应明确配置读写权限:
<provider
android:name=".NoteProvider"
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" />
同时声明自定义权限,并根据需求选择 signature 等保护级别。不要为了让示例“能访问”就直接把 Provider 无限制地导出;导出的 Provider 是应用对外开放的数据接口。
六、权限模型和临时 URI 授权
Provider 权限通常有三层:
例如,使用系统文件选择器选择图片后,可以把 URI 和临时读权限交给接收方:
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "image/*"
addCategory(Intent.CATEGORY_OPENABLE)
}
openDocument.launch(intent)
拿到 URI 后,应优先使用 ContentResolver.openInputStream(uri) 读取,不要假设 URI 一定对应一个真实文件路径。SAF、MediaStore 和云端 Provider 都可能只提供流式访问。
七、数据变化通知与 ContentObserver
Provider 修改数据后,应调用 notifyChange():
context!!.contentResolver.notifyChange(NoteContract.CONTENT_URI, null)
查询得到的 Cursor 也可以设置通知 URI:
cursor.setNotificationUri(
context!!.contentResolver,
NoteContract.CONTENT_URI
)
访问方可以注册 ContentObserver 感知变化:
val observer = object : ContentObserver(Handler(Looper.getMainLooper())) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
reloadNotes()
}
}
contentResolver.registerContentObserver(
NoteContract.CONTENT_URI,
true,
observer
)
// 页面销毁时注销
contentResolver.unregisterContentObserver(observer)
通知表示“数据可能变了”,不是把完整数据推送给观察者。收到通知后仍然要重新查询,并且要避免因为一次批量操作触发大量重复刷新。
八、批量操作和事务
如果需要连续插入多条记录,可以使用 ContentProviderOperation:
val operations = listOf(
ContentProviderOperation.newInsert(NoteContract.CONTENT_URI)
.withValue(NoteContract.TITLE, "第一篇笔记")
.withValue(NoteContract.BODY, "开始学习 Provider")
.build(),
ContentProviderOperation.newInsert(NoteContract.CONTENT_URI)
.withValue(NoteContract.TITLE, "第二篇笔记")
.withValue(NoteContract.BODY, "理解 URI 和 Cursor")
.build()
)
contentResolver.applyBatch(NoteContract.AUTHORITY, operations)
Provider 可以重写 applyBatch(),使用数据库事务保证一组操作要么全部成功,要么全部回滚。涉及多步写入时,事务比循环调用多个 insert() 更安全,也更高效。
九、分页、取消和大数据查询
Content Provider 查询可能跨进程传输数据,不要一次返回几十万行。新版本 Android 的 Provider 可以结合分页参数、稳定排序和取消信号优化大数据集;简单项目至少要做到:
- 明确 projection,只取需要的列;
- 使用 LIMIT 或分页方案控制结果量;
- 在后台线程查询;
- 及时关闭 Cursor;
- 对用户离开页面的情况支持取消查询。
如果只是本应用的数据库访问,Room 通常比手写 Provider 更简单。Room 负责实体、DAO、线程和迁移;只有需要跨应用暴露数据,或需要兼容系统已有数据协议时,才更有必要实现自定义 Provider。
十、几个现成 Provider 的区别
| 联系人 | ContactsContract | 先申请权限,再用 Resolver 查询 |
| 图片和视频 | MediaStore | 使用 URI 和流,不依赖真实路径 |
| 文件选择 | Storage Access Framework | 使用 ACTION_OPEN_DOCUMENT |
| 本应用数据库 | Room | 不要为了内部 DAO 额外实现 Provider |
| 跨应用共享文件 | FileProvider | 使用临时 URI 授权,不暴露文件路径 |
FileProvider 也是 Provider 的一个实际应用:它把应用私有文件安全地转换为 content:// URI,供其他应用临时读取或分享。
十一、常见错误和排查方法
| SecurityException | 没有 Manifest 或运行时权限 | 检查权限声明和授权结果 |
| Unknown URI | authority 或 path 不匹配 | 打印完整 URI,检查 UriMatcher |
| 查询结果为空 | projection、selection 或权限问题 | 先用最小查询确认 Provider 可访问 |
| CursorIndexOutOfBoundsException | 没有 moveToNext() 或列索引错误 | 检查游标位置和列是否存在 |
| IllegalArgumentException | Provider 不接受当前 URI | 检查集合 URI 和单条 URI 的区别 |
| 页面重复刷新 | 重复注册 Observer 或广播 | 在生命周期中配对注册、注销 |
| Android 11 找不到目标应用 | 包可见性限制 | 必要时配置 <queries>,但不要滥用 |
十二、一个循序渐进的练习项目
我建议做一个“联系人快速拨号”应用:
写在最后
Content Provider 的核心不是“记住几个 CRUD 方法”,而是理解一套数据访问协议:Uri 定位资源,ContentResolver 发起访问,ContentProvider 执行操作,Cursor 返回结果,权限决定谁可以访问。
先从系统联系人和 MediaStore 学会“用”,再用一个小型笔记 Provider 学会“写”。等你能解释清楚 URI、权限、Cursor 和数据变化通知之间的关系,Content Provider 就不再是四大组件里最难的那个了。
— 小进





