摘要:新同学照着手册填了个
{z}/{x}/{y}模板,上线两周运营群开始炸:地图白一块、放大到 18 级底图没了、刷新频繁就加载不出。根因是——他把"拼 URL"当成了一件事,而在线底图接入其实是三件事:多域名轮询防限流、压级别防空白、多底图可热切换。这篇用一个工厂单例收口,并复盘三个真实线上事故。
目录(TOC)
- 一、上线两周后,运营群炸了
- 二、工厂的核心:TileSet + 模板数组
- 三、多域名轮询:防限流的关键
- 四、压级别:名义级别 ≠ 实际可用级别
- 五、四类服务 + 注记叠加层
- 六、矢量瓦片源:一个静默失败的大坑
- 七、三个线上事故复盘
- 八、与 osmdroid 的异同
- 九、如果今天重写:五件事
- 十、落地清单
一、上线两周后,运营群炸了
新同学接入天地图,照着手册填了个 {z}/{x}/{y} 模板,跑起来一切正常,美滋滋提了 MR。
上线两周,运营群里开始有人反馈:
- 地图突然白一块;
- 放大到 18 级以上底图就没了;
- 频繁刷新的时候直接加载不出图。
你看一眼代码就知道问题在哪:他把"拼 URL"当成了一件事,而在线底图接入其实是三件事。
- 单域名被限流:瓦片请求极密集,一个域名会被服务端限速 / 封 IP;
- 名义级别 ≠ 实际可用级别:服务商标称支持到 20 级,实际 18 级以上返回 404 / 空白;
- 多套底图要能热切换:OSM / 谷歌 / 天地图,影像 / 矢量 / 注记,组合繁多。
把"怎么拼 URL、怎么轮询域名、怎么钳级别"散落在每个页面里,会迅速失控。DEMO 用一个瓦片源工厂单例统一管理。
flowchart TB
PAGE["页面<br/>只说'我要天地图影像'"] --> F["瓦片源工厂<br/>无状态单例"]
subgraph FACT["工厂内部收口的三件事"]
T1["① 拼 URL 模板"]
T2["② 多域名轮询"]
T3["③ 压 maxZoom"]
end
F --> FACT
F --> OUT["四件套<br/>主 Source + 主 Layer<br/>注记 Source + 注记 Layer"]
OUT --> STYLE["MapLibre Style"]
style F fill:#f6ffed,stroke:#52c41a,stroke-width:2px
style FACT fill:#fff7e6,stroke:#fa8c16
二、工厂的核心:TileSet + 模板数组
MapLibre 用 TileSet 的 tileUrlTemplates 声明一组 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 级以上,底图突然变空白、业务点悬空。
解法:给 TileSet 设 maxZoom,把请求级别钳在服务端真实可用上限:
tileSet.maxZoom = TDT_MAX_USABLE_ZOOM.toFloat() // = 18
flowchart LR
Z["用户缩放到 19 级"] --> C{"超过 TileSet.maxZoom ?"}
C -->|"是"| STOP["不再请求瓦片<br/>(避免 404/空白)"]
C -->|"否"| GO["正常请求"]
STOP --> CONT["地图视图仍可继续放大<br/>业务绘制不受影响"]
STOP --> HIDE["联动:隐藏底图/注记层<br/>避免'空白底图+悬浮点'"]
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,一片空白还不报错
加了 VectorSource 和 SymbolLayer,地图上什么都看不见,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/Lvs 谷歌x/y/z),模板字符串里极易看走眼。 - 工厂做成无状态单例,页面只消费
Source/Layer,URL 拼接、轮询、钳级别全收口在工厂内。
完整可运行源码:GitCode 仓库 · android_osmdroid_maplibre
对照阅读:本系列第 02 篇(osmdroid 在线底图接入:从 URL 模板到生产级瓦片源工厂)——同一件事,osmdroid 要重写方法、MapLibre 只需填模板数组,对照看能理解声明式配置的收益边界。
延伸阅读:本系列第 08 篇(MapLibre 坐标纠偏)
评论区聊聊:你的底图被限流过吗?有没有收到过"地图被风刮走了"这种让人哭笑不得的 Bug 单?说说你的线上事故现场。
本系列为 osmdroid / MapLibre 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。