15、Collections KTX:Kotlin集合框架的KTX扩展,如associateBy、groupBy

说实话,Kotlin的集合操作是我每天写代码时最离不开的部分。尤其是 associateBygroupBy 这两个扩展函数,它们就像瑞士军刀里的两把主力刀片——一个帮你快速建立索引,一个帮你做数据分组。今天我就带你彻底搞懂它们。

为什么需要这两个扩展?

先问个问题:你手头有一个用户列表,想通过用户ID快速找到某个用户,你会怎么做?

最原始的做法是遍历列表,一个个比对。但这样每次查找都是O(n)复杂度,数据量一上来就卡了。更好的做法是把列表转成Map,用ID做key,用户对象做value。这样查找就变成了O(1)。

嗯,associateBy 就是干这个的。而 groupBy 呢?它解决的是「一对多」的问题——比如按年龄分组,同一个年龄的人放到一个列表里。

核心区别一句话:

  • associateBy:每个key对应一个元素(key必须唯一)
  • groupBy:每个key对应一个元素列表(key可以重复)

associateBy:建立一对一索引

我在项目中经常用 associateBy 来缓存数据。比如从服务器拉取了一堆商品信息,我需要根据商品ID快速更新某个商品的状态。

data class Product(
    val id: Long,
    val name: String,
    val price: Double
)

val products = listOf(
    Product(1, "手机", 2999.0),
    Product(2, "耳机", 199.0),
    Product(3, "充电器", 49.0)
)

// 用ID做key,建立索引
val productMap: Map<Long, Product> = products.associateBy { it.id }

// 现在查找就很快了
val target = productMap[2]  // 直接拿到耳机
println(target?.name)       // 输出:耳机

这里有个坑,我曾经踩过:如果列表里有重复的key,associateBy 会保留最后一个出现的元素。前面的会被覆盖掉。

我曾经遇到过的问题:

有一次从数据库查数据,ID字段理论上唯一,但有个bug导致出现了重复ID。用 associateBy 后,数据莫名其妙少了。排查了半天才发现是数据源的问题。所以建议:如果你不确定key是否唯一,先用 distinctBy 去重,或者改用 groupBy 再取第一个。

associateBy 的变体

除了基本用法,还有几个变体也很实用:

// 只保留value的某个字段
val nameMap: Map<Long, String> = products.associateBy(
    keySelector = { it.id },
    valueTransform = { it.name }
)
// 结果:{1=手机, 2=耳机, 3=充电器}

// 如果key可能为null,用associateByNotNull
val nullableMap = products.associateBy { it.name.takeIf { it.length > 1 } }
// 这个会过滤掉key为null的条目

我个人习惯用 associateBy 配合 valueTransform 来提取数据。比如从用户列表里提取「用户ID -> 用户昵称」的映射,这样既保留了索引,又减少了内存占用。

groupBy:一对多分组

说白了,groupBy 就是按某个条件把数据分到不同的桶里。每个桶是一个列表,桶的标签就是key。

data class Student(
    val name: String,
    val grade: Int,  // 年级
    val score: Int
)

val students = listOf(
    Student("张三", 1, 85),
    Student("李四", 1, 92),
    Student("王五", 2, 78),
    Student("赵六", 2, 88),
    Student("钱七", 3, 95)
)

// 按年级分组
val byGrade: Map<Int, List<Student>> = students.groupBy { it.grade }

// 遍历每个年级的学生
byGrade.forEach { (grade, list) ->
    println("$grade 年级有 ${list.size} 个学生")
}

你想想看,这个功能在报表统计里有多好用。我做过一个成绩分析系统,用 groupBy 按班级分组,然后对每个组计算平均分、最高分、最低分,代码写起来非常清爽。

groupBy 的进阶用法

// 分组后只保留需要的字段
val nameByGrade: Map<Int, List<String>> = students.groupBy(
    keySelector = { it.grade },
    valueTransform = { it.name }
)
// 结果:{1=[张三, 李四], 2=[王五, 赵六], 3=[钱七]}

// 分组后对每组做聚合
val scoreStats: Map<Int, Int> = students.groupBy(
    keySelector = { it.grade },
    valueTransform = { it.score }
).mapValues { (_, scores) ->
    scores.average().toInt()
}
// 结果:{1=88, 2=83, 3=95}

小技巧:

