摘要:把 5 万个监测点一股脑塞进 GeoJSON 丢给地图,真机上拖动掉帧、缩放转圈。核心思想一句话——渲染目标不是"所有真实点",而是"屏幕上看得见的代表性点"。这篇讲三级过滤管线(范围裁剪 → 像素叠加 → 聚合分析),并复盘三个真实踩坑:只在初始化过滤、过滤堵在 UI 线程、归一化格数写死导致跨机型翻车。
目录(TOC)
- 一、换到旗舰机也只是"没那么卡"
- 二、设计总览:三级过滤管线
- 三、第①级:范围裁剪
- 四、第②级:像素叠加(含归一化 Key)
- 五、第③级:几何 / 聚合分析
- 六、与 GPU 渲染配合
- 七、三个真实踩坑复盘
- 八、与 osmdroid 的异同
- 九、如果今天重写:五件事
- 十、坑对照表与落地清单
一、换到旗舰机也只是"没那么卡"
打开一个监测平台 App,地图上要叠加几万个监测散点。你下意识地把所有点塞进 GeoJSON 一把梭丢给地图,然后……真机上开始卡:拖动地图掉帧、缩放转圈、内存看着往上飙。
你第一反应是"换台手机试试",但换到旗舰机也只是把"卡"变成"没那么卡"。
问题不在手机,在渲染策略。
移动端常见需求:在地图上叠加数万乃至数十万个散点(监测点、轨迹采样、网格采样等)。如果一次性把全部点塞进数据源交给地图渲染,会同时撞上两个瓶颈:
- 数据量瓶颈:GeoJSON 体积大、解析与上传 GPU 慢,内存占用高;
- 绘制量瓶颈:大量点密集重叠在同一区域,屏幕就那么大,画 5 万个点和画 2000 个点在视觉上没区别,纯属浪费 GPU 填充率。
DEMO 实测对比:5 万点全量渲染 vs 三级过滤后,屏幕上真正画出来的可能只有几千个。
视觉等价,开销差一个数量级。
二、设计总览:三级过滤管线
把"要不要画这个点"拆成三道关卡,逐层砍掉不需要的点:
flowchart TB
ALL["原始点集<br/>5 万"]
ALL --> L1{"① 范围裁剪<br/>在视野包围盒内?"}
L1 -->|"否"| DROP1["丢弃<br/>(约 -80%)"]
L1 -->|"是"| L2{"② 像素叠加<br/>同像素已有代表点?"}
L2 -->|"是"| DROP2["丢弃<br/>(再 -90%)"]
L2 -->|"否"| L3["③ 几何/聚合分析<br/>候选集最终判定"]
L3 --> SRC["GeoJsonSource"]
SRC --> GPU["CircleLayer<br/>GPU 绘制"]
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
style GPU fill:#f9f0ff,stroke:#722ed1,stroke-width:2px
核心理念:
渲染目标不是"所有真实点",而是"屏幕上看得见的代表性点"。
三、第①级:范围裁剪
地图 SDK 提供"当前可见区域"的经纬度包围盒(南 / 北 / 西 / 东四至)。任何落在该盒子外的点,用户根本看不见,直接跳过:
for ((lat, lon) in points) {
if (lat > bounds.north || lat < bounds.south ||
lon > bounds.east || lon < bounds.west) {
continue // 视野外,丢弃
}
// 进入下一级
}
这一步把"全球点集"砍到"当前屏幕点集",通常已经削减 80%+。
它的成本是 4 次数值比较,不做任何投影和几何运算——最便宜的判断放在最前面,这是整条管线的设计原则。
四、第②级:像素叠加(含归一化 Key)
裁剪后剩下的点里,很多在屏幕上几乎落在同一像素。把经纬度投影成屏幕坐标,用 (像素x, 像素y) 作为 Key 做去重:同一像素只保留遇到的第一个点。
val merged = HashMap<Pair<Int, Int>, Pair<Double, Double>>()
for ((lat, lon) in inViewPoints) {
val px = projection.toScreenLocation(LatLng(lat, lon))
// 归一化像素 Key(按屏幕宽归一,跨设备像素密度差异可控)
val key = Pair((px.x / screenW * 2000).toInt(),
(px.y / screenW * 2000).toInt())
if (merged.containsKey(key)) continue // 同像素已有人,丢弃
merged[key] = lat to lon
}
return merged.values.toList()
Key 的设计是这一级的核心
DEMO 用 (x/W * 2000, y/W * 2000) 把像素坐标归一化到约 2000 格,既抹平不同机型 DPI 差异,也避免"1 像素精度去重导致密度过高"。
2000 是可调的"聚合粒度":
- 越大 → 越接近真实像素,保留的点越多,越精细;
- 越小 → 越激进合并,点越少,越流畅。
公式 ↔ 代码一致性核对:代码
key = ((px.x / screenW * 2000).toInt(), (px.y / screenW * 2000).toInt()),与文本"按屏幕宽归一、约 2000 格"完全一致;projection.toScreenLocation即"经纬度 → 屏幕像素"投影,对应文本描述。无偏差。
去重后每个像素一个代表点,绘制量被压到"像素数量级"。
五、第③级:几何 / 聚合分析
严格说,"像素去重"本身就是第②级里的聚合动作;第③级留给更精细的几何判定。DEMO 中第三级退化为"取去重后的代表点集合"——即 merged.values。
在更复杂场景里,这一级可以替换成:
- 按像素格做聚类计数(同一格内 N 个点 → 画一个带数字的气泡);
- 或做密度热力(按格内点数上色);
- 或只保留几何上最关键的点(如极值点、首末点)。
DEMO 选择最轻量的"代表点"策略,因为目标只是"看得见、不卡"。
这个取舍值得说明:三级不是"越复杂越好",而是"够用即止"。如果业务只要求"看到分布",代表点策略就足够,上聚类和热力反而增加计算开销和实现复杂度。
六、与 GPU 渲染配合
过滤后的点集挂到 GeoJsonSource,配一个 CircleLayer 交给 GPU 画圆点。这一步是性能倍增器:数据源里的点已经很少,GPU 几乎零压力。
相机每次停下(cameraIdle)都重新跑一遍三级过滤:
map.addOnCameraIdleListener {
layer.updateData(visibleBounds, screenWidth, projection)
}
updateData 内部:启用了过滤就走三级管线,否则全量(DEMO 留了"切换过滤"按钮做 A/B 对比,Toast 同时报"原始 5 万 / 过滤后 X 个")。
flowchart LR
CAM["相机停止<br/>cameraIdle"] --> CALC["后台线程<br/>三级过滤计算"]
CALC --> POST["post 回主线程"]
POST --> UPD["GeoJsonSource<br/>updateData"]
UPD --> GPU["GPU 渲染<br/>CircleLayer"]
style CALC fill:#fff7e6,stroke:#fa8c16,stroke-width:2px
style CAM fill:#e6f7ff,stroke:#1890ff
注意这条链路上的线程切换——下面事故 B 就栽在这里。
七、三个真实踩坑复盘
这套管线看着简单,我第一次落地时踩了三个真实的坑:
事故 A:只在加载时过滤,一平移就露馅
最初我只在页面初始化时跑了一次三级过滤,加载很快很流畅。结果用户一拖动地图,视野外的点全回来了——因为 GeoJsonSource 里的数据还是全量,之前过滤只是在初始化时做了一次快照。
后来改成 cameraIdle 里每次重算。
过滤不是"初始化动作",是"相机状态同步动作"。
事故 B:过滤在 UI 线程跑,卡顿从渲染转移到了计算
5 万点遍历 + HashMap 去重,在真机上也要几十毫秒,正好堵在 UI 线程的卡顿点上。
换到后台线程算,再把结果 post 回主线程更新源,立刻丝滑。
这个坑最能说明问题的本质:优化前的卡顿在 GPU 绘制,优化后的卡顿在 CPU 计算——如果你只做了一半(过滤了但还在主线程算),等于把卡顿从一处搬到另一处,用户体验毫无改善。
生产代码里过滤这段一定要扔出 UI 线程。
事故 C:归一化格数用物理像素写死,跨机型翻车
早期用 (px.x, px.y) 直接当 Key,低分辨率机器上点全被合并光、高分辨率机器上又几乎不去重。改成按 screenW 归一化之后才稳定。
"聚合粒度"必须跟设备解耦,归一化是必须的,不是可选项。
这类 bug 阴险的地方在于:在开发机上一测就是对的,只有换设备才现形。所以跨机型测试不是可选项,尤其是跟像素、DPI 相关的逻辑。
八、与 osmdroid 的异同
通用原理(完全一致)
三级过滤(范围裁剪 → 像素叠加 → 几何分析)是一套引擎无关的渲染优化思想。osmdroid 篇(06)里讲的范围裁剪、像素 Key 去重、几何叠加分析,与本文从粗到精的管线一模一样,瓶颈根因也相同——"全量遍历 + 全量绘制"。
MapLibre 专属实现差异
| 维度 | osmdroid 篇(06) | 本文(MapLibre) |
|---|---|---|
| 绘制后端 | Canvas 命令式 drawPoint | 过滤结果挂 GeoJsonSource + CircleLayer,GPU 绘制 |
| 重绘触发 | onDraw / 投影变化自动回调 | 相机 cameraIdle 回调里重算并 updateData 刷新源 |
| 过滤落点 | 在 Overlay 的绘制循环里逐点判断 | 在"数据 → 源"这一层过滤,GPU 只消费过滤后的小数据集 |
| 聚合升级 | 手动在 Canvas 上画计数气泡 | CircleLayer 上叠 SymbolLayer 显示聚合数,或换热力图层 |
| Key 优化 | 用 Long 打包 (x,y) 避免装箱 | 归一化到约 2000 格,跨 DPI 稳定 |
一句话:
"只画看得见的"是通用真理;MapLibre 的优势是把过滤放在"数据进 GPU 之前",让 GPU 永远只处理屏幕量级的点,而不是像 Canvas 那样在每一帧绘制循环里再筛。
这正是引擎升级带来的范式差异——同样的算法,落在 GPU 声明式管线里收益更稳。
九、如果今天重写:五件事
DEMO 的三级过滤是一段顺序代码,能跑、够用。如果今天重写:
1. 过滤链抽象成可插拔管线
把"范围裁剪""像素叠加""几何分析"各自封装成实现统一接口的 Filter,用一个 List 串起来。新增策略(比如"按格聚类计数")就是往链里插一个新 Filter,而不是在 updateData 里堆 if/else。
有了管线之后,第③级从"取代表点"换成聚类气泡或热力,只是换一节链的事。
2. 协程 + Flow 把计算彻底搬离 UI 线程
相机事件用 debounce 去抖(避免 move 一帧触发一次重算),过滤在 Dispatchers.Default 跑,结果用 Flow 收敛后回主线程增量更新 GeoJsonSource——而不是每次都 setGeoJson 全量替换。增量更新能少触发一轮 GPU 上传。
3. 聚合粒度自适应 DPI
把 2000 格改成基于"逻辑屏幕宽度"计算,同时暴露成构造参数,让不同机型都能拿到"每个像素约一个点"的密度,而不是一刀切。
4. 过滤后点数做成可观测指标
DEMO 用 Toast 报"原始 5 万 / 过滤后 X 个",生产环境应该把这个数字接进性能监控,作为回归基线——哪天过滤策略被改坏了,指标一波动立刻知道。
5. 聚合显示交给图层组合
需要"数字气泡"时,CircleLayer 上叠一层 SymbolLayer 放计数文本,渲染完全交给 GPU,不用像 Canvas 那样手写气泡绘制。
十、坑对照表与落地清单
| # | 症状 | 根因 | 解法 |
|---|---|---|---|
| 1 | 拖动后点全回来 / 缺失 | 只在初始化过滤一次 | 在 cameraIdle 里每次重算 |
| 2 | 过滤后依然卡 | 过滤跑在 UI 线程 | 后台线程计算,post 回主线程更新源 |
| 3 | 低配机点被合并光 / 高配机不去重 | Key 用物理像素,未归一化 | 按 screenW 归一化到固定格数 |
| 4 | 聚合粒度不合适 | 格数写死 | 做成构造参数,基于逻辑像素计算 |
| 5 | 全量分支被误用 | 演示开关默认开 | 生产默认 filtered = true |
| 6 | 每次都全量 setGeoJson | 未做增量更新 | 增量更新源,少一轮 GPU 上传 |
| 7 | 相机移动时一帧一算 | cameraMove 未去抖 | debounce 去抖 + cameraIdle 兜底 |
落地清单:
- 过滤必须在
cameraIdle/cameraMove(去抖后)里重算; - 归一化格数基于逻辑像素而非物理像素;
- 生产默认启用过滤,"全量"仅作对比;
- 计算量随点数线性增长,务必放到后台线程;
- 把"过滤后实际点数"暴露成指标,方便性能回归测试。
完整可运行源码:GitCode 仓库 · android_osmdroid_maplibre
对照阅读:本系列第 06 篇(osmdroid 数万点性能优化:三级过滤管线)——同一套算法,一个跑在 Canvas 绘制循环里、一个前置到"数据进 GPU 之前",对照看能理解声明式管线的收益从哪来。
延伸阅读:本系列第 08 篇(MapLibre 坐标纠偏)· 第 09 篇(MapLibre 瓦片源工厂)
评论区聊聊:你处理过多少量级的点?是在哪里发现"全量渲染"顶不住的?有没有踩过"过滤跑在 UI 线程"这种"优化了个寂寞"的坑?
本系列为 osmdroid / MapLibre 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。