MapLibre 实战 11|用户说"我的地块丢了":一个 sealed class 图层模型,和四个让我重构三版的坑

摘要:点、线、面三类几何,第一版各写一套拼 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 把问题拆成四段职责,各自单例收口:

flowchart TB subgraph M[&#34;第 1 段:统一数据模型&#34;] SC[&#34;sealed class LayerConfig<br/>Point / Line / Polygon&#34;] end subgraph C[&#34;第 2 段:统一转换器&#34;] CV[&#34;LayerConfig → GeoJSON<br/>→ Symbol/Line/Fill Layer&#34;] end subgraph P[&#34;第 3 段:XML 持久化&#34;] XML[&#34;配置落盘<br/>关页可恢复&#34;] end subgraph S[&#34;第 4 段:状态仓&#34;] ST[&#34;MapStateStore<br/>运行时唯一真源&#34;] end SC --> CV CV --> ST ST <--> XML ST --> RENDER[&#34;MapLibre Style&#34;] style SC fill:#e6f7ff,stroke:#1890ff,stroke-width:2px style CV fill:#f6ffed,stroke:#52c41a,stroke-width:2px style XML fill:#fff7e6,stroke:#fa8c16,stroke-width:2px style ST fill:#f9f0ff,stroke:#722ed1,stroke-width:2px

二、第 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→PointLineLayer→LineStringPolygonLayer→Polygon 且环点顺序保留------与转换器实现逐一对应; 文本图标、圆头线、面 + 描边三层结构均与代码一致;图层叠放顺序 polygon, polygonOutline, line, pointBuiltLayers.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=falsestyle.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 的调用顺序隐式 决定叠放。重写版会给 LayerConfigzIndex 字段,转换器按 zIndex 排序后再 add,多图层互叠顺序可配置、可持久化

4. 支持 undo / redo 与命令模式

add/remove/toggleVisible 全部走命令对象,状态仓维护撤销栈。用户误删一个图层能一键恢复,这在测绘业务(画错了要回退)里是刚需,而"状态与渲染解耦"的架构让这件事天然好做。

5. 类型扩展插件化

新几何类型不再需要改转换器的 when 大分支------用"类型 → 转换器"的注册表,新增类型注册一个转换器即可。密封类保证类型安全,注册表保证扩展点开放。


九、坑对照表与落地清单

# 症状 根因 解法
1 新增类型后运行时 ClassCastException Any + instanceof,漏分支也能编译 改用 sealed classwhen 穷举
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 双引擎对照实战,源码开源可运行。如果这篇对你有帮助,点个赞收藏一下,后续会持续更新地图接入、数据绘制、性能优化的完整链路。

相关推荐
xqchen1 小时前
代码差异可视化方案选型:diff2html 实战指南
前端
名字还没想好☜1 小时前
用 Zustand 做 React 全局状态管理:告别 Context 重渲染,3 个实战模式与持久化
前端
IMPYLH1 小时前
HTML 的 <section> 元素
前端·html
X1A0RAN2 小时前
密匣 PsdKeep:一个属于你自己的 Chrome 账号记事本
前端·chrome
追涨杀跌老能手2 小时前
GD32H759 + RT-Thread 工控实战--第4篇 SDRAM,SDIO,以及触摸屏
开源·嵌入式
529宝宝起名网2 小时前
用 Python 开发历史名字查询与起名灵感工具:从古籍人物数据库到名字文化故事生成
开发语言·前端·python
linux_cfan2 小时前
videojs v10 源代码系列解读:10 · `DestroyMixin`:双重 rAF 延迟销毁
前端·javascript·音视频
计算机魔术师2 小时前
英伟达是人工智能领域的"中央银行"
前端
我的div丢了肿么办2 小时前
顶部区域固定,左侧区域滚动,右侧区域滚动,彼此独立
前端·css