摘要:野外没网了地图怎么显示?八种数据怎么不写成一锅粥?比例尺为什么被数据盖住?这三个问题看着不相干,其实是同一件事的三个维度------数据从哪来、画成什么样、叠在哪一层。这篇用一个统一的分层架构把它们串起来,从离线三方案讲到图层模型,再讲到 Overlay 体系。
目录(TOC)
- 一、三个现场:地图"分层"的三个坑
- 二、根因:地图是分层的,三个维度必须正交
- 三、维度一:数据从哪来------离线三方案
- 四、维度二:画成什么样------统一图层模型
- [五、维度三:叠在哪一层------Overlay 体系](#五、维度三:叠在哪一层——Overlay 体系 "#%E4%BA%94%E7%BB%B4%E5%BA%A6%E4%B8%89%E5%8F%A0%E5%9C%A8%E5%93%AA%E4%B8%80%E5%B1%82overlay-%E4%BD%93%E7%B3%BB")
- 六、合起来:一个完整的分层架构
- 七、坑对照表
- 八、结论
一、三个现场:地图"分层"的三个坑
1.1 现场一:没有网的野外,三种离线资源接哪个?
测绘、野外作业、应急这些场景,网络是奢侈品。没有网,在线瓦片就全废。出发前你手头有三类"离线资源",但不知道用哪套流程接:
- 一份矢量地图文件 (如
.map)------存的是道路、水系、建筑等矢量几何 + 属性,不是现成图片; - 一堆预切好的瓦片(打包成 zip / sqlite)------每个级别、每个网格一张现成 PNG;
- 一张高清影像图(JPG / TIF,附坐标信息文件)------一整张大图,对应一片真实地理区域。
你心想:是不是都用"瓦片源"接就行?结果发现每种都卡在不一样的地方:矢量文件渲染出来是灰的、瓦片归档加载不出、影像图要么位置对不上要么直接内存爆掉。
很多人以为"离线"只是"把在线瓦片下载下来",其实那只是其中一种。
1.2 现场二:八种数据,代码写成一锅粥
地图上要叠的东西五花八门:点库、矢量文件、CAD 图、影像......每种数据的格式、来源、渲染方式都不同。不做抽象时,你会写出这样的"面条代码":
kotlin
fun drawPointLibrary(canvas) { /* 点库专用绘制 */ }
fun drawShp(canvas) { /* shp 专用绘制 */ }
fun drawDxf(canvas) { /* dxf 专用绘制 */ }
// 每加一种数据,就加一个 if 分支、一段独立绘制、一套独立保存
加一种数据,就要动好几处代码;想统一控制"某类要素显不显示""用什么符号""要不要标注",更是无从下手------因为每种数据都有自己的状态。数据越多,这套代码越像一锅粥。
1.3 现场三:比例尺被数据盖住,旋转时跟着转
地图上除了底图,还有一堆"盖在上面"的东西:比例尺、罗盘、经纬网格、影像图、业务数据......刚开始挂 Overlay,你可能"想起来就 add":
kotlin
mapView.overlays.add(scaleBar) // 比例尺
mapView.overlays.add(compass) // 罗盘
mapView.overlays.add(gridline) // 经纬网格
mapView.overlays.add(myDataLayer) // 业务数据
跑起来遇到三个典型问题:
- 顺序不对:比例尺、网格画在了数据下面,被数据盖住;或者网格线盖住了图斑看不清。
- 旋转错乱:旋转地图时,某些 Overlay 跟着转、某些该"钉在屏幕上"却转了。
- 影像图层成堆:一张张 add 出来,想统一控制 / 清除很麻烦。
二、根因:地图是分层的,三个维度必须正交
把上面三个现场放在一起看,会发现它们在问三个不同的问题:
| 现场 | 真正在问的问题 |
|---|---|
| 离线三方案 | 数据从哪来?(矢量包 / 瓦片归档 / 影像贴图) |
| 八种数据面条代码 | 画成什么样?(可见性、符号、标注) |
| Overlay 顺序错乱 | 叠在哪一层?(层级顺序、是否随图旋转) |
这三个维度是正交的,必须分开管理。 而绝大多数地图代码的混乱,都来自把这三个维度混在了一起:
- 把"数据从哪来"和"画成什么样"混在一起 → 每加一种数据就复制一套绘制 + 一套状态管理(现场二);
- 把"叠在哪一层"和"画成什么样"混在一起 → 层级顺序写死在各处 add 调用里(现场三);
- 把"数据从哪来"的三种形态当成同一种东西处理 → 三种离线资源用同一套流程接,全都接不对(现场一)。
下面三个维度逐个拆。
三、维度一:数据从哪来------离线三方案
3.1 本质差异:三种"换算时机"
离线地图的本质差异,在于**"矢量 → 图片"这件事是在什么时候、由谁完成的**:
- 矢量包 :存的不是图,是"要素 + 属性 + 渲染规则"。用的时候现算------渲染器按规则把矢量几何画成瓦片。好处是无限缩放不糊、体积小;代价是首次渲染要算、要有渲染引擎。
- 瓦片归档 :事先把每一级每一格都切好成 PNG,打包。用的时候纯取,几乎零计算。好处是极快;代价是级别固定、离线时不能缩放超范围。
- 影像贴图 :一张大图 + 坐标信息(四个角对应经纬度)。用的时候把图按地理范围铺到地图上。好处是超高清、适合航拍 / 扫描图;代价是内存压力大、要做降采样。
3.2 矢量包:接一个"离线渲染瓦片源"
kotlin
// 矢量包:注册图形工厂 + 渲染主题,构造离线渲染瓦片源
val theme = AssetsRenderTheme(context, "renderthemes/", "rendertheme-v4.xml")
val provider = OfflineRenderProvider(
files = arrayOf(mapFile), // 矢量地图文件
theme = theme, // 渲染规则(配色、线型、字号)
context = context
)
mapView.setTileProvider(provider)
最容易漏的一步:必须先初始化图形渲染工厂,否则渲染源无法工作------"渲染出来是灰的"这个症状,八成就栽在这儿。渲染主题决定了最终"长什么样",换主题 = 换风格。
3.3 瓦片归档:注册扩展名 + 从归档取源
kotlin
// 瓦片归档:识别扩展名 -> 打开归档 -> 取其中的瓦片源
val archives = archiveFactory.open(file) // 支持 zip / sqlite 等
val tileSource = archives.first().tileSources.first()
mapView.setTileSource(tileSource)
关键点:要先判断文件扩展名是否被引擎支持 ,不支持的归档类型要提前注册。读出来的瓦片源,它的级别范围由归档内容决定------别设超过归档范围的级别,否则会请求到不存在的瓦片。
3.4 影像贴图:算角点经纬度 + 降采样 + 铺图
kotlin
// 影像贴图:读角点坐标 -> 解码位图(逐步降采样)-> 铺到地理范围
val topLeft = readCornerGeoPoint(jgwFile, prjFile) // 左上角经纬度
val bottomRight = readCornerGeoPoint(jgwFile, prjFile) // 右下角经纬度
var bitmap = decodeBitmap(imageFile) // 可能 OOM
// 内存不足时逐步降采样,直到能解出为止
while (bitmap == null && sampleFactor < MAX_SAMPLE) {
sampleFactor += 1
bitmap = decodeBitmap(imageFile, sampleFactor)
}
GroundOverlay(bitmap, topLeft, bottomRight).let { mapView.overlays.add(it) }
三个关键点:
- 角点经纬度来自配套坐标文件 (
.jgw/.prj),否则位置对不上; - TIF 这种大图要降采样,否则内存爆------上面的"逐步降采样直到成功"是防御式写法,比一次性 try 更稳;
- 整图作为一个叠加层铺上去,而非切成瓦片。
3.5 选型对照表
| 场景 | 数据形态 | 推荐方案 | 理由 |
|---|---|---|---|
| 导航 / 城市路网 | 矢量数据包 | 矢量渲染 | 体积小、无限缩放、可换主题 |
| 离线底图(固定级别) | 预切瓦片 | 瓦片归档 | 快、零计算、稳定 |
| 航拍 / 扫描图 / 施工图 | 单张大图 | 影像贴图 | 高清、真实地理对齐 |
"离线"不等于"一个方案"。 真正该做的是写一个"离线地图门面",根据文件类型自动路由到三种实现之一------用户拖进来什么,就用对应流程加载,而不是要求用户先分门别类。
四、维度二:画成什么样------统一图层模型
4.1 把"数据差异"和"图层配置"分开
问题出在两个维度的概念被混淆了:
- 数据来源(是点库、是 SHP、是 DXF、是影像)------这是"怎么读、怎么解析"的差异;
- 图层配置(显示与否、符号、大小、标注)------这是"画成什么样"的差异。
前者可以各不相同,但后者完全可以是同一套字段。所以正确抽象是:
一个图层配置模型,携带"数据类型"这个标签,用标签决定"读什么",用统一字段决定"画成什么样"。
4.2 图层配置模型
kotlin
data class LayerConfig(
val dataType: Int, // 数据类型标签:点库 / shp / dxf / 影像 ...
var layerName: String, // 图层名(用于路由到具体数据源)
var display: Boolean, // 是否可见
var entityType: Int, // 地物类型:点 / 线 / 面
var symbolIndex: Int, // 符号索引
var symbolSize: Int, // 符号大小
var displayLabel: Boolean, // 是否显示标注
var labelSize: Int // 标注大小
)
关键:"数据怎么读"由 dataType 决定,"画成什么样"由符号 / 标注字段决定。两者解耦,图层才能被统一管理。
4.3 统一渲染管线
kotlin
fun drawLayers(canvas: Canvas) {
for (config in layerList) {
if (!config.display) continue // 不可见就跳过
when (config.dataType) {
SHP -> shpSource.draw(canvas, config)
DXF -> dxfSource.draw(canvas, config)
POINT_LIBRARY -> pointLibrary.draw(canvas, config)
// 新增数据类型时,只需在此加一个分支
}
}
}
这条管线的价值:加一种新数据,只多一个路由分支;而"可见性过滤""符号应用"这些通用逻辑,所有图层共享,不用重复写。
4.4 持久化:整个图层列表序列化成一份 XML
kotlin
fun save() {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument()
// 遍历图层列表,每个图层生成一个 XML 节点,字段逐个写入
transformer.transform(DOMSource(doc), StreamResult(layerConfigFile))
}
fun load() {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(layerConfigFile)
// 遍历节点,还原成 LayerConfig 对象,回填到图层列表
// 解析失败时回退到默认配置(如默认底图)
}
持久化的关键是**"默认值兜底"**:配置文件缺失或损坏时,要能回退到一份内置默认图层(如默认底图),而不是崩溃或空白。这一条看着小,实际救过我好几次------配置文件在真机上被写坏是常有的事。
五、维度三:叠在哪一层------Overlay 体系
5.1 根因:Overlay 是"有序栈",层级 = 添加顺序
Overlay 系统的核心规则很简单:
列表越靠后的 Overlay,绘制时越靠上(越后画,盖在越前面之上)。
所以"比例尺该在最上""数据该在底图之上、网格之下",本质是添加顺序问题,而不是什么魔法。理解了"顺序即层级",一切都能用"调整列表顺序"解决。
另外一个常被忽略的点:Overlay 不只是"画东西",它还能响应地图手势 (点击、滚动、缩放),并且每个 Overlay 可以独立启用 / 禁用 。所以它是一个可交互、可开关的图层单元------这也是为什么"用容器归组"比"往列表里塞一堆"更值得。
5.2 分清"随图旋转"与"屏幕固定"
- 屏幕固定类(比例尺、罗盘):无论如何旋转、移动,都钉在屏幕某处,不该随地图旋转;
- 随图变换类(网格、数据、影像):跟随地图平移、缩放、旋转。
所以叠加顺序要优先保证屏幕固定类在最上:
kotlin
// 顺序:底层数据类(后 add 的覆盖上面)... 屏幕固定类最后加
mapView.overlays.add(dataLayer) // 随图
mapView.overlays.add(gridline) // 随图,但应在数据之上
mapView.overlays.add(scaleBar) // 屏幕固定
mapView.overlays.add(compass) // 屏幕固定
5.3 用容器管理成组的 Overlay
当有大量同类 Overlay(比如一堆影像图)时,别一个个往顶层列表塞,用**容器(Folder)**归组:
kotlin
val imageFolder = FolderOverlay()
fun addImage(overlay: Overlay) {
imageFolder.add(overlay) // 归入影像容器
mapView.overlays.add(imageFolder) // 容器作为一个整体挂到地图
}
fun clearImages() {
imageFolder.items.clear() // 整体清除影像,不影响其他层
mapView.invalidate()
}
容器的价值:成组管理、整体控制。要清影像,清容器即可;容器本身在列表里只占一个位置,顺序可控。
5.4 "总绘图层"模式:所有业务绘制统一出口
当业务数据非常多、类型各异时,与其往列表塞一堆 Overlay,不如用一个总绘图层 :它在一个 draw 回调里,统一绘制所有业务数据。
kotlin
class GisOverlay(private val dataProvider: DataProvider) : Overlay() {
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow) return
// 统一在这里绘制所有业务数据
dataProvider.allFeatures().forEach { drawFeature(it, canvas, mapView) }
}
}
好处有三:
- 地图层列表保持简洁(就一个总绘图层);
- 所有业务绘制的顺序、裁剪、坐标变换集中在一处控制;
- 配合"随图旋转"策略,总绘图层可以决定"跟着转还是不转"。
这个模式和上一节的"统一渲染管线"是同一件事的两个面:图层配置模型决定"画什么、画成什么样",总绘图层决定"在哪儿统一画"。
六、合起来:一个完整的分层架构
把三个维度合起来,得到的是这样一个架构:
要点:
- 数据从哪来 :三种离线形态(或在线瓦片源)统一抽象成"数据源",由
dataType标签路由; - 画成什么样 :一个
LayerConfig模型 + 一份 XML,配置驱动,加图层不改代码; - 叠在哪一层:一个有序 Overlay 栈,屏幕固定层放最上,同类归并到容器,业务绘制走总绘图层单一出口。
管理地图就从"到处 add/remove、到处加 if 分支",变成了"改配置、调顺序"。
七、坑对照表
| 症状 | 维度 | 根因 | 解法 |
|---|---|---|---|
| 矢量文件渲染出来是灰的 | 数据从哪来 | 没初始化图形渲染工厂 | 初始化工厂 + 配渲染主题 |
| 瓦片归档加载不出 | 数据从哪来 | 扩展名未注册 / 级别超出归档范围 | 注册扩展名、压级别到归档范围 |
| 影像图位置对不上 | 数据从哪来 | 没读配套坐标文件算角点 | 读 .jgw/.prj 算角点经纬度 |
| 加载大图 OOM | 数据从哪来 | 一次性全量解码 | 逐步降采样直到成功 |
| 加一种数据要改好几处 | 画成什么样 | 数据来源与图层配置耦合 | 统一 LayerConfig + 路由管线 |
| 配置损坏后地图空白 | 画成什么样 | 没有默认值兜底 | 解析失败回退内置默认图层 |
| 比例尺被数据盖住 | 叠在哪一层 | 添加顺序错(屏幕固定类应先加) | 屏幕固定类放最后 |
| 旋转时罗盘跟着转 | 叠在哪一层 | 未区分屏幕固定 / 随图变换 | 分两类,固定类最上 |
| 影像图层成堆难清理 | 叠在哪一层 | 一个个 add 到顶层列表 | FolderOverlay 归组 |
八、结论
- 地图是分层的,"数据从哪来 / 画成什么样 / 叠在哪一层"三个维度必须正交,混在一起就是混乱的源头。
- 离线三方案的差异在"换算时机":矢量现算、瓦片现取、影像现贴,按数据来源和场景选型,最好用"离线门面"按文件类型自动路由。
- 统一图层模型 :一个
LayerConfig承载dataType标签 + 显示属性,新增数据类型只加一个路由分支;整个列表可序列化为 XML,且要有默认值兜底。 - Overlay 是有序栈,添加顺序 = 绘制层级 ,越靠后越靠上;分清屏幕固定类 与随图变换类。
- 大量同类 Overlay 用容器归组 ;业务数据繁多时用总绘图层统一出口。
- 最终形态是配置驱动:加图层、调顺序、换符号都是数据操作,不碰代码。
完整可运行源码 :GitCode 仓库 · android_osmdroid_maplibre
对照阅读:同一套分层思想在 MapLibre 上的落地,见《MapLibre 统一图层模型:sealed class + 声明式渲染 + XML 持久化》------Kotlin 的 sealed class 能把"数据类型标签"做得比 int 常量安全得多,对照看很有启发。
延伸阅读:本系列第 01 篇(坐标纠偏)· 第 02 篇(在线底图接入)
评论区聊聊:你项目里的地图分层是怎么做的?有没有因为"三个维度混在一起"导致加一种数据就要大改?说说你的分层方式。
本系列为 osmdroid / MapLibre 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。
