第七篇:割草机地图编辑器设计,拖点、禁区、多边形校验与数据提交

上一篇我们拆解了割草机地图中的核心业务对象:

复制代码
工作区域
禁区
通道
充电桩
RTK 基站
设备位置
实时轨迹

其中,工作区域、禁区、通道和充电桩属于地图配置数据。

它们决定:

  • 割草机可以在哪里工作;

  • 哪些区域禁止进入;

  • 不同草坪之间如何通行;

  • 设备如何离开和返回充电桩。

但这些地图数据不会永远保持不变。

用户可能需要:

  • 调整工作区域边界;

  • 增加或删除禁区;

  • 修改区域名称;

  • 增加连接通道;

  • 调整充电桩位置;

  • 修改区域割草参数。

因此,割草机 App 通常还需要一个地图编辑器。

表面上看,地图编辑器只是让用户拖动几个点。

但真正实现时,它需要同时处理:

复制代码
地图手势
坐标转换
本地编辑副本
多边形几何校验
撤销与重做
地图版本冲突
云端保存
设备同步
异常恢复

这一篇,我们就从移动端角度拆解一个完整的割草机地图编辑器应该如何设计。


一、为什么不能直接修改服务器返回的地图对象

假设 App 从服务器获取到地图:

复制代码
data class MapConfiguration(
    val mapId: String,
    val workZones: List<WorkZone>,
    val forbiddenZones: List<ForbiddenZone>,
    val channels: List<Channel>,
    val chargingStation: ChargingStation?,
    val version: Long
)

用户进入编辑页后,开始拖动工作区边界。

一种简单实现是直接修改当前地图状态:

复制代码
currentMap = currentMap.copy(
    workZones = updatedZones
)

但这会带来一个问题:

当前页面展示的正式地图和用户尚未保存的编辑结果混在了一起。

例如用户:

复制代码
拖动了边界
增加了一个禁区
修改了一条通道
最后点击取消

此时 App 应该恢复进入编辑页前的地图。

如果正式地图对象已经被不断修改,取消操作就很难处理。

更合理的方式是:

复制代码
正式地图 MapConfiguration
        ↓
创建本地编辑副本 MapDraft
        ↓
用户只修改 MapDraft
        ↓
保存成功后再替换正式地图

二、什么是地图编辑副本

地图编辑副本可以理解为:

当前用户正在编辑、但尚未正式生效的一份地图草稿。

例如:

复制代码
data class MapDraft(
    val mapId: String,
    val baseVersion: Long,
    val workZones: List<EditableWorkZone>,
    val forbiddenZones: List<EditableForbiddenZone>,
    val channels: List<EditableChannel>,
    val chargingStation: EditableChargingStation?,
    val isDirty: Boolean,
    val validationResult: MapValidationResult
)

其中:

复制代码
baseVersion

表示这份草稿基于哪个正式地图版本创建。

例如:

复制代码
云端地图版本:12
本地草稿 baseVersion:12

用户的所有编辑行为只作用于 MapDraft

正式地图仍然保持不变:

复制代码
MapConfiguration:
服务器确认的正式地图

MapDraft:
用户尚未保存的编辑结果

用户点击取消时:

复制代码
丢弃 MapDraft
回到 MapConfiguration

用户保存成功时:

复制代码
MapDraft
 ↓
上传服务器
 ↓
服务器生成新版本
 ↓
更新正式 MapConfiguration

三、地图编辑器应该有哪些状态

地图编辑不是一个简单的 isEditing

它可能经历:

复制代码
进入编辑
 ↓
创建草稿
 ↓
拖动边界
 ↓
本地校验
 ↓
提交云端
 ↓
同步设备
 ↓
编辑完成

过程中还可能出现:

  • 校验失败;

  • 网络请求失败;

  • 地图版本冲突;

  • 设备同步失败;

  • App 被关闭;

  • 用户取消编辑。

可以定义:

复制代码
sealed interface MapEditorState {

    data object Loading : MapEditorState

    data class Editing(
        val draft: MapDraft
    ) : MapEditorState

    data class Validating(
        val draft: MapDraft
    ) : MapEditorState

    data class SavingToCloud(
        val draft: MapDraft
    ) : MapEditorState

    data class SyncingToDevice(
        val mapVersion: Long
    ) : MapEditorState

    data class Saved(
        val mapVersion: Long
    ) : MapEditorState

