6、添加联系人:构建ContentValues、插入ContactsContract.RawContacts、插入Data表数据、事务处理与批量插入

好,我们直接进入正题。添加联系人,说白了就是把一个“人”的信息拆成两大部分,塞进系统联系人数据库里。你想想看,一个联系人可能有姓名、电话、邮箱、地址……这些数据在 Android 系统里不是一股脑儿存一张表的,而是分成了 RawContactsData 两张核心表。

我个人习惯把 RawContacts 理解成“联系人外壳”,它只记录这个联系人是属于哪个账户的(比如 Google 账号、手机本地账号)。而真正的数据,比如“张三”、“13800138000”,全部存在 Data 表里。嗯,这里要注意:必须先插入 RawContacts,拿到它的 _id,才能往 Data 表里写数据

核心流程:
1. 构建一个空的 ContentValues,插入 RawContacts → 获取 rawContactId
2. 用同一个 rawContactId,构建多条 ContentValues,分别插入 Data 表
3. 用事务包裹整个过程,保证要么全部成功,要么全部回滚

6.1 构建 ContentValues:把数据装进“键值对”

ContentValues 是什么?说白了就是一个 Map,key 是列名,value 是你要存的值。我刚开始做联系人开发时,总觉得这玩意儿跟 Bundle 差不多,但有一点不同:ContentValues 的 key 必须是 String,value 只能是基本类型或 String

举个例子,你要插入一条手机号数据:

ContentValues values = new ContentValues();
values.put(Data.RAW_CONTACT_ID, rawContactId);
values.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
values.put(Phone.NUMBER, "13800138000");
values.put(Phone.TYPE, Phone.TYPE_MOBILE);

这里 Data.MIMETYPE 是关键中的关键。它告诉系统:这条 Data 记录是什么类型。是电话?是邮箱?还是姓名?每种类型对应不同的 MIMETYPE 常量。我整理了一张常用表:

数据类型 MIMETYPE 常量 常用字段
姓名 StructuredName.CONTENT_ITEM_TYPE DISPLAY_NAME, GIVEN_NAME, FAMILY_NAME
手机号 Phone.CONTENT_ITEM_TYPE NUMBER, TYPE
邮箱 Email.CONTENT_ITEM_TYPE ADDRESS, TYPE
地址 StructuredPostal.CONTENT_ITEM_TYPE STREET, CITY, POSTCODE
避坑指南: 我曾经在插入姓名时忘了设置 DISPLAY_NAME,结果联系人列表里显示了一堆空白。后来排查才发现,系统默认用 DISPLAY_NAME 作为列表展示字段。所以,姓名数据一定要给 DISPLAY_NAME,哪怕你只存了一个姓。

6.2 插入 RawContacts:拿到“联系人身份证”

插入 RawContacts 其实很简单,只需要一个空的 ContentValues,但必须指定 ACCOUNT_NAMEACCOUNT_TYPE。如果你不指定,系统会默认用本地账户。我个人习惯显式指定:

ContentValues rawValues = new ContentValues();
rawValues.put(RawContacts.ACCOUNT_NAME, "com.android.contacts");
rawValues.put(RawContacts.ACCOUNT_TYPE, "local");

Uri rawUri = getContentResolver().insert(RawContacts.CONTENT_URI, rawValues);
long rawContactId = ContentUris.parseId(rawUri);

这里返回的 rawContactId 就是后续所有 Data 记录的“外键”。你想想看,没有这个 ID,Data 表里的数据就不知道属于谁。

6.3 插入 Data 表数据:一条一条往里塞

拿到 rawContactId 之后,就可以往 Data 表里插数据了。每条数据都是一个独立的 ContentValues,但必须共享同一个 rawContactId。比如你要插入姓名和电话:

// 插入姓名
ContentValues nameValues = new ContentValues();
nameValues.put(Data.RAW_CONTACT_ID, rawContactId);
nameValues.put(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE);
nameValues.put(StructuredName.DISPLAY_NAME, "张三");
getContentResolver().insert(Data.CONTENT_URI, nameValues);

// 插入电话
ContentValues phoneValues = new ContentValues();
phoneValues.put(Data.RAW_CONTACT_ID, rawContactId);
phoneValues.put(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE);
phoneValues.put(Phone.NUMBER, "13800138000");
phoneValues.put(Phone.TYPE, Phone.TYPE_MOBILE);
getContentResolver().insert(Data.CONTENT_URI, phoneValues);

