摘要:散点、线、面到处各写一套?本文用一个密封类 LayerConfig 统一三类几何,通过统一转换器声明式地生成 Symbol/Line/Fill 图层,再配 XML 落盘与状态仓唯一真源,实现"关页不丢、配置即图层"。
文章导读
- 适用引擎/版本:MapLibre Android
- 完整源码:https://gitcode.com/qq_16064871/android_osmdroid_maplibre
- 阅读场景:地图上需要叠加点(监测点)+ 线(边界/轨迹)+ 面(区域/地块)三类几何,且要保存用户配置、跨页面共享
问题引入
MapLibre 图层模型 最朴素的写法是"点、线、面各写一套":各拼各的 GeoJSON、各建各的 Source/Layer、各存各的状态,结果链路重复三遍、关页丢图层、多页面无法共享。本文把图层抽象成"数据"而非"绘制动作":sealed class LayerConfig 编译期穷举三类几何,统一转换器把配置声明式地生成 SymbolLayer/LineLayer/FillLayer,再经 XML 持久化落盘,配合 MapStateStore 状态仓做唯一真源。与 osmdroid 篇(07)同源不同渲染管线。
副标题:一个"图层模型"怎么同时扛住点、线、面,并关页不丢。与 osmdroid 篇(07)同源不同渲染管线。
一、问题:散点、线、面到处各写一套
业务常要在地图上叠加"点(监测点)+ 线(边界/轨迹)+ 面(区域/地块)"三类几何。最直接的写法是每类各写一遍:自己拼 GeoJSON、自己建 Source、自己建 Layer、自己加样式、自己存状态。结果:
- 三类几何的"数据 → 图层"链路重复三遍,出 bug 概率 ×3。
- 关闭/重新打开页面时,用户画的图层全没了(状态没存)。
- 多页面想共享同一套图层配置,无处安放。
DEMO 把问题拆成三段职责,各自单例收口:
text
图层配置(数据模型)
→ 统一转换器(数据 → GeoJSON → 声明式 Layer)
→ XML 持久化(配置落盘,重开可恢复)
→ 状态仓(运行时唯一真源,控制显隐/重建)
二、第 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 覆盖所有子类,新增几何类型时漏处理会编译报错------把"忘了某类"从运行时崩溃提前到编译期。
三、第 2 段:统一转换器(数据 → 声明式 Layer)
转换器吃一份 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(Android 的 SharedPreferences 字符串键值,或独立 XML 文件)。DEMO 用 <map> 根 + 每个图层一组 <layer> 子元素:
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直接恢复。
六、与 osmdroid 的异同:通用原理 vs MapLibre 实现
通用原理(一致):osmdroid 篇(07)提出的核心思想------"一个统一图层配置模型(带数据类型标签)+ 一条渲染管线按类型路由 + 整体序列化成一份 XML + 默认值兜底"------与本文完全一致。把"数据来源"与"图层配置"解耦、配置驱动而非代码驱动,是跨引擎的通用架构。
MapLibre 专属实现差异:
| 维度 | osmdroid 篇(07) | 本文(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 命令式绘制------前者更安全,后者更流畅且风格统一。 两者"关页不丢、配置即图层"的目标完全相同。
七、落地清单与坑
- 坑 1:用
Any存几何 ,新增类型编译不报错、运行时崩溃。改用密封类,when穷举保安全。 - 坑 2:面只画填充不描边 ,看不清边界。务必配一条
LineLayer描边。 - 坑 3:点用图标资源 ,增加素材依赖与多分辨率维护。DEMO 用
●文本当图标,零资源。 - 坑 4:状态只存内存不落盘,关页丢失。每次变更即写 XML。
- 坑 5:多页面各持一份图层列表,互相覆盖。收口到状态仓单例。
八、小结
把"图层"当成数据 而非绘制动作来管理,收益是连锁的:
- 密封类统一三类几何,新增类型编译期可控;
- 转换器把数据声明式地变成 Symbol/Line/Fill 图层,渲染零手写循环;
- XML 把配置落盘,关页可恢复;
- 状态仓做唯一真源,多页面共享、变更即持久化。
这一套下来,"画线画面上传保存"不再是散落各页的胶水代码,而是一个可测试、可持久化的模块。
完整工程源码见:https://gitcode.com/qq_16064871/android_osmdroid_maplibre