摘要:一次性在地图上画 3 万个测量点,拖动像看幻灯片,缩放直接卡死几秒。根因不是"某个 API 用错",而是全量遍历 + 全量绘制——每帧都在为 29000 个屏幕外的点做坐标变换。这篇给出一条从粗到精的三级过滤管线(范围裁剪 → 像素叠加 → 几何分析),附实测性能对比,以及数据再上一个数量级时的空间索引兜底方案。
目录(TOC)
- 一、先讲个真事:拖动地图像看幻灯片
- 二、根因:瓶颈不是循环慢,是画得太多
- 三、第一级:屏幕范围裁剪(粗筛)
- 四、第二级:像素叠加(合并重叠)
- 五、第三级:几何叠加分析(精确判定)
- 六、把过滤内建到渲染器
- 七、数据再上一个数量级:空间索引兜底
- 八、像素叠加 vs 聚类抽稀
- 九、结论
一、先讲个真事:拖动地图像看幻灯片
某次项目要在地图上一次性展示 3 万个测量点。第一版代码"很顺利"地写完了——就是一个 for 循环,把所有点画到 Canvas 上。
真机一跑,拖动地图像在看幻灯片:每拖一下要等好久画面才跟上,稍微缩放一下直接卡死几秒。
排障时我盯着 Profile 看,发现每一帧都在做同一件事:把 3 万个点全部做坐标变换、全部调用 drawPoint,哪怕其中 2 万 9 千个点根本不在当前屏幕里。
最直观(也最容易卡)的写法长这样:
fun drawAll(features: List<Feature>, mapView: MapView, canvas: Canvas) {
for (feature in features) { // 数万个点,全遍历
val px = surveyToPixels(feature) // 每个都算屏幕坐标
canvas.drawPoint(px, paint)
}
}
二、根因:瓶颈不是循环慢,是画得太多
反常识点:数据量从几百涨到几万,卡顿不是"某个 API 用错",而是你把所有要素都画了一遍,哪怕大部分根本不在当前屏幕里。
真正的问题有三层:
- 卡顿:每次重绘(拖动 / 缩放)都遍历全量,数万个点全算全画,帧率暴跌;
- 白画:大量点根本不在当前屏幕,画了也看不见,纯浪费;
- 重叠:缩小时,成百上千个点挤在同一个像素上,画出来黑压压一片,毫无信息量。
第三点常被忽略:即使性能扛得住,把 3 万个点全画上去,用户看到的就是一团黑——性能问题和可读性问题,其实是同一个问题的两面。
所以要优化,核心不是"让循环更快",而是减少真正画出来的数量——这就是"过滤"。
先看一组实测数量级对比
3 万点场景的实测参考(具体数值随设备与数据分布浮动):
| 阶段 | 每帧绘制量 | 每帧耗时参考 | 帧率参考 |
|---|---|---|---|
| 全量绘制(不做任何过滤) | 3 万次坐标变换 + 3 万次 draw | ~80ms | ≈ 12 FPS |
| ① 范围裁剪后 | 约 5 千个视野内点 | ~20ms | ≈ 50 FPS |
| ② + 像素叠加后 | 约 500 个"代表点" | ~8ms | 稳定 60 FPS |
| ③ 几何分析仅用于点选 | 只对候选集做 | 单次点击 < 5ms | — |
差距是两个数量级:从"每帧 3 万次绘制"压到"每帧几百次",卡顿从根源上消失。
三、第一级:屏幕范围裁剪(粗筛)
先把"当前屏幕对应的测量范围"算出来(把屏幕四角反算成业务坐标),然后用包围盒判断每个要素是否与它相交,不相交的直接跳过:
// 1. 由屏幕四角反算出当前视野的测量范围
fun currentViewRange(mapView: MapView): SurveyRange {
val bl = mapView.projection.fromPixels(0, mapView.height) // 左下
val tr = mapView.projection.fromPixels(mapView.width, 0) // 右上
return SurveyRange(
minN = latLonToSurvey(bl).n,
maxN = latLonToSurvey(tr).n,
minE = latLonToSurvey(bl).e,
maxE = latLonToSurvey(tr).e
)
}
// 2. 绘制前先做范围粗筛
fun drawVisible(features: List<Feature>, view: SurveyRange, mapView: MapView, canvas: Canvas) {
for (feature in features) {
// 要素的包围盒不在视野内 -> 直接跳过,不做坐标变换
if (!feature.bounds.intersects(view)) continue
drawOne(feature, mapView, canvas)
}
}
为什么包围盒粗筛最划算?
因为它只比对几个数值,不做任何坐标变换和几何运算:
// 包围盒相交判断:四次比较,零分配
fun intersects(a: SurveyRange, b: SurveyRange): Boolean =
a.minN <= b.maxN && a.maxN >= b.minN &&
a.minE <= b.maxE && a.maxE >= b.minE
对比一下三种操作的成本量级:
| 操作 | 成本 | 说明 |
|---|---|---|
| 包围盒相交判断 | 4 次数值比较 | 纳秒级,零内存分配 |
| 仿射坐标换算 | 几次乘加 | 每点一次 |
canvas.drawPoint | 跨 JNI 到 Skia | 最贵,微秒级 |
| 几何叠加分析 | 完整几何求交 | 最贵,禁止全量用 |
过滤的核心原则:越便宜的判断越要靠前,越贵的越要靠后。 用 4 次比较换掉一次 JNI 绘制调用,这笔账怎么算都划算。
在我那个 3 万点项目里,这一步直接就把每帧绘制量从 3 万砍到了 5 千。
四、第二级:像素叠加(合并重叠)
缩小时,很多要素会落到同一屏幕像素上。与其全画,不如按像素网格合并:同一像素只保留一个代表点,其余丢弃。
// 像素叠加:把落在同一像素内的多个点合并成一个
fun drawMerged(features: List<Feature>, view: SurveyRange, mapView: MapView, canvas: Canvas) {
val drawn = HashMap<Long, Int>() // 已画过的像素 -> 叠加计数
for (feature in features) {
if (!feature.bounds.intersects(view)) continue
val px = surveyToPixels(feature) // 批量仿射换算
val key = pixelKey(px.x, px.y) // 用 Long 做 key,避免装箱
val count = drawn[key]
if (count != null) {
drawn[key] = count + 1 // 已画过:只累加计数
continue
}
drawn[key] = 1
canvas.drawPoint(px, paint)
}
// 可选:对每个已画像素标注"叠了几个点"
}
一个实打实的实现优化:用 Long 做 Key
上面的 pixelKey 值得展开说。最直观的写法是用 HashMap<PointF, Int>,但 PointF 是对象,每个点都要 new 一个出来做 key——3 万个点就是 3 万次对象分配,GC 压力直接上来了,反而拖慢。
更好的做法是把 (x, y) 两个 int 打包进一个 Long:
// 把 (x, y) 打包成一个 Long:高 32 位放 x,低 32 位放 y
fun pixelKey(x: Float, y: Float): Long =
(x.toInt().toLong() shl 32) or (y.toInt().toLong() and 0xFFFFFFFFL)
这样 key 是原始类型,零对象分配。配合 LongSparseArray(Android 提供的 primitive key 容器,比 HashMap<Long, Int> 更省内存)效果更好。
这类细节在"每帧执行几千次"的代码路径上,收益非常明显——同样是"做过滤",实现方式的差别能有几十毫秒。
效果
以像素为 Key 去重,落在同一像素的多个点只画一个(或显示叠加数量)。缩小时,屏幕要素总量被压缩到"像素数"级别:5 千点 → 约 500 个代表点,每帧 8ms 上下,再砍一个数量级。
五、第三级:几何叠加分析(精确判定)
当需要"精确判断要素是否被某区域选中 / 命中"时(点选、框选),范围裁剪和像素合并不够精确,需要几何叠加分析——用几何库做精确的相交判断:
// 几何叠加分析:用几何库精确判断要素是否与选择区域相交
fun isSelected(feature: Feature, selectRegion: Geometry): Boolean {
val featureGeom = feature.toGeometry() // 要素 -> 几何对象
return selectRegion.intersects(featureGeom) // 精确叠加分析
}
关键纪律:几何叠加分析开销远大于范围判断,所以它绝不能用来遍历全量。
正确顺序是:
flowchart LR
ALL["全量要素<br/>3 万"] --> L1["① 范围裁剪<br/>包围盒粗筛"]
L1 -->|"剩 5 千"| L2["② 像素叠加<br/>去重合并"]
L2 -->|"剩 500"| DRAW["绘制"]
L1 -->|"候选集"| L3["③ 几何叠加分析<br/>仅点选时"]
L3 --> HIT["精确命中判定"]
style L1 fill:#e6f7ff,stroke:#1890ff,stroke-width:2px
style L2 fill:#f6ffed,stroke:#52c41a,stroke-width:2px
style L3 fill:#fff7e6,stroke:#fa8c16,stroke-width:2px
先用范围裁剪(第 1 级)粗筛到"候选集",再对候选集做几何叠加分析(第 3 级)精确判定。三级是从粗到精、层层收窄,昂贵的操作只作用于少数候选。
六、把过滤内建到渲染器
如果重写,我不会在业务代码里手动做这三步,而是把过滤内建到自定义总绘图层里,作为渲染器的固定策略:
class GisOverlay(private val provider: DataProvider) : Overlay() {
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow) return
val view = currentViewRange(mapView) // ① 算视野范围
val drawn = LongSparseArray<Int>()
for (feature in provider.queryByRange(view)) { // ① 范围裁剪(可走索引)
val px = surveyToPixels(feature) // 仿射换算
val key = pixelKey(px.x, px.y)
if (drawn[key] != null) { // ② 像素叠加
drawn.put(key, drawn[key] + 1)
continue
}
drawn.put(key, 1)
drawOne(feature, px, canvas)
}
}
}
这样,任何业务数据(点库、矢量、CAD)接入后,绘制过滤自动生效,业务代码不需要关心性能。
核心思想是"只画看得见的、画得下的"——这比任何局部调优都有效。
另外还有两个可配置点值得做:
- 把"像素叠加"做成开关:有些业务要求"每个点都看到",那就按业务开关决定是"合并计数"还是"全画",而不是一刀切;
- 计数可视化:叠加数可以用颜色深浅 / 点大小表达,一张图同时表达"分布密度"和"位置",信息量反而比全画更高。
七、数据再上一个数量级:空间索引兜底
当数据量上到几十万、几百万时,单靠"遍历一遍 + 包围盒判断"的线性过滤也不够快了——哪怕每次判断只要 4 次比较,几百万次累积起来也有几十毫秒。
这时要给要素建空间索引,把"范围查询"从 O(N) 降到 O(logN):
| 方案 | 查询复杂度 | 适用场景 |
|---|---|---|
| 线性遍历 + 包围盒 | O(N) | 几万级,最简单够用 |
| 网格索引(Grid) | O(k),k 为命中网格内要素数 | 分布较均匀,实现简单 |
| R-Tree | O(logN) | 几十万级以上,分布不均 |
工程上的选择顺序:先上最简单的线性过滤(几万点完全够用),等真的碰到瓶颈再上索引。过早引入 R-Tree 会增加数据更新的复杂度(插入 / 删除要维护树结构),而绝大多数项目根本到不了那个量级。
判断标准很简单:当"过滤本身"在 Profile 里显形了,就是上索引的时候。
八、像素叠加 vs 聚类抽稀
评论区常有人问:合并重叠点,用"像素叠加"还是"聚类抽稀"?两者解决的其实是不同层次的问题:
| 维度 | 像素叠加 | 聚类抽稀(如网格聚类、K-Means) |
|---|---|---|
| 原理 | 按屏幕像素网格去重 | 按地理距离把邻近点聚成簇 |
| 依赖屏幕状态 | 是(依赖当前缩放) | 否(基于地理坐标) |
| 每帧成本 | 极低(一次哈希查表) | 较高(要算距离、迭代) |
| 结果稳定性 | 缩放变化时效��不同 | 稳定,缩放不影响分簇 |
| 适合场景 | 实时绘制的性能兜底 | 需要"语义聚合"的分析展示 |
我的选择:绘制路径上用像素叠加(它便宜且与屏幕状态天然对齐),需要"显示这一片有 300 个点"这类语义聚合时,再上聚类——而且聚类结果应该缓存,不要每帧重算。
简单说:像素叠加管"画得动",聚类管"看得懂"。
九、结论
- 海量要素卡顿的根因是全量遍历 + 全量绘制,不是某个 API 慢;而且"画得动"和"看得懂"是同一个问题的两面——3 万个点全画上去,用户看到的就是一团黑。
- 第一级范围裁剪:算当前视野测量范围,用包围盒粗筛(4 次数值比较,零分配),快速扔掉视野外要素。
- 第二级像素叠加:以屏幕像素为 Key 去重,同一像素只画一个(或显示叠加数)。用 Long 打包 (x,y) 做 key,避免每点一个对象分配。
- 第三级几何叠加分析:用几何库精确判定,只对候选集做,绝不遍历全量。
- 过滤的核心原则:越便宜的判断越靠前,越贵的越靠后。
- 三级从粗到精层层收窄,把过滤内建到渲染器,业务代码无需关心性能。
- 数量级参考:全量 3 万点约 12 FPS → 三级过滤后稳定 60 FPS,绘制量从"万级"压到"百级"。
- 数据上到几十万级再上空间索引(R-Tree / 网格),不要过早优化。
完整可运行源码:GitCode 仓库 · android_osmdroid_maplibre
对照阅读:同一套三级过滤管线在 MapLibre 上的实现,见《MapLibre 海量点渲染:三级过滤管线是怎么省下一个数量级的》——MapLibre 的 GeoJSON Source 自带一些过滤能力,对照看能分清"哪些该自己做、哪些可以交给引擎"。
延伸阅读:本系列第 04 篇(矢量数据与坐标变换)· 第 03 篇(分层世界:总绘图层)
评论区聊聊:你一次性画过多少个点?卡到几帧?"像素叠加"和"聚类抽稀",你更倾向哪个?
本系列为 osmdroid / MapLibre 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。