osmdroid 地图实战 02|在线底图接入:从 URL 模板到生产级瓦片源工厂(谷歌 + 天地图双案例)

0 阅读11分钟

摘要:接谷歌影像,地址明明是对的,一放大就花屏;接天地图,放大到米级直接崩,影像上还没有地名。这两个坑的根因是同一件事——你没搞懂"瓦片源"到底是什么契约。这篇用两个真实案例,把在线底图从"抄代码碰运气"讲到"填空式接入",最后给出一个可复用的生产级瓦片源工厂。

目录(TOC)


一、两个真实的翻车现场

1.1 现场一:谷歌影像,地址明明是对的,凭什么花屏?

需求来了:"把谷歌影像作为底图。"

很多人用开源地图引擎时,默认走内置的公共瓦片源,一搜代码发现引擎没直接提供谷歌——于是上网抄一段"魔改版"的瓦片源,跑起来第一屏能显示,但一旦放大缩到某些级别就花屏,或者被服务端限流。

你反复检查,地址明明是对的。代码大概长这样:

// 自以为接入完成,但瓦片地址拼错了
val source = XYTileSource(
    name = "GoogleImage",
    minZoom = 0,
    maxZoom = 22,
    tileSizePixels = 256,
    imageFilenameEnding = "",
    baseUrl = "http://mts1.google.com/vt/lyrs=y&hl=x-local"
)
mapView.setTileSource(source)

一运行,第一屏是花的,或者干脆一片空白。

凭什么?

答案藏在引擎的一个细节里:XYTileSource 的基类会按自己的规则把瓦片坐标拼进 URL 尾部,而谷歌的服务要求的是 x/y/z 这种带名字的参数。引擎默认拼出来的东西,和谷歌要的不一样——所以你得接管 URL 拼接

问题不在于"没有现成的",而在于绝大多数人没搞懂瓦片源的 URL 到底是怎么拼出来的

1.2 现场二:天地图,放大就崩,影像上还没有地名

天地图是国内最常见的在线底图来源。但很多人一接就遇到两个坎:

// 想当然的接入:一个瓦片源搞定一切
val source = OnlineTileSourceBase(
    name = "TianDiTu",
    minZoom = 1, maxZoom = 22,  // 觉得级别越高越好
    tileSizePixels = 256,
    imageFilenameEnding = "",
    baseUrl = arrayOf("http://t1.tianditu.com/DataServer?T=img_w&tk=xxx")
) { tile ->
    // 拼 URL ...
}
mapView.setTileSource(source)

运行后发现两个问题:

  1. 缩放到米级比例尺时,地图就崩了或变花——明明服务支持大级别,为什么不行?
  2. 影像地图没地名、没道路名——看着像"哑图"。

前一个坎的答案藏在"最大级别"这个参数里,后一个坎的答案藏在"注记要单独叠一个图层"这个设计里。

两个现场、三个症状,根因指向同一处。


二、根因:瓦片源是一个"坐标 → URL"的契约

2.1 瓦片坐标是怎么来的

地图引擎渲染底图时,会为屏幕上的每个瓦片计算一个 (x, y, zoom) 三元组——这就是瓦片坐标

  • zoom:缩放级别(0 级时整个世界是一张 256×256 的图);
  • x / y:该级别下瓦片在网格里的行列号(zoom 级时网格是 2^zoom × 2^zoom)。

这里有个容易踩的细节:Y 轴方向。主流 Web 瓦片规范(XYZ / Slippy Map)的 Y 轴向下为正(原点在左上角),而部分规范(如 TMS)Y 轴向上为正。两者互为 y_tms = 2^zoom - 1 - y_xyz。如果你接的服务用了另一套规范,画面会出现上下颠倒或整体错位——这也是"花屏"的一个常见来源,且极难从 URL 上看出来。

2.2 引擎知道坐标,但不知道 URL 长什么样

关键在于:引擎知道 (x, y, zoom),但它不知道你的服务商希望 URL 长什么样。

于是"瓦片源"这个抽象,本质上就是一件简单的事:

拿到 (x, y, zoom),拼出一个能拿到这张图的 URL。

