Android 地图十万要素不卡顿:空间网格 + 渐进式加载的移动端实践

十万要素不能一次全画?先画视口里的,边拖边补,把卡顿拆成流畅。

前言

在手机上显示地图,最怕遇到两个词:一是"十万个点",二是"首次加载"。十万个要素如果一次性全部构建成三角形再上传 GPU,卡顿是板上钉钉的事------构建要花几百毫秒,上传又要几百毫秒,期间屏幕直接冻结。

有人会想:那就先只加载屏幕能看到的范围呗。道理没错,但"屏幕范围"是会变的------你手指一拖,视野变了,之前加载的要不要卸载?新出现区域的要素从哪读、按什么顺序读?加载到一半用户又拖走了怎么办?这一连串问题,比"一次全加载"要复杂得多。

而我看到的这套移动端地图引擎,用固定空间网格 + 渐进式加载把这个问题解决得很漂亮。它甚至有专门针对"10 万以上要素"的优化路径。本篇就讲它是怎么做到的。

一、根因:问题本质是"调度",不是"渲染"

把十万要素一次画完会卡,是因为把全部成本压在了第一帧。而把成本摊开到很多帧里,每一帧只干一点点活,用户就几乎感觉不到卡。

但"摊开"有个前提:必须知道先干哪部分、后干哪部分。这就需要一个能把空间划分开的结构,让我们能回答"视口里有哪些格子、这些格子该按什么顺序加载"。

这就是空间网格的用处:把整个数据范围切成一个固定行列的矩形网格,每个格子是独立的加载单元。视口在哪,就优先加载和它相交的格子。

二、解法:网格切分 + 优先级调度 + 状态机

3.1 网格怎么切

切网格前,先用数据集的总范围算出该切成多少格。一个朴素的做法是:给定"每格最多放多少要素",用总要素数除以它得到目标格数,再按范围的长宽比调整行列数,让格子尽量接近正方形。

这段根据总要素数和每格上限算出网格行列,关键是用了 floor 而非 (int) 截断,天然支持负坐标:

kotlin 复制代码
// 根据总要素数 + 每格上限,算出网格行列
fun computeOptimalGrid(totalFeatures: Int, maxPerCell: Int): Grid {
    if (totalFeatures <= 0) return Grid(cols = 1, rows = 1)
    val count = ceil(totalFeatures.toDouble() / maxPerCell).toInt().coerceAtLeast(1)
    val aspect = totalBounds.width / max(totalBounds.height, 1e-10)
    val cols = round(sqrt(count * aspect)).toInt().coerceAtLeast(1)
    val rows = max(1, count / cols)
    return Grid(cols, rows)
}

这样每个格子的空间范围就能通过行列号反算出来,而且天然支持负坐标------用 floor 而不是 (int) 截断,避免负数边界出错。

3.2 视口查询按优先级排序

视口平移或缩放后,查询与视口相交的格子,并按"格子中心到视口中心的距离"升序排列。这样最靠近屏幕中央的数据最先出现,符合用户的视觉预期------眼睛盯着的地方最先加载好。

这段查出与视口相交的格子,并按距视口中心距离升序排好,保证屏幕中央先加载:

kotlin 复制代码
fun queryCells(viewport: Bounds): List<Int> {
    // 先算出视口覆盖的行列范围
    val cells = mutableListOf<Int>()
    for (r in minRow..maxRow) {
        for (c in minCol..maxCol) {
            val id = r * cols + c
            if (!emptyCells.contains(id) && !loadedCells.contains(id)) {
                cells.add(id)
            }
        }
    }
    // 按距视口中心距离升序
    return cells.sortedBy { cellCenterDistSq(it, cx, cy) }
}

3.3 多线程建 Mesh + GL 线程消费

真正花时间的是把每个格子里的要素转成 GPU 可用的三角形网格(Mesh)。这一步用固定线程池并行做:每个 worker 负责一个格子,建完的 Mesh 塞进一个有界队列;GL 渲染线程每帧从队列里取,取到就上传 GPU 并立即刷新一帧。

这段是 worker 线程为一个格子构建 Mesh 并入队的逻辑,中途检查 aborted 可随时中断:

kotlin 复制代码
// worker 线程:为一个格子构建 Mesh,入队
executor.submit {
    for (fid in cellFids(cid)) {
        if (aborted) break
        val feature = readFeature(fid)
        val mesh = visualizer.buildMesh(feature, context)
        queue.offer(CellMesh(cid, mesh), 100, TimeUnit.MILLISECONDS)
        markLoaded(fid)
    }
    completeQueue.offer(cid) // 通知 GL 线程:这格处理完了
}