    data class ValidationFailed(
        val draft: MapDraft,
        val errors: List<MapValidationError>
    ) : MapEditorState

    data class VersionConflict(
        val localDraft: MapDraft,
        val latestCloudVersion: Long
    ) : MapEditorState

    data class SaveFailed(
        val draft: MapDraft,
        val reason: String
    ) : MapEditorState
}

这样页面就能明确展示:

复制代码
正在编辑
正在检查地图
正在保存地图
正在同步设备
地图保存失败
地图版本冲突

而不是只显示一个模糊的加载动画。


四、地图编辑操作应该如何建模

用户在地图上可能执行很多操作:

复制代码
拖动顶点
增加顶点
删除顶点
移动整个禁区
增加禁区
删除禁区
增加通道
修改充电桩
撤销
重做

这些操作可以统一建模为编辑 Action:

复制代码
sealed interface MapEditAction {

    data class SelectElement(
        val elementId: String
    ) : MapEditAction

    data class MoveVertex(
        val elementId: String,
        val vertexIndex: Int,
        val newPoint: GeoPoint
    ) : MapEditAction

    data class AddVertex(
        val elementId: String,
        val afterIndex: Int,
        val point: GeoPoint
    ) : MapEditAction

    data class RemoveVertex(
        val elementId: String,
        val vertexIndex: Int
    ) : MapEditAction

    data class AddForbiddenZone(
        val zone: EditableForbiddenZone
    ) : MapEditAction

    data class RemoveForbiddenZone(
        val forbiddenZoneId: String
    ) : MapEditAction

    data class AddChannel(
        val channel: EditableChannel
    ) : MapEditAction

    data class MoveChargingStation(
        val position: GeoPoint
    ) : MapEditAction
}

所有编辑动作再通过统一 Reducer 更新草稿:

复制代码
当前 MapDraft
+
MapEditAction
=
新的 MapDraft

五、地图编辑 Reducer 负责什么

Reducer 负责根据当前草稿和编辑操作,计算新的草稿。

例如拖动工作区顶点:

复制代码
object MapEditorReducer {

    fun reduce(
        current: MapDraft,
        action: MapEditAction
    ): MapDraft {
        return when (action) {
            is MapEditAction.MoveVertex -> {
                moveVertex(
                    current = current,
                    action = action
                )
            }

            is MapEditAction.AddForbiddenZone -> {
                current.copy(
                    forbiddenZones =
                        current.forbiddenZones + action.zone,
                    isDirty = true
                )
            }

            is MapEditAction.RemoveForbiddenZone -> {
                current.copy(
                    forbiddenZones =
                        current.forbiddenZones.filterNot {
                            it.forbiddenZoneId ==
                                action.forbiddenZoneId
                        },
                    isDirty = true
                )
            }

            else -> current
        }
    }
}

移动顶点:

复制代码
private fun moveVertex(
    current: MapDraft,
    action: MapEditAction.MoveVertex
): MapDraft {
    val zones = current.workZones.map { zone ->
        if (zone.zoneId != action.elementId) {
            zone
        } else {
            zone.copy(
                boundary = zone.boundary.mapIndexed {
                        index,
                        point ->
                    if (index == action.vertexIndex) {
                        action.newPoint
                    } else {
                        point
                    }
                }
            )
        }
    }

    return current.copy(
        workZones = zones,
        isDirty = true
    )
}

Reducer 只负责状态计算,不负责:

  • 调用地图 SDK;

  • 请求服务器;

  • 保存数据库;

  • 显示 Toast;

  • 上传地图。

这些副作用仍然由 ViewModel、UseCase 和 Repository 负责。


六、拖动一个边界点时发生了什么

用户手指拖动一个边界点,完整链路可能是:

复制代码
用户触摸顶点 Marker
        ↓
地图 SDK 返回屏幕拖动位置
        ↓
屏幕坐标转换为地图坐标
        ↓
地图坐标转换为业务 GeoPoint
        ↓
发送 MoveVertex Action
        ↓
Reducer 更新 MapDraft
        ↓
执行快速几何校验
        ↓
地图重新绘制边界

可以表示为:

复制代码
ScreenPoint
 ↓
Map SDK Coordinate
 ↓
GeoPoint
 ↓
MapEditAction.MoveVertex
 ↓
MapDraft

地图 SDK 只负责告诉业务层:

复制代码
用户把第 3 个点拖到了某个坐标

至于这个坐标是否合法,不应该由地图 SDK 决定。


