地图数据要稳,靠的是每一段都接一层中间模型。
一份 GeoTIFF 影像要叠到地图上、一条从 Google Earth 导出的 KML 轨迹要画出来、客户还要求底图从一家 SDK 换成另一家------这三个需求单独看都不难,难点在于它们会同时出现。把它们硬拼在一起,最常见的结局是:每接一种数据格式就为每家的地图 SDK 写一套绘制代码,组合数量随格式和引擎线性膨胀,最后谁都不敢动。
真正让人头疼的从来不是"会不会读 KML"或"会不会调某家 SDK 的画线接口",而是"读出来的东西"和"画出来的地方"之间缺了一层稳定的翻译。GDAL 能读栅格和矢量但不负责画,KML 是 XML 但各家 SDK 的画点画线 API 各不同,六种底图引擎的投影和手势也互不相同。本文要解决的麻烦,就是给这条链路补上"中间模型",让上游格式和下游引擎谁都不直接认识谁。
一、数据入口:GDAL 统一"读"的能力
这一节谈的是链路最上游------一份地理数据文件怎么进到 App 里。测绘与 GIS 领域绕不开 GDAL(Geospatial Data Abstraction Library),它是地理空间数据的"万能读取器",能读栅格(GeoTIFF、IMG 等)也能读矢量(Shapefile、GeoJSON 等),还自带一整套坐标参考系(CRS)变换能力。但它只是"读"的专家,不替你画。要把"能读"变成"能画",得先把 GDAL 的能力边界想清楚。
1.1 GDAL 强在哪:把"打开→读取区域"收敛成一套 API
很多"自定义地图格式"长得很像,内部却千差万别:有的按波段存、有的带坐标参考系、有的含金字塔(overview)。手写解析器读几种常见格式就够呛,更别说要处理几十种历史格式。
GDAL 的价值恰恰在于:它把"打开文件 → 读取某区域的数据"统一成了同一套 API ,不管背后是哪种格式。这就是它的核心抽象------数据集(Dataset)。上层只需要说"给我第 1 波段、从 (x,y) 开始宽高 (w,h) 的窗口数据",至于文件是 TIFF 还是 IMG,内部怎么排布,全部被 Dataset 吞掉。这一层抽象的价值,是后面所有"渲染层不关心格式"的前提。
补充一个移动端特有的边界:桌面端可以把整文件 mmap 进地址空间再按需页取,移动端没有这种奢侈,所以"按区域读取"不是优化项而是刚需。Dataset 还统一暴露了波段数、投影元数据、NoData 标记这些信息,渲染层靠它们决定怎么上色、要不要做坐标变换------这些元数据也是"读"的一部分,不该在业务侧重新猜测。
1.2 栅格渲染:把数值数组喂给渲染管线
栅格数据的本质是"规则网格上的采样值"(比如高程、影像亮度)。GDAL 读出来的是一块二维数值数组。要画出来,关键是把它转成"带坐标的像素":
kotlin
class RasterLayer(private val dataset: GdalDataset) {
// 读取某个地理范围的栅格数据
fun readWindow(x: Int, y: Int, width: Int, height: Int): FloatArray {
val buffer = FloatArray(width * height)
dataset.rasterBand(1).readRaster(
xOff = x, yOff = y,
xSize = width, ySize = height,
buffer = buffer
)
return buffer
}
}
拿到数值数组后,渲染层做两件事。第一件是做色带映射(高程值 → 颜色),或直接把影像波段转成位图纹理:
kotlin
// 高程值 → 颜色:色带映射把数值数组变成可贴图的像素
fun mapColor(value: Float, min: Float, max: Float): Int {
val t = ((value - min) / (max - min)).coerceIn(0f, 1f)
return when {
t < 0.33f -> lowColor // 低值
t < 0.66f -> midColor // 中值
else -> highColor // 高值
}
}
多波段影像(比如真彩色 RGB 三波段)还要先做波段合成:把三个波段的数值分别映射到 R/G/B,再整体转成位图纹理;遇到 NoData 像元则置透明,否则会在影像边缘画出一圈假数据。这正是"GDAL 管读、绘制层管画"的边界------NoData 语义由 GDAL 给出,是否透明由绘制层决定。
第二件是做地理坐标 → 屏幕坐标的变换,把栅格"贴"到地图的正确位置。关键在坐标变换:栅格自带"像素 → 地理坐标"的仿射变换(原点 + 分辨率 + 旋转),渲染层要先做"地理 → 屏幕"的投影变换,两者复合,才能让栅格落在正确经纬度上:
kotlin
// 栅格自带的仿射变换:像素 → 地理坐标
// geoX = originX + pixelX * scaleX + pixelY * rotX
// geoY = originY + pixelX * rotY + pixelY * scaleY
fun pixelToGeo(px: Int, py: Int, gt: DoubleArray): Pair<Double, Double> {
val gx = gt[0] + px * gt[1] + py * gt[2]
val gy = gt[3] + px * gt[4] + py * gt[5]
return gx to gy
}
注意仿射变换的第三、四项(rotX / rotY)在绝大多数规则影像里是 0,但遇到倾斜航拍或 rotated grid 时非 0,复合变换的顺序不能反:必须先做"像素→地理"的仿射,再做"地理→屏幕"的投影,反过来的结果会在旋转影像上产生系统性错位。这也是为什么坐标变换必须收在渲染层、不能让业务侧自己拼矩阵。
下面这张图把"栅格文件 → 屏幕"的整条流转画出来:仿射变换负责"像素→地理",投影变换负责"地理→屏幕",色带映射负责"数值→颜色",三者汇到 Canvas/OpenGL 才完成一次贴图。
1.3 矢量绘制:把几何变成图形
矢量数据(Shapefile 等)存的是几何对象:点、线、面。GDAL 读出来的是带坐标序列的几何体,绘制层要把它转成可绘制的路径:
kotlin
class VectorLayer(private val layer: GdalLayer) {
fun geometries(): List<Geometry> {
return buildList {
var feature = layer.nextFeature()
while (feature != null) {
add(feature.geometry())
feature = layer.nextFeature()
}
}
}
}
// 绘制:把几何坐标序列转成屏幕路径
fun drawGeometry(geometry: Geometry, canvas: Canvas, transform: GeoToScreen) {
when (geometry.type) {
GeometryType.POLYGON -> {
val path = Path()
geometry.points().forEachIndexed { i, p ->
val (x, y) = transform.toScreen(p)
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
canvas.drawPath(path, fillPaint)
}
}
}
上面的 drawGeometry 只画了多边形,是刻意简化。真实几何还有多点、多线、带洞多边形(外环 outerRing 之外还有 innerRing),完整实现要递归处理环与子几何;另外要素往往带属性表(名称、类型、备注),GDAL 会把属性随几何一起取出,业务侧才能在点注记上挂文案。这些"几何 + 属性"的产出,同样属于中间模型,与具体绘制 API 无关。
所以 GDAL 在这里扮演"几何数据源 ":负责解析格式、给出标准化的几何对象;绘制则交给上层图形库(Canvas / OpenGL)去画。GDAL 管"读",绘制层管"画",职责清晰。这一点很重要------它决定了 GDAL 只是链路里"供给数据"的一环,而不是渲染本身。
1.4 移动端接 GDAL 的现实四关
接 GDAL 到移动端,光"能用"还不够,有几个现实问题必须处理:
- 编译与 ABI:GDAL 是原生库,要针对 ARM64 等 ABI 交叉编译,配置工程是一大块工作;
- JNI 封装:C++ API 要包一层 JNI 才能给上层调用,封装的好坏直接影响易用性;
- 性能 :GDAL 按需读取(窗口读取、金字塔),移动端绝不能一次性把整幅图读进内存,必须按视口分块读 + 用 overview;
- CRS 变换:读到的数据可能是不同坐标系,渲染前要统一投影到目标坐标系。
JNI 封装这一关还有两个隐蔽陷阱:一是跨语言对象生命周期,C++ 侧的 Dataset 句柄必须显式关闭,否则 fd 与内存泄漏在长时间运行后拖垮 App;二是异常透传,GDAL 的 CPL 错误要转成 Java 异常,否则读取失败会被静默吞掉。此外所有 GDAL 调用都应放在后台线程------一次大范围 readRaster 是毫秒到秒级阻塞,主线程调用直接 ANR。
性能这一关值得单独展开。整幅栅格读进内存,在桌面端也许只是慢一点,在移动端就是 OOM 与掉帧。正确做法是只取当前视口覆盖的块,配合 overview 金字塔在缩小级别时读低分辨率层:
kotlin
// 按视口分块读取:绝不整幅读进内存
fun loadVisible(raster: RasterSource, view: Bounds, tile: Int = 256) {
for (row in 0 until view.height step tile) {
for (col in 0 until view.width step tile) {
val block = raster.readWindow(col, row, tile, tile) // 只取视口内的块
cache.put(col to row, block)
}
}
}
所以一个可用的"GDAL 渲染层",绝不是简单调一下 API,而是要把分块读取、坐标统一、色带/路径绘制封装成一个业务无关的模块。
1.5 GDAL 与自研解析:不是二选一
有人会问:既然接 GDAL 这么麻烦,为什么不自己写解析?答案不是二选一。GDAL 解决"格式多"的问题,自研解析解决"轻量固定格式"的问题,二者适用面不同。下面的对比表把它们摆清楚:
| 维度 | GDAL | 自研解析 |
|---|---|---|
| 格式覆盖 | 几十种栅格/矢量格式 | 仅已实现的一两种 |
| 坐标参考系 | 内置 CRS 变换 | 需自己实现 |
| 读取方式 | 窗口读取 / 金字塔 | 全图或自管分块 |
| 接入成本 | 交叉编译 + JNI 封装高 | 低,但能力有限 |
| 适用场景 | 多格式专业数据 | 单一固定格式轻量需求 |
真正稳的架构是:上层只依赖"我能从这个图层读到某区域的数值 / 几何"这个抽象 ,至于底层是 GDAL、是 GeoPackage、还是自解析格式,都不影响渲染层。以后想支持新格式,加一个"实现同样抽象的数据源"即可,渲染层一行不改。GDAL 是解决"格式多样"的利器,而抽象是解决"格式会变"的利器,两者配合,才是移动端 GIS 渲染的完整答案。
二、格式解析:KML 从 XML 走到数据模型
链路走到中段,遇到另一种上游------KML。它和 GDAL 的栅格/矢量不是一回事:KML 是 Google Earth 生态里到处都是的 .kml 文件,点标记、折线轨迹、多边形区域全用 XML 描述。难点不在"解析 XML",而在"把读出来的东西画到你接的那家地图 SDK 上"。
2.1 KML 长什么样
KML 是 XML,语义却很直观。一个点地标大概长这样:
xml
<Placemark>
<name>观测点A</name>
<Point>
<coordinates>116.4,39.9,0</coordinates>
</Point>
</Placemark>
Placemark 是"一个地标",Point / LineString / Polygon 是"几何类型",coordinates 里是经纬度。解析的本质就是遍历 XML,把几何和样式抽成结构化对象。
KML 不是扁平的:Document / Folder / Placemark 可以层层嵌套,name 与样式还能沿层级继承;coordinates 也支持 MultiGeometry 把多个几何捆在一个 Placemark 里。解析层要递归下降、把继承下来的 name/style 摊平到每个几何上,否则画出来的标注会丢名字或丢颜色。
2.2 解析层:XML → 纯数据模型
第一步,把 XML 解析成和 SDK 无关的数据模型。这里的关键是不要混入任何地图 SDK 的类型,只定义纯粹的"几何 + 样式"结构:
kotlin
sealed class KmlGeometry {
data class Point(val lon: Double, val lat: Double, val alt: Double = 0.0) : KmlGeometry()
data class LineString(val points: List<Triple<Double, Double, Double>>) : KmlGeometry()
data class Polygon(val outerRing: List<Triple<Double, Double, Double>>) : KmlGeometry()
}
data class KmlPlacemark(
val name: String,
val geometry: KmlGeometry,
val color: Long? = null,
)
解析逻辑用 XML 拉取器(Pull Parser)逐节点处理:
kotlin
fun parseKml(xml: String): List<KmlPlacemark> {
val parser = XmlPullParser.newInstance()
parser.setInput(xml.reader())
return buildList {
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
when {
parser.name == "Point" -> add(parsePoint(parser))
parser.name == "LineString" -> add(parseLineString(parser))
}
parser.next()
}
}
}
这里特意用 Pull Parser 而不是 DOM:KML 文件可能很大,DOM 会一次性建整棵对象树占满内存,Pull 流式逐节点处理,内存只随当前几何增长。另一个坑是命名空间------KML 带 xmlns 声明,某些解析器默认不展开命名空间会导致 name == "Point" 匹配失败,要在 parser 上显式处理。
到这一步,"能读懂 KML"已经完成,而且完全不依赖任何地图 SDK 。这一步产出的 KmlGeometry / KmlPlacemark 是纯数据,可以被任意消费端复用。把"解析"和"绘制"切开,是后面换引擎不重写解析的前提。
2.3 绘制适配层:模型 → SDK 图形
真正麻烦的在这一层。你的地图 SDK 提供的画图能力通常是一套统一的"叠加层 / 图形元素"API------往地图上加一个"覆盖物"(marker / polyline / polygon),恰好对应 KML 的"地图标注"和"轨迹可视化"。适配层要把上一步的纯模型翻译成 SDK 能理解的对象:
kotlin
interface OverlayAdapter {
fun addPoint(lon: Double, lat: Double, style: OverlayStyle) // 地图标注
fun addPolyline(points: List<GeoPoint>, style: OverlayStyle) // 轨迹可视化
fun addPolygon(points: List<GeoPoint>, style: OverlayStyle)
}
class KmlRenderer(private val overlay: OverlayAdapter) {
fun render(placemarks: List<KmlPlacemark>) {
placemarks.forEach { p ->
when (val g = p.geometry) {
is KmlGeometry.Point -> overlay.addPoint(g.lon, g.lat, styleOf(p))
is KmlGeometry.LineString ->
overlay.addPolyline(g.points.map { it.toGeo() }, styleOf(p))
is KmlGeometry.Polygon ->
overlay.addPolygon(g.points.map { it.toGeo() }, styleOf(p))
}
}
}
}
值得强调:KmlRenderer 不持有任何 SDK 类型,只依赖 OverlayAdapter 接口,这让它能脱离真机做单元测试------塞一个记录调用次数的假 Adapter,就能验证"3 个 Placemark 分派成了 3 次 addPoint",不依赖任何地图视图。这是"解析/绘制分离"带来的额外红利:可测性,而不只是可换引擎。
OverlayAdapter 就是这个适配层的门面,也就是二次封装发生的地方。它的实现因 SDK 而异 ------如果你用一套现成的覆盖物扩展库,就在这里把它的 API 再收敛一层;但 KmlRenderer 的"解析 + 分派"逻辑完全复用。以后想换一家地图 SDK,只需重新实现 OverlayAdapter,KML 解析一行不用改。
2.4 坐标与视觉两道坎
画 KML 上地图,实际会碰到两类易错点,而且都该关在适配层里:
1. 坐标约定不一致。 KML 里 coordinates 是"经度,纬度,高度"的顺序,而某些地图 SDK 内部或某些绘制 API 用的是"纬度,经度"(或反序)。适配层必须在这里做一次明确的坐标交换,否则画出来的点会飞到大洋对岸。轨迹可视化的每一条折线都要过这一关,顺序错了整条轨迹都是镜像的:
kotlin
// KML coordinates 是 "经度,纬度,高度",部分 SDK 画点用 "纬度,经度"
// 适配层在这里做一次明确的交换,避免点飞到大洋对岸
fun kmlToSdk(lon: Double, lat: Double): GeoPoint {
return GeoPoint(lat, lon) // 明确交换顺序,集中在一处
}
2. 样式映射。 KML 用 <style> 描述颜色、线宽、是否填充。适配层要把这些"样式语义"翻译成 SDK 支持的画笔/填充参数。有些 SDK 只支持固定的几档线宽,就得做一次"近似映射"------这是适配层最容易悄悄丢信息的地方,尤其是标注的图标和轨迹线的宽度。
还有两个容易被忽略的边界:一是海拔(alt)在 2D 地图里通常被忽略,但涉地形剖面时又必须保留,适配层要决定"是否透传第三维";二是颜色字节序,KML 的 <color> 是 AABBGGRR(注意不是 ARGB),直接按 ARGB 解析会把红蓝色反转,这类信息丢失同样该关在适配层。
把这两道坎关在适配层里,上层的解析与分派就能保持干净。下面这张流程图把"XML → 模型 → 图形"的全过程画出来,注意坐标交换发生在分派之后、落到 SDK 之前:
三、引擎适配:一套内核驱动六种底图
链路最下游是"画到哪"。做移动端 GIS 的人迟早会撞上这样一个尴尬局面:项目一开始选了某家地图 SDK,半年后客户要求换成另一家(或者同时要支持离线自绘底图),结果整个地图相关的业务代码------量算、叠加图层、坐标转换、手势处理------全绑死在那家 SDK 的类型上,改动像抽积木一样牵一发动全身。六种底图引擎(高德、百度、谷歌、OSM、NAVER、自研)的差异,必须在这里被"关起来"。
3.1 问题:业务只关心"地图能干什么"
业务侧真正关心的从来不是"底图是谁画的",而是这些能力:在地图上叠加自己的点、线、面、注记;屏幕坐标与地理坐标互转;统一的缩放、平移、旋转手势;一套坐标系贯穿始终。
而底图引擎千差万别:有的 SDK 自带手势和瓦片系统(高德、百度、谷歌),有的需要完全自绘(OSM、NAVER、自研引擎),坐标系还分经纬度、Web 墨卡托、火星坐标。如果业务代码直接 import 某家 SDK 的类,上述能力就会散落在一堆 if (isAmap) ... else if (isBaidu) ... 里。所以第一要务是:让业务代码只认识"地图内核",不认识任何具体底图。
3.2 依赖倒置:三组契约 + 一个注册中心
解法的核心是依赖倒置:内核定义"地图视图应该长什么样"的接口,具体底图反过来去实现这些接口,而不是业务去依赖具体底图。内核甩出三组关键契约:
kotlin
// 内核甩出的三组关键契约(节选):业务只依赖这些接口
interface MapViewContract {
fun transform(): TransformContract // 屏幕像素 到 地理坐标
fun map(): CoreMap // 内核自绘业务层
fun viewBounds(): Bounds // 当前可视地理范围
fun zoomTo(level: Double) // 缩放
}
interface TransformContract {
fun toPixels(coordinate: Coordinate, reuse: Point): Point
fun fromPixels(pixel: Point, reuse: Coordinate): Coordinate
}
// 引擎契约:完全自绘型底图实现,负责瓦片渲染
abstract class EngineBase(context: Context, mapView: MapViewContract) {
abstract fun onDrawFrame(canvas: Canvas)
abstract fun viewBounds(): Bounds
}
- 地图视图契约:一个完整可显示的地图控件,对外暴露缩放、平移、刷新、取视窗范围等能力;
- 坐标转换契约:负责"屏幕像素 ↔ 地理坐标"的互转,是所有空间交互的基石;
- 引擎契约:描述底层瓦片如何渲染,供完全自绘型底图使用。
再加上一个注册中心 :内核不认识任何具体引擎,由外部模块在启动时"报名"------"我是第 N 号引擎,能渲染这些底图源,渲染类是这个"。这样内核就像一个可插拔的插槽面板。业务图层(点线面注记)的绘制则被内核收敛为统一的自绘层:无论底图是谁,业务数据都由内核用同一套 Canvas/OpenGL 绘制逻辑叠加在最上层。
为什么是"运行时注册"而不是编译期枚举?因为底图往往要插件化:客户现场按需下发某个底图模块,或离线包只带自研引擎。若把六种引擎硬编码成 when 分支编进内核,包体积和耦合都失控;注册中心让"哪些引擎可用"在启动时由已加载的模块决定,内核本身始终干净。
3.3 包装型接入:第三方 SDK 当底图
最常见的情况------把高德、百度、谷歌这类"自带瓦片 + 手势"的 SDK 当底图。做法是做一个适配器控件,继承内核的地图视图契约,内部把第三方 SDK 的视图当子控件嵌入,再用内核的自绘层叠加业务数据:
kotlin
// 适配器控件:第三方 SDK 承载底图与手势,内核自绘层承载业务图层
class ThirdPartyMapView(context: Context) : FrameLayout(context), MapViewContract {
// 第三方 SDK 原生视图,负责瓦片底图、缩放平移手势
private val nativeView = NativeSdkMapView(context)
// 复用内核的自绘层,叠加点/线/面/注记
private val overlayMap = CoreBaseMap(this)
override fun transform(): TransformContract = NativeTransform(nativeView)
override fun map(): CoreMap = overlayMap
override fun viewBounds(): Bounds {
// 借第三方 SDK 的投影能力,把可视区域翻译成内核的地理范围
val region = nativeView.projection.visibleRegion
return Bounds(region.west, region.east, region.south, region.north)
}
override fun zoomTo(level: Double) {
nativeView.moveCamera(NativeCameraUpdate.zoomBy(level))
}
}
坐标转换契约是这里最容易踩坑的地方。每个 SDK 的坐标表达方式不同(有的用 LatLng,有的用经纬度对),适配器负责把它们统一翻译回内核的 Coordinate:
kotlin
// 把第三方 SDK 的屏幕点、经纬度,翻译成内核统一的地理坐标
class NativeTransform(private val nativeView: NativeSdkMapView) : TransformContract {
override fun toPixels(coordinate: Coordinate, reuse: Point): Point {
val latLng = LatLng(coordinate.y, coordinate.x)
return nativeView.projection.toScreenLocation(latLng)
}
override fun fromPixels(pixel: Point, reuse: Coordinate): Coordinate {
val latLng = nativeView.projection.fromScreenLocation(pixel)
return Coordinate(latLng.longitude, latLng.latitude)
}
}
包装型还有一个现实摩擦:第三方 SDK 自带缩放手势,业务层可能也要在地图上做自定义手势(比如框选、画测区),两者会抢事件。适配层要在 onInterceptTouchEvent 层面划清边界------纯浏览手势交给 SDK,业务手势由内核拦截,不能让两家各自处理同一根手指。
3.4 引擎型接入:完全自绘瓦片
另一类底图(OSM、NAVER、自研引擎)没有现成的"SDK 视图",需要内核自己把瓦片画出来。这类实现继承引擎基类,自己管理瓦片下载、缓存、投影与叠加,不依赖任何第三方渲染:
kotlin
// 自研瓦片引擎:继承内核引擎基类,自行管理瓦片与投影
class OsmLikeEngine(context: Context, mapView: MapViewContract) : EngineBase(context, mapView) {
private val tileProvider = OnlineTileProvider()
override fun onDrawFrame(canvas: Canvas) {
drawTiles(canvas, tileProvider) // 画底图瓦片
drawOverlays(canvas) // 画业务叠加层
}
override fun viewBounds(): Bounds {
return projection.visibleBoundingBox
}
}
引擎型要自己管瓦片:在线源走 OnlineTileProvider 下载并落 LRU 缓存,离线源(LOCAL)则直接从文件系统按 z/x/y 读瓦片,两者的差异只在 provider 实现,绘制与投影逻辑共用。这里最容易出的是"瓦片错位"------z 级别与投影不一致时整层偏移,必须在 provider 与 projection 之间对齐坐标系,不能让绘制层背锅。
3.5 插件注册:把引擎"插"进内核
光有实现还不够,内核必须能在运行时找到它们。这里用模块 + 注册中心解耦:每个底图是一个独立的功能模块,在初始化时向内核报名:
kotlin
// 底图以"功能模块"形式存在,init 时向内核注册自己
class OsmMapModule : FeatureModule {
override fun init(application: Application) {
MapViewRegistry.registerEngine(
engineType = EngineType.OSM,
engineClass = OsmLikeEngine::class.java,
supportedSources = listOf(
TileSource.TIANDITU_IMAGE,
TileSource.GOOGLE_VECTOR,
TileSource.BING_IMAGE,
TileSource.OSM_VECTOR,
TileSource.LOCAL,
TileSource.WMS,
TileSource.XYZ
// 共十余种底图源
)
)
}
}
注册中心内部是一张映射表:引擎类型 → 引擎类 + 它能渲染的底图源集合。业务侧只需说"我要用 OSM 引擎 + 天地图影像源",内核就去表里查到类、实例化、挂载。要新增一家底图,写一个模块 + 一个引擎类,注册一下即可,内核零改动:
kotlin
// 注册中心内部:引擎类型 到 引擎类 + 可渲染底图源集合
object MapViewRegistry {
private val engines = mutableMapOf<EngineType, EngineEntry>()
fun registerEngine(type: EngineType, klass: Class<*>, sources: List<TileSource>) {
engines[type] = EngineEntry(klass, sources)
}
fun lookup(type: EngineType, source: TileSource): Class<*>? {
val e = engines[type] ?: return null
return if (source in e.sources) e.klass else null
}
}
3.6 坐标系差异由适配器吸收
不同底图的坐标系不一致,这一层差异也被适配器"吞掉":高德 / OSM / 谷歌类引擎,内核统一按 WGS84 经纬度处理;自研墨卡托引擎,内核按 Web 墨卡托处理,转换契约负责与屏幕互转;火星坐标类引擎(百度等),在模块初始化时设置坐标类型,转换契约内部完成纠偏翻译。业务层从头到尾只面对内核的 Coordinate 与 Bounds,永远不碰具体坐标系的细节。
3.7 依赖边界:内核稳定、外围易变
工程上,这六个底图模块都单向依赖 内核模块;内核不反向依赖任何一个。依赖方式上还做了区分:需要对外暴露地图视图类型的模块,用 api 依赖内核,让宿主 App 也能拿到统一接口;仅在内部使用的模块,用 implementation 依赖,收敛暴露面。调试阶段依赖源码工程、发布阶段依赖已编译的产物,二者通过构建开关切换,互不影响开发体验与发布形态。
下面这张类图把"契约 + 两种实现 + 注册"的结构定下来,注意所有箭头都指向内核抽象,具体引擎只实现抽象、不反向被业务依赖:
四、中间模型:收敛组合爆炸的关键
前面三节各管一段:GDAL 把"读"统一,KML 把"解析"和"绘制"切开,引擎适配把"六种底图"关进内核。把它们串起来的,其实只有一个判断------地图数据链路的稳定,靠的是"每一段都用中间模型对接",而不是让上游格式直接面对下游引擎。
4.1 上游两种、下游六种,直接对接会爆炸
把组合算清楚:上游有 GDAL(栅格 + 矢量)和 KML 两种来源,下游有六种底图引擎。如果没有中间模型,每种来源要为每个引擎写一套对接,光是"数据 → 引擎"就是 2 × 6 = 12 种直连代码;再算上"GDAL 栅格 / 矢量"是两个子类,实际组合更多。更糟的是,每新增一种格式或一种引擎,都要补一整排直连,主干代码被改得千疮百孔。
唯一能收敛组合爆炸的,是中间模型。它把"上游多变"和"下游多变"这两维拆开:上游只负责产出中间模型,下游只负责消费中间模型,两者通过中间模型解耦。于是新增格式 = 加一个"产出中间模型的源",新增引擎 = 加一个"消费中间模型的适配器",彼此正交,不再互相放大。
举个更扎心的反例:一份 KML 轨迹要画到全部六种引擎上。没有中间模型时,你得为每家 SDK 写一套"读 KML + 调它画线 API"的代码,6 份直连;而有了 KmlGeometry 中间模型,解析只写一次,6 个引擎各写一个 OverlayAdapter 即可,且解析层可被单元测试复用。格式和引擎任一维扩张,成本都从"乘积"降成"相加"。
4.2 三层中间模型
整条链路其实叠了三层中间模型,每一层都只用纯结构、不依赖任何具体格式或具体引擎:
- 图层抽象层 :
RasterSource/VectorSource把"能读某区域数值 / 几何"抽象出来,GDAL 只是它的一种实现; - KML 模型层 :
KmlGeometry/KmlPlacemark是解析产出的纯数据,与 SDK 无关; - 内核坐标层 :
Coordinate/Bounds是贯穿全链路的空间语言,所有引擎的转换契约都翻译回它。
kotlin
// 贯穿全链路的中间模型:内核只认 Coordinate 与 Bounds
data class Coordinate(val x: Double, val y: Double) // x=经度, y=纬度(WGS84 约定)
data class Bounds(val west: Double, val east: Double, val south: Double, val north: Double)
// 栅格图层抽象:渲染层只依赖"能读某区域数值/几何"
interface RasterSource {
fun readWindow(x: Int, y: Int, w: Int, h: Int): FloatArray
}
interface VectorSource {
fun geometries(): List<Geometry>
}
下面这张总览图把"GDAL / KML → 中间模型 → 六种引擎"的链路一次性画全,注意中间模型层是唯一的"收口"处:
4.3 统一判断
回看三篇源文的共同主张,其实是同一句话的三个侧面:GDAL 篇说"上层只依赖图层抽象"、KML 篇说"解析层和绘制适配层分开"、引擎篇说"业务只依赖内核契约"。把它们抽象到一层,就是------地图数据链路的稳定,靠的是每一段都用中间模型对接,而不是让上游格式直接面对下游引擎。GDAL 与 KML 是两种上游,六种地图引擎是多个下游,唯一能收敛组合爆炸的,是一层稳定的中间表示。
五、工程落地:坐标系、性能与依赖三道现实关
道理讲清了,落到工程上还有三道反复被验证的现实关要过。它们恰好对应前面三节各自最容易翻车的地方,集中成一张对照表,方便排障时直接查:
| 现象 | 根因 | 解法 |
|---|---|---|
| KML 点画到大洋对岸 | 经纬度顺序误用(经度,纬度 vs 纬度,经度) | 适配层做一次明确坐标交换,集中在一处 |
| 换底图后业务代码大面积重写 | 业务直接 import 具体 SDK 类型 | 业务只依赖内核抽象,差异由适配器翻译 |
| 移动端加载影像 OOM / 卡顿 | 整幅栅格读进内存 | 按视口分块读 + 用 overview 金字塔 |
| 轨迹线宽 / 标注图标对不上 | KML 样式到 SDK 线宽档位需近似映射 | 适配层做样式近似映射,并记录信息丢失 |
| 新增底图要改主干代码 | if-else 工厂硬编码 | 注册中心映射表 + 启动注册,新增零侵入 |
| 坐标整体偏移 / 飞掉 | 坐标系不统一(WGS84 / 墨卡托 / 火星) | 转换契约内部纠偏,业务只认 Coordinate |
5.1 坐标系:WGS84 / Web 墨卡托 / 火星坐标
坐标系是移动端地图最隐蔽的坑。六种引擎分三类坐标系,差异必须被适配器吸收,业务层永远只面对内核的 Coordinate:
| 引擎 | 接入范式 | 坐标系 | 手势 / 瓦片 |
|---|---|---|---|
| 高德 | 包装型 | WGS84 | 自带 |
| 百度 | 包装型 | 火星坐标(纠偏) | 自带 |
| 谷歌 | 包装型 | WGS84 | 自带 |
| OSM | 引擎型 | WGS84 | 自绘 |
| NAVER | 引擎型 | WGS84 | 自绘 |
| 自研 | 引擎型 | Web 墨卡托 | 自绘 |
关键约束只有一条:转换契约内部完成纠偏翻译,绝不让业务代码去判断"当前是哪家坐标系"。一旦业务层开始写 if (isMars) 这类分支,中间模型的收口就被打破了。
5.2 性能:按视口分块读 + 金字塔
性能关集中在栅格这一侧。GDAL 的窗口读取和 overview 金字塔不是"优化项",而是移动端能不能跑的硬前提。整幅影像读进内存常见后果是:低配机直接 OOM、首帧等待数秒、平移时反复全量重读。正确做法是视口驱动的分块读取(见 1.4 的 loadVisible),并把 overview 当作缩小级别的默认数据源。矢量侧虽然不存在整图内存问题,但要素数量大时也要做视口裁剪与分级显示,避免一次性把十万级几何全送进绘制层。
金字塔的级别选择不是随意的:要根据当前视口分辨率反推该用 overview 的哪一层,选高了模糊、选低了仍卡。公式上,目标分辨率 ≈ 视口像素跨度 / 地理跨度,取不低于它的 overview 级别即可。矢量侧对应的是 LOD(多细节层级):缩放越小只画聚合后的要素,放大才展开十万级几何,否则绘制层会被一次性喂爆。
5.3 依赖:单向 + api/implementation 区分
依赖关集中在引擎侧。内核必须保持"零具体依赖":六个底图模块单向依赖内核,内核不反向依赖任何一个。暴露面用 Gradle 的 api 与 implementation 区分------需要宿主 App 拿到统一接口时用 api,仅内部使用就用 implementation。这一道关看似和"数据"无关,却是中间模型能长期稳定的工程地基:一旦内核反向依赖了某个具体引擎,单向边界就被打破,前面所有解耦都白做。
六、小结
- 地图数据链路 = 读进来(GDAL)→ 翻译成模型(KML 解析)→ 画到引擎(六引擎适配),三段各自解决一类"多变"。
- GDAL 管"读"、绘制层管"画":栅格读数值数组做色带映射 + 仿射变换,矢量由 GDAL 给几何、上层转屏幕路径。
- KML 必须"解析层 / 绘制适配层"分离:解析产出纯模型不依赖 SDK,换引擎只重写
OverlayAdapter。 - 六引擎靠"接口契约 + 自绘业务层 + 注册中心"收口,接入只有包装型与引擎型两种范式,新增底图零侵入。
- 稳定靠中间模型而非直连:每层只用纯结构(RasterSource / KmlGeometry / Coordinate),让上游多变与下游多变正交解耦,收敛组合爆炸。
你们项目里换底图引擎时,业务代码做过抽象吗,还是直接 import 了某家 SDK 的类型?