在构建业务分布热图时,我们往往拥有一批带经纬度坐标的记录,比如门店流水、用户下单位置或传感器上报点。要快速看出哪里密集、哪里稀疏,最实用的做法是在后端把这些点按地理网格归并,再输出每个网格的计数。Java 8之后的Stream API提供了Collectors.groupingBy,它本质上是一个Map生产者,能够依据分类函数把元素分到不同桶里,非常适合做这种坐标聚合。

一、为什么不能直接用原始经纬度做分组键
如果拿每条记录的经度、纬度原值作为groupingBy的分类键,由于浮点坐标几乎不可能完全相等,结果会是几万条数据分出几万个组,每个组只有一条,完全失去聚合意义。热图需要的是一定空间粒度下的密度,例如把同一城市街区或同一公里方格内的点算在一起。
因此要先定义网格化规则。常见方案是保留小数点后两位(约一公里精度),或者将经纬度乘以倍数后取整,形成网格编号。这样相近坐标会落入同一个键,groupingBy才能真正起到统计作用。
二、基础用法:按四舍五入网格分组计数
下面示例把业务点按两位小数的经纬网格分组,并统计每个网格的出现次数。这里分类函数返回一个由纬度和经度拼接的字符串键,下游用Collectors.counting()做计数。
import java.util.*;
import java.util.stream.*;
class BizPoint {
double lng;
double lat;
BizPoint(double lng, double lat) {
this.lng = lng;
this.lat = lat;
}
}
public class HeatmapDemo {
public static void main(String[] args) {
List<BizPoint> points = Arrays.asList(
new BizPoint(116.40, 39.90),
new BizPoint(116.41, 39.91),
new BizPoint(116.40, 39.90),
new BizPoint(121.47, 31.23)
);
Map<String, Long> gridCount = points.stream()
.collect(Collectors.groupingBy(
p -> String.format("%.2f,%.2f", p.lat, p.lng),
Collectors.counting()
));
gridCount.forEach((k, v) -> System.out.println(k + " => " + v));
}
}
运行后会输出类似“39.90,116.40 => 2”的键值对,表示对应网格有两条业务记录。这种写法简单直观,适合数据量不大、精度要求不高的后台报表。
它的优势是纯内存操作,不依赖数据库空间插件;缺点是字符串键占用内存稍多,且如果后续要画热力图,前端还需自行解析经纬度。可以在分类函数中直接返回自定义Grid对象来优化。
三、使用自定义网格对象提升可读性
为了避免字符串拼接与解析,可以定义一个不可变的Grid类,重写equals和hashCode,让groupingBy用它做键。这样每个网格自带经纬度范围,方便直接序列化给图表组件。
import java.util.*;
import java.util.stream.*;
class Grid {
int latGrid;
int lngGrid;
Grid(double lat, double lng) {
this.latGrid = (int) Math.floor(lat * 100);
this.lngGrid = (int) Math.floor(lng * 100);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Grid)) return false;
Grid g = (Grid) o;
return latGrid == g.latGrid && lngGrid == g.lngGrid;
}
@Override
public int hashCode() {
return Objects.hash(latGrid, lngGrid);
}
}
public class GridDemo {
public static void main(String[] args) {
List<BizPoint> pts = Arrays.asList(
new BizPoint(116.402, 39.905),
new BizPoint(116.399, 39.908)
);
Map<Grid, Long> m = pts.stream()
.collect(Collectors.groupingBy(
p -> new Grid(p.lat, p.lng),
Collectors.counting()
));
m.forEach((g, c) -> System.out.println(g.latGrid + "," + g.lngGrid + " 数量:" + c));
}
}
通过floor乘以100,我们把坐标离散到百分度网格,同一网格的对象会被判等归并。相比字符串键,Grid对象语义更清晰,也更容易扩展出网格中心点计算等方法。
要注意equals和hashCode必须基于网格字段而非原始坐标,否则分组会失效。若网格精度要动态调整,可把倍数提取为常量传入构造函数。
四、结合下游收集器输出热力所需结构
热图前端通常期望收到[{lng,lat,count}]这样的数组。我们可以在groupingBy之后用Collectors.collectingAndThen做转换,或者在分类阶段直接收集成DTO。
import java.util.*;
import java.util.stream.*;
class HeatCell {
double lng;
double lat;
long count;
HeatCell(double lng, double lat, long count) {
this.lng = lng;
this.lat = lat;
this.count = count;
}
}
public class HeatExport {
public static void main(String[] args) {
List<BizPoint> data = Arrays.asList(
new BizPoint(116.40, 39.90),
new BizPoint(116.40, 39.90)
);
List<HeatCell> cells = data.stream()
.collect(Collectors.groupingBy(
p -> new Grid(p.lat, p.lng),
Collectors.counting()
))
.entrySet().stream()
.map(e -> new HeatCell(
e.getKey().lngGrid / 100.0,
e.getKey().latGrid / 100.0,
e.getValue()
))
.collect(Collectors.toList());
cells.forEach(c -> System.out.println(c.lng + "," + c.lat + "," + c.count));
}
}
这段代码先按Grid分组计数,再把每个Entry映射为HeatCell,经度纬度用网格编号除回原比例作为代表点。输出列表可直接转JSON给ECharts热力图层。
这种方式的缺点是两次流操作略有开销,但在万级数据下可以忽略。如果数据量达到百万,建议改用并行流parallelStream,并注意Grid对象创建开销。
五、常见误区与性能注意
一个典型错误是拿BigDecimal或未规整的double直接做键,导致精度误差让本应同格的点被分开。另一个误区是在groupingBy里使用有副作用的分类函数,比如修改外部计数器,这会破坏Stream的无状态假设。
当业务点来自数据库时,也可以考虑在SQL里用floor(lng*100)先group by,减少内存压力。但若已加载到应用做复杂加工,Collectors.groupingBy仍是清晰且易维护的方案。合理选择网格大小,才能让热图既不太碎也不失真。
Collectors.groupingBy地理坐标业务热图修改时间:2026-08-08 03:42:31