osmdroid 地图实战 06|3 万点从 12 FPS 到稳定 60:三级过滤管线是怎么省下一个数量级的

0 阅读11分钟

摘要:一次性在地图上画 3 万个测量点,拖动像看幻灯片,缩放直接卡死几秒。根因不是"某个 API 用错",而是全量遍历 + 全量绘制——每帧都在为 29000 个屏幕外的点做坐标变换。这篇给出一条从粗到精的三级过滤管线(范围裁剪 → 像素叠加 → 几何分析),附实测性能对比,以及数据再上一个数量级时的空间索引兜底方案。

目录(TOC)


一、先讲个真事:拖动地图像看幻灯片

某次项目要在地图上一次性展示 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 用错",而是你把所有要素都画了一遍,哪怕大部分根本不在当前屏幕里。

真正的问题有三层:

  1. 卡顿:每次重绘(拖动 / 缩放)都遍历全量,数万个点全算全画,帧率暴跌;
  2. 白画:大量点根本不在当前屏幕,画了也看不见,纯浪费;
  3. 重叠:缩小时,成百上千个点挤在同一个像素上,画出来黑压压一片,毫无信息量

第三点常被忽略:即使性能扛得住,把 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[&#34;全量要素<br/>3 万&#34;] --> L1[&#34;① 范围裁剪<br/>包围盒粗筛&#34;]
    L1 -->|&#34;剩 5 千&#34;| L2[&#34;② 像素叠加<br/>去重合并&#34;]
    L2 -->|&#34;剩 500&#34;| DRAW[&#34;绘制&#34;]
    L1 -->|&#34;候选集&#34;| L3[&#34;③ 几何叠加分析<br/>仅点选时&#34;]
    L3 --> HIT[&#34;精确命中判定&#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

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