GL 线程每帧做的事很轻:把队列里的 Mesh 全部取走上传,再检查有没有格子完成,完成就刷新一帧。这样构建和渲染被两个线程解耦,构建再慢也不阻塞绘制。

3.4 状态机:IDLE → LOADING → FALLBACK

加载过程用一个简单状态机管理,避免各种边界条件打架:

  • IDLE:还没开始。首次需要更新时进入 LOADING,初始化网格索引。
  • LOADING:每帧按视口优先级调度格子,worker 并行构建。所有格子处理完、但仍有要素没加载到时,进入 FALLBACK。
  • FALLBACK:格子是粗粒度划分,可能有些要素"卡"在格子缝隙或跨格没被拾到,此时用全量迭代器顺序兜底加载剩余要素,每攒够一批刷新一帧;一旦视口变了出现新格子,立即退回 LOADING 优先加载新格子。

这段是状态机每帧的核心调度:消费队列、提交新格子、检查是否全部完成:

kotlin 复制代码
private fun onUpdate() {
    when (state) {
        IDLE -> { state = LOADING; initSpatialGrid() }
        LOADING, FALLBACK -> {
            drainMeshQueue()          // GL 线程消费 Mesh
            pollCellComplete()        // 处理完成的格子,上传 + 刷新
            if (noWorkInFlight()) {
                if (state == FALLBACK) scheduleFallbackBatch()
                else scheduleNextCell() // 提交新格子给线程池
            }
            checkAllDone()
        }
    }
}

反直觉的坑 :兜底加载时,如果数据集里有些要素没有有效几何,实际加载数会小于预估总数。如果不对总数做修正,状态机会认为"永远没加载完"而死循环。所以兜底结束后要把 totalFeatures 修正为实际加载数。

兜底别忘了修正总数,否则状态机会因为"永远没加载完"陷入死循环。

三、升华:这种"空间 + 时间"两维调度可以推广到哪

这套方案的通用价值在于,它把"大数据量渲染"拆成了两个独立维度:

  • 空间维度:用网格把数据切块,回答"先加载哪"。
  • 时间维度:用状态机 + 双线程把构建摊到多帧,回答"怎么不卡"。

同样的思路可以推广到很多场景:地图瓦片的分级加载、图片大图的分块渐进显示、流式列表的按需渲染。核心都是同一句话------把"一次性的大成本"转成"有优先级的多帧小成本",配合一个能回答"下一步该做什么"的调度器。

四、结论

  • 大数据量渲染的痛点是**"把成本压在第一帧",解法是摊到多帧**。
  • 固定空间网格负责"空间划分 + 优先级排序",让屏幕中心数据最先出现。
  • worker 线程建 Mesh、GL 线程消费上传,用双线程解耦构建与绘制。
  • 状态机(IDLE → LOADING → FALLBACK)兜住边界,兜底加载修正总数防死循环。
  • 这套**"空间 + 时间"两维调度**,能迁移到瓦片、大图、流式列表等大量场景。

你在项目里做过类似的"渐进式加载"吗?遇到过哪些卡顿或死循环的坑,欢迎在评论区聊聊。

相关推荐
宁波鹿语心理1 小时前
注意缺陷多动障碍儿童及家庭的心理支持:理解特质,重建价值
大数据
跨境数据猎手2 小时前
反向海淘是什么:2026从系统架构到业务落地
大数据·产品运营·需求分析
TDengine (老段)2 小时前
TDengine 常见问题 TOP7
大数据·数据库·时序数据库·常见问题·tdengine·涛思数据
探数API小喇叭2 小时前
天气 API 接口思路:拆解一套包含 4 个子接口的轻量化气象服务
大数据·api·天气预报
方向研究2 小时前
硬盘健康信息监测
大数据
Godikov2 小时前
Android 工业终端保活实战:前台服务 + 开机自启 + 更新自启的三重保障
android
字节暗面3 小时前
SO加固强度怎么量化?腾讯ACE与FairGuard静态分析实测
android·逆向
yt004yt3 小时前
绿岛数字化平台搭建:VOCs 监测与能碳数据一体化方案
大数据·分布式
广州宏帝箱包4 小时前
出口背包的包装有没有防潮、防摔的加固处理
大数据·人工智能