27、联系人合并与拆分:合并重复联系人UI、选择主联系人、拆分已合并联系人、数据一致性保证
说实话,联系人合并与拆分这个功能,在系统通讯录里看着不起眼,但做起来坑特别多。我记得几年前做一款企业通讯录App时,用户反馈说「我明明合并了两个联系人,怎么过几天又变成两个了?」——后来一查,是同步逻辑没处理好。嗯,今天我们就来彻底聊透这块。
27.1 合并重复联系人的UI设计
合并的入口,我个人习惯放在联系人详情页的菜单里。用户点开一个联系人,如果系统检测到有重复项,就在顶部给个提示条:「发现2个重复联系人,点击合并」。
合并的UI流程大致分三步:
- 展示重复列表:列出所有疑似重复的联系人,每项显示姓名、电话号码、邮箱等关键信息。
- 选择要合并的项:用户勾选需要合并的联系人,至少选两个。
- 选择主联系人:这一步很关键,后面会细讲。
我建议在列表项右侧加一个「相似度」标签,比如「90%匹配」,让用户心里有数。曾经有个项目,我们直接用Levenshtein距离算名字相似度,再结合号码完全匹配,效果还不错。
核心原则:合并操作必须是用户主动触发的,千万别自动合并。你想想看,万一张三和李四只是同名,自动合并就出大事了。
27.2 选择主联系人
合并时,用户需要指定一个「主联系人」。说白了,合并后的联系人以谁的信息为准?
我一般这样设计UI:在合并确认页,每个候选联系人卡片左上角有一个单选按钮。默认选中信息最完整的那一个。怎么判断信息最完整?我定过一个规则:
| 字段 | 权重 | 说明 |
|---|---|---|
| 姓名 | 10 | 非空即得分 |
| 电话号码 | 8 | 每个号码加4分 |
| 邮箱 | 6 | 每个邮箱加3分 |
| 地址 | 5 | 非空即得分 |
| 公司/职位 | 4 | 非空即得分 |
得分最高的自动成为主联系人。但用户也可以手动切换。嗯,这里要注意:切换主联系人时,下面的信息预览要实时更新,让用户看到合并后的效果。
小技巧:在预览区用颜色区分信息来源。比如主联系人的字段用蓝色背景,其他联系人的字段用灰色背景。这样用户一眼就能看出哪些信息会被保留。
27.3 合并的数据模型设计
合并不是简单地把两个联系人记录删掉一个。我建议用「聚合根」的思路来设计。
数据库里加一张表:
CREATE TABLE contact_merge_group (
group_id TEXT PRIMARY KEY,
primary_contact_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE contact_merge_member (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id TEXT NOT NULL,
contact_id TEXT NOT NULL,
is_deleted INTEGER DEFAULT 0,
FOREIGN KEY (group_id) REFERENCES contact_merge_group(group_id),
FOREIGN KEY (contact_id) REFERENCES contacts(contact_id)
);
合并时,把参与合并的所有联系人ID都记录到contact_merge_member表里。主联系人单独标记在contact_merge_group表里。这样拆分时才能找回原始数据。
我曾经踩过一个坑:合并时直接把非主联系人删掉了。结果用户想拆分,数据已经没了。后来改成软删除,把is_deleted置为1,数据保留但不在普通列表里展示。
27.4 拆分已合并联系人
拆分是合并的逆操作。UI上,在合并后的联系人详情页加一个「拆分联系人」按钮。点击后弹窗确认:「拆分后,将恢复为3个独立联系人」。
拆分逻辑其实不复杂:
- 从
contact_merge_member表里查出所有成员。 - 把
is_deleted为1的成员恢复为0。 - 删除
contact_merge_group里的记录。 - 主联系人保持不变,其他联系人重新生成独立的联系人记录。
这里有个细节:拆分后,原来合并时从非主联系人带过来的信息怎么办?我个人习惯是:拆分时,把合并期间新增的、属于非主联系人的字段归还回去。如果某个字段在合并后被用户编辑过,那就保留在主联系人上,不拆分。
警告:拆分操作不可逆!一旦拆分,合并时的关联关系就断了。我建议在拆分前自动备份一份数据快照,或者至少让用户确认两次。
27.5 数据一致性保证
数据一致性是合并拆分功能里最头疼的部分。为什么?因为联系人数据可能同时被多个地方修改——本地修改、云端同步、其他App通过ContentProvider访问。
我总结了几条保证一致性的策略:
- 事务包裹:合并和拆分操作必须在一个数据库事务里完成。要么全部成功,要么全部回滚。
- 版本号机制:每个联系人加一个
version字段。合并时检查版本号,如果发现版本号变了,说明数据被其他进程修改过,这时候要提示用户刷新后再试。 - 同步冲突处理:如果云端也支持合并,那本地和云端之间需要协商合并策略。我建议以最后一次修改的时间戳为准,或者让用户手动选择。
举个例子,伪代码大概是这样:
fun mergeContacts(primaryId: String, otherIds: List<String>): Boolean {
return db.transaction {
// 1. 检查版本号
val primaryVersion = getContactVersion(primaryId)
for (id in otherIds) {
val version = getContactVersion(id)
if (version != primaryVersion) {
throw DataConflictException("数据已被修改,请刷新")
}
}
// 2. 合并字段
val mergedData = mergeFields(primaryId, otherIds)
updateContact(primaryId, mergedData)
// 3. 记录合并关系
insertMergeGroup(primaryId, otherIds)
// 4. 软删除其他联系人
for (id in otherIds) {
softDeleteContact(id)
}
// 5. 更新版本号
incrementVersion(primaryId)
}
}
我曾经遇到过一个线上问题:用户在合并过程中突然来电,App被系统回收。合并事务只执行了一半,导致部分联系人被软删了,但主联系人没更新。从那以后,我强制要求所有合并操作都在ViewModel层加一个mergeLock,防止并发操作。
27.6 用SVG梳理核心流程
下面这张图,我把合并与拆分的完整流程画出来了。你看一眼就能明白整体脉络。
从图上你能看到,合并和拆分是两个对称的流程,中间的数据一致性模块是两者的共同基础。说白了,没有一致性保证,合并拆分就是空中楼阁。
27.7 避坑指南
最后,分享几个我亲身踩过的坑:
- 不要用自增ID做主键:合并拆分时,ID可能会变。我建议用UUID,全局唯一。
- 注意ContentProvider的notifyChange:合并后一定要通知其他观察者更新UI,否则列表还是旧的。
- 测试边界情况:比如合并两个空联系人、拆分一个从未合并过的联系人、在同步过程中合并……这些场景我都出过bug。
我的习惯:每次发布前,写一个自动化测试脚本,模拟100次随机合并和拆分操作,然后校验数据完整性。虽然跑起来慢,但心里踏实。
好了,关于联系人合并与拆分,核心就是这些。UI设计要清晰,数据模型要健壮,一致性要死磕。做到这三点,这个功能基本就稳了。
公众号:蓝海资料掘金营,微信deep3321