从 KML 的文档结构、颜色字节序到样式继承,讲清解析、坐标投影与渲染的完整链路。
前言
Google Earth 里到处是 .kml、.kmz------点、线、面全用 XML 描述。打开它,第一反应是"解析 XML 而已",可真动手才发现:读出来只是开始,正确画上地图才是工作量所在。
翻车点几乎每次都一样:点飞到大洋对岸(经纬度写反)、半透明画成实心、线宽粗得离谱、<Style> 不生效、gx:Track 直接空白。这不是 XML 解析的问题,而是"KML 语义 → 地图图形"没翻译对。
我拆过一套把 KML 封装后适配到地图 SDK 的绘制模块。本文按"文档结构 → 解析 → 样式 → 渲染 → 入库"讲透每一步;解析层与绘制层分开,KML 逻辑才不跟着某家 SDK 走。命名空间、颜色字段、坐标顺序均以 OGC KML 规范为准。
一、先认清 KML 是什么
一.1 命名空间与容器层级
KML 是 OGC 标准:2.2 版命名空间 http://www.opengis.net/kml/2.2,2.3 版 http://www.opengis.net/kml/2.3;Google 扩展(gx:)在 http://www.google.com/kml/ext/2.2,真实解析要同时认识两个命名空间 。根是 <kml>,层级 kml > Document > Folder > Placemark:Document 可放共享的 <Style>/<StyleMap>/<Schema>,是样式仓库;Folder 负责分组与显隐;两者都可递归下钻。
一.2 几何元素
Point:一个坐标,对应标注,如<coordinates>116.4,39.9,0</coordinates>;LineString:一串坐标连成折线;LinearRing:首尾必须闭合的环;Polygon:outerBoundaryIs外环 + 零到多个innerBoundaryIs内环(洞),环里的coordinates序列与折线同构。
还有 altitudeMode(贴地/相对地面/绝对高度);二维渲染可忽略,但解析时要能安全跳过,别因不认识而中断整个文件。
一.3 两个必须写对的细节
坐标顺序 lon,lat,alt,经度在前 ;组内逗号、组间空白,高度可省。很多 SDK 用 (lat, lon),不在适配层显式交换就会画错位置。颜色是 aabbggrr (aa alpha、bb blue、gg green、rr red),与 0xAARRGGBB 红蓝相反。
| KML color | 含义 | 等价 0xAARRGGBB |
|---|---|---|
ff0000ff |
不透明红 | 0xFFFF0000 |
7f00ff00 |
半透明绿 | 0x7F00FF00 |
二、解析层:XML → 数据模型
二.1 为什么用 XmlPullParser
Android 解析 XML 有三条路:DOM(建整棵树,内存大)、SAX(回调式,绕)、XmlPullParser(拉取式,最省内存 ,同时只持当前节点)。它按事件推进:START_DOCUMENT/START_TAG/TEXT/END_TAG/END_DOCUMENT。三个方法最常用:nextText() 读文本并把解析器推进到该元素结束标签;skip() 跳完整棵子树;getAttributeValue(null,"id") 取属性;按命名空间区分时用 setNamespaceAware(true)。
二.2 先定义与 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,
)
真实工程会给面补内环、给坐标抽一个具名类型(免得 Triple 的三个位置靠记忆),并挂上 styleUrl、ExtendedData;下文的解析器就用这套最小模型。
二.3 完整的拉取式解析
先看原文雏形,它把"遍历 XML、按几何类型分派"讲清楚了:
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()
}
}
}
真实实现要处理容器递归、样式收集与跳过。核心是 skip()------跳完当前元素整棵子树:
kotlin
private fun skip(parser: XmlPullParser) {
val outer = parser.depth
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.END_TAG && parser.depth == outer) return
}
}
主循环就是"认得的处理、不认得的跳过",容器用同样分派递归下钻:
kotlin
class KmlDocumentParser {
private val styleById = HashMap<String, Long>() // id -> 已转好的 ARGB
private val styleMapById = HashMap<String, String>() // StyleMap id -> 普通态样式 id
fun parse(parser: XmlPullParser): List<KmlPlacemark> {
val result = ArrayList<KmlPlacemark>()
parser.require(XmlPullParser.START_TAG, null, "kml")
readLevel(parser, result)
return result
}
// 处理"某一层":遇到 Document/Folder 就递归,等于自动下钻容器
private fun readLevel(parser: XmlPullParser, out: MutableList<KmlPlacemark>) {
val outer = parser.depth
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.END_TAG && parser.depth == outer) return
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name) {
"Document", "Folder" -> readLevel(parser, out)
"Style" -> parseStyle(parser)
"StyleMap" -> parseStyleMap(parser)
"Placemark"-> parsePlacemark(parser)?.let(out::add)
else -> skip(parser)
}
}
}
}
coordinates 的切分是高频出错点:组内逗号、组间空白,高度可省:
kotlin
data class KmlCoordinate(val lon: Double, val lat: Double, val alt: Double = 0.0)
private fun parseCoordinates(raw: String): List<KmlCoordinate> =
raw.trim().split(Regex("\\s+")).mapNotNull { t ->
val p = t.split(",")
if (p.size < 2) return@mapNotNull null
KmlCoordinate(p[0].trim().toDouble(), p[1].trim().toDouble(),
if (p.size >= 3) p[2].trim().toDouble() else 0.0)
}
几何读取的本质是"在子树里找到 coordinates";面无非是分别读外环与内环两段:
kotlin
// 取子树里的第一段 coordinates(Point 用)或整段(线/面用)
private fun readCoords(parser: XmlPullParser): List<KmlCoordinate> {
var list = emptyList<KmlCoordinate>()
val outer = parser.depth
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.END_TAG && parser.depth == outer) break
if (parser.eventType != XmlPullParser.START_TAG) continue
if (parser.name == "coordinates") list = parseCoordinates(parser.nextText()) else skip(parser)
}
return list
}
private fun parsePlacemark(parser: XmlPullParser): KmlPlacemark? {
var name = ""; var styleId: String? = null; var g: KmlGeometry? = null
val outer = parser.depth
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.END_TAG && parser.depth == outer) break
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name) {
"name" -> name = parser.nextText()
"styleUrl" -> styleId = parser.nextText().removePrefix("#")
"Point" -> readCoords(parser).firstOrNull()?.let {
g = KmlGeometry.Point(it.lon, it.lat, it.alt)
}
"LineString" -> g = KmlGeometry.LineString(readCoords(parser).map { t(it) })
"Polygon" -> g = KmlGeometry.Polygon(readCoords(parser).map { t(it) }) // 内环同法
else -> skip(parser)
}
}
val geometry = g ?: return null
return KmlPlacemark(name, geometry, styleById[styleMapById[styleId] ?: styleId])
}
private fun t(c: KmlCoordinate) = Triple(c.lon, c.lat, c.alt)
styleUrl 可能指向 Style 也可能指向 StyleMap,后者要先解引用到普通态------就是上面 styleMapById[styleId] ?: styleId 那行。至此"读懂 KML"完成,且完全不依赖任何地图 SDK。
三、样式体系:颜色、线宽与填充
三.1 五类样式元素
Style 聚合五类子元素:IconStyle(<Icon><href>、<scale>、<color>)、LabelStyle、LineStyle(<color>、<width>)、PolyStyle(<color>、<fill>、<outline>)、BalloonStyle。典型定义:
xml
<Style id="redLine">
<LineStyle><color>ff0000ff</color><width>3</width></LineStyle>
<PolyStyle><color>4d00ff00</color><fill>1</fill></PolyStyle>
</Style>
解析:先记 Style 的 id,再把各子样式文本收进 map:
kotlin
private fun parseStyle(parser: XmlPullParser) {
val id = parser.getAttributeValue(null, "id") ?: run { skip(parser); return }
var color: Long? = null
val outer = parser.depth
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.END_TAG && parser.depth == outer) break
if (parser.eventType != XmlPullParser.START_TAG) continue
when (parser.name) { // <LineStyle>/<PolyStyle>/<IconStyle> 同构,取各自 <color>
"LineStyle", "PolyStyle", "IconStyle" ->
readTexts(parser)["color"]?.let { color = kmlColorToArgb(it).toLong() }
}
}
styleById[id] = color
}
// 收集元素下所有后代文本;Icon 里还有一层 href,递归再读一层
private fun readTexts(parser: XmlPullParser): Map<String, String> {
val m = LinkedHashMap<String, String>()
val outer = parser.depth
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.END_TAG && parser.depth == outer) break
if (parser.eventType != XmlPullParser.START_TAG) continue
if (parser.name == "Icon") m.putAll(readTexts(parser)) else m[parser.name] = parser.nextText()
}
return m
}
三.2 aabbggrr 转画笔颜色
拿到 color 先转成画笔要的 0xAARRGGBB,注意红蓝交换:
kotlin
// KML: aabbggrr -> Android: 0xAARRGGBB
fun kmlColorToArgb(kmlColor: String): Int {
val v = kmlColor.trim().removePrefix("#").toLong(16)
val aa = ((v shr 24) and 0xFF).toInt() // alpha
val bb = ((v shr 16) and 0xFF).toInt() // blue
val gg = ((v shr 8) and 0xFF).toInt() // green
val rr = (v and 0xFF).toInt() // red
return (aa shl 24) or (rr shl 16) or (gg shl 8) or bb
}
kmlColorToArgb("ff0000ff") 必须得到 0xFFFF0000(纯红);若得到 0xFF0000FF,说明红蓝写反------这是最容易被"颜色深了点"放过、上线才被发现的一类错误。
三.3 线宽与缩放
<width> 单位是屏幕像素 :width=3 的线任何层级都约 3 像素,不随缩放变化。若自绘先把经纬度投影成世界坐标(米)再缩放,固定世界长度会随缩放变化,线就"放大变粗、缩小变细":想复现像素语义要按比例尺反算宽度。
三.4 styleUrl 与 StyleMap
真实文件很少内联样式,而是 <Style id> 定义一次、<styleUrl>#id</styleUrl> 引用,解析必须先收样式、后结算引用 。StyleMap 更高一层,用 <Pair> 给普通态(normal)与高亮态(highlight)各配一个 <styleUrl>;移动端一般只用普通态,把 normal 映射到样式 id 即可。注意 styleUrl 可同时给多个(空白分隔),后者覆盖前者。
四、绘制适配层:把模型翻译成 SDK 的图形
四.1 适配门面
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))
}
}
}
}
OverlayAdapter 是门面,也就是"二次封装"发生的地方,实现因 SDK 而异 ;而 KmlRenderer 的"解析+分派"逻辑完全复用。换 SDK 只重写 OverlayAdapter,KML 解析一行不改。KML 的 <style> 到 SDK 画笔参数的近似映射(尤其标注图标与轨迹线宽)也收在这一层,它是适配层最容易悄悄丢信息的地方。
四.2 坐标交换
KML 是 lon,lat,alt,有些 SDK 用"纬度,经度"。必须在适配器边界做一次明确交换 ,否则点飞到大洋对岸、轨迹整条镜像。把交换固化成单一入口 fun KmlCoordinate.toGeoPoint() = GeoPoint(lat, lon),全项目只有这里可能出错。
四.3 经纬度到屏幕像素
第三方 SDK 的投影一般由 projection 提供(toScreenLocation/fromScreenLocation);自绘则要自己投影,标准做法是 Web 墨卡托(EPSG:3857),世界像素宽为 256 × 2^zoom:
kotlin
object WebMercator {
private const val MAX_LAT = 85.05112878 // 墨卡托能表达的纬度上限
private fun worldSize(zoom: Int) = 256.0 * (1 shl zoom) // 世界像素宽 = 256 × 2^zoom
fun lonToX(lon: Double, zoom: Int) = (lon + 180.0) / 360.0 * worldSize(zoom) // 经度线性
// 纬度非线性;R = 6378137 是 WGS-84 长半轴
fun latToY(lat: Double, zoom: Int): Double {
val phi = Math.toRadians(lat.coerceIn(-MAX_LAT, MAX_LAT))
return (1.0 - ln(tan(phi) + 1.0 / cos(phi)) / Math.PI) / 2.0 * worldSize(zoom)
}
}
地物的世界像素减去屏幕左上角对应的世界像素即得屏幕坐标;这条链每一环要用同一套投影,否则地物整体偏移。
五、进阶:轨迹、扩展数据与海量地标
五.1 gx:Track
普通 LineString 没有时间。带时间戳的轨迹用 Google 扩展 gx:Track(命名空间 http://www.google.com/kml/ext/2.2),由两个并列数组 组成------一串 <when> 与等量 <gx:coord>:
xml
<gx:Track>
<when>2024-01-01T08:00:00Z</when>
<when>2024-01-01T08:00:05Z</when>
<gx:coord>116.400 39.900 50</gx:coord>
<gx:coord>116.401 39.901 51</gx:coord>
</gx:Track>
两处与 coordinates 不同:gx:coord 内部是空格分隔的 lon lat alt ;<when> 与 <gx:coord> 是独立序列、按序一一对应 。只认 LineString 的解析器遇到它就一片空白。
五.2 ExtendedData
<ExtendedData> 携带业务属性,常见两种写法:<Data name="roadId"><value>R1024</value></Data>(键值对),或 <SchemaData> 里配 <SimpleData name="level">3</SimpleData>(对齐 Schema)。统一收进 Map<String,String>,不参与画图,但可用于点选弹窗、筛选、着色。
五.3 批量入库 + 按视野查询
几万个地标时别每次渲染都重解析整篇 XML,做法是"解析一次、入库一次、之后按视野查":
sql
CREATE TABLE kml_placemark (
id INTEGER PRIMARY KEY, name TEXT, lon REAL, lat REAL, geom BLOB, style TEXT
);
-- 按包围盒建空间索引(SQLite 的 R*Tree 虚拟表)
CREATE VIRTUAL TABLE kml_index USING rtree(id, minLon, maxLon, minLat, maxLat);
查询用视野经纬度范围,只取屏幕内的:
kotlin
val cursor = db.rawQuery(
"SELECT p.* FROM kml_index i JOIN kml_placemark p ON p.id = i.id " +
"WHERE i.maxLon >= ? AND i.minLon <= ? AND i.maxLat >= ? AND i.minLat <= ?",
arrayOf(west.toString(), east.toString(), south.toString(), north.toString()),
)
RTree 要如实说:**SQLite 官方提供 R Tree 扩展,但 Android 系统自带 SQLite 是否启用该模块视系统与厂商而定**;工程上通常自带 SQLite,或退化为在经纬度上建普通索引。思路一致:把范围查询下沉到数据库。
六、升华:适配器模式的价值
这是**适配器模式(Adapter)**的典型应用:KML 是"统一数据语义",各家 SDK 是"各不相同的图形后端",中间夹一个 OverlayAdapter 做翻译。凡"一种标准格式 + 多种消费端 "的场合都同理,共同点是:稳定的一侧只依赖抽象,多变的一侧各自实现。
结论
- 解析的本质是"XML → 结构化模型",这一步不该依赖任何地图 SDK。
- 记牢两个规范细节:
coordinates是lon,lat,alt(经度在前) ,color是aabbggrr(红蓝与 ARGB 相反)。 - 难点在绘制适配层:把纯模型翻译成某家 SDK 的覆盖物/图形 API,二次封装就在这里。
- 用
OverlayAdapter门面隔离差异,换 SDK 只重写实现,解析分派逻辑复用。 XmlPullParser的nextText()/skip()/depth是拉取式解析三件套,只处理认识的节点、其余安全跳过。<Style>/<StyleMap>/styleUrl是共享与继承关系,解析要先收样式、后结算引用。- KML 的
<width>是屏幕像素语义,自绘时要按缩放反算,否则线宽随缩放漂移。 - 海量地标应解析一次、批量入库、按视野查询 ;
gx:Track用<when>+<gx:coord>两列配对,别漏。
你在解析 KML 或其它矢量格式时,踩过坐标顺序、颜色字节序还是样式丢失的坑?欢迎在评论区交流,也可以说说你接的是哪家地图 SDK。
关键词标签 :#Android #KML #地图标注 #轨迹可视化 #适配器模式 #XML解析 #GIS #矢量绘制