七、拖动过程中是否需要每一帧都校验

地图拖动事件可能非常频繁。

用户移动手指时,地图 SDK 可能连续回调几十次甚至几百次。

如果每次都执行完整校验:

复制代码
多边形自相交
禁区包含关系
区域重叠
通道连通性
安全距离

可能导致页面卡顿。

因此,可以将校验分成两类。

1. 拖动中的快速校验

用于实时预览:

  • 点是否超出允许范围;

  • 是否明显穿过相邻边;

  • 当前多边形是否出现自相交;

  • 点是否进入完全非法区域。

结果可以通过颜色提示:

复制代码
绿色边界:
当前拖动位置合法

红色边界:
当前边界存在冲突

2. 拖动结束后的完整校验

用户松手后再执行:

  • 多边形自相交;

  • 区域面积;

  • 最短边长度;

  • 禁区是否位于工作区内;

  • 多禁区重叠;

  • 通道连通性;

  • 充电桩可达性;

  • 地图整体合法性。

可以概括为:

复制代码
拖动过程中:
保证交互流畅,做必要校验

拖动结束后:
执行完整业务校验

八、多边形为什么必须做合法性校验

用户在地图上拖动顶点时,可能产生业务上无效的多边形。

例如:

复制代码
边界自相交
多个点重合
面积过小
某条边过短
区域完全折叠
禁区跨出工作区

如果这些非法地图直接同步给设备,可能导致:

  • 路径规划失败;

  • 设备无法启动任务;

  • 设备越界;

  • 固件解析异常;

  • 回充路径失效。

因此,地图校验不是单纯为了 UI 美观,而是设备安全的一部分。


九、多边形最基础的校验有哪些

一个工作区或禁区多边形,至少需要经过以下校验。

1. 顶点数量

多边形至少需要三个有效顶点。

复制代码
fun hasEnoughPoints(
    points: List<GeoPoint>
): Boolean {
    return points.distinct().size >= 3
}

2. 重复点

连续两个点不能完全相同,也不应该距离过近。

复制代码
P1 → P2 → P2 → P3

这种数据可能形成零长度边。

可以设置最小点距:

复制代码
相邻顶点距离
>
设备或地图规定的最小阈值

3. 面积不能过小

三个点虽然能形成多边形,但可能几乎位于同一条直线上。

复制代码
P1 ───── P2 ───── P3

这种区域面积接近零,不能作为有效工作区。


4. 边长不能过短

如果相邻顶点距离太近,会产生大量细碎边界。

这可能影响:

  • 路径规划;

  • 边界跟随;

  • 地图显示;

  • 编辑体验。


5. 多边形不能自相交

例如一个蝴蝶结形状:

复制代码
P1 ───── P2
  ╲     ╱
    ╳
  ╱     ╲
P4 ───── P3

边界发生交叉后,多边形内部和外部就不再明确。


十、如何判断多边形是否自相交

多边形由多条线段组成。

判断自相交的基本思路是:

检查任意两条不相邻的边是否发生交叉。

例如多边形:

复制代码
P1 → P2 → P3 → P4 → P1

包含边:

复制代码
E1:P1 → P2
E2:P2 → P3
E3:P3 → P4
E4:P4 → P1

相邻边共享端点,不算自相交。

需要检查:

复制代码
E1 是否与 E3 相交
E2 是否与 E4 相交

伪代码:

复制代码
fun hasSelfIntersection(
    points: List<LocalPoint>
): Boolean {
    val edges = buildEdges(points)

    for (i in edges.indices) {
        for (j in i + 1 until edges.size) {
            if (areAdjacentEdges(
                    i,
                    j,
                    edges.size
                )
            ) {
                continue
            }

            if (segmentsIntersect(
                    edges[i],
                    edges[j]
                )
            ) {
                return true
            }
        }
    }

    return false
}

这里最好使用局部平面坐标进行几何运算,而不是直接在经纬度上进行简单计算。

因为庭院范围较小时,可以先将经纬度投影到局部坐标:

复制代码
GeoPoint
 ↓
LocalPoint(x, y)
 ↓
多边形几何计算

这样面积、距离和线段相交计算会更直观。


十一、为什么几何计算最好使用局部坐标

经纬度表示的是球面位置。

但常见的线段、面积和距离算法通常基于平面坐标。

如果直接把:

复制代码
longitude 当成 x
latitude 当成 y

在很小的庭院范围内可能看不出明显问题,但从架构上并不严谨。

