MapLibre 实战 10|5 万点一拖就卡:三级过滤管线,和"过滤跑在 UI 线程"那个坑

1 阅读11分钟

摘要:把 5 万个监测点一股脑塞进 GeoJSON 丢给地图,真机上拖动掉帧、缩放转圈。核心思想一句话——渲染目标不是"所有真实点",而是"屏幕上看得见的代表性点"。这篇讲三级过滤管线(范围裁剪 → 像素叠加 → 聚合分析),并复盘三个真实踩坑:只在初始化过滤、过滤堵在 UI 线程、归一化格数写死导致跨机型翻车。

目录(TOC)


一、换到旗舰机也只是"没那么卡"

打开一个监测平台 App,地图上要叠加几万个监测散点。你下意识地把所有点塞进 GeoJSON 一把梭丢给地图,然后……真机上开始卡:拖动地图掉帧、缩放转圈、内存看着往上飙。

你第一反应是"换台手机试试",但换到旗舰机也只是把"卡"变成"没那么卡"

问题不在手机,在渲染策略。

移动端常见需求:在地图上叠加数万乃至数十万个散点(监测点、轨迹采样、网格采样等)。如果一次性把全部点塞进数据源交给地图渲染,会同时撞上两个瓶颈:

  1. 数据量瓶颈:GeoJSON 体积大、解析与上传 GPU 慢,内存占用高;
  2. 绘制量瓶颈:大量点密集重叠在同一区域,屏幕就那么大,画 5 万个点和画 2000 个点在视觉上没区别,纯属浪费 GPU 填充率。

DEMO 实测对比:5 万点全量渲染 vs 三级过滤后,屏幕上真正画出来的可能只有几千个。

视觉等价,开销差一个数量级。


二、设计总览:三级过滤管线

把"要不要画这个点"拆成三道关卡,逐层砍掉不需要的点:

flowchart TB
    ALL[&#34;原始点集<br/>5 万&#34;]
    ALL --> L1{&#34;① 范围裁剪<br/>在视野包围盒内?&#34;}
    L1 -->|&#34;否&#34;| DROP1[&#34;丢弃<br/>(约 -80%)&#34;]
    L1 -->|&#34;是&#34;| L2{&#34;② 像素叠加<br/>同像素已有代表点?&#34;}
    L2 -->|&#34;是&#34;| DROP2[&#34;丢弃<br/>(再 -90%)&#34;]
    L2 -->|&#34;否&#34;| L3[&#34;③ 几何/聚合分析<br/>候选集最终判定&#34;]
    L3 --> SRC[&#34;GeoJsonSource&#34;]
    SRC --> GPU[&#34;CircleLayer<br/>GPU 绘制&#34;]

    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[&#34;相机停止<br/>cameraIdle&#34;] --> CALC[&#34;后台线程<br/>三级过滤计算&#34;]
    CALC --> POST[&#34;post 回主线程&#34;]
    POST --> UPD[&#34;GeoJsonSource<br/>updateData&#34;]
    UPD --> GPU[&#34;GPU 渲染<br/>CircleLayer&#34;]

    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 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。