第14章 - 矢量瓦片与数据源进阶

14.1 数据源类型与它们真正的差别

include/mln/style/sources/ 下只有 8 个头文件,对应样式规范里的全部 source 类型:

类型 头文件 瓦片从哪来 能否 data-driven
vector vector_source.hpp URL 模板或 TileJSON,MVT/MLT 字节 是
raster raster_source.hpp 同样的瓦片机制,输出图片 否(图是固定的)
raster-dem raster_dem_source.hpp 同上,PNG 编码高程 否
geojson geojson_source.hpp 内联 GeoJSON 或 URL,运行时切瓦片 是
image image_source.hpp 单张图 + 四个角坐标 否
custom-geometry custom_geometry_source.hpp 你自己算的瓦片几何 是
custom-vector custom_vector_source.hpp 你自己产出的 MVT 字节 是

vector 与 raster 共用同一套 TileSource 基类(tile_source.hpp),差别只在渲染管线;geojson 走另一条路——它没有瓦片文件,GeoJSONData 在运行时用 geojson-vt 的算法切瓦片。

14.1.1 两种瓦片坐标系

Tileset::Scheme 只有 XYZ 与 TMS 两个值:

enum class Scheme : bool { XYZ, TMS };

XYZ 的 y 从北往南递增,TMS 的 y 从南往北递增。同一个 z/x/y 在两个方案里指向不同瓦片,接自建服务时常见「地图上下颠倒」就是这里配错。OSGeo 的 XYZ 服务用 XYZ,老式 TileCache 用 TMS。

14.1.2 MVT 与 MLT

Tileset::VectorEncoding 有两个值,VectorSource 的构造函数把默认值定在 Mapbox:

VectorSource(std::string id,
             variant<std::string, Tileset> urlOrTileset,
             std::optional<float> maxZoom = std::nullopt,
             std::optional<float> minZoom = std::nullopt,
             Tileset::VectorEncoding encoding = Tileset::VectorEncoding::Mapbox);

Mapbox 是 MVT(protobuf 矢量瓦片),生态里的绝大多数数据都是它;MLT 是 MapLibre Tiles。选错编码的表现是瓦片解析出 0 个要素,控制台没有任何报错——解析器把它当合法 protobuf 读完了,只是字段不对。

14.2 矢量源:URL、TileJSON、内联瓦片

VectorSource 接受 variant<std::string, Tileset>——要么给一个 URL 让它去拉 TileJSON,要么直接给 Tileset。三种声明方式:

// 1) 给 TileJSON URL:样式里最简洁,适合第三方服务
auto vs1 = std::make_shared<mln::style::VectorSource>(
    "roads", "https://example.com/roads/tiles.json");

// 2) 内联 Tileset:完全自控,不产生额外的 TileJSON 请求
mln::Tileset ts;
ts.tiles = {"https://example.com/roads/{z}/{x}/{y}.pbf"};
ts.zoomRange = {0, 14};
ts.scheme = mln::Tileset::Scheme::XYZ;
ts.vectorEncoding = mln::Tileset::VectorEncoding::Mapbox;
ts.attribution = "© Example";
auto vs2 = std::make_shared<mln::style::VectorSource>(
    "roads", ts, /* maxZoom = */ 14.0f, /* minZoom = */ 0.0f);

// 3) 运行时替换瓦片列表(多 CDN 轮换)
vs2->setTiles({"https://a.example.com/{z}/{x}/{y}.pbf",
               "https://b.example.com/{z}/{x}/{y}.pbf"});

VectorSource 的 maxZoom / minZoom 是 optional<float>,传 std::nullopt 表示用 TileJSON 里的值。第三个参数 maxZoom 决定从哪里开始 overscale:源的数据只到 14 级,用户放大到 18 级时,15–18 级用 14 级的瓦片放大显示,这就是 OverscaledTileID 里 overscaledZ 的含义。

14.2.1 OverscaledTileID 与多世界副本

include/mln/tile/tile_id.hpp 里三个 ID 类型分工明确:

类型 字段 用途
CanonicalTileID z/x/y 实际存在的数据瓦片,z 不超过源的 maxzoom
OverscaledTileID overscaledZ/wrap/z/x/y 渲染用的瓦片,overscaledZ 是渲染级别,z/x/y 是数据瓦片
UnwrappedTileID wrap/z/x/y 跨世界副本的连续坐标,用于计算矩阵

overscaleFactor() 就是 2^(overscaledZ - z)。放大 4 级时这个值是 16,意味着一个数据瓦片要放大 16 倍铺满。wrap 是东西方向的世界副本索引(-N..N),缩得很小时屏幕上会看到多份世界,wrap 记录每一份。

14.3 GeoJSON 源:选项比瓦片源多得多

GeoJSONOptions 是唯一一个把「切瓦片」和「聚合」两套参数都塞在一起的结构:

struct GeoJSONOptions {
    // geojson-vt 参数
    uint8_t  minzoom = 0;
    uint8_t  maxzoom = 18;
    uint16_t tileSize = util::tileSize_I;   // 512
    uint16_t buffer = 128;
    double   tolerance = 0.375;
    bool     lineMetrics = false;

