MapLibre 实战 09|Bug 单写着"地图被风刮走了":一个瓦片源工厂,是三个线上事故换来的

9 阅读11分钟

摘要:新同学照着手册填了个 {z}/{x}/{y} 模板,上线两周运营群开始炸:地图白一块、放大到 18 级底图没了、刷新频繁就加载不出。根因是——他把"拼 URL"当成了一件事,而在线底图接入其实是三件事:多域名轮询防限流、压级别防空白、多底图可热切换。这篇用一个工厂单例收口,并复盘三个真实线上事故。

目录(TOC)


一、上线两周后,运营群炸了

新同学接入天地图,照着手册填了个 {z}/{x}/{y} 模板,跑起来一切正常,美滋滋提了 MR。

上线两周,运营群里开始有人反馈:

  • 地图突然白一块
  • 放大到 18 级以上底图就没了
  • 频繁刷新的时候直接加载不出图

你看一眼代码就知道问题在哪:他把"拼 URL"当成了一件事,而在线底图接入其实是三件事。

  1. 单域名被限流:瓦片请求极密集,一个域名会被服务端限速 / 封 IP;
  2. 名义级别 ≠ 实际可用级别:服务商标称支持到 20 级,实际 18 级以上返回 404 / 空白;
  3. 多套底图要能热切换:OSM / 谷歌 / 天地图,影像 / 矢量 / 注记,组合繁多。

把"怎么拼 URL、怎么轮询域名、怎么钳级别"散落在每个页面里,会迅速失控。DEMO 用一个瓦片源工厂单例统一管理。

