13、联系人去重:AggregationExceptions表、自动聚合规则、手动聚合与拆分、处理重复联系人
说到联系人去重,这其实是个挺头疼的问题。我早期做联系人模块时,就被用户投诉过「为什么我手机里有两个张三?」。你想想看,用户从微信导一次、从SIM卡同步一次、再从云端恢复一次,同一个人的信息就散落成好几份了。
Android系统为了解决这个问题,设计了一套聚合机制。说白了,就是让系统自动判断哪些联系人其实是同一个人,然后合并展示。但这套机制并不完美,所以又提供了手动干预的接口。今天我们就来彻底搞懂它。
核心知识点:联系人去重依赖三个关键要素——AggregationExceptions表、自动聚合规则、手动聚合与拆分操作。
13.1 AggregationExceptions表:聚合的「裁判」
这张表是联系人聚合的核心控制表。它存在于ContactsProvider的数据库中,专门用来记录「哪些联系人应该合并」或「哪些联系人必须分开」。
表结构其实很简单,就三个关键字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| _id | INTEGER | 主键,自增 |
| contact_id1 | INTEGER | 第一个联系人的ID |
| contact_id2 | INTEGER | 第二个联系人的ID |
| type | INTEGER | 聚合类型:0=自动,1=强制合并,2=强制拆分 |
type字段的值很有意思。0表示让系统自己决定,1表示「我不管,这俩必须合并」,2表示「这俩绝对不能合并」。我在项目中遇到过用户反馈说两个同名联系人总是被自动合并,但其实是不同的人。这时候就需要用type=2来强制拆分。
操作这张表,需要通过ContentResolver来执行。直接操作数据库是不被允许的,因为ContactsProvider封装了内部的聚合逻辑。
// 强制合并两个联系人
ContentValues values = new ContentValues();
values.put(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER);
values.put(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1);
values.put(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2);
getContentResolver().update(
AggregationExceptions.CONTENT_URI,
values,
null,
null
);
小提示:操作AggregationExceptions表时,传入的是raw_contact_id,而不是contact_id。这两个ID的区别,我在前面章节讲过,raw_contact_id是原始联系人ID,一个联系人可能对应多个原始联系人。
13.2 自动聚合规则:系统是怎么判断的?
系统不会凭空猜测两个联系人是不是同一个人。它有一套匹配规则,说白了就是看「相似度」。
自动聚合主要依据以下几个维度:
- 姓名匹配:显示名称完全一致,或者结构化姓名(姓+名)匹配
- 电话号码匹配:两个联系人拥有相同的电话号码,这是最高优先级的匹配
- 邮箱匹配:相同的邮箱地址也会触发聚合
- 昵称/备注匹配:某些场景下,系统也会参考这些辅助信息
但这里有个坑。我曾经遇到过一个案例:用户有两个联系人,一个存的是「张三」,另一个存的是「张三(经理)」。系统默认不会把它们聚合,因为显示名称不完全一致。用户就抱怨说为什么同一个人的信息分成了两处。
为什么会这样?因为系统的自动聚合策略是保守的。它宁可漏掉一些可能的合并,也不愿意错误合并。你想想看,如果把两个不同的人合并了,那才是真正的灾难。
自动聚合的触发时机也有讲究:
- 插入新的原始联系人时
- 更新已有联系人的关键字段时(如修改姓名、添加电话)
- 系统空闲时,后台会定期执行聚合扫描
嗯,这里要注意,自动聚合不是实时的。有时候你刚添加一个联系人,发现它没有立即和已有的合并,别急,等几秒钟或者重启一下联系人应用就好了。
13.3 手动聚合与拆分:给用户一把「手术刀」
自动聚合搞不定的时候,就需要用户手动介入了。Android系统提供了两个核心操作:合并(Join)和拆分(Separate)。
手动合并的流程通常是这样的:
- 用户选择两个或多个联系人
- 系统调用AggregationExceptions表的更新接口,设置type为KEEP_TOGETHER
- ContactsProvider收到请求后,重新计算聚合关系
- 最终这些联系人被合并到一个contact_id下
手动拆分则相反:
- 用户选择已经聚合在一起的某个原始联系人
- 系统设置type为KEEP_SEPARATE
- 系统拆分后,各自独立成单独的联系人
代码实现上,手动拆分稍微复杂一点。因为你需要先找到当前聚合组中的所有原始联系人,然后指定要拆分的那个。
// 手动拆分:将某个原始联系人从聚合组中分离
ContentValues values = new ContentValues();
values.put(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_SEPARATE);
values.put(AggregationExceptions.RAW_CONTACT_ID1, rawContactIdToSeparate);
// 注意:RAW_CONTACT_ID2 传入聚合组中的另一个原始联系人ID
values.put(AggregationExceptions.RAW_CONTACT_ID2, anotherRawContactIdInGroup);
getContentResolver().update(
AggregationExceptions.CONTENT_URI,
values,
null,
null
);
警告:手动拆分时,必须传入两个原始联系人ID。系统会根据这两个ID所在的聚合组进行拆分操作。如果只传一个ID,系统不知道要从哪个组里拆出来,操作会失败。
13.4 处理重复联系人的完整策略
在实际开发中,我们不能完全依赖系统的自动聚合。用户手机上可能已经积累了大量的重复数据。这时候需要我们自己实现一套去重逻辑。
我个人习惯的做法是分三步走:
第一步:检测重复
遍历所有联系人,按照姓名、电话、邮箱等维度分组。找到那些高度相似的联系人对。
// 伪代码:检测重复联系人
Map<String, List<Long>> phoneIndex = new HashMap<>();
// 遍历所有原始联系人,建立电话->原始联系人ID的索引
for (RawContact contact : allRawContacts) {
for (String phone : contact.getPhones()) {
phoneIndex.computeIfAbsent(phone, k -> new ArrayList<>()).add(contact.getId());
}
}
// 找出同一个电话对应多个原始联系人的情况
for (Map.Entry<String, List<Long>> entry : phoneIndex.entrySet()) {
if (entry.getValue().size() > 1) {
// 这些原始联系人可能是重复的
reportDuplicate(entry.getKey(), entry.getValue());
}
}
第二步:用户确认
检测到重复后,不要直接合并。我建议弹出一个界面,让用户确认哪些确实是同一个人。因为有些场景下,比如夫妻共用同一个家庭电话,系统会误判为重复。
第三步:执行合并
用户确认后,调用AggregationExceptions表进行强制合并。同时要注意合并数据:把两个原始联系人的电话、邮箱、地址等信息合并到一起,避免数据丢失。
我曾经犯过一个错误:合并时只保留了其中一个联系人的数据,另一个的数据被覆盖了。用户后来发现「咦,我存的备注怎么没了?」。所以合并数据时,一定要做「并集」操作,而不是「覆盖」。
13.5 知识体系总览
下面这张图总结了联系人去重的完整知识结构,你可以对照着梳理思路:
总结一下,联系人去重不是简单的「删掉重复的」就完事了。它涉及到数据表的操作、系统规则的理解、以及用户交互的设计。我在实际项目中,通常会把自动聚合作为第一道防线,手动聚合作为补充,再配合自己的去重检测逻辑,这样基本能覆盖90%以上的场景。
剩下的10%呢?嗯,交给用户自己决定吧。毕竟有些「重复」在用户眼里可能并不是重复,我们做系统的,不能替用户做所有决定。
避坑指南:我曾经在批量合并时忘记处理事务,结果合并到一半崩溃了,导致部分联系人数据损坏。所以批量操作AggregationExceptions表时,一定要用批量模式(ContentProviderOperation),并且开启事务保护。
公众号:蓝海资料掘金营,微信deep3321