欢迎光临
我们一直在努力

读取联系人到设计数据接口:小进的 Android Content Provider 学习路线

从读取联系人到设计数据接口:小进的 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 权限通常有三层:

  • Manifest 权限:控制谁能读、谁能写;
  • 路径级权限:不同 URI 路径可以有不同读写权限;
  • 临时授权:通过 Intent 只给目标应用短时间访问某个 URI。
  • 例如,使用系统文件选择器选择图片后,可以把 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 的区别

    场景常见 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>,但不要滥用

    十二、一个循序渐进的练习项目

    我建议做一个“联系人快速拨号”应用:

  • 申请 READ_CONTACTS,显示联系人姓名和号码;
  • 使用 ContentResolver 查询并在后台线程处理 Cursor;
  • 点击联系人后使用 Intent.ACTION_DIAL 打开拨号界面;
  • 添加 ContentObserver,当联系人变化时刷新列表;
  • 再创建一个只在应用内部使用的笔记 Provider;
  • 用 FileProvider 分享一张应用私有目录中的图片;
  • 最后为 Provider 加上读写权限,并用第二个测试应用验证访问边界。
  • 写在最后

    Content Provider 的核心不是“记住几个 CRUD 方法”,而是理解一套数据访问协议:Uri 定位资源,ContentResolver 发起访问,ContentProvider 执行操作,Cursor 返回结果,权限决定谁可以访问。

    先从系统联系人和 MediaStore 学会“用”,再用一个小型笔记 Provider 学会“写”。等你能解释清楚 URI、权限、Cursor 和数据变化通知之间的关系,Content Provider 就不再是四大组件里最难的那个了。

    — 小进

    赞(0)
    未经允许不得转载:171主机测评 » 读取联系人到设计数据接口:小进的 Android Content Provider 学习路线
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址