更合理的方式是:

复制代码
WGS84 / RTK 坐标
        ↓
以庭院某个点为原点
        ↓
转换为局部米制坐标
        ↓
执行几何校验

例如:

复制代码
data class LocalPoint(
    val xMeters: Double,
    val yMeters: Double
)

这样:

  • 距离单位是米;

  • 面积单位是平方米;

  • 通道宽度更容易比较;

  • 安全距离更容易计算;

  • 多边形算法更稳定。


十二、禁区应该进行哪些校验

禁区不仅需要满足普通多边形规则,还需要满足它与工作区的关系。

1. 禁区是否位于工作区内部

禁区通常应该完全位于某个工作区域内。

复制代码
工作区
┌────────────────┐
│     ┌────┐     │
│     │禁区│     │
│     └────┘     │
└────────────────┘

不能出现:

复制代码
禁区部分跨出工作区边界

可以先检查禁区所有顶点是否位于工作区内,再检查禁区边界是否与工作区边界发生交叉。


2. 禁区是否与工作区边界距离过近

即使禁区位于工作区内部,如果距离边界过近,也可能形成设备无法通过的狭窄空间。

例如:

复制代码
工作区边界
│
│  20 cm
│ ┌────禁区────┐

如果设备宽度大于剩余通道宽度,这个区域可能不可达。

因此,业务可能要求:

复制代码
禁区与工作区边界的最小距离
>
设备安全宽度

3. 多个禁区能否重叠

通常两个禁区不应该异常重叠。

复制代码
禁区 A
   与
禁区 B

如果发生重叠,可以:

  • 禁止保存;

  • 自动合并;

  • 允许重叠但提示用户。

具体取决于设备地图协议。

移动端不能只从视觉上判断"重叠也没关系",还要看设备是否支持。


4. 禁区面积是否合理

过小的禁区可能没有实际意义,也可能小于设备定位误差。

因此,可以设置:

复制代码
最小禁区面积
最小边长
最小安全缓冲范围

十三、通道应该进行哪些校验

通道是连接工作区域的路径对象。

它需要校验的不只是自身形状,还要校验区域关系。

1. 起点和终点是否连接有效区域

复制代码
fromZoneId
toZoneId

必须对应真实存在的工作区域。

并且通常不能相同:

复制代码
fromZoneId != toZoneId

2. 通道入口是否位于区域边界附近

通道起点应该连接来源区域,终点应该连接目标区域。

不能出现:

复制代码
通道悬浮在两个区域中间

否则设备不知道从哪里进入通道。


3. 通道是否经过禁区

通道路径不应该穿过:

  • 水池禁区;

  • 花坛禁区;

  • 其他不可通行区域。


4. 通道宽度是否满足设备通行

如果通道模型包含宽度,需要检查:

复制代码
通道宽度
>
设备宽度 + 安全余量

否则地图从视觉上看是连通的,设备实际上却无法通过。


5. 通道是否造成无效环路或孤立区域

区域与通道可以形成一张图。

保存前可以检查:

复制代码
充电桩所在区域
是否能到达所有启用的工作区域

如果某个区域完全不可达,可以提示:

复制代码
后院区域没有可用通道,
设备无法从充电桩到达该区域。

十四、充电桩需要进行哪些校验

充电桩通常需要检查:

  • 是否位于地图允许范围;

  • 是否关联有效工作区;

  • 方向是否有效;

  • 是否存在离开充电桩的路径;

  • 是否能够通过通道到达工作区域;

  • 是否与禁区冲突。

例如:

复制代码
充电桩存在
但没有连接任何工作区域

这张地图虽然可以显示,但任务无法执行。

因此,地图整体校验不能只检查每个元素自身是否合法,还需要检查元素之间的关系。


十五、如何设计统一的地图校验结果

不要只返回:

复制代码
true / false

因为用户需要知道具体哪里有问题。

可以定义:

复制代码
data class MapValidationResult(
    val isValid: Boolean,
    val errors: List<MapValidationError>,
    val warnings: List<MapValidationWarning>
)

错误类型:

复制代码
sealed interface MapValidationError {

    data class PolygonSelfIntersected(
        val elementId: String
    ) : MapValidationError

    data class AreaTooSmall(
        val elementId: String
    ) : MapValidationError

    data class ForbiddenZoneOutsideWorkZone(
        val forbiddenZoneId: String
    ) : MapValidationError

