摘要:点、线、面三类几何,第一版各写一套拼 GeoJSON 的代码,三个文件几乎一样只差几何类型------然后改了点类忘了同步线类、关页重开全没了、多页面复用只能再复制一遍。这篇给出一套四段职责架构:sealed class 统一模型 → 转换器转声明式 Layer → XML 持久化 → 状态仓唯一真源,并复盘四个真实踩坑(含"我的地块丢了")。
目录(TOC)
- 一、三个文件几乎一样,只差几何类型
- [二、第 1 段:统一数据模型(密封类)](#二、第 1 段:统一数据模型(密封类) "#%E4%BA%8C%E7%AC%AC-1-%E6%AE%B5%E7%BB%9F%E4%B8%80%E6%95%B0%E6%8D%AE%E6%A8%A1%E5%9E%8B%E5%AF%86%E5%B0%81%E7%B1%BB")
- [三、第 2 段:统一转换器](#三、第 2 段:统一转换器 "#%E4%B8%89%E7%AC%AC-2-%E6%AE%B5%E7%BB%9F%E4%B8%80%E8%BD%AC%E6%8D%A2%E5%99%A8")
- [四、第 3 段:XML 持久化](#四、第 3 段:XML 持久化 "#%E5%9B%9B%E7%AC%AC-3-%E6%AE%B5xml-%E6%8C%81%E4%B9%85%E5%8C%96")
- [五、第 4 段:状态仓(唯一真源)](#五、第 4 段:状态仓(唯一真源) "#%E4%BA%94%E7%AC%AC-4-%E6%AE%B5%E7%8A%B6%E6%80%81%E4%BB%93%E5%94%AF%E4%B8%80%E7%9C%9F%E6%BA%90")
- 六、四个真实踩坑复盘
- [七、与 osmdroid 的异同](#七、与 osmdroid 的异同 "#%E4%B8%83%E4%B8%8E-osmdroid-%E7%9A%84%E5%BC%82%E5%90%8C")
- 八、如果今天重写:五件事
- 九、坑对照表与落地清单
一、三个文件几乎一样,只差几何类型
需求很简单:地图上叠加三类东西------监测点、边界线、区域地块。每个都要能画、能改颜色、能隐藏、能保存。
很多人的第一版代码长这样:点类写一遍拼 GeoJSON 的代码,线类复制一份改个名字,面类再复制一份加个填充。三个文件几乎一样,只差几何类型。
然后 bug 来了:
- 改了点类的颜色逻辑,忘了同步线类;
- 用户画完关闭页面,重开全没了;
- 另一个页面想复用同一套图层,只能再复制一遍。
业务常要在地图上叠加"点(监测点)+ 线(边界 / 轨迹)+ 面(区域 / 地块)"三类几何。最直接的写法是每类各写一遍:自己拼 GeoJSON、自己建 Source、自己建 Layer、自己加样式、自己存状态。结果:
- 三类几何的"数据 → 图层"链路重复三遍,出 bug 概率 ×3;
- 关闭 / 重新打开页面时,用户画的图层全没了(状态没存);
- 多页面想共享同一套图层配置,无处安放。
DEMO 把问题拆成四段职责,各自单例收口:
二、第 1 段:统一数据模型(密封类)
把"一个图层"抽象成一份配置数据,用密封类表达三类几何,外加公共字段(id、名称、显隐、颜色、宽度、不透明度):
kotlin
sealed class LayerConfig {
abstract val id: String
abstract val name: String
abstract val visible: Boolean
data class PointLayer(
override val id: String, override val name: String, override val visible: Boolean,
val color: String, val points: List<Pair<Double, Double>> // (lat, lon)
) : LayerConfig()
data class LineLayer(
override val id: String, override val name: String, override val visible: Boolean,
val color: String, val width: Float,
val points: List<Pair<Double, Double>>
) : LayerConfig()
data class PolygonLayer(
override val id: String, override val name: String, override val visible: Boolean,
val color: String, val opacity: Float,
val rings: List<List<Pair<Double, Double>>>
) : LayerConfig()
}
为什么用密封类而非 Any + instanceof?
编译器强制 when 覆盖所有子类 ,新增几何类型时漏处理会编译报错------把"忘了某类"从运行时崩溃提前到编译期。
对比一下两种建模方式:
| 维度 | Any + instanceof / int 标签 |
sealed class |
|---|---|---|
| 漏处理分支 | 编译通过,运行时崩溃 | 编译报错 |
| 类型安全 | 需手动强转 | 智能转换,无需强转 |
| 新增类型成本 | 改所有 when,易漏 |
编译器帮你找出所有该改的地方 |
| 数据携带 | 几何数据靠强转后取 | 子类各自携带专属字段 |
这是这份架构里最值钱的一处设计------它把一类"能编译但会崩"的 bug 直接消灭在编译期。
三、第 2 段:统一转换器
转换器吃一份 LayerConfig 列表,把每类几何转成 GeoJSON,再分发到对应的 SymbolLayer / LineLayer / FillLayer。
核心是把"手动逐要素绘制"换成"声明式图层 "------只描述样式,不写绘制循环:
kotlin
fun build(features): BuiltLayers {
val points = mutableListOf<Feature>()
val lines = mutableListOf<Feature>()
val polys = mutableListOf<Feature>()
for (f in features) when (f) {
is PointLayer -> points.add(Feature.fromGeometry(Point.fromLngLat(f.lon, f.lat)))
is LineLayer -> lines.add(Feature.fromGeometry(
LineString.fromLngLats(f.points.map { Point.fromLngLat(it.lon, it.lat) })))
is PolygonLayer-> polys.add(Feature.fromGeometry(
Polygon.fromLngLats(f.rings.map { ring ->
ring.map { Point.fromLngLat(it.lon, it.lat) } })))
}
val pointSrc = GeoJsonSource(prefix+"-point-src", FeatureCollection.fromFeatures(points))
val lineSrc = GeoJsonSource(prefix+"-line-src", FeatureCollection.fromFeatures(lines))
val polySrc = GeoJsonSource(prefix+"-polygon-src", FeatureCollection.fromFeatures(polys))
val pointLayer = SymbolLayer(prefix+"-point", prefix+"-point-src")
.withProperties(iconAllowOverlap(true), iconIgnorePlacement(true),
textColor(f.color), textSize(8f), textField("●"))
val lineLayer = LineLayer(prefix+"-line", prefix+"-line-src")
.withProperties(lineColor(f.color), lineWidth(f.width),
lineCap(ROUND), lineJoin(ROUND))
val polyLayer = FillLayer(prefix+"-polygon", prefix+"-polygon-src")
.withProperties(fillColor(f.color), fillOpacity(f.opacity))
val polyOutline = LineLayer(prefix+"-polygon-outline", prefix+"-polygon-src")
.withProperties(lineColor(f.color), lineWidth(2f))
return BuiltLayers(pointSrc, lineSrc, polySrc,
pointLayer, lineLayer, polyLayer, polyOutline)
}
要点:
- 一次性分三类 :点 →
SymbolLayer(用●文本当图标,避开图标资源依赖)、线 →LineLayer(圆头圆角)、面 →FillLayer+ 一条LineLayer描边(面必须有轮廓才看得清); - 声明式 :上层拿到的
BuiltLayers.sources / .layers直接addSource/addLayer,无需关心内部 GeoJSON 怎么拼; - 叠加顺序 :
polygon → polygonOutline → line → point(面在下、点在最上),保证视觉层次正确。
公式 ↔ 代码一致性核对 :本文为架构型,无数学公式。几何映射逻辑------
PointLayer→Point、LineLayer→LineString、PolygonLayer→Polygon且环点顺序保留------与转换器实现逐一对应;●文本图标、圆头线、面 + 描边三层结构均与代码一致;图层叠放顺序polygon, polygonOutline, line, point与BuiltLayers.layers返回顺序一致。无偏差。
四、第 3 段:XML 持久化
用户画的图层必须能关页重开不丢 。方案是把 LayerConfig 列表序列化进 XML:
xml
<map>
<layer type="point" id="p1" name="监测点A" visible="true"
color="#FF5050" points="23.1,113.2;23.2,113.3" />
<layer type="polygon" id="g1" name="地块1" visible="true"
color="#3D8BFF" opacity="0.35"
rings="23.1,113.2;23.2,113.2;23.2,113.3;23.1,113.3" />
</map>
序列化关键点:
- 几何点用
lat,lon分号拼接成字符串 存文本(避免嵌套结构爆炸)。解析时再split(";")→split(",")还原; - 密封类
type字段 :存point/line/polygon字符串,读回时when(type)决定反序列化成哪个子类,与密封类枚举严格对应; - 分层读写 :先读全部
<layer>,按id重建LayerConfig列表,再交给转换器重建图层------状态与渲染解耦。
kotlin
fun save(layers: List<LayerConfig>) {
val doc = newDocument("map")
for (l in layers) doc.addLayerElement(l) // 按 type 写出不同属性
writeToFile(doc)
}
fun load(): List<LayerConfig> {
val doc = readFromFile()
return doc.layerElements.map { el ->
when (el.type) {
"point" -> PointLayer(...)
"line" -> LineLayer(...)
"polygon" -> PolygonLayer(...)
}
}
}
五、第 4 段:状态仓(唯一真源)
把"当前有哪些图层、各自显隐"收口到一个运行时单例,页面只跟它打交道:
kotlin
object MapStateStore {
private val layers = mutableListOf<LayerConfig>()
fun getAll(): List<LayerConfig> = layers.toList()
fun add(layer: LayerConfig) { layers.add(layer); save(); rebuild() }
fun remove(id: String) { layers.removeIf { it.id == id }; save(); rebuild() }
fun toggleVisible(id: String) {
layers.find { it.id == id }?.let { it.visible = !it.visible }; save(); rebuild()
}
private fun rebuild() { /* 用转换器重建 + 按 visible 显隐 */ }
private fun save() { xmlStore.save(layers) } // 每次变更落盘
}
三个设计要点:
- 唯一真源 :所有页面读
getAll(),写只走add/remove/toggleVisible,避免多份状态不一致; - 变更即落盘 :每次增删改都
xmlStore.save,配合启动时load,实现"关页不丢"; - 显隐不重建几何 :
visible=false只style.removeLayer/ 隐藏,不丢几何数据 ;改回true直接恢复。
最后一条是个容易做错的细节:如果隐藏时把几何数据也清了,恢复时就要重新解析一遍------"不显示"和"不存在"是两件事。
六、四个真实踩坑复盘
这套架构我迭代了三版才稳下来,几个坑值得单独说:
坑 A:用 Any 存几何,新增类型在运行时空指针
第一版用 List<Any> + instanceof 判断类型,加"文本标注"新类型时,漏改了转换器里的一个分支,编译全过,运行到那行直接 ClassCastException。
换成 sealed class 之后,漏分支直接编译报错------把"运行时才发现"提前到"编译期就拦住",这是架构给的第一份安全感。
坑 B:面只画填充不描边,用户以为没画上
第一版面用纯 FillLayer,同色地块挨在一起完全分不清边界,用户反复报"我的地块丢了"。
加了 LineLayer 描边后一眼可见。
面类数据没有轮廓等于没画。
这个坑的教训是:"画出来了"和"用户看得见"是两回事。 技术上数据都在、填充也画了,但视觉上无法区分,用户的结论就是"丢了"。
坑 C:点用图标资源,多分辨率 + 素材缺失连环炸
最初点的图层用 iconImage("marker"),要维护 mipmap 多套尺寸。某次删素材误删了一张,点全画不出来还不报错。
换成 textField("●") 文本图标后零资源依赖,这个问题彻底消失。
顺带一个额外收益:文本图标的颜色可以直接用 textColor 改,不用为每种颜色准备一套图标资源。
坑 D:状态只存内存,关页被用户投诉"画了白画"
早期 MapStateStore 不落盘,页面销毁重建后图层清空。加"变更即 save()"之后才真正闭环。
"能画出来"和"关页不丢"是两件事,后者才是持久化的价值。
七、与 osmdroid 的异同
通用原理(一致)
osmdroid 篇(03 分层世界)提出的核心思想------"一个统一图层配置模型(带数据类型标签)+ 一条渲染管线按类型路由 + 整体序列化成一份 XML + 默认值兜底"------与本文完全一致。把"数据来源"与"图层配置"解耦、配置驱动而非代码驱动,是跨引擎的通用架构。
MapLibre 专属实现差异
| 维度 | osmdroid 篇(03) | 本文(MapLibre) |
|---|---|---|
| 模型表达 | data class LayerConfig(含 dataType 整型标签) |
sealed class LayerConfig(点/线/面为子类型,when 穷举保安全) |
| 渲染管线 | 遍历配置 → when(dataType) 路由到各 Overlay 的 draw(canvas) |
遍历配置 → 转 GeoJSON → 分发到 Symbol/Line/Fill 声明式 Layer |
| 绘制方式 | Canvas 命令式逐要素绘制 | GPU 声明式图层(只描述样式,不写绘制循环) |
| 面 / 线渲染 | Overlay 内手动描边、填充 | FillLayer + 一条 LineLayer 描边组合 |
| 点图标 | 图标资源 | 用 ● 文本当图标,零资源依赖 |
一句话:
"统一模型 + XML 持久化 + 配置驱动"是通用架构;MapLibre 用密封类(编译期穷举)替代整型标签(运行时路由),并用 GPU 声明式图层替代 Canvas 命令式绘制------前者更安全,后者更流畅且风格统一。
两者"关页不丢、配置即图层"的目标完全相同。
八、如果今天重写:五件事
DEMO 这套"密封类 + 转换器 + XML + 状态仓"是很好的基线。如果今天重写,我会在四个方向上再进一层:
1. 序列化升级:XML → 版本化 JSON / Room
XML 手写解析容易出 split(";") 这种脆解析。重写版会改用带 schemaVersion 的 JSON(或直接进 Room 表),读回时按版本迁移 ,字段演进不再靠手改解析器。sealed class 保留,type 字段改由序列化器自动打标。
2. 转换器加缓存与增量更新
目前 rebuild() 每次全量重建所有 Source / Layer,图层多时白白浪费。重写版给 LayerConfig 算内容哈希 ,哈希没变就不重建;几何变了也尽量走 GeoJsonSource.setGeoJson 增量替换,而不是整源删了重加------这对海量点场景尤其关键(和三级过滤篇的增量思路同源)。
3. 图层排序显式化
DEMO 靠 addLayer 的调用顺序隐式 决定叠放。重写版会给 LayerConfig 加 zIndex 字段,转换器按 zIndex 排序后再 add,多图层互叠顺序可配置、可持久化。
4. 支持 undo / redo 与命令模式
add/remove/toggleVisible 全部走命令对象,状态仓维护撤销栈。用户误删一个图层能一键恢复,这在测绘业务(画错了要回退)里是刚需,而"状态与渲染解耦"的架构让这件事天然好做。
5. 类型扩展插件化
新几何类型不再需要改转换器的 when 大分支------用"类型 → 转换器"的注册表,新增类型注册一个转换器即可。密封类保证类型安全,注册表保证扩展点开放。
九、坑对照表与落地清单
| # | 症状 | 根因 | 解法 |
|---|---|---|---|
| 1 | 新增类型后运行时 ClassCastException |
用 Any + instanceof,漏分支也能编译 |
改用 sealed class,when 穷举 |
| 2 | 用户报"地块丢了" | 面只画填充不描边,同色无法区分 | 必配一条 LineLayer 描边 |
| 3 | 点全画不出还不报错 | 图标资源缺失 | 用 ● 文本图标,零资源依赖 |
| 4 | 关页重开图层清空 | 状态只存内存 | 变更即落盘 |
| 5 | 多页面图层互相覆盖 | 各持一份图层列表 | 收口到状态仓单例 |
| 6 | 隐藏后再显示要重新解析 | 隐藏时清了几何数据 | 隐藏只 removeLayer,不丢数据 |
| 7 | 图层多了重建变慢 | rebuild() 每次全量 |
内容哈希 + 增量 setGeoJson |
| 8 | 叠放顺序不可控 | 依赖 addLayer 调用顺序 |
加 zIndex 字段显式排序 |
落地清单:
- 用密封类统一三类几何,新增类型编译期可控;
- 转换器把数据声明式变成 Symbol / Line / Fill 图层,渲染零手写循环;
- 面必配描边,点优先用文本图标;
- XML(或版本化 JSON)把配置落盘,关页可恢复;
- 状态仓做唯一真源,多页面共享、变更即持久化。
完整可运行源码 :GitCode 仓库 · android_osmdroid_maplibre
对照阅读:本系列第 03 篇(osmdroid 分层世界:离线方案、图层模型与 Overlay 体系)------同一套"统一模型 + 配置驱动"架构,osmdroid 用 int 标签 + Canvas 命令式,MapLibre 用 sealed class + GPU 声明式,对照看能理解"类型安全"和"渲染范式"各自带来的收益。
延伸阅读:本系列第 10 篇(MapLibre 海量点三级过滤:增量更新思路同源)
评论区聊聊:你的图层状态是怎么存的?遇到过"关页白画"或者"多页面互相覆盖"的坑吗?有没有接过"我的地块丢了"这种让人一头雾水的 Bug 单?
本系列为 osmdroid / MapLibre 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。