    // supercluster 参数
    bool     cluster = false;
    uint16_t clusterRadius = 50;
    uint8_t  clusterMaxZoom = 17;
    size_t   clusterMinPoints = 2;
    // clusterProperties: map<string, pair<Expression, Expression>>

    // 更新策略
    bool synchronousUpdate = false;
};

几个必须知道的取舍:

  • maxzoom 默认 18,是切瓦片的级别,不是显示的最大级别。设太大会让一次 setGeoJSON 卡住主线程(切瓦片在后台,但对象创建在调用线程)。
  • tolerance 是简化容差,单位是「瓦片像素」。0.375 是默认值;调大到 1.0 会让线更早简化,数据量降下来但形状变钝。
  • buffer 是瓦片外扩的像素数,单位同样是瓦片像素。128 意味着每边多出 128 像素,跨瓦片边界的线宽、标注不会被切断。
  • cluster = true 时启用 supercluster,clusterRadius 默认 50 像素,clusterMinPoints 默认 2——即 2 个点就聚。clusterMaxZoom 只影响聚合层级,超过该级别不再聚合。
auto opts = std::make_shared<mln::style::GeoJSONOptions>();
opts->cluster = true;
opts->clusterRadius = 50;
opts->clusterMaxZoom = 14;
opts->clusterMinPoints = 3;
opts->tolerance = 0.5;
auto source = std::make_shared<mln::style::GeoJSONSource>("poi", opts);
source->setGeoJSON(geojson);

14.3.1 增量更新与同步切换

setGeoJSON() 每次都会重建整个数据对象。数据量大时用 setGeoJSONData(std::shared_ptr<GeoJSONData>)——GeoJSONData::create() 可以在你自己的线程上预先切好瓦片,再整块换上去。

synchronousUpdate 与 GeoJSONSource::setOverrideSynchronousUpdate() 控制更新是同步还是异步。同步的好处是「设置完立刻能查到要素」,代价是调用线程会被占用;异步省时间但 queryRenderedFeatures 可能查不到刚设进去的数据。测试里常用同步,生产里用异步。

14.4 image 源与运行时图片

ImageSource 只有一个角坐标数组和一张图,适合「一张配准好的扫描图 / 单幅遥感影像」。

auto src = std::make_shared<mln::style::ImageSource>("scan",
    std::array<mln::LatLng, 4>{
        mln::LatLng{39.90, 116.30},   // 左上
        mln::LatLng{39.90, 116.50},   // 右上
        mln::LatLng{39.80, 116.50},   // 右下
        mln::LatLng{39.80, 116.30}}); // 左下
src->setURL("https://example.com/scan.png");
map.getStyle().addSource(src);

四个角的顺序是「左上、右上、右下、左下」,不是顺时针从任意角开始。顺序错了图像会翻转或扭曲成蝴蝶结。setImage(PremultipliedImage&&) 可以绕过网络直接塞图,注意像素格式是预乘 alpha——普通 RGBA 直接塞进去,半透明区域会偏暗。

14.5 自定义源的取舍

CustomGeometrySource 与 CustomVectorSource 已在第 13 章讲过 API,这里只讲选型:

维度 CustomGeometrySource CustomVectorSource
输入 GeoJSON 对象 MVT 字节
序列化成本 低(内存对象) 高(要自己编码 protobuf)
属性承载 有,但只能走 feature 的 properties 完整,支持 source-layer 与任意字段
适合规模 单瓦片数千要素 单瓦片数万要素
调试难度 低(可直接 dump GeoJSON) 高(要解 protobuf)

判断标准很简单:单瓦片要素数低于一万、不需要复杂属性表达式,就用几何源。超过这个量级、或者要复用现成的 MVT 编码器,才上矢量源。

14.6 本地瓦片包:mbtiles 与 pmtiles

FileSourceType 里有独立的 Mbtiles 与 Pmtiles 两类,样式里直接用协议前缀引用:

{"sources": {
  "basemap": {"type": "vector", "url": "mbtiles:///data/basemap.mbtiles"},
  "sat": {"type": "raster", "url": "pmtiles://https://cdn.example.com/sat.pmtiles"}
}}

mbtiles:// 后面必须是不带查询串的绝对路径(MBTilesFileSource 的注释写明「只能加载本地文件的绝对路径」)。pmtiles:// 后面可以是一个本地路径,也可以是 HTTP URL——PMTiles 支持按目录(directory)做范围请求,所以能直接从 CDN 上取单个分块,不用整包下载。

两者都必须显式编译开关:MLN_WITH_PMTILES 默认 ON;MBTiles 依赖 SQLite 相关代码,在移动端一般由 SDK 默认带上。-DMLN_WITH_CORE_ONLY=ON 时这两类源都不会注册,样式里写 mbtiles:// 会得到「无可用的 FileSource」——表现为地图一直空白且没有网络请求。

14.7 排错

