12、联系人排序:按姓名排序(DisplayName)、按最近联系时间排序、自定义排序规则、Locale-aware排序

排序这件事,看起来简单,做起来全是坑。

我记得刚做联系人模块那会儿,产品经理过来说:「帮我把联系人按姓名排个序,很简单的。」我当时心想,不就是个 Collections.sort() 嘛,三分钟搞定。结果呢?搞了整整两天。为什么?因为「按姓名排序」这四个字,在中文语境下,远比你想的复杂。

今天我们就来彻底聊透联系人排序。我会把我在项目中踩过的坑、总结的经验,全都摊开来讲。

12.1 按 DisplayName 排序:最基础也最坑

先看最直观的做法。假设我们从 ContentProvider 拿到了联系人列表,每个联系人有一个 displayName 字段。直接排序的代码长这样:

List<Contact> contacts = getContactsFromProvider();
Collections.sort(contacts, new Comparator<Contact>() {
    @Override
    public int compare(Contact c1, Contact c2) {
        return c1.getDisplayName().compareTo(c2.getDisplayName());
    }
});

这段代码在英文环境下跑得挺好。但放到中文环境,问题就来了。

⚠️ 注意: String.compareTo() 使用的是 Unicode 码点排序。对于中文,它按的是汉字在 Unicode 表中的编码顺序,而不是拼音顺序。比如「张」的 Unicode 是 5F20,「李」是 674E,所以「张」会排在「李」前面——这跟拼音顺序正好相反。

我曾经在一个海外版的项目里用了这个方案,测试同学拿中文联系人一测,直接提了个 P0 级别的 bug。嗯,从那以后我再也不敢直接用 compareTo 排中文了。

12.2 按最近联系时间排序:用数据说话

按时间排序相对简单,但有个细节容易被忽略。

Android 的联系人数据库里,ContactsContract.Data 表中有一个 LAST_TIME_CONTACTED 字段。如果用户曾经联系过某个联系人,这个字段会记录最后一次联系的时间戳(毫秒)。

排序代码:

Collections.sort(contacts, new Comparator<Contact>() {
    @Override
    public int compare(Contact c1, Contact c2) {
        // 注意:时间戳越大表示越近,所以用 c2 - c1 实现降序
        return Long.compare(c2.getLastContactedTime(), c1.getLastContactedTime());
    }
});

这里有个坑:如果某个联系人从未被联系过,LAST_TIME_CONTACTED 的值是 0。直接排序的话,这些「零值」联系人会全部排在前面(因为 0 比任何正数都小,但我们是降序排列,0 反而成了最大值)。

我个人习惯的做法是:把从未联系过的联系人放到列表末尾,按字母顺序排列。这样用户看到的是「最近联系的人」在最上面,而不是一堆没打过电话的陌生人。

Collections.sort(contacts, new Comparator<Contact>() {
    @Override
    public int compare(Contact c1, Contact c2) {
        long t1 = c1.getLastContactedTime();
        long t2 = c2.getLastContactedTime();
        
        // 两个都有联系时间,按时间降序
        if (t1 > 0 && t2 > 0) {
            return Long.compare(t2, t1);
        }
        // 只有一个有联系时间,有时间的排前面
        if (t1 > 0) return -1;
        if (t2 > 0) return 1;
        // 两个都没有联系时间,按姓名升序
        return c1.getDisplayName().compareTo(c2.getDisplayName());
    }
});

12.3 自定义排序规则:灵活才是王道

实际项目中,排序规则往往不是单一的。用户可能希望:

  • 置顶联系人永远在最前面
  • 有头像的联系人排在无头像的前面
  • 公司联系人排在个人联系人的前面

这时候就需要一个「多级排序器」。我一般会这样设计:

public class ContactComparator implements Comparator<Contact> {
    
    private List<Comparator<Contact>> comparators = new ArrayList<>();
    
    public ContactComparator addComparator(Comparator<Contact> c) {
        comparators.add(c);
        return this;  // 支持链式调用
    }
    