嗯,这里有个坑:Data.CONTENT_URI 是同一个,但每条记录的 MIMETYPE 不同,系统会根据 MIMETYPE 解析对应的字段。如果你把电话号码填到了姓名字段里,系统不会报错,但显示出来就是乱的。

6.4 事务处理:要么全有,要么全无

你想想看,如果插入姓名成功了,但插入电话时失败了,这个联系人就成了“只有名字没有电话”的残次品。为了避免这种情况,必须用事务把多次插入包起来

Android 的 ContentProvider 操作支持 applyBatch 方法,它允许你把多个 ContentProviderOperation 打包成一个事务。我个人强烈推荐这种方式,比手动管理 SQLiteTransaction 要安全得多:

ArrayList<ContentProviderOperation> ops = new ArrayList<>();

// 1. 插入 RawContacts
ops.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
        .withValue(RawContacts.ACCOUNT_NAME, "com.android.contacts")
        .withValue(RawContacts.ACCOUNT_TYPE, "local")
        .build());

// 2. 插入姓名(依赖上一步的 rawContactId)
ops.add(ContentProviderOperation.newInsert(Data.CONTENT_URI)
        .withValueBackReference(Data.RAW_CONTACT_ID, 0)  // 0 表示引用第1个操作的结果
        .withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE)
        .withValue(StructuredName.DISPLAY_NAME, "张三")
        .build());

// 3. 插入电话
ops.add(ContentProviderOperation.newInsert(Data.CONTENT_URI)
        .withValueBackReference(Data.RAW_CONTACT_ID, 0)
        .withValue(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE)
        .withValue(Phone.NUMBER, "13800138000")
        .withValue(Phone.TYPE, Phone.TYPE_MOBILE)
        .build());

// 执行事务
ContentProviderResult[] results = getContentResolver()
        .applyBatch(ContactsContract.AUTHORITY, ops);
注意: withValueBackReference 里的第二个参数是操作索引。0 表示引用第0个操作(即插入 RawContacts)返回的 ID。如果你有多个 RawContacts 插入,索引要对应好。我曾经在这里搞错过索引,结果电话数据关联到了错误的联系人上,排查了半天。

6.5 批量插入:一次处理多个联系人

如果你需要一次性导入 100 个联系人,千万别用 for 循环一个个调 insert。那样会触发 100 次 ContentProvider 跨进程调用,性能极差。正确的做法是把所有操作打包到一个 applyBatch 里:

ArrayList<ContentProviderOperation> ops = new ArrayList<>();
int rawContactIndex = 0;

for (ContactData contact : contactList) {
    // 插入 RawContacts
    ops.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
            .withValue(RawContacts.ACCOUNT_NAME, "com.android.contacts")
            .withValue(RawContacts.ACCOUNT_TYPE, "local")
            .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, contact.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, contact.phone)
            .withValue(Phone.TYPE, Phone.TYPE_MOBILE)
            .build());

    rawContactIndex += 3;  // 每个联系人占3个操作(1个RawContacts + 2个Data)
}

getContentResolver().applyBatch(ContactsContract.AUTHORITY, ops);

你想想看,这样一次跨进程调用就搞定了所有联系人,效率提升不是一点半点。我在项目里曾经用这种方式导入 500 个联系人,耗时不到 2 秒,而用循环插入的方式要 30 多秒。

6.6 整体流程示意图

下面这张图帮你理清整个添加联系人的数据流向:

添加联系人核心流程 1. 构建ContentValues 姓名/电话/邮箱等数据 2. 插入RawContacts 获取 rawContactId 3. 插入Data表 多条数据共享rawContactId 事务包裹(applyBatch) 要么全部插入成功,要么全部回滚 使用 withValueBackReference 自动关联 rawContactId 批量插入多个联系人 将所有操作打包到同一个 ArrayList 中,一次 applyBatch 完成 避免多次跨进程调用,性能提升 10 倍以上 核心:RawContacts 是外壳,Data 是内容,事务保证原子性
个人经验: 我在做通讯录同步功能时,发现批量插入时如果某个联系人的数据格式有问题(比如电话号码为空),整个事务会全部回滚。所以建议在构建 ContentValues 之前,先做一次数据校验,把不合法的数据过滤掉。这样能避免“一颗老鼠屎坏了一锅粥”的情况。

好了,添加联系人的核心内容就这些。说白了就是三步走:构建 ContentValues → 插入 RawContacts 拿 ID → 插入 Data 表。再加上事务和批量操作,你就能高效、安全地往系统联系人数据库里写数据了。

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