如果你需要分组后对每组做复杂计算,可以结合 groupingByfoldaggregate。比如计算每个年级的总分和人数:

val result = students.groupingBy { it.grade }
    .fold(0 to 0) { acc, student ->
        (acc.first + student.score) to (acc.second + 1)
    }
// 结果:{1=(177, 2), 2=(166, 2), 3=(95, 1)}

associateBy vs groupBy:如何选择?

场景 用哪个 原因
通过ID查找单个对象 associateBy key唯一,直接映射
按类别统计数量 groupBy 一个类别可能有多个元素
提取字段映射 associateBy + valueTransform 一对一映射,简洁高效
数据报表/聚合 groupBy + mapValues 分组后做统计计算
key可能重复但想保留所有 groupBy 不会丢失数据
key可能重复但只取一个 associateBy(保留最后一个) 或者用 distinctBy 预处理

实战:一个完整的例子

我记得有一次做订单管理系统,需要把订单按状态分组,同时还要能通过订单号快速查找。这两个扩展正好派上用场:

data class Order(
    val orderId: String,
    val userId: Long,
    val status: String,  // "pending", "paid", "shipped", "completed"
    val amount: Double
)

val orders = listOf(
    Order("A001", 1, "paid", 299.0),
    Order("A002", 2, "pending", 59.0),
    Order("A003", 1, "paid", 199.0),
    Order("A004", 3, "shipped", 899.0),
    Order("A005", 2, "completed", 129.0)
)

// 1. 按状态分组,方便统计
val ordersByStatus: Map<String, List<Order>> = orders.groupBy { it.status }
println("待支付订单数:${ordersByStatus["pending"]?.size ?: 0}")

// 2. 建立订单号索引,方便快速查找
val orderIndex: Map<String, Order> = orders.associateBy { it.orderId }
val targetOrder = orderIndex["A003"]
println("订单A003的金额:${targetOrder?.amount}")

// 3. 按用户分组,查看每个用户的订单
val ordersByUser: Map<Long, List<Order>> = orders.groupBy { it.userId }
ordersByUser.forEach { (userId, userOrders) ->
    val total = userOrders.sumOf { it.amount }
    println("用户$userId 总消费:$total")
}

你看,三个需求,三行核心代码就搞定了。要是用Java写,至少得写十几个循环和临时变量。

性能与注意事项

虽然这两个扩展用起来很爽,但有些细节还是要注意:

  • 内存占用associateBygroupBy 都会创建新的Map对象。如果列表很大(比如几十万条),会占用不少内存。我建议只在需要频繁查找的场景下使用,用完就释放引用。
  • 懒加载:这两个函数都是立即执行的(eager),不是懒加载。如果你只需要部分数据,考虑用 sequence 配合 associateBygroupBy
  • null keyassociateBygroupBy 都允许key为null。但Map里只能有一个null key。如果你不确定key是否可能为null,可以用 associateByNotNullgroupBy 后手动处理。

我的建议:

在Android开发中,如果你需要频繁从列表里查找数据,比如在Adapter的 bind 方法里,强烈建议在列表数据更新时,提前用 associateBy 建立索引。这样每次bind时直接查Map,比遍历列表快得多。我优化过一个列表卡顿问题,就是靠这个思路把滑动帧率从40fps提到了60fps。

知识体系总览

下面这张图帮你理清这两个扩展的关系和适用场景:

Collections KTX 核心扩展 List<T> 原始数据集合 associateBy 一对一映射 groupBy 一对多分组 Map<K, T> key → 单个元素 Map<K, List<T>> key → 元素列表 典型应用场景 associateBy:通过ID快速查找 缓存、索引、去重 groupBy:按类别统计 报表、分组展示、聚合 组合使用:索引+分组 复杂业务逻辑 选择依据:key是否唯一 → 唯一用associateBy,不唯一用groupBy

说白了,这两个扩展就是让你用声明式的方式处理集合,而不是写一堆for循环。我个人觉得,掌握它们之后,代码的可读性和维护性都上了一个台阶。你想想看,一行代码能搞定的事情,为什么要写十行呢?

最后一个小建议:

刚开始用的时候,可能会搞混 associateBygroupBy 的返回值类型。记住一个口诀:「associateBy 返回 Map<K, T>,groupBy 返回 Map<K, List<T>>」。多写几次就自然记住了。


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