第18章 - 性能优化与最佳实践

18.1 先定位瓶颈,再谈优化

MapLibre 的性能问题几乎都落在四个环节,表现类似(掉帧、卡顿、白屏)但原因完全不同:

环节 症状 定位手段
网络/瓦片 首屏白,缩放时一阵一阵地加载 DevTools Network 看瓦片请求数与大小
worker 解析 地图能拖,但新瓦片出现慢,主线程空闲 Performance 面板看 worker 任务(选择 Worker 线程)
主线程 JS 拖动不卡、缩放卡;自己的监听回调耗时长 Performance 面板的 Main 轨道
GPU 绘制 稳定低帧率、风扇转、移动端发热 map._showOverdrawInspector、Chrome GPU 面板

先用内置调试开关确认是哪一环:

map.showTileBoundaries = true;     // 画出瓦片边界,看是否在疯狂请求
map.showCollisionBoxes = true;     // 画出符号碰撞框,看布局成本
map.showPadding = true;            // 画出 viewport padding 区域

showTileBoundaries 打开后缩放地图,如果边界框频繁出现又消失,说明瓦片缓存太小或瓦片请求过多,问题在网络/缓存层;边界稳定但帧率低,往 GPU 方向查。

18.2 渲染相关的参数

Map 构造选项里与性能直接相关的,默认值都在 src/ui/map.ts 的 defaultOptions:

选项 默认值 影响
fadeDuration 300(ms) 瓦片淡入时长。设为 0 可显著减少切换时的重绘,但会有”跳变”感
maxTileCacheSize null(动态) 每个源保留的瓦片上限
maxTileCacheZoomLevels 5(config.MAX_TILE_CACHE_ZOOM_LEVELS) 动态缓存按”视口瓦片数 × 该值”计算
cancelPendingTileRequestsWhileZooming true 缩放中取消排队请求,避免加载无用的中间级别
refreshExpiredTiles true 到期瓦片是否重新请求
crossSourceCollisions true 跨源碰撞检测。关掉可省符号布局开销,代价是不同源的标注会重叠
maxCanvasSize [4096, 4096] canvas 最大像素尺寸,按 GL MAX_TEXTURE_SIZE 量级保守取值
zoomLevelsToOverscale 4 最多向上过采样几级
terrainSkirtLength 'auto' 地形裙边长度

maxTileCacheSize 的默认值不是固定数字,而是按视口算出来的(src/tile/tile_manager.ts):

updateCacheSize(transform) {
    const widthInTiles = Math.ceil(transform.width / this._source.tileSize) + 1;
    const heightInTiles = Math.ceil(transform.height / this._source.tileSize) + 1;
    const approxTilesInView = widthInTiles * heightInTiles;
    const commonZoomRange = this._maxTileCacheZoomLevels === null ?
        config.MAX_TILE_CACHE_ZOOM_LEVELS : this._maxTileCacheZoomLevels;
    const viewDependentMaxSize = Math.floor(approxTilesInView * commonZoomRange);
    const maxSize = typeof this._maxTileCacheSize === 'number' ?
        Math.min(this._maxTileCacheSize, viewDependentMaxSize) : viewDependentMaxSize;
    this._outOfViewCache.setMaxSize(maxSize);
}

按 1920×1080、瓦片 512px 计算:ceil(1920/512)+1 = 5,ceil(1080/512)+1 = 4,视口约 20 块瓦片,乘以 5 个 zoom 级别 = 每源约 100 块离屏缓存。全屏地图 + 3 个矢量源就是 300 块瓦片常驻内存,每块瓦片解压后的几何数据通常在几百 KB 到几 MB,这是内存占用的主要来源。

要压内存,就把 maxTileCacheSize 设成一个绝对上限(比如 64);要提流畅度(来回缩放不重新下载),就调大 maxTileCacheZoomLevels。

18.3 主线程与 worker

v6.17.0 的 worker 数默认是 1(src/util/worker_pool.ts):

const availableLogicalProcessors = Math.floor(browser.hardwareConcurrency / 2);
WorkerPool.workerCount = isSafari(globalThis) ? Math.max(Math.min(availableLogicalProcessors, 3), 1) : 1;