flowchart LR
    A[&#34;引擎算出<br/>(x, y, zoom)&#34;] --> B{&#34;瓦片源<br/>TileSource&#34;}
    B -->|&#34;内置服务&#34;| C[&#34;引擎自带拼接规则&#34;]
    B -->|&#34;自定义服务&#34;| D[&#34;你重写 getTileURLString&#34;]
    C --> E[&#34;可下载的图片 URL&#34;]
    D --> E
    E --> F[&#34;瓦片调度器下载 → 渲染&#34;]
    style B fill:#fff7e6,stroke:#fa8c16,stroke-width:2px
    style D fill:#f6ffed,stroke:#52c41a,stroke-width:2px

内置的服务,引擎已经写好了拼接规则;自定义的服务,拼接规则由你接管。

所以两个现场的根因是同一句:

引擎没有为每一种服务内置拼接规则,你得自己实现这个"契约"。 而"级别上限"和"注记是否独立"这两件事,是这个契约里最容易被忽略的两个字段。


三、案例一:谷歌影像——接管 URL 拼接

3.1 重写 URL 拼接:把坐标按服务要求拼进去

关键在重写"取 URL"的方法,从瓦片索引里拆出 x/y/zoom,再按服务的参数名拼好:

// 自定义瓦片源:把引擎算好的瓦片坐标,拼成服务端认识的 URL
class GoogleImageSource : XYTileSource(
    name = "GoogleImage",
    minZoom = 0,
    maxZoom = 22,
    tileSizePixels = 256,
    imageFilenameEnding = "",
    baseUrl = arrayOf("http://mts1.google.com/vt/lyrs=y&hl=x-local")
) {
    override fun getTileURLString(tile: Long): String {
        val x = MapTileIndex.getX(tile)
        val y = MapTileIndex.getY(tile)
        val zoom = MapTileIndex.getZoom(tile)
        // 服务端要求 x/y/z 命名参数,与引擎默认拼法不同
        return "$baseUrl&x=$x&y=$y&z=$zoom"
    }
}

这里 baseUrl 和拼接是分离的:baseUrl 只描述"服务 + 样式",真正的坐标在拼接阶段动态补上。这个分离很重要——它让你换域名、换样式时不用动拼接逻辑。

3.2 多域名轮询:规避服务端限流

在线瓦片服务通常提供多个镜像域名(如 mts1/2/3)。如果只写死一个域名,请求集中在单点,容易被限流。做法是把它们都塞进 base 地址列表:

// 多域名:引擎会按某个策略轮询,避免单点被打爆
val source = XYTileSource(
    name = "GoogleImage",
    minZoom = 0,
    maxZoom = 22,
    tileSizePixels = 256,
    imageFilenameEnding = "",
    baseUrl = arrayOf(
        "http://mts1.google.com/vt/lyrs=y&hl=x-local",
        "http://mts2.google.com/vt/lyrs=y&hl=x-local",
        "http://mts3.google.com/vt/lyrs=y&hl=x-local"
    )
) {
    override fun getTileURLString(tile: Long): String {
        val x = MapTileIndex.getX(tile)
        val y = MapTileIndex.getY(tile)
        val zoom = MapTileIndex.getZoom(tile)
        return "$baseUrl&x=$x&y=$y&z=$zoom"
    }
}

注意一个反直觉的点:虽然拼接只用了"当前这一个域名"的 baseUrl,但把全部域名列进去,引擎在派发请求时会分散到这些域名上。这是在线瓦片接入的通用姿势——你不用自己写轮询逻辑,引擎的调度器会做。

3.3 按图层名切换:影像 vs 矢量

同一个服务往往有"影像"和"矢量"两种样式,差异只是 URL 里的一个参数(lyrs=y 是影像、lyrs=m 是矢量)。可以定义两个瓦片源,用图层名做路由:

// 图层名 -> 瓦片源 的路由表
fun tileSourceFor(layerName: String): TileSource = when (layerName) {
    "影像底图" -> googleImageSource
    "矢量底图" -> googleVectorSource
    else -> defaultSource
}

// 切换底图:一行代码,但背后的瓦片调度器会整体换血
mapView.setTileSource(tileSourceFor(currentLayerName))

setTileSource 之所以重要,是因为它不止换了 URL 模板,还会触发瓦片调度器重新拉取当前视野的瓦片——所以"切换底图"天然自带刷新,你不用额外手动 invalidate。


四、案例二:天地图——四套服务 + 注记叠加

4.1 天地图是"四套服务"而非"一套"

天地图的服务按内容分四类,各自独立:

flowchart LR
    TD[天地图服务] --> IMG[&#34;影像 img_w&#34;]
    TD --> CIA[&#34;影像注记 cia_w&#34;]
    TD --> VEC[&#34;矢量 vec_w&#34;]
    TD --> CVA[&#34;矢量注记 cva_w&#34;]

    IMG --> U1[影像底图]
    CIA --> O1[叠加在影像上]
    VEC --> U2[矢量底图]
    CVA --> O2[叠加在矢量上]

    style IMG fill:#e6f7ff,stroke:#1890ff
    style VEC fill:#e6f7ff,stroke:#1890ff
    style CIA fill:#fff7e6,stroke:#fa8c16
    style CVA fill:#fff7e6,stroke:#fa8c16
  • 影像 / 矢量:是"底图",负责地形地貌、道路骨架;
  • 注记(影像注记 / 矢量注记):是"地名、路名、门牌"这些文字标注,和底图是两套瓦片,要单独叠加一个图层,盖在底图之上。

所以"带地名的影像地图" = 影像底图层 + 影像注记层,两层叠加。漏掉注记层,就是"哑图"。

4.2 带级别限制的瓦片源:压住最大级别

关于"放大就崩":天地图底图服务虽然名义上支持到很高的级别,但实际有效的最大级别有限。如果你按瓦片源的默认最大级别去请求,会请求到服务端不存在的级别,轻则花屏、重则崩溃。

解决办法是把该服务的最大级别显式压到服务端真正支持的值——这样即便继续放大,引擎也只请求有效级别,不会崩:

// 影像底图瓦片源:URL 模板 + 硬性级别上限
val imageSource = OnlineTileSourceBase(
    name = "TianDiTuImg",
    minZoom = 1,
    maxZoom = 22,          // 名义上限(会被下面重写压下来)
    tileSizePixels = 256,
    imageFilenameEnding = "",
    baseUrl = arrayOf(
        "http://t1.tianditu.com/DataServer?T=img_w",
        "http://t2.tianditu.com/DataServer?T=img_w",
        "http://t3.tianditu.com/DataServer?T=img_w"
    )
) {
    override fun getTileURLString(tile: Long): String {
        val x = MapTileIndex.getX(tile)
        val y = MapTileIndex.getY(tile)
        val zoom = MapTileIndex.getZoom(tile)
        return "$baseUrl&X=$x&Y=$y&L=$zoom$TOKEN"
    }

    // 关键:把实际可用级别压到服务端支持的值,防止放大后崩/花屏
    override fun getMaximumZoomLevel(): Int = MAX_USABLE_ZOOM
}

URL 参数含义:T 指定服务类型(img_w 影像 / vec_w 矢量 / cia_w 影像注记 / cva_w 矢量注记),X/Y/L 是瓦片坐标,tk 是申请到的密钥。

注意这里的参数名大小写:天地图用大写的 X/Y/L,而谷歌用小写的 x/y/z。这种"看起来无关紧要"的差异,正是自定义拼接必须自己接管的原因——引擎不会替你猜。

4.3 叠加注记层:底图 + 注记 = 两层

// 底图用主瓦片源
mapView.setTileSource(imageSource)

// 注记是独立的第二层,用"瓦片叠加层"盖在底图之上
val annotationOverlay = TilesOverlay(
    MapTileProviderBasic(context, annotationSource),  // 注记瓦片源
    context
)
mapView.overlayManager.add(annotationOverlay)

4.4 放大到超大级别时关掉注记

当缩放到米级(接近级别上限)时,注记已经密集到没有意义,反而可能拖慢甚至出问题。可以在缩放监听里,超过某级别时关掉注记层

mapView.addMapListener(object : MapListener {
    override fun onZoom(event: ZoomEvent): Boolean {
        if (event.zoomLevel > HUGE_ZOOM) {
            annotationOverlay.isEnabled = false   // 大级别关注记
            mapView.overlayManager.tilesOverlay.isEnabled = false
        } else {
            annotationOverlay.isEnabled = true
            mapView.overlayManager.tilesOverlay.isEnabled = true
        }
        return false
    }
    override fun onScroll(event: ScrollEvent): Boolean = false
})

这一步同时解决了两个问题:大级别下关底图、关注记,既避免了请求无效级别,也省资源。


五、提炼:生产级瓦片源工厂

把谷歌和天地图这两个例子抽象掉,你会得到一个通用结论:

任何在线瓦片服务,无论它是影像、矢量、还是某个私有协议,本质上都是给一个 (x, y, zoom),返回一个可下载图片的 URL。差别只在:参数叫什么、域名有几个、是否有 token、是否需要签名。

5.1 对接任何新服务,只需问清四件事

#问题决定了什么
1最小 / 最大级别是多少?缩放范围约束(防崩溃)
2瓦片尺寸是多少?常见 256,部分服务 512
3域名有几个?是否要多域名轮询(防限流)
4URL 参数叫什么、要不要 token / 签名?拼接规则
+1Y 轴是 XYZ 还是 TMS 规范?决定要不要翻转 Y(最易漏的一项

把这四(五)件事问清楚,"接入任何瓦片源"就从"抄代码碰运气"变成填空

5.2 把"服务 + 注记 + 级别策略"建模成一组

如果重写,我不会把底图当成"一个瓦片源",而是当成一组服务,用一个"底图配置器"统一管理:

flowchart TB
    C[&#34;底图配置器<br/>BaseMapConfigurator&#34;] --> A{&#34;选择服务&#34;}
    A -->|&#34;影像&#34;| S1[&#34;影像源 + 影像注记源&#34;]
    A -->|&#34;矢量&#34;| S2[&#34;矢量源 + 矢量注记源&#34;]
    S1 --> L[&#34;级别策略<br/>getMaximumZoomLevel&#34;]
    S2 --> L
    L --> M[&#34;叠加层组合<br/>底图 + 注记&#34;]
    M --> Z[&#34;缩放监听<br/>大级别关注记&#34;]
    style C fill:#f6ffed,stroke:#52c41a,stroke-width:2px
    style L fill:#fff1f0,stroke:#f5222d,stroke-width:2px

要点是:"选底图"是一个复合动作——它同时决定主瓦片源、注记叠加层、以及级别策略。把这三件事绑在一起,而不是散落在各处,切换底图才不会"只换了底图、忘换注记"。

5.3 在线底图接入的坑对照表

症状根因解法
第一屏能显示,某些级别花屏引擎默认拼法与服务端参数名不一致重写 getTileURLString
画面上下颠倒 / 整体错位Y 轴规范不一致(XYZ vs TMS)翻转 Y:2^zoom - 1 - y
放大到米级崩溃请求了服务端不存在的级别重写 getMaximumZoomLevel 压级别
影像底图没有地名注记是独立服务,需要单独叠加层TilesOverlay 注记层
用一会儿就被限流单域名请求集中多域名全部列进 baseUrl
切换底图后没刷新误解 setTileSource 行为它本身会触发调度器重拉,无需手动 invalidate

六、结论

  • 瓦片源的本质是**"(x, y, zoom) → 可下载 URL"的契约**,内置服务已实现,自定义服务要自己实现拼接。
  • 自定义瓦片源要重写 URL 拼接方法,把引擎算好的瓦片坐标,按服务端要求的参数名拼好(注意大小写、x/y/z vs X/Y/L)。
  • Y 轴规范(XYZ vs TMS)是最易漏的一项,错了会整体错位且难从 URL 看出。
  • 多域名轮询是规避限流的通用做法,把镜像域名都列进 base 地址,调度器自动分散。
  • 服务名义最大级别 ≠ 实际可用级别,要显式压到服务端支持的值,防放大崩溃。
  • "带地名的底图" = 底图层 + 注记层两层叠加;大级别时关掉注记省资源。
  • 把"底图 + 注记 + 级别策略"封装成一组配置,切换底图才干净。

Screenshot_20260826_141533.png

Screenshot_20260826_141554.png

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

对照阅读:同一套瓦片源工厂在 MapLibre 上的实现,见《MapLibre 瓦片源工厂:多域名轮询 + 压级别的在线底图接入》——两个引擎的瓦片源抽象差异很大,对照看能理解得更透。

延伸阅读:本系列第 01 篇(坐标纠偏:WGS-84 落到 GCJ-02 底图,为什么要转两次)

评论区聊聊:你接过最折腾的瓦片服务是哪家?有没有因为 Y 轴规范或者"名义最大级别"翻过车?说说你的踩坑经历。


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