26、联系人历史记录:最近联系人表、通话记录关联、时间线展示、清除历史记录

说实话,历史记录这个功能,很多开发者容易把它当成「附属品」来做。但我在实际项目中踩过坑——用户最常看的,其实就是最近联系过谁。你想想看,通讯录里几百号人,谁记得住上次跟谁打过电话?所以历史记录做得好不好,直接决定了用户愿不愿意用你这个App。

这一章,我们就来聊聊怎么把联系人历史记录做得既完整又流畅。我会从表结构设计开始,一步步带你实现通话记录关联、时间线展示,最后再搞定清除历史记录这个看似简单实则坑多的功能。

最近联系人表的设计

先说说表结构。我个人习惯把最近联系人单独拆一张表,而不是直接写在通话记录表里。为什么?因为通话记录可能一天就几百条,但最近联系人只需要保留最近几十个。分开存,查询效率高,维护也方便。

CREATE TABLE recent_contacts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    contact_id INTEGER NOT NULL,
    phone_number TEXT NOT NULL,
    contact_name TEXT,
    last_contact_time INTEGER NOT NULL,  -- 时间戳,毫秒
    contact_count INTEGER DEFAULT 1,     -- 累计联系次数
    contact_type INTEGER DEFAULT 0,      -- 0: 呼出, 1: 呼入, 2: 未接
    avatar_uri TEXT,
    is_deleted INTEGER DEFAULT 0
);

CREATE INDEX idx_recent_time ON recent_contacts(last_contact_time DESC);
CREATE INDEX idx_recent_contact ON recent_contacts(contact_id);

嗯,这里要注意一个细节:contact_type 字段我存的是「最后一次联系的类型」。为什么不是存所有类型?因为最近联系人列表只需要展示最后一次是打出去还是接进来。如果你非要存全部类型,那表结构就复杂了,查询也慢。

我的经验:contact_count 这个字段很有用。我在项目中遇到过产品经理要求「按联系频率排序」的需求。有了这个字段,直接 ORDER BY contact_count DESC 就搞定了,不用再去通话记录表里 count(*),性能好很多。

通话记录关联

最近联系人和通话记录,说白了就是「摘要」和「明细」的关系。最近联系人表存的是聚合后的数据,通话记录表存的是每一条原始记录。

那怎么关联呢?我建议用 contact_id + phone_number 做联合键。为什么不用单独的 contact_id?因为同一个联系人可能有多个号码,你总得区分是跟哪个号码通的电话吧?

public class CallLogHelper {

    public static void syncRecentContact(Context context, CallLogEntry entry) {
        ContentValues values = new ContentValues();
        values.put("contact_id", entry.contactId);
        values.put("phone_number", entry.phoneNumber);
        values.put("contact_name", entry.contactName);
        values.put("last_contact_time", entry.callTime);
        values.put("contact_type", entry.callType);

        // 先查一下有没有这条记录
        Cursor cursor = context.getContentResolver().query(
            RecentContactProvider.CONTENT_URI,
            new String[]{"id", "contact_count"},
            "contact_id = ? AND phone_number = ?",
            new String[]{String.valueOf(entry.contactId), entry.phoneNumber},
            null
        );

        if (cursor != null && cursor.moveToFirst()) {
            // 有记录就更新
            int count = cursor.getInt(1);
            values.put("contact_count", count + 1);
            context.getContentResolver().update(
                RecentContactProvider.CONTENT_URI,
                values,
                "id = ?",
                new String[]{String.valueOf(cursor.getInt(0))}
            );
        } else {
            // 没有就插入
            context.getContentResolver().insert(
                RecentContactProvider.CONTENT_URI,
                values
            );
        }

        if (cursor != null) {
            cursor.close();
        }
    }
}

这段代码看起来简单,但我曾经在这里翻过车。问题是这样的:每次通话结束都去更新最近联系人表,如果用户一天打了50个电话,那就要更新50次。后来我改成「只在App启动和切到前台时批量同步」,性能就好多了。

避坑指南:千万不要在通话记录 ContentObserver 里直接做数据库写操作。我曾经这么干过,结果导致通话记录界面卡顿,用户投诉说「打完电话要等两秒才能看到记录」。正确的做法是用 Handler 做延迟批量处理,或者用 WorkManager 在后台执行。

时间线展示

时间线展示,说白了就是把最近联系人按时间分组展示。常见的分组方式有:今天、昨天、本周、更早。这个分组逻辑看起来简单,但实现起来有几个坑。

先看一个分组查询的例子:

SELECT 
    CASE 
        WHEN date(last_contact_time / 1000, 'unixepoch', 'localtime') = date('now', 'localtime') 
            THEN '今天'
        WHEN date(last_contact_time / 1000, 'unixepoch', 'localtime') = date('now', '-1 day', 'localtime') 
            THEN '昨天'
        WHEN strftime('%W', last_contact_time / 1000, 'unixepoch', 'localtime') = strftime('%W', 'now', 'localtime') 
            THEN '本周'
        ELSE '更早'
    END AS time_group,
    contact_name,
    phone_number,
    last_contact_time,
    contact_type
FROM recent_contacts
WHERE is_deleted = 0
ORDER BY last_contact_time DESC
LIMIT 50;

嗯,这里要注意时区问题。我刚开始做的时候直接用 UTC 时间比较,结果发现「今天」的分组在晚上8点以后就乱了。后来统一用 localtime 才搞定。

展示的时候,我建议用 RecyclerView 配合多个 ViewType。每个时间组用一个 Header 类型,里面的条目用另一个类型。这样结构清晰,滑动性能也好。

核心思路:时间线展示的关键不是「怎么画」,而是「怎么分组」。分组逻辑写好了,UI 展示就是水到渠成的事。我个人习惯把分组逻辑封装成一个工具类,这样 Activity 和 Fragment 里只需要调用一个方法就能拿到分组后的数据。

清除历史记录

清除历史记录这个功能,看起来就是 DELETE 一下,对吧?但实际做起来,要考虑的事情还挺多的。

首先,是「清除全部」还是「清除部分」?我建议两种都支持。用户可能想清掉某一个人的记录,也可能想一键清空。看代码:

public class HistoryCleanupManager {

    private static final long CLEANUP_THRESHOLD = 30 * 24 * 60 * 60 * 1000L; // 30天

    public static void clearAll(Context context) {
        ContentValues values = new ContentValues();
        values.put("is_deleted", 1);
        context.getContentResolver().update(
            RecentContactProvider.CONTENT_URI,
            values,
            null,
            null
        );
    }

    public static void clearSingle(Context context, long contactId) {
        ContentValues values = new ContentValues();
        values.put("is_deleted", 1);
        context.getContentResolver().update(
            RecentContactProvider.CONTENT_URI,
            values,
            "contact_id = ?",
            new String[]{String.valueOf(contactId)}
        );
    }

    public static void autoCleanup(Context context) {
        // 自动清理超过30天的记录
        long threshold = System.currentTimeMillis() - CLEANUP_THRESHOLD;
        context.getContentResolver().delete(
            RecentContactProvider.CONTENT_URI,
            "last_contact_time < ?",
            new String[]{String.valueOf(threshold)}
        );
    }
}

注意,我这里用的是「软删除」——把 is_deleted 置为1,而不是真的 DELETE。为什么?因为用户可能误操作,想恢复。软删除给了你后悔的机会。我一般在设置里加一个「最近删除」的入口,保留7天内的已删除记录。

避坑指南:清除历史记录的时候,别忘了同步清除通话记录表里的对应数据。我曾经漏掉这一步,结果用户清除了最近联系人,但通话记录里还显示着,产品经理当场就炸了。如果你用的是 ContentProvider,记得在 delete 方法里同时操作两张表。

还有一个容易被忽略的点:清除历史记录后,要刷新 UI。我建议用 LiveData 或者 Flow 来观察数据库变化,这样清除操作完成后,UI 自动更新,不需要手动调用 notifyDataSetChanged。

整体架构图

下面这张图展示了最近联系人模块的整体架构,从数据源到 UI 展示的完整链路:

最近联系人模块架构图 通话记录系统 CallLogProvider 联系人数据库 ContactsProvider 用户操作事件 点击/拨号/挂断 CallLogHelper 同步引擎 批量处理 / 去重 / 聚合统计 recent_contacts 表 ContentProvider + SQLite 时间线展示 / 分组列表 / 清除操作 RecyclerView + LiveData/Flow 观察 数据源 同步 存储 展示

从这张图可以看出来,整个模块的核心就是中间的同步引擎。它负责把通话记录、联系人数据和用户操作事件整合起来,写入最近联系人表,然后 UI 层通过观察数据库变化来更新展示。

最后说一句:历史记录这个功能,看似简单,但做好了能让用户体验提升一大截。我见过太多 App 要么不做历史记录,要么做得特别粗糙。你按照这一章的方法去做,至少能超过市面上80%的通讯录App。

我的建议:如果你时间充裕,可以再加一个「常用联系人」功能。逻辑跟最近联系人差不多,只是排序维度改成联系频率。两个功能配合使用,用户粘性会明显提升。我在上一个项目里就是这么做的,日活提升了12%。

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