map、filter、flatMap、groupBy 这几个操作符,语法简单到不需要讲——真正值钱的是什么时候不该用。面试里问"集合操作符链怎么用",答"链式调用很优雅"是零分;答"每步都产生中间集合,十万级数据上要用 Sequence 或者优化链路"才是及格线。这篇讲的就是这条工程判断线。
先把结论放在前面:集合操作符每次调用都产生一个新集合(map/filter/take/sorted 都如此,只有 forEach/count/sum 等终端操作不产生)。所以一条 list.map{}.filter{}.sorted{}.take(10) 在十万级列表上会分配四个中间集合、遍历三遍数据。优化有三条路:①把 filter 提到 map 之前(先减少数据量);②用 asSequence() 惰性化;③把链路"削平"成一次遍历(用 fold/for 手动写)。这三条要能说出什么时候选哪条。
机制拆解
先看每步的成本。一次 map 的实现是:申请一个同规模的新集合 → 遍历源集合 → 对每个元素应用转换 → 加入新集合。所以 list.map { it.a }.map { it.b } 的成本是"两次遍历 + 两个新集合",而 list.map { it.b }(在 lambda 里直接取 b)只走一次。这两者在写法上只差几个字符,在十万级数据上差距明显。
filter 尤其要注意:它虽然只保留部分元素,但新集合的容量通常按源集合大小预分配(ArrayList 的构造里用 collectionSizeOrDefault(10)),所以过滤后仍占用接近源规模的数组——filter 不是"省内存"的操作。
sortedBy/sortedWith 是最贵的之一:它必须把所有元素物化到新集合再排序,O(n log n) 且分配完整副本。所以"先排序再 take 前 10 条"要格外小心,正确做法是 minByOrNull/maxByOrNull(O(n) 无排序),或者先 filter 缩小范围再排。
groupBy 返回 Map<K, List<V>>,内部为每个 key 建一个列表——在 key 基数极大(比如按用户 id 分组百万条)时会产生大量小列表,内存开销远超预期。
这些坑的正确绕法
最常见的坑是在十万级列表上链式 map + filter,中间集合反复分配,内存与耗时双高。 表现是滑动卡顿、GC 频繁、Android 上直接 OOM;或者在 Compose 里把大列表做一次链式转换就掉帧。根因是每一步都是 eager 的,中间结果必须完整物化。修法有三条,按优先级:①调整顺序——filter 先于 map,让后续处理的数据量变小;②asSequence()——asSequence().map{}.filter{}.toList() 只遍历一次,中间不物化(内部是状态机,不是集合);③削平链路——能用一次 for 表达的就别用链。
其次是把"先排序再取前 N"当成常规写法,在大列表上浪费 O(n log n)。 表现是列表页加载耗时随数据量线性上升;定位到 sortedBy { }.take(10)。修法分两种:只需要极值就用 minByOrNull/maxBy;需要有序的前 N 条用 sortedWith 但先 filter/take 缩小候选集,或者用 PriorityQueue 维护 top-N(复杂度 O(n log k))。Android 侧一个典型场景是"取最近 5 条记录"——如果只按时间排序,sortedByDescending{}.take(5) 可以换成 sortedByDescending{} 之后只对已排序结果取,但更省的写法是先用时间阈值 filter 再排。
还有一个更隐蔽的坑:在链里写有副作用的逻辑,被误认为只会执行一次。 表现是 list.forEach { Log.d(...) } 里加了个计数器,计数结果与预期不符;或者 map 里做了网络请求,实际调用次数与元素数对不上(因为 map 后接 filter 时,已转换但被过滤掉的元素也已经执行了转换)。根因是"map 的转换对每个元素执行一次"发生在后续过滤之前。修法是:链式操作里只做纯转换,副作用(打日志、写缓存、埋点)放到终端操作 forEach 或链路之后。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
val data: List<Item> = loadItems() // 假设 10 万条
// 1) 反例:顺序不对 + 多余的二次映射
val bad = data.map {
it.price }.filter {
it > 100 }.map {
it * 2 }
// 两次 map + 一次 filter = 3 次遍历、2 个中间集合
// 2) 正解:先过滤,减少后续数据量
val good = data.filter {
it.price > 100 }.map {
it.price * 2 }
// 3) 正解:一次遍历直接出结果
val flat = data.mapNotNull {
item -> // 一步完成 filter + map
if (item.price > 100) item.price * 2 else null
}
// 4) 正解:asSequence 惰性化,只遍历一次,无中间集合
val lazy = data.asSequence()
.filter {
it.price > 100 }
.map {
it.price * 2 }
.sortedByDescending() // 这里才物化
.take(10)
.toList()
// 5) 只要极值就别排序:O(n) 且零分配
val maxPrice = data.maxByOrNull {
it.price } ?: 0
// 6) 副作用要放终端操作
var count = 0
data.asSequence().filter {
it.valid }.forEach {
count++ } // 明确只遍历有效项
// 反例:把副作用写在 map 的转换里,被后续过滤的元素也已经执行了
val risk = data.map {
doSomething(it) }.filter {
it.ok } // doSomething 对每个元素都执行了
// 7) groupBy 的 key 基数要控制
val byUser: Map<Long, List<Item>> = data.groupBy {
it.userId }
// key 基数极大时,改成先聚合或用自定义键,避免海量小列表
这段代码值得盯三处:第一处,第 1、2、3 段把"同样的逻辑、三种写法"并列,直接看出成本差异;第二处,mapNotNull 是链式操作里最好用的一招——一次遍历同时完成过滤与映射;第三处,第 6 段用注释点明"副作用写在 map 里会对被后续过滤掉的元素也执行",这是链式操作最隐蔽的坑。
这题在面试里怎么问、怎么答
"集合操作符链什么时候该用、什么时候该用 for?"给判据:①数据量小(千级以下)且变换是纯的 → 链式,可读性收益远大于成本;②数据量大或变换有 IO → asSequence 或手写 for;③需要 break/continue 提前退出 → 必须用 for(takeWhile 是可选替代);④需要索引 → forEachIndexed 或 for。
"asSequence 有什么代价?"答三点:①多次遍历时每次重新执行(冷流,不是缓存),所以只消费一次最划算;②take/takeWhile 之前的操作是逐元素的,sortedBy 之后的操作会物化整个序列,所以顺序影响很大;③Sequence 没有索引访问(get 只能按序号遍历),按下标访问反而更慢。
"为什么 filter 不省内存?"答:它仍要遍历每个元素才能决定去留,且 ArrayList 构造时按源集合大小预分配容量,所以峰值内存不低于源集合。filter 省的是后续处理的数据量与时间,不是当下的内存。
"使用中遇到过什么问题?"案例一:订单列表 8 万条做 filter+map+sortedBy+take,低端机上加载 3 秒;改为 asSequence 并把时间阈值 filter 前置后降到 300ms。案例二:转换链里做了埋点,统计到的次数与最终列表长度不符;定位到 map 的转换对后续被过滤的元素也执行了;修复为把埋点移到 forEach。
再补一个工程上值得讲清的一点:在 Android 上,集合链的性能问题常常和 UI 刷新绑在一起。 具体表现为:在 RecyclerView.onBindViewHolder 里对全量数据做一次链式转换(哪怕数据量不大),每帧都会重新执行,导致滑动卡顿——链式操作本身不慢,每帧重复执行才是问题。修法是把结果缓存为 val displayList by lazy { ... } 或放到 ViewModel 里算好,onBind 只取单项。Compose 里同理:remember 包裹的派生计算要明确 key,否则每次重组重算。这条把"集合性能"与"UI 性能"连起来,是 Android 岗特有的追问方向。
给正在准备面试的你
把这题画成一张"链式成本示意图":画一条水平的数据流,左边是源集合(10 万条),右边是结果。中间画四个方块 map → filter → sortedBy → take,方块之间用箭头连,每个方块上方标"新集合 + 一次遍历",下方标"容量≈源规模"。再画一条从 asSequence 出发的虚线路径,绕开方块直达 toList,虚线上标"只遍历一次,无中间集合"。图下方并排三行建议:调顺序(filter 前置)、惰性化(asSequence)、削平(mapNotNull / for)。面试时用这张图说明"优雅与成本的取舍",比背 API 有说服力。
再补工程案例与踩坑——应用落点是找出项目里数据量较大的链式调用,逐条判断:能否把 filter 前置、能否改 asSequence、能否用 mapNotNull 合并、把"极值 + take"改成 maxBy/minBy;并把 onBindViewHolder 与 Compose 派生计算里的链式结果缓存到 ViewModel 或 remember 中。
复习时别孤立刷题:Sequence 与惰性求值——本篇的 asSequence 正是下一篇的主题,冷流语义的多消费问题是它的核心坑。
划两句重点:每个操作符都产生新集合,filter 不省内存;先 filter 后 map、asSequence 惰性化、mapNotNull 合并、maxByOrNull 代替"排序取前 N";链里只做纯转换,副作用放终端操作;asSequence 多次消费会重复执行。
下一篇聊 Sequence 与惰性求值:大数据量集合优化——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:集合三大类与只读可变两套体系
下一篇预告:Sequence-与惰性求值:大数据量集合优化
有任何问题欢迎在评论区留言交流。