    data class ChannelNotConnected(
        val channelId: String
    ) : MapValidationError

    data class ZoneUnreachable(
        val zoneId: String
    ) : MapValidationError

    data object ChargingStationUnavailable :
        MapValidationError
}

警告可以表示:

复制代码
地图可以保存
但可能影响设备效果

例如:

  • 区域边界过于复杂;

  • 通道较窄;

  • 禁区距离边界较近;

  • 顶点数量过多;

  • 工作区面积接近设备上限。


十六、错误应该如何显示在地图上

只弹出一句:

复制代码
地图数据不合法

用户通常不知道应该改哪里。

更合理的方式是将错误定位到具体地图元素。

例如:

复制代码
前院边界存在交叉

同时:

  • 将错误边界标红;

  • 高亮交叉线段;

  • 自动移动到错误区域;

  • 在底部显示处理建议。

可以设计 UI 错误模型:

复制代码
data class MapValidationUiError(
    val elementId: String?,
    val message: String,
    val focusPoint: GeoPoint?,
    val severity: ErrorSeverity
)

用户点击错误列表后:

复制代码
选中对应区域
 ↓
地图移动到错误位置
 ↓
高亮错误线段或顶点

十七、为什么需要撤销和重做

地图编辑经常需要尝试。

用户可能:

复制代码
拖动一个点
发现位置不合适
想回到上一步

如果没有撤销功能,只能退出编辑重新开始。

因此,可以维护历史状态:

复制代码
data class MapEditHistory(
    val undoStack: List<MapDraft>,
    val current: MapDraft,
    val redoStack: List<MapDraft>
)

执行新操作:

复制代码
current 放入 undoStack
 ↓
生成新的 current
 ↓
清空 redoStack

撤销:

复制代码
current 放入 redoStack
 ↓
取出 undoStack 最后一项
 ↓
作为新的 current

重做:

复制代码
current 放入 undoStack
 ↓
取出 redoStack 最后一项
 ↓
作为新的 current

是否需要保存每一次拖动回调

不建议把拖动过程中的每一个坐标都放入撤销栈。

例如用户拖动一个点 2 秒钟,可能产生 100 次位置回调。

如果全部保存:

复制代码
撤销一次
只回退 1 毫米

体验会很差。

更合理的做法是:

复制代码
拖动开始:
记录原始状态

拖动过程:
只更新预览

拖动结束:
把一次完整拖动作为一个历史操作

也就是说:

一次用户意图,对应一次撤销记录。


十八、保存按钮什么时候可以点击

保存按钮不应该只根据 isDirty 判断。

通常需要同时满足:

复制代码
地图已经发生修改
当前没有致命校验错误
没有正在保存
没有版本冲突

例如:

复制代码
data class MapEditorUiState(
    val draft: MapDraft?,
    val isDirty: Boolean,
    val isValid: Boolean,
    val isSaving: Boolean,
    val hasVersionConflict: Boolean
) {
    val canSave: Boolean
        get() = isDirty &&
            isValid &&
            !isSaving &&
            !hasVersionConflict
}

但也可以允许用户点击保存后再执行最终校验。

此时流程是:

复制代码
点击保存
 ↓
进入 Validating
 ↓
校验通过
    ↓
上传云端

校验失败
    ↓
返回编辑状态并定位错误

十九、地图保存的完整流程

地图保存通常不是简单的一次接口请求。

更完整的流程是:

复制代码
用户点击保存
        ↓
冻结当前 MapDraft
        ↓
执行完整地图校验
        ↓
将草稿转换为提交模型
        ↓
携带 baseVersion 请求云端
        ↓
云端再次校验
        ↓
云端保存并生成新版本
        ↓
云端同步地图到设备
        ↓
设备校验并保存地图
        ↓
设备返回同步结果
        ↓
App 更新地图同步状态

可以拆成两个阶段。

第一阶段:保存到云端

复制代码
MapDraft
 ↓ HTTPS
云端地图版本更新

第二阶段:同步到设备

复制代码
云端新地图
 ↓ MQTT / MQTTS
割草机设备
 ↓
设备校验并保存

因此:

复制代码
云端保存成功
≠
设备地图同步成功

二十、提交数据应该使用什么模型

不建议直接把页面的 MapDraft 原样提交给服务器。

因为 MapDraft 可能包含:

  • 当前选中的区域;

  • 拖动中的顶点;

  • UI 高亮状态;

  • 校验提示;

  • 是否展开面板;

  • 撤销栈。