矢量图层全空,但瓦片请求 200:三种原因。vectorEncoding 选错(MVT 数据声明成 MLT);图层的 source-layer 与瓦片里的 layer 名不一致;Tileset.scheme 配错导致取到的是别的瓦片。用 gl 之外的 dirty 手段查:抓一个瓦片字节,用 mapbox-vector-tile 之类工具解出 layer 名。

地图上下颠倒:Scheme 用了 TMS 但服务是 XYZ。表现很规律——低 zoom 时大致正常,高 zoom 时南北错位越来越离谱。

放大到高 zoom 变糊且没有新请求:源的 maxzoom 太小,超过后走 overscale。这是预期行为,不是 bug。想要更清晰只能提高数据源的 maxzoom。

geojson 源改了数据但地图不动:调的是 setURL() 但 URL 没变,或者 setGeoJSON() 之后没有触发重绘。GeoJSONSource 内部有 requestGeneration 计数防止旧请求覆盖新数据;如果自己缓存了 GeoJSONData 指针并复用,换数据时不会生效——必须重新 create。

聚合图层里 point_count 全是 1:cluster 没开,或者 clusterMaxZoom 小于当前级别。注意 clusterMaxZoom 的语义是「超过这个级别不再聚合」,设成 0 等于永远不聚合。

mbtiles:// 报找不到文件:路径必须绝对且不带 file:// 前缀。Android 上资源在 APK 内时不能用 MBTiles,得先拷到私有目录。

14.8 参数速查

参数 所属 默认值 说明
Tileset::Scheme tileset XYZ TMS 时 y 轴反向
Tileset::VectorEncoding tileset Mapbox(MVT) 另一值是 MLT
Tileset::RasterEncoding tileset 无默认 必须给 Mapbox 或 Terrarium
Tileset.zoomRange tileset {0, DEFAULT_MAX_ZOOM}(22) 数据可用级别
VectorSource.maxZoom 构造参数 nullopt 超过则 overscale
GeoJSONOptions.minzoom / maxzoom geojson 0 / 18 切瓦片级别范围
GeoJSONOptions.tileSize geojson 512 与 util::tileSize_I 一致
GeoJSONOptions.buffer geojson 128 瓦片外扩像素
GeoJSONOptions.tolerance geojson 0.375 简化容差
GeoJSONOptions.cluster geojson false 是否启用 supercluster
GeoJSONOptions.clusterRadius geojson 50 聚合半径(像素)
GeoJSONOptions.clusterMaxZoom geojson 17 超过该级别不再聚合
GeoJSONOptions.clusterMinPoints geojson 2 达到该点数才聚合
GeoJSONOptions.synchronousUpdate geojson false 同步还是异步切换数据
OverscaledTileID::overscaleFactor() tile id 2^(overscaledZ - z) 放大倍数

14.9 生产建议

瓦片源优先内联 Tileset,不用 TileJSON。多一次 TileJSON 请求就多一个启动延迟点,也多了「服务端改了 TileJSON 导致客户端行为漂移」的风险。只有当瓦片列表由服务端动态下发(多 CDN、灰度)时才用 URL 形式。

maxzoom 对齐数据实际级别。声明成 14 而数据只有 12,用户放大到 13 就会看到糊的 12 级瓦片;反过来声明 12 而数据有 14,后两级永远用不上。用 sqlite3 或 pmtiles 工具查一遍数据的实际级别范围再写。

GeoJSON 的 tolerance 不要为了「更精确」调到 0。调到 0 等于不简化,切瓦片的内存和 bucket 构建时间会成倍上升,视觉上几乎看不出差别。0.375 到 0.6 之间是实用区间。

聚合参数按业务定,不要用默认值上线。clusterRadius = 50 是屏幕像素,在 3x 屏上相当于 150 物理像素——低 zoom 时聚得太狠。clusterMaxZoom = 17 也是一个很「高」的默认值,全国级 POI 数据通常 12–14 就该停止聚合。

本地瓦片包优先 PMTiles。单文件、支持范围请求、可以直接放对象存储,不需要 SQLite 运行时。MBTiles 的优势是工具链成熟(mbgl-offline、tile-join 都直接产出它)和随机读快,做「预置包」仍然好用。

在 CI 里校验瓦片包的 zoom 范围与坐标系。这两个属性错了,表现是「地图能出来但不对」,比直接报错更难查。一个 10 行的脚本读 metadata 表就能兜住。

14.10 本章要点

结论 依据
八种 source 类型,vector/raster/raster-dem 共用 TileSource 基类 include/mln/style/sources/
XYZ 与 TMS 的 y 轴方向相反,配错表现为南北颠倒 tileset.hpp 的 Scheme
矢量编码默认 MVT,另一选项是 MLT;错了会解析出 0 要素 VectorSource 构造默认参数
overscaleFactor = 2^(overscaledZ - z),超过源 maxzoom 后走放大 tile_id.hpp
GeoJSON 的 tolerance 0.375、buffer 128、tileSize 512 GeoJSONOptions 默认值
聚合默认关闭,开启后 clusterRadius 50、clusterMaxZoom 17 同上
mbtiles:// 只接受绝对本地路径,pmtiles:// 支持 HTTP 范围请求 mbtiles_file_source.hpp 注释