27、联系人合并与拆分:合并重复联系人UI、选择主联系人、拆分已合并联系人、数据一致性保证

说实话,联系人合并与拆分这个功能,在系统通讯录里看着不起眼,但做起来坑特别多。我记得几年前做一款企业通讯录App时,用户反馈说「我明明合并了两个联系人,怎么过几天又变成两个了?」——后来一查,是同步逻辑没处理好。嗯,今天我们就来彻底聊透这块。

27.1 合并重复联系人的UI设计

合并的入口,我个人习惯放在联系人详情页的菜单里。用户点开一个联系人,如果系统检测到有重复项,就在顶部给个提示条:「发现2个重复联系人,点击合并」。

合并的UI流程大致分三步:

  1. 展示重复列表:列出所有疑似重复的联系人,每项显示姓名、电话号码、邮箱等关键信息。
  2. 选择要合并的项:用户勾选需要合并的联系人,至少选两个。
  3. 选择主联系人:这一步很关键,后面会细讲。

我建议在列表项右侧加一个「相似度」标签,比如「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个独立联系人」。

拆分逻辑其实不复杂:

  1. contact_merge_member表里查出所有成员。
  2. is_deleted为1的成员恢复为0。
  3. 删除contact_merge_group里的记录。
  4. 主联系人保持不变,其他联系人重新生成独立的联系人记录。

这里有个细节:拆分后,原来合并时从非主联系人带过来的信息怎么办?我个人习惯是:拆分时,把合并期间新增的、属于非主联系人的字段归还回去。如果某个字段在合并后被用户编辑过,那就保留在主联系人上,不拆分。

警告:拆分操作不可逆!一旦拆分,合并时的关联关系就断了。我建议在拆分前自动备份一份数据快照,或者至少让用户确认两次。

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梳理核心流程

下面这张图,我把合并与拆分的完整流程画出来了。你看一眼就能明白整体脉络。

联系人合并与拆分核心流程 合并流程 检测重复联系人 用户选择要合并的项 选择主联系人 事务执行合并 更新UI & 同步云端 拆分流程 用户点击拆分 二次确认弹窗 恢复软删除联系人 事务执行拆分 更新UI & 同步云端 数据一致性 事务包裹 版本号检查 软删除机制 冲突处理 并发锁 贯穿始终

从图上你能看到,合并和拆分是两个对称的流程,中间的数据一致性模块是两者的共同基础。说白了,没有一致性保证,合并拆分就是空中楼阁。

27.7 避坑指南

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

  • 不要用自增ID做主键:合并拆分时,ID可能会变。我建议用UUID,全局唯一。
  • 注意ContentProvider的notifyChange:合并后一定要通知其他观察者更新UI,否则列表还是旧的。
  • 测试边界情况:比如合并两个空联系人、拆分一个从未合并过的联系人、在同步过程中合并……这些场景我都出过bug。

我的习惯:每次发布前,写一个自动化测试脚本,模拟100次随机合并和拆分操作,然后校验数据完整性。虽然跑起来慢,但心里踏实。

好了,关于联系人合并与拆分,核心就是这些。UI设计要清晰,数据模型要健壮,一致性要死磕。做到这三点,这个功能基本就稳了。


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