应该转换成专门的提交对象:

复制代码
data class UpdateMapRequest(
    val mapId: String,
    val baseVersion: Long,
    val workZones: List<WorkZoneRequest>,
    val forbiddenZones: List<ForbiddenZoneRequest>,
    val channels: List<ChannelRequest>,
    val chargingStation: ChargingStationRequest?
)

转换过程:

复制代码
MapDraft
 ↓
过滤 UI 状态
 ↓
坐标格式转换
 ↓
精度处理
 ↓
生成 UpdateMapRequest

二十一、坐标提交时为什么要处理精度

用户拖动地图顶点后,坐标可能包含很多小数位。

例如:

复制代码
120.123456789123
31.123456789456

但设备协议可能只支持固定精度。

如果 App、云端和设备使用不同精度,可能出现:

复制代码
App 保存前的边界
和
设备最终使用的边界
存在轻微差异

因此,坐标精度最好由协议统一定义。

例如:

复制代码
fun GeoPoint.normalize(): GeoPoint {
    return GeoPoint(
        latitude = latitude.roundTo(7),
        longitude = longitude.roundTo(7)
    )
}

更重要的是:

本地校验最好基于最终会提交给设备的坐标精度。

否则可能出现本地校验合法,但坐标截断后设备侧变成自相交或边长过短。


二十二、为什么云端还要再次校验

移动端已经做了完整校验,云端仍然不能直接相信客户端。

原因包括:

  • 客户端版本过旧;

  • 不同平台校验规则不一致;

  • 请求数据被篡改;

  • 设备能力发生变化;

  • 地图已经被其他用户修改;

  • 服务端拥有更多业务数据。

因此,校验通常分为三层。

复制代码
移动端校验:
保证编辑体验,及时提示用户

云端校验:
保证业务规则和版本一致

设备校验:
保证固件能够安全执行

三层校验并不重复。

它们分别保护不同边界。


二十三、地图版本冲突应该如何处理

假设用户进入编辑页时:

复制代码
地图版本:12

编辑过程中,家庭成员在另一台手机上保存了新地图:

复制代码
服务器最新版本:13

当前用户提交:

复制代码
{
  "mapId": "MAP_10001",
  "baseVersion": 12
}

服务器发现:

复制代码
baseVersion 12
≠
latestVersion 13

就应该返回版本冲突,而不是直接覆盖版本 13。

移动端进入:

复制代码
VersionConflict

版本冲突后有哪些处理方式

方案一:放弃本地修改,加载最新地图

适合修改较少的情况。

复制代码
丢弃本地草稿
 ↓
下载版本 13
 ↓
重新编辑

方案二:保留本地草稿,用户手动重新处理

先保存本地编辑内容,再加载最新地图,让用户重新应用部分修改。

方案三:自动合并

只有在编辑对象完全不冲突时才可能实现。

例如:

复制代码
用户 A 修改前院名称
用户 B 增加后院禁区

理论上可以合并。

但如果两个人都修改同一个工作区边界,自动合并非常困难。

对于安全要求较高的设备地图,不建议简单地使用"最后保存者覆盖前面所有人"。


二十四、保存失败后为什么不能丢失草稿

用户可能花费十几分钟调整地图。

如果保存时网络失败,却直接退出编辑页,体验会非常差。

因此,保存失败后应该:

复制代码
保留当前 MapDraft
保留撤销与重做历史
显示失败原因
允许用户重新提交

例如:

复制代码
data class SavedMapDraft(
    val mapId: String,
    val baseVersion: Long,
    val draftJson: String,
    val updatedAt: Long
)

草稿可以暂时保存到本地数据库。

重新进入编辑页时提示:

复制代码
检测到一份未保存的地图草稿,
是否继续编辑?

二十五、App 被系统回收后如何恢复编辑现场

地图编辑过程中,App 可能因为:

  • 内存不足;

  • 后台时间过长;

  • 系统回收进程;

  • 用户意外关闭 App;

丢失内存状态。

因此,可以在关键操作后保存草稿:

复制代码
拖动结束
增加禁区
删除区域
修改通道

不需要在每一帧拖动时都写数据库。

可以使用防抖:

复制代码
用户完成一次操作
 ↓
延迟短暂时间
 ↓
保存最新草稿

恢复流程:

复制代码
重新进入地图编辑页
 ↓
检查本地草稿
 ↓
比较 baseVersion 和云端版本
 ↓