flowchart TB
    PAGE[&#34;页面<br/>只说'我要天地图影像'&#34;] --> F[&#34;瓦片源工厂<br/>无状态单例&#34;]

    subgraph FACT[&#34;工厂内部收口的三件事&#34;]
        T1[&#34;① 拼 URL 模板&#34;]
        T2[&#34;② 多域名轮询&#34;]
        T3[&#34;③ 压 maxZoom&#34;]
    end

    F --> FACT
    F --> OUT[&#34;四件套<br/>主 Source + 主 Layer<br/>注记 Source + 注记 Layer&#34;]
    OUT --> STYLE[&#34;MapLibre Style&#34;]

    style F fill:#f6ffed,stroke:#52c41a,stroke-width:2px
    style FACT fill:#fff7e6,stroke:#fa8c16

二、工厂的核心:TileSet + 模板数组

MapLibre 用 TileSettileUrlTemplates 声明一组 URL 模板,引擎自动调度加载。工厂本质就是"按类型拼出对的模板数组":

// OSM:单域名 XYZ 模板
fun osmRaster(): RasterSource =
    RasterSource("osm-src",
        TileSet("2.1.0", *OsmDomains.map { "$it/{z}/{x}/{y}.png" }.toTypedArray()))

// 谷歌影像:多域名 + 接管参数拼接
fun googleImageRaster(): RasterSource =
    RasterSource("google-image-src",
        TileSet("2.1.0", *GoogleImageDomains.map { "$it&x={x}&y={y}&z={z}" }.toTypedArray()))

要点:

  • 模板里 {z}/{x}/{y} 是占位符,引擎运行时替换成真实行列号。谷歌系用 &x={x}&y={y}&z={z} 是因为它把坐标当查询参数;OSM / 天地图用路径式。
  • 返回的是 RasterSource + 配套 RasterLayer,页面拿到直接 addSource / addLayer,无需关心 URL。

对比一下 osmdroid:那边要重写 getTileURLString() 方法,把 (x,y,zoom)MapTileIndex 里拆出来手动拼;这边直接给模板字符串,引擎替你填。这就是命令式与声明式的差别。


三、多域名轮询:防限流的关键

把多个镜像域名放进模板数组,引擎会在多个域名间轮换发请求,天然摊薄单域名压力:

// OSM 域名(DEMO 仅一个公共域)
OsmDomains = ["https://tile.openstreetmap.org"]

// 谷歌影像域名(mts1~mts3 轮询)
GoogleImageDomains = [
    "http://mts1.google.com/vt/lyrs=y",
    "http://mts2.google.com/vt/lyrs=y",
    "http://mts3.google.com/vt/lyrs=y"
]

// 天地图域名(t1~t6 轮询)
TianDiTuDomains = ["t1","t2","t3","t4","t5","t6"]

天地图的轮询写法更典型——域名段 t1~t6 循环,拼到固定服务地址前:

private fun tianDiTuRaster(sourceId, service): RasterSource {
    val tiles = TianDiTuDomains.map { domain ->
        "http://$domain.tianditu.gov.cn/DataServer?T=$service&X={x}&Y={y}&L={z}&tk=$Token"
    }.toTypedArray()
    val tileSet = TileSet("2.1.0", *tiles)
    tileSet.maxZoom = TDT_MAX_USABLE_ZOOM.toFloat()   // 压级别
    return RasterSource(sourceId, tileSet)
}

说明:DEMO 把域名列表集中放在配置对象里(OsmDomains / GoogleImageDomains / TianDiTuDomains),工厂只负责"拼"。这样将来加域名、换密钥都不用动工厂逻辑。

注意天地图的参数是大写的 X/Y/L,谷歌是小写的 x/y/z ——这种大小写差异在声明式模板里更容易看走眼,因为模板字符串长得都差不多。


四、压级别:名义级别 ≠ 实际可用级别

这是最容易踩的坑。天地图标注"支持到 18 级",但部分服务类型(注记 cia/cva)实际在更高缩放会返回空瓦片。

不处理的结果:放大到 18 级以上,底图突然变空白、业务点悬空。

解法:给 TileSetmaxZoom,把请求级别钳在服务端真实可用上限

tileSet.maxZoom = TDT_MAX_USABLE_ZOOM.toFloat()   // = 18
flowchart LR
    Z[&#34;用户缩放到 19 级&#34;] --> C{&#34;超过 TileSet.maxZoom ?&#34;}
    C -->|&#34;是&#34;| STOP[&#34;不再请求瓦片<br/>(避免 404/空白)&#34;]
    C -->|&#34;否&#34;| GO[&#34;正常请求&#34;]
    STOP --> CONT[&#34;地图视图仍可继续放大<br/>业务绘制不受影响&#34;]
    STOP --> HIDE[&#34;联动:隐藏底图/注记层<br/>避免'空白底图+悬浮点'&#34;]

    style STOP fill:#fff1f0,stroke:#f5222d,stroke-width:2px
    style CONT fill:#f6ffed,stroke:#52c41a,stroke-width:2px

超过该级别的视野,引擎不再请求瓦片(避免无效请求与空白),但地图视图本身仍可继续放大——业务绘制(点、线、矢量)不受影响。

DEMO 还配合"缩放联动":超过 HUGE_ZOOM(=18)时隐藏底图 / 注记图层,仅保留业务层,避免"空白底图 + 悬浮点"的诡异观感。

这两件事必须配对做:只压级别不隐藏图层 → 看到空白底图;只隐藏图层不压级别 → 引擎还在发无效请求,浪费流量还可能被限流。


五、四类服务 + 注记叠加层

天地图一个底图其实是"主图 + 注记"两层:

  • 影像:img_w / 注记:cia_w
  • 矢量:vec_w / 注记:cva_w

注记是独立的第二层,盖在主图之上。工厂为每个服务都产出"主源 + 主层 + 注记源 + 注记层"四件套:

fun switchBaseMap(baseSource, baseLayer, annotationSource?, annotationLayer?, name) {
    applyTileSource(baseSource, baseLayer)
    removeAnnotationLayer()
    if (annotationSource != null && annotationLayer != null) {
        style.addSource(annotationSource)
        style.addLayer(annotationLayer)   // 注记盖在主图之上
        this.annotationLayer = annotationLayer
    }
}

OSM 没有注记层(annotationSource = null),调用时传空即可,逻辑统一。

这个"四件套"抽象是关键:切换底图从"改 URL"变成"换一组 Source + Layer"的复合动作,主图与注记不会脱节——不会出现"换了底图、注记还是上一家的"这种尴尬。


六、矢量瓦片源:一个静默失败的大坑

工厂不止光栅瓦片,也产出矢量瓦片源(VectorSource),指向 .pbf 瓦片:

fun demoVectorSource(): VectorSource =
    VectorSource("demo-vector-src",
        TileSet("2.1.0", VECTOR_TILE_URL + "/{z}/{x}/{y}.pbf"))

fun demoVectorLabelLayer(): SymbolLayer =
    SymbolLayer("demo-vector-label", "demo-vector-src")
        .withSourceLayer("place")            // 必须指定矢量瓦片内部图层名
        .withProperties(textField("{name}"), ...)

矢量瓦片要指定 source-layer(瓦片内部的图层名,如 place),否则渲染不出。这是光栅 / 矢量两套源在用法上的最大差异。

而且这个错误静默失败——不报错、不崩溃,就是什么都不画。排查起来特别费劲,详见下面事故 C。


七、三个线上事故复盘

瓦片源工厂这个模块,我是在线上事故里长教训的。三个坑讲出来共勉:

事故 A:漏设 maxZoom,线上底图"凭空消失"

第一版天地图接入没设 TileSet.maxZoom,本地测到 18 级没事,用户一放大到 19 级,底图整片空白、业务点悬空。

最魔幻的是 Bug 单描述写的是"地图被风刮走了"。

从那以后 maxZoom 成了工厂里所有源的标准配置项,不设就不让过 code review

事故 B:单域名硬扛,测试环境永远测不出限流

谷歌影像只填了一个域名 mts1,开发期流量小没事,集成测试一跑全量请求,直接 403 一大片。后来改成 mts1~mts3 三域名轮询才消停。

限流是概率事件,流量上去才会露头,开发期"没被限"不代表"不会限"。

这类问题的可怕之处在于:它在 code review 里看不出来,在单元测试里也测不出来,只有在真实流量下才现形。所以多域名应该是默认配置,而不是"出问题了才加"

事故 C:矢量瓦片漏 source-layer,一片空白还不报错

加了 VectorSourceSymbolLayer,地图上什么都看不见,Logcat 也没异常——因为 source-layer 只是让引擎找不到对应图层数据,静默不画

查了半天才意识到矢量瓦片必须指内部图层名。这是光栅 / 矢量心智切换里最隐蔽的一个坑。

这类"静默失败"的排查成本远高于崩溃——崩溃有堆栈,静默失败什么都没有。防御手段是:加矢量图层时,先用已知存在的 source-layer 名验证一遍链路通不通,再换成业务需要的名字。


八、与 osmdroid 的异同

通用原理(一致)

多域名轮询防限流、把名义缩放钳到实际可用级别、主图 + 注记双层叠加、OSM / 谷歌 / 天地图多底图切换——这些是接入任何在线瓦片源的通用工程要点,与引擎无关。

MapLibre 专属实现差异

维度osmdroid 篇(02)本文(MapLibre)
源模型MapTileProviderArray / 自定义 provider 拼装TileSet.tileUrlTemplates 直接填模板数组
URL 拼接重写 getTileURLString() 手动拼模板占位符,引擎替填
多域轮询需自己实现 provider 数组调度多域名模板丢进数组,引擎自动轮换
压级别重写 getMaximumZoomLevel()TileSet.maxZoom 一处搞定
矢量瓦片无原生矢量瓦片概念原生 VectorSource + source-layer,一等公民
注记层多叠一个 Overlay多叠一个 RasterLayer(同属"源 + 图层"模型)

一句话:

轮询、压级别、多底图是通用知识;MapLibre 把它们收口成"TileSet 模板数组 + maxZoom"的声明式配置,比 osmdroid 的 provider 数组更简洁,且原生支持矢量瓦片(.pbf)——这是 osmdroid 不具备的能力。


九、如果今天重写:五件事

DEMO 的工厂是"一个类 + 一堆函数",够用但还能更工程化:

1. 把"每种底图"抽象成独立 Provider

定义 TileSourceProvider 接口(返回 Source + Layer + 注记 Layer 三元组),OSM、谷歌、天地图各一个实现类。加新底图 = 新增一个类,工厂只负责注册表与切换,不再堆 if/else 分服务类型。

2. 域名健康度动态剔除

模板数组是静态的,某个镜像域名挂了就只能白白浪费请求。重写版会维护一个"最近 N 次请求失败"的域名黑名单,失败的域名自动从模板数组移除,兜底回退到健康域名——配合引擎重试,底图可用性高一个档次。

3. 密钥与域名走构建注入 / 服务端下发

tk、域名列表都进配置中心,App 启动拉取。换密钥不用发版,泄露了也能快速吊销。 DEMO 里集中到配置对象只是第一步,生产要跟构建 / 下发流程打通。

4. http / https 按环境区分

谷歌影像在 DEMO 里是 http,明文请求在真机上容易被拦。重写版让域名配置携带协议层,Android 9+ 强制 HTTPS 时统一升级,避免"只有某些机型加载不出图"的诡异问题。

5. 工厂产物接缓存与离线归档

既然源都从工厂出,干脆把"该底图是否有本地归档、是否允许内存 / 磁盘缓存"也放进配置,在线离线一体切换,跟"离线三路线"篇打通。


十、落地清单

  • 坑 1:忘设 maxZoom,高缩放下底图空白。务必把名义级别钳到实际可用值,并配套联动隐藏图层。
  • 坑 2:单域名硬扛,被限流。OSM 公共域虽只有一个,生产应自建代理或多域镜像;谷歌 / 天地图务必多域轮询。
  • 坑 3:密钥写死在代码。DEMO 把 tk 集中到配置对象,生产应走构建注入 / 服务端下发。
  • 坑 4:矢量瓦片漏 source-layer,渲染空白且静默失败
  • 坑 5:参数名大小写(天地图 X/Y/L vs 谷歌 x/y/z),模板字符串里极易看走眼。
  • 工厂做成无状态单例,页面只消费 Source / Layer,URL 拼接、轮询、钳级别全收口在工厂内。

完整可运行源码GitCode 仓库 · android_osmdroid_maplibre

对照阅读:本系列第 02 篇(osmdroid 在线底图接入:从 URL 模板到生产级瓦片源工厂)——同一件事,osmdroid 要重写方法、MapLibre 只需填模板数组,对照看能理解声明式配置的收益边界。

延伸阅读:本系列第 08 篇(MapLibre 坐标纠偏)

评论区聊聊:你的底图被限流过吗?有没有收到过"地图被风刮走了"这种让人哭笑不得的 Bug 单?说说你的线上事故现场。


本系列为 osmdroid / MapLibre 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。