14、Contacts Provider:读取联系人、查询联系人、修改联系人
说到 Android 系统里的联系人,你可能会觉得不就是个通讯录嘛,有什么好讲的?
嗯,我刚开始也是这么想的。直到有一次,我需要做一个企业通讯录同步的功能,才发现 Contacts Provider 这玩意儿,远比想象中复杂。它背后是一套完整的关系型数据库设计,涉及多张表的联合查询、账户管理、聚合逻辑……说白了,它就是一个微型 CRM 系统。
14.1 Contacts Provider 的底层结构
在动手之前,我们先搞清楚它的数据模型。Contacts Provider 有三张核心表,我习惯把它们比作三层结构:
| 表名 | 作用 | 类比 |
|---|---|---|
ContactsContract.Contacts |
联系人聚合层,一个联系人可能对应多个账号 | 人的概念 |
ContactsContract.RawContacts |
原始联系人数据,每个账号一条记录 | 账号里的名片 |
ContactsContract.Data |
具体数据行,比如电话、邮箱、地址 | 名片上的字段 |
为什么这么设计?你想想看,你的手机里可能同时登录了 Google 账号、微信账号、企业账号。同一个联系人,在不同账号下可能有不同的名字和电话。Contacts Provider 通过聚合层,把这些碎片信息合并成一条联系人记录给你看。这就是 Contacts 表存在的意义。
核心原则:永远不要直接操作 Contacts 表。增删改都应该针对 RawContacts 和 Data 表。系统会自动帮你做聚合。
14.2 读取联系人:从查询到展示
读取联系人,说白了就是一次 ContentResolver 的 query 操作。但这里有个坑——你需要处理 Cursor 的遍历和关闭。我曾经见过线上崩溃,就是因为 Cursor 没有及时关闭,导致内存泄漏。
先看一个标准的读取联系人列表的代码:
ContentResolver resolver = getContentResolver();
Uri uri = ContactsContract.Contacts.CONTENT_URI;
String[] projection = new String[]{
ContactsContract.Contacts._ID,
ContactsContract.Contacts.DISPLAY_NAME,
ContactsContract.Contacts.HAS_PHONE_NUMBER
};
Cursor cursor = null;
try {
cursor = resolver.query(uri, projection, null, null, null);
if (cursor != null && cursor.moveToFirst()) {
do {
long id = cursor.getLong(0);
String name = cursor.getString(1);
boolean hasPhone = cursor.getInt(2) > 0;
// 处理数据
} while (cursor.moveToNext());
}
} finally {
if (cursor != null) {
cursor.close();
}
}
这里我用了 try-finally 来确保 Cursor 一定被关闭。你可能会说,现在都用 LiveData 或者 Flow 了,谁还手动关 Cursor?嗯,但底层原理是一样的——资源释放不能依赖框架。
14.3 查询联系人:带条件的搜索
查询联系人,最常用的场景就是搜索。比如用户输入「张三」,你要从通讯录里把匹配的记录找出来。
Contacts Provider 支持 LIKE 查询,但要注意性能。我建议你加上 LIMIT 和 SORT_ORDER,避免全表扫描。
String selection = ContactsContract.Contacts.DISPLAY_NAME + " LIKE ?";
String[] selectionArgs = new String[]{"%" + keyword + "%"};
String sortOrder = ContactsContract.Contacts.DISPLAY_NAME + " ASC";
Cursor cursor = resolver.query(
ContactsContract.Contacts.CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
);
小技巧:如果搜索量很大,可以考虑使用 ContactsContract.Contacts.CONTENT_FILTER_URI。这个 URI 内部做了索引优化,查询速度会快很多。
另外,查询电话和邮箱时,需要关联 Data 表。因为一个联系人可能有多个电话,你不能只查 Contacts 表。
Uri dataUri = ContactsContract.Data.CONTENT_URI;
String dataSelection = ContactsContract.Data.CONTACT_ID + " = ? AND " +
ContactsContract.Data.MIMETYPE + " = ?";
String[] dataArgs = new String[]{String.valueOf(contactId),
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE};
Cursor phoneCursor = resolver.query(dataUri, null, dataSelection, dataArgs, null);
这里用 MIMETYPE 来区分数据类型。电话的 MIME 类型是 Phone.CONTENT_ITEM_TYPE,邮箱是 Email.CONTENT_ITEM_TYPE。这个设计很巧妙——通过 MIME 类型,一张 Data 表就能存储所有类型的数据。
14.4 修改联系人:增删改的完整流程
修改联系人,比读取要复杂得多。因为你需要操作多张表,而且涉及到事务。
我个人习惯用 ContentProviderOperation 来做批量操作。这样既能保证原子性,又能减少 IPC 调用次数。
14.4.1 新增联系人
新增一个联系人,需要先插入 RawContacts,再插入 Data。两步操作必须在一个事务里完成。
ArrayList<ContentProviderOperation> ops = new ArrayList<>();
// 第一步:插入 RawContacts
int rawContactIndex = ops.size();
ops.add(ContentProviderOperation.newInsert(
RawContacts.CONTENT_URI)
.withValue(RawContacts.ACCOUNT_TYPE, accountType)
.withValue(RawContacts.ACCOUNT_NAME, accountName)
.build());
// 第二步:插入姓名
ops.add(ContentProviderOperation.newInsert(
Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, rawContactIndex)
.withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE)
.withValue(StructuredName.DISPLAY_NAME, "张三")
.build());
// 第三步:插入电话
ops.add(ContentProviderOperation.newInsert(
Data.CONTENT_URI)
.withValueBackReference(Data.RAW_CONTACT_ID, rawContactIndex)
.withValue(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE)
.withValue(Phone.NUMBER, "13800138000")
.withValue(Phone.TYPE, Phone.TYPE_MOBILE)
.build());
ContentProviderResult[] results = resolver.applyBatch(
ContactsContract.AUTHORITY, ops);
注意这里的 withValueBackReference。它的作用是把第一步生成的 RawContacts 的 _ID,自动填入第二步和第三步的 RAW_CONTACT_ID 字段。这个技巧我第一次用的时候还觉得挺绕,后来发现它确实能避免手动传 ID 的麻烦。
14.4.2 更新联系人
更新联系人,同样用批量操作。比如修改电话号码:
ArrayList<ContentProviderOperation> ops = new ArrayList<>();
ops.add(ContentProviderOperation.newUpdate(Data.CONTENT_URI)
.withSelection(Data._ID + " = ?", new String[]{String.valueOf(dataId)})
.withValue(Phone.NUMBER, "13900139000")
.build());
resolver.applyBatch(ContactsContract.AUTHORITY, ops);
警告:更新时一定要指定 _ID,不要只靠 CONTACT_ID 和 MIMETYPE 来定位。因为同一个联系人可能有多个同类型的 Data 行(比如两个手机号),不指定 ID 可能会更新错行。
14.4.3 删除联系人
删除联系人,我建议删除 RawContacts 而不是 Contacts。因为 Contacts 是聚合视图,直接删除可能会引起系统异常。
ops.add(ContentProviderOperation.newDelete(RawContacts.CONTENT_URI)
.withSelection(RawContacts._ID + " = ?", new String[]{String.valueOf(rawContactId)})
.build());
如果你要彻底删除一个联系人(包括所有账号下的数据),需要删除所有关联的 RawContacts。系统检测到某个 Contacts 下没有任何 RawContacts 时,会自动清理掉那条 Contacts 记录。
14.5 知识体系总览
下面这张图,是我梳理的 Contacts Provider 操作的核心流程。你可以把它当作一个速查地图:
14.6 避坑指南
最后,分享几个我踩过的坑:
- 权限问题:Android 6.0 以上需要动态申请
READ_CONTACTS和WRITE_CONTACTS权限。我曾经在适配时忘了处理拒绝回调,导致用户无法搜索联系人。 - 账户类型:插入 RawContacts 时,
ACCOUNT_TYPE和ACCOUNT_NAME可以为 null。但如果你指定了,系统会把它归到对应的账户下。不指定的话,联系人会显示为「本地联系人」。 - 聚合逻辑:系统根据姓名、电话等字段自动聚合联系人。如果你插入两个名字相同但电话不同的 RawContacts,系统可能会把它们合并成一条 Contacts。这个行为不可控,所以不要依赖 Contacts 的 _ID 做业务关联。
- 性能问题:不要在 UI 线程做大量查询。Contacts Provider 的查询涉及多表 JOIN,数据量大时可能耗时几百毫秒。我建议用 Loader 或者协程来做异步查询。
嗯,关于 Contacts Provider 的核心操作,差不多就这些了。记住一个原则:把 Contacts Provider 当成一个远程数据库来对待,操作要谨慎,资源要释放,权限要检查。这样基本不会出大问题。