十万要素不能一次全画?先画视口里的,边拖边补,把卡顿拆成流畅。
前言
在手机上显示地图,最怕遇到两个词:一是"十万个点",二是"首次加载"。十万个要素如果一次性全部构建成三角形再上传 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)兜住边界,兜底加载修正总数防死循环。
- 这套**"空间 + 时间"两维调度**,能迁移到瓦片、大图、流式列表等大量场景。
你在项目里做过类似的"渐进式加载"吗?遇到过哪些卡顿或死循环的坑,欢迎在评论区聊聊。