14、Contacts Provider:读取联系人、查询联系人、修改联系人

说到 Android 系统里的联系人,你可能会觉得不就是个通讯录嘛,有什么好讲的?

嗯,我刚开始也是这么想的。直到有一次,我需要做一个企业通讯录同步的功能,才发现 Contacts Provider 这玩意儿,远比想象中复杂。它背后是一套完整的关系型数据库设计,涉及多张表的联合查询、账户管理、聚合逻辑……说白了,它就是一个微型 CRM 系统。

14.1 Contacts Provider 的底层结构

在动手之前,我们先搞清楚它的数据模型。Contacts Provider 有三张核心表,我习惯把它们比作三层结构:

表名 作用 类比
ContactsContract.Contacts 联系人聚合层,一个联系人可能对应多个账号 人的概念
ContactsContract.RawContacts 原始联系人数据,每个账号一条记录 账号里的名片
ContactsContract.Data 具体数据行,比如电话、邮箱、地址 名片上的字段

为什么这么设计?你想想看,你的手机里可能同时登录了 Google 账号、微信账号、企业账号。同一个联系人,在不同账号下可能有不同的名字和电话。Contacts Provider 通过聚合层,把这些碎片信息合并成一条联系人记录给你看。这就是 Contacts 表存在的意义。

核心原则:永远不要直接操作 Contacts 表。增删改都应该针对 RawContactsData 表。系统会自动帮你做聚合。

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 查询,但要注意性能。我建议你加上 LIMITSORT_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_IDMIMETYPE 来定位。因为同一个联系人可能有多个同类型的 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 操作的核心流程。你可以把它当作一个速查地图:

Contacts Provider 操作流程 Contacts(聚合层) RawContacts(原始层) Data(数据层) 1:N 1:N 操作入口:ContentResolver.query / applyBatch 查询流程 Contacts → RawContacts → Data(MIME过滤) 修改流程 批量操作:ContentProviderOperation ⚠️ 注意事项 1. 增删改操作 RawContacts 和 Data,不要直接操作 Contacts 2. 更新时务必指定 _ID,避免误更新

14.6 避坑指南

最后,分享几个我踩过的坑:

  • 权限问题:Android 6.0 以上需要动态申请 READ_CONTACTSWRITE_CONTACTS 权限。我曾经在适配时忘了处理拒绝回调,导致用户无法搜索联系人。
  • 账户类型:插入 RawContacts 时,ACCOUNT_TYPEACCOUNT_NAME 可以为 null。但如果你指定了,系统会把它归到对应的账户下。不指定的话,联系人会显示为「本地联系人」。
  • 聚合逻辑:系统根据姓名、电话等字段自动聚合联系人。如果你插入两个名字相同但电话不同的 RawContacts,系统可能会把它们合并成一条 Contacts。这个行为不可控,所以不要依赖 Contacts 的 _ID 做业务关联。
  • 性能问题:不要在 UI 线程做大量查询。Contacts Provider 的查询涉及多表 JOIN,数据量大时可能耗时几百毫秒。我建议用 Loader 或者协程来做异步查询。

嗯,关于 Contacts Provider 的核心操作,差不多就这些了。记住一个原则:把 Contacts Provider 当成一个远程数据库来对待,操作要谨慎,资源要释放,权限要检查。这样基本不会出大问题。


公众号:蓝海资料掘金营,微信deep3321