    @Override
    public int compare(Contact c1, Contact c2) {
        for (Comparator<Contact> comparator : comparators) {
            int result = comparator.compare(c1, c2);
            if (result != 0) {
                return result;
            }
        }
        return 0;
    }
}

// 使用示例
ContactComparator comparator = new ContactComparator()
    .addComparator(new StarredComparator())      // 置顶优先
    .addComparator(new HasPhotoComparator())      // 有头像优先
    .addComparator(new NameComparator(locale));   // 最后按姓名排序

Collections.sort(contacts, comparator);

你想想看,这种设计的好处是什么?每个比较器只负责一件事,职责单一。想调整排序规则的时候,改一个比较器就行,不会影响其他逻辑。

12.4 Locale-aware 排序:中文排序的终极方案

终于到了最核心的部分。Locale-aware 排序,说白了就是「让排序规则跟着语言走」。中文按拼音排,日文按五十音图排,阿拉伯文按字母排——这才是用户期望的。

Android 提供了 java.text.Collator 类来实现这个功能。它是专门为不同语言环境设计的字符串比较器。

public class LocaleAwareNameComparator implements Comparator<Contact> {
    
    private final Collator collator;
    
    public LocaleAwareNameComparator(Locale locale) {
        // 创建一个针对特定语言环境的 Collator
        this.collator = Collator.getInstance(locale);
        // 设置强度:忽略大小写和变音符号
        this.collator.setStrength(Collator.SECONDARY);
    }
    
    @Override
    public int compare(Contact c1, Contact c2) {
        String name1 = c1.getDisplayName();
        String name2 = c2.getDisplayName();
        
        // 处理空值
        if (name1 == null && name2 == null) return 0;
        if (name1 == null) return -1;
        if (name2 == null) return 1;
        
        return collator.compare(name1, name2);
    }
}

// 使用示例
Locale locale = Locale.CHINA;  // 或者从系统设置获取
Comparator<Contact> comparator = new LocaleAwareNameComparator(locale);
Collections.sort(contacts, comparator);

💡 关键点:

  • Collator.getInstance(Locale.CHINA) 会按拼音排序中文
  • Collator.getInstance(Locale.JAPAN) 会按五十音图排序日文
  • Collator.getInstance(Locale.US) 会按英文字母排序

这里有个细节:Collator 的强度设置。我一般用 SECONDARY,因为它会忽略大小写差异,但会区分变音符号(比如 é 和 e)。如果你希望连变音符号也忽略,就用 PRIMARY

💡 避坑指南:

我曾经遇到过一个性能问题:当联系人数量超过 5000 时,每次排序都卡顿明显。后来发现是 Collator 对象创建得太频繁了。解决方案很简单——把 Collator 实例缓存起来,不要每次排序都 new 一个。

12.5 知识体系总览

下面这张图总结了联系人排序的核心逻辑和决策路径:

联系人排序核心逻辑 联系人排序入口 选择排序类型 按姓名排序 DisplayName 排序 按联系时间排序 LAST_TIME_CONTACTED 自定义排序 多级比较器组合 Locale-aware 排序处理 排序完成

12.6 实战建议

最后,给你几个我在项目中沉淀下来的建议:

  • 优先使用 Collator:不要自己写拼音转换库,Android 系统自带的 Collator 已经处理好了绝大多数语言的排序规则。自己写的话,多音字、生僻字都是坑。
  • 排序放在子线程:联系人列表动辄上千条,如果在主线程排序,用户会明显感觉到卡顿。用 AsyncTask 或者 Coroutine 异步处理。
  • 缓存排序结果:如果联系人数据没有变化,不要重复排序。用一个 isDirty 标志位来控制是否需要重新排序。
  • 考虑性能边界:当联系人超过 10000 时,Collections.sort() 的耗时可能超过 100ms。这时候可以考虑用 Arrays.sort() 或者并行排序。

📌 一句话总结:

联系人排序的核心不是「怎么排」,而是「用什么规则排」。Locale-aware 排序 + 多级比较器组合,基本能覆盖 99% 的业务场景。


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