Safari 上用 CPU 核心数的一半、上限 3;其他浏览器固定 1 个。这个默认值来自 A/B 测试(源码注释指向 PR #2354),不是”按核心数自动扩”。瓦片解析(PBF 反序列化、几何布局、索引构建)全在这一个 worker 里排队。

数据量大、或者同时开了多个地图实例时,可以调:

import { getWorkerCount, setWorkerCount } from 'maplibre-gl';

console.log(getWorkerCount());   // 1(Chrome)
setWorkerCount(4);               // 必须在创建任何 Map 之前调用
const map = new maplibregl.Map({ /* ... */ });

调大不是免费的:每个 worker 有自己的 WASM/JS 堆,多个 worker 之间不能共享解析结果,小心内存翻倍。桌面端 2–4 个通常合适,移动端保持 1。

图片(栅格瓦片、sprite、图标)的并发上限是全局的:

import { getMaxParallelImageRequests, setMaxParallelImageRequests } from 'maplibre-gl';
console.log(getMaxParallelImageRequests());  // 16
setMaxParallelImageRequests(24);

默认 16,同时有 MAX_PARALLEL_IMAGE_REQUESTS_PER_FRAME: 8 限制每帧发起的数量,避免一帧内爆出几十个请求。栅格底图密集时可提到 24,但超过浏览器同域连接上限(HTTP/1.1 是 6,HTTP/2 多路复用后宽松)意味着请求排队,收益递减。

18.3.1 建立可比较的基线

优化没有基线就是猜。三个数字足够做决策,且都能在浏览器里直接量:

// 1) 首帧:从 new Map 到 'load' 的时间
const t0 = performance.now();
const map = new maplibregl.Map({ /* ... */ });
map.once('load', () => console.log('首帧', (performance.now() - t0).toFixed(0), 'ms'));

// 2) 缩放流畅度:统计两次 idle 之间的渲染帧间隔
let last = performance.now();
const gaps = [];
function onRender() {
  const now = performance.now();
  if (now - last < 200) gaps.push(now - last);   // 过滤掉非连续帧
  last = now;
}
map.on('render', onRender);
map.on('idle', () => {
  if (gaps.length) {
    const avg = gaps.reduce((a, b) => a + b, 0) / gaps.length;
    console.log('平均帧间隔', avg.toFixed(1), 'ms →', (1000 / avg).toFixed(0), 'fps');
    gaps.length = 0;
  }
});

// 3) 常驻内存(仅 Chromium):usedJSHeapSize 单位是字节
console.log('JS 堆', (performance.memory.usedJSHeapSize / 1048576).toFixed(1), 'MB');

在同一台设备、同一份数据上,改一个参数前后各测三次取中位数。map.on('render') 只在真正重绘时触发,正好可以用来判断”地图是不是在空转”——静止状态下这个回调一次都不该有,有就说明某处一直在 triggerRepaint()。

18.4 数据层优化:换表示比换参数有效

同样的数据,表示方式不同,性能差一个数量级:

GeoJSON 内联在 JS 里         → 数百 MB 字符串,解析即卡死
GeoJSON 从 URL 加载          → 几十 MB,worker 端切瓦片,首屏 1–3 s
简化 + 降精度的 GeoJSON      → 体积降 60%–80%
矢量瓦片(服务端/PMTiles)   → 首屏几百 ms,按需加载

具体手段按收益排序:

降精度。 GeoJSON 坐标常有 15–17 位小数,实际 6 位(约 1 cm)足够。用 mapshaper 或 Turf 的 coordEach 批量取整。

去属性。 只保留 paint/filter 用到的字段。属性是 PBF 里的大头,删掉不用的能省 30%–50%。

简化几何。 用 @turf/simplify 或 PostGIS ST_Simplify,容差按目标缩放级别的地面分辨率取(z12 约 38 m/像素 → 容差 20–40 m)。

换矢量瓦片。 超过约 5 万要素的静态数据,一律转瓦片。maxzoom 设到真实精度,靠过采样显示更高层级。

maxzoom 与 minzoom 收紧。 GeoJSON 源的 maxzoom 默认 18,把点源设到 12 能大幅减少高缩放层的瓦片数;图层加 minzoom: 8 避免低缩放的无效布局。

18.5 图层层面:少一遍就是一遍

// 1) 关掉跨源碰撞:不同源的标注不再互相避让
new maplibregl.Map({ crossSourceCollisions: false, /* ... */ });

// 2) 关闭不需要的淡入:静态底图设 0 可减少重绘
new maplibregl.Map({ fadeDuration: 0, /* ... */ });

// 3) 只在该级别可见的图层加 minzoom / maxzoom
map.addLayer({ id: 'buildings', type: 'fill-extrusion', source: 'vec', 'source-layer': 'building',
  minzoom: 14, /* ... */ });

// 4) 用 filter 提前排除要素,比画出来再隐藏便宜
map.setFilter('parcels', ['>', ['get', 'area'], 200]);

优先级从高到低:减少图层数量 > 收紧 zoom 范围 > 提高过滤选择性 > 关碰撞检测 > 调缓存参数。多数”卡”的项目,问题在同时挂了十几个半透明图层,而不是某个参数没调。

18.5.1 一次典型的优化过程

以”15 万建筑面 + 3 个矢量底图源,缩放掉到 20 fps”为例,按上面的顺序走一遍:

  1. showTileBoundaries = true 观察:缩放时瓦片反复出现—消失,说明缓存命中率低。把 maxTileCacheZoomLevels 从 5 提到 7,帧率无明显变化 → 瓶颈不在缓存。
  2. Performance 面板看 Main 轨道:move 事件上挂了一个自定义回调,每次遍历全部 15 万 queryRenderedFeatures 结果 → 改成只在 moveend 触发,掉帧消失。
  3. 剩余掉帧集中在 GPU:fill 图层用了 fill-antialias: true(默认),大面数据叠加时边缘采样成本高。该层数据只在 z14+ 显示,加 minzoom: 14 后低缩放完全跳过。
  4. 最后压体积:maxzoom 从 18 调到 15,瓦片请求数下降约 60%。

四步里没有一步是”调大缓存”,三条结论都来自观察。反过来,先改 maxTileCacheSize 再看效果,通常只是让内存翻倍。

18.6 启动与销毁

// 预创建 worker 与 GL 上下文,减少第二个地图实例的首帧延迟
import { prewarm } from 'maplibre-gl';
prewarm();
// 不再需要时释放
import { clearPrewarmedResources } from 'maplibre-gl';
clearPrewarmedResources();

单页应用里”进详情页再返回列表”,如果不销毁地图,canvas 与 GL 上下文会一直占显存;正确做法是 map.remove(),它内部会终止瓦片请求、释放纹理、解绑事件。注意 remove() 之后不能再调用任何方法,重新显示要重建 Map。

18.7 排错

现象 原因 修法
首屏慢但拖动流畅 瓦片请求过多或首屏数据过大 收紧 maxzoom、改用瓦片源、加 bounds
拖动时频繁掉帧 主线程被事件回调占满 用 Performance 面板定位回调;节流 move 监听
缩放时白块闪 cancelPendingTileRequestsWhileZooming 取消过激 保留默认;或提高缓存
内存持续增长 瓦片缓存过大 + 未 remove() 的地图实例 设 maxTileCacheSize;路由切换销毁地图
移动端 GPU 报上下文丢失 纹理太多(地形 RTT 尤甚) 关闭地形 / 减少源 / 降 maxCanvasSize
worker 解析排队 workerCount: 1,同时多个瓦片源 setWorkerCount(2..4),在创建地图前调用

18.8 生产建议

首屏只加载看得见的数据。 用 fitBounds 圈定范围、给源加 bounds、按需加载矢量源,比全局加载后过滤快得多。

fadeDuration 视底图类型定。 矢量底图保留 300ms 让颜色过渡自然;栅格照片类底图设 0,避免切换时的半透明重绘。

移动端把 worker 保持 1。 手机核心多但功耗墙明显,多个 worker 同时解析反而更慢更热。

监控三项指标就够。 首帧时间、缩放时的掉帧率、常驻内存(performance.memory 仅 Chrome 可用)。有这三条曲线,绝大多数优化决策不需要再猜。

升级主版本前回归性能。 瓦片缓存公式、worker 数、默认 fadeDuration 这些都在版本间变过,v5→v6 的迁移指南里列了行为变化,升级后要重测指标。

18.9 本章小结

要点 事实
worker 数 默认 1;Safari 为 min(核心数/2, 3);setWorkerCount 须在建图前调用
图片并发 MAX_PARALLEL_IMAGE_REQUESTS: 16,每帧上限 8
瓦片缓存 默认动态:视口瓦片数 × 5 个缩放级别;maxTileCacheSize 设绝对值可压内存
淡入 fadeDuration 默认 300ms,设为 0 减少重绘
碰撞 crossSourceCollisions 默认 true,关掉省布局开销
数据层 降精度、去属性、简化、换矢量瓦片,收益远大于调参数