版本一致:恢复草稿
 ↓
版本不一致:进入冲突处理

二十六、地图同步到设备失败怎么办

云端保存成功后,设备可能因为以下原因同步失败:

  • 设备离线;

  • MQTT 消息未送达;

  • 地图数据超出设备限制;

  • 设备存储空间不足;

  • 固件版本不支持新格式;

  • 设备校验地图失败;

  • 地图版本不连续。

此时不能把云端地图回滚成旧版本,也不能告诉用户"地图已经完全生效"。

可以显示:

复制代码
地图已保存到云端
但尚未同步到设备

同步状态:

复制代码
enum class MapSyncState {
    SYNCED,
    SYNCING_TO_DEVICE,
    WAITING_DEVICE_ONLINE,
    DEVICE_REJECTED,
    SYNC_FAILED
}

设备重新上线后,云端可以再次下发地图。

任务启动前仍需检查:

复制代码
cloudVersion == deviceVersion

二十七、编辑期间是否允许设备继续割草

这需要产品和安全规则明确规定。

一种常见策略是:

复制代码
用户进入编辑页:
允许查看和调整草稿

用户提交地图:
停止创建新任务

设备正在执行任务:
禁止修改当前任务使用的地图

也可以允许编辑,但新地图只在当前任务完成后生效。

需要明确区分:

复制代码
当前任务使用版本:12
云端待生效版本:13

否则设备运行过程中突然切换边界,可能产生危险。

因此,地图版本不仅用于冲突控制,还应该与具体任务关联:

复制代码
data class MowingTask(
    val taskId: String,
    val mapId: String,
    val mapVersion: Long
)

这样可以知道某次任务到底使用的是哪一版地图。


二十八、地图 SDK 和编辑业务如何分工

地图 SDK 负责:

  • 绘制 Polygon;

  • 绘制 Polyline;

  • 绘制 Marker;

  • 接收拖动事件;

  • 地图缩放;

  • 屏幕坐标转换;

  • 点击和长按手势。

地图编辑业务负责:

  • 当前正在编辑什么;

  • 哪个顶点被选中;

  • 拖动后如何更新草稿;

  • 多边形是否合法;

  • 禁区是否越界;

  • 通道是否连通;

  • 是否可以保存;

  • 如何撤销和重做;

  • 如何提交地图。

可以概括为:

复制代码
地图 SDK:
负责"怎么画、怎么拖"

编辑器 Domain:
负责"拖完之后是否合理"

二十九、一个推荐的地图编辑数据流

完整数据流可以设计为:

复制代码
地图 SDK 拖动事件
        ↓
坐标转换
        ↓
MapEditAction
        ↓
MapEditorReducer
        ↓
新的 MapDraft
        ↓
MapValidator
        ↓
MapEditorUiState
        ↓
地图重新绘制

保存时:

复制代码
用户点击保存
        ↓
SaveMapUseCase
        ↓
完整地图校验
        ↓
MapDraft 转 Request
        ↓
MapRepository
        ↓ HTTPS
云端保存新版本
        ↓
设备地图同步
        ↓
同步状态推送 App

三十、推荐的模块结构

地图编辑器可以独立为一个业务模块:

复制代码
feature-map-editor
├── presentation
│   ├── MapEditorScreen
│   ├── MapEditorViewModel
│   ├── MapEditorUiState
│   └── MapEditorUiEvent
│
├── domain
│   ├── MapDraft
│   ├── MapEditAction
│   ├── MapEditorReducer
│   ├── MapValidator
│   ├── PolygonValidator
│   ├── ChannelValidator
│   ├── MapEditHistory
│   └── SaveMapUseCase
│
└── data
    ├── MapDraftLocalDataSource
    ├── MapRemoteDataSource
    ├── MapRepositoryImpl
    └── MapDraftMapper

几何能力可以进一步下沉:

复制代码
core-geometry
├── LocalPoint
├── LineSegment
├── Polygon
├── PointInPolygon
├── SegmentIntersection
├── PolygonArea
└── DistanceCalculator

这样地图编辑器和其他业务可以共同复用几何计算能力。


三十一、地图编辑器中常见的错误

1. 直接修改正式地图对象

用户取消编辑时无法恢复原地图。

2. 拖动过程中每一次回调都写入撤销栈

一次拖动会产生大量无意义的撤销步骤。

3. 只在保存时检查顶点数量

没有检查自相交、面积和区域关系。

4. 直接在经纬度上执行所有平面几何算法

距离、面积和安全宽度难以准确表达。

5. 禁区只检查顶点是否在工作区内

边界仍然可能与工作区发生交叉。

6. 通道只保存一条视觉折线

没有关联来源区域、目标区域和实际通行宽度。

7. 本地校验通过就认为地图一定合法

云端和设备仍然需要独立校验。

8. 云端保存成功就提示地图完全生效

设备可能尚未完成地图同步。

9. 地图版本冲突时直接覆盖服务器数据

可能丢失其他家庭成员的修改。

10. 保存失败后丢弃草稿

用户长时间编辑的结果全部丢失。

11. 把地图 SDK 对象保存到 Domain 层

地图业务与具体平台和 SDK 强耦合。


三十二、地图编辑器的核心原则

第一:

复制代码
正式地图
≠
用户当前编辑草稿

第二:

复制代码
地图拖动事件
需要转换为明确的业务 Action

第三:

复制代码
地图 SDK 负责交互和绘制
Domain 负责地图是否合法

第四:

复制代码
拖动过程中做快速校验
拖动结束和保存前做完整校验

第五:

复制代码
多边形合法
不只代表能画出来
还要保证设备能够安全执行

第六:

复制代码
一次用户编辑意图
对应一次撤销记录

第七:

复制代码
移动端校验通过
≠
云端和设备一定接受

第八:

复制代码
云端保存成功
≠
设备地图已经生效

第九:

复制代码
保存失败
不应该丢失用户草稿

第十:

复制代码
地图版本
应该与具体任务和设备同步状态关联

总结

割草机地图编辑器看起来只是拖动边界点,但它背后其实是一套完整的地图业务系统。

用户进入编辑页后,移动端不应该直接修改正式地图,而应该创建一份独立的:

复制代码
MapDraft

所有操作都转换为:

复制代码
MapEditAction

再通过:

复制代码
MapEditorReducer

生成新的草稿状态。

地图编辑过程中,需要校验:

  • 顶点数量;

  • 重复点;

  • 最短边长;

  • 区域面积;

  • 多边形自相交;

  • 禁区是否位于工作区内部;

  • 禁区是否与边界距离过近;

  • 通道是否连接有效区域;

  • 通道宽度是否允许设备通过;

  • 充电桩是否能够到达工作区域;

  • 所有启用区域是否连通。

撤销和重做不应该记录拖动中的每一个坐标,而应该把一次完整拖动视为一个用户操作。

保存地图时,完整链路应该是:

复制代码
本地草稿
 ↓
移动端校验
 ↓
携带 baseVersion 提交云端
 ↓
云端再次校验并生成新版本
 ↓
同步地图到设备
 ↓
设备校验并确认

其中必须明确:

复制代码
App 校验成功
≠
云端保存成功

云端保存成功
≠
设备同步成功

保存失败后,需要保留本地草稿;发生版本冲突时,也不能直接覆盖其他用户已经保存的新地图。

一个稳定的地图编辑器,真正需要解决的不是"如何让用户拖动一个点",而是:

如何保证用户的每一次编辑都能够被正确表达、可靠保存、避免冲突,并最终转化成设备能够安全执行的地图。

这才是割草机地图编辑器设计的核心。

下一篇预告

《智能割草机为什么需要 RTK?从厘米级定位到轨迹丢失》

下一篇将继续拆解:

  • 普通 GPS 为什么难以满足无边界线割草机;

  • RTK、基站、移动站和差分数据分别是什么;

  • Fix、Float 和定位丢失分别表示什么;

  • App 应该如何展示 RTK 状态;

  • RTK 变差后设备为什么可能暂停任务;

  • 坐标漂移和消息延迟如何区分;

  • 实时轨迹出现跳点时移动端应该如何处理。

相关推荐
Industio_触觉智能2 个月前
瑞芯微RK3576机器视觉场景之割草机+无人清扫车
嵌入式硬件·硬件工程·边缘计算·智能硬件·rk3576·割草机·rk3576j
深圳市青牛科技实业有限公司 小芋圆2 年前
【青牛科技】2K02 电动工具专用调速电路芯片描述
单片机·嵌入式硬件·智能马桶·直流有刷风扇调速·割草机·小型电钻·电动工具调速
TA远方3 年前
【像素画板】游戏地图编辑器-uniapp项目开发流程详解
游戏·uni-app·像素画板·地图编辑·游戏地图·迷宫地图·像素地图