PAYATHON 2026

如何在 Android Kotlin 应用中根据测量编码实现自动地形绘图?

技术老齐

建议把自动绘图做成独立的 Kotlin 规则引擎,按采集顺序读取测点、解析编码,生成几何对象和校验结果。Android 自定义 View 负责显示结果。

SQLite 中的测量记录作为数据依据,图形则由这些记录派生,可以重新生成。以后新增编码、修正历史测点、接入 GNSS 或增加导出功能,都能复用同一套规则,业务判断也不必写进 onDraw()。

先确定编码和连接规则

AXE、BAT-DEB 等编码的含义需要由项目约定。仅凭名称,无法确定两个相隔的 AXE 点是否属于同一条线,也无法判断 BAT-FIN 是最后一个顶点,还是单纯的结束指令。

初期可以采用以下约定,后续再扩展:

编码示例约定
AXE同一采集序列中连续出现的点组成一条折线;遇到其他编码时结束当前折线
BAT-DEB开始一个建筑物轮廓,当前测点是第一个顶点
BAT-SUIV向当前轮廓追加顶点
BAT-FIN当前测点是最后一个顶点,然后申请闭合
PEBT保留为测点;符号和含义由项目编码表定义
未知编码保留原始记录,产生诊断;在这套约定中中断当前序列

这套约定要求建筑物顶点连续采集。如果外业允许插测其他地物,就需要增加明确的对象标识,例如独立字段 featureGroupId,再按对象维护状态。

不要仅凭距离接近就把测点归入同一建筑物。相邻建筑、重复测量和交叉作业都可能造成误连。

将存储、规则和显示分开

现有 SQLite Repository 可以继续使用,各部分按以下职责组织:

模拟器 / 手工输入 / GNSS 适配器
              │
              ▼
       测点校验与保存
              │
              ▼
      SQLite / Repository
              │ 测点快照
              ▼
     编码解析器 + 规则引擎
              │
              ▼
     几何对象 + 草稿 + 诊断
              │
              ▼
         ViewModel
              │
              ▼
       Android 自定义 View

几何对象 ──► DXF / DWG 导出器

规则引擎尽量使用纯 Kotlin 数据类型,不依赖 Canvas、Path、Paint 或 Android Context。

输入和输出可以定义为:

fun rebuild(
    points: List<SurveyPoint>,
    rules: RuleSet
): DrawingResult

DrawingResult 至少应包含:

  • 已生成的几何对象,例如点符号、折线和闭合轮廓。
  • 未完成或无效的候选图形。
  • 能定位到测点的诊断信息。

每个几何对象还应记录来源测点 ID、图层、规则标识和规则版本,方便用户点击错误轮廓后找到对应的外业记录。

用明确的采集顺序生成图形

测点模型可以先定义这些字段:

data class XYZ(
    val x: Double,
    val y: Double,
    val z: Double
)

data class SurveyPoint(
    val id: String,
    val projectId: String,
    val sessionId: String,
    val sequence: Long,
    val position: XYZ,
    val rawCode: String
)

各字段的用途是:

  • id 标识记录,编辑坐标或编码时保持不变。
  • sessionId 隔离不同采集批次。
  • sequence 表示该批次内的业务顺序。
  • rawCode 保留输入原文,便于纠错和重新解析。

查询一个批次时,要明确指定排序:

SELECT *
FROM survey_points
WHERE project_id = :projectId
  AND session_id = :sessionId
ORDER BY sequence ASC;

建议为 (project_id, session_id, sequence) 建立唯一约束,在事务中分配序号并插入测点。多个输入源同时写入时,由同一个写入入口协调。

不要依赖 SQLite 的默认返回顺序,也不要按 X、Y、Z 或点名排序。时间戳可以用于追溯,但同一时刻可能有多条记录,设备时钟也可能变化,因此不能只靠时间戳确定顺序。

规则引擎应分别处理各个采集批次。即使前一批次的最后一个点和后一批次的第一个点都使用 AXE 编码,也不应直接连接。

编码解析与状态机分别实现

先把字符串转换成结构化编码。不要用 contains("BAT") 之类的模糊匹配,否则拼写错误也可能触发绘图。

import java.util.Locale

enum class SurveyCode {
    AXE,
    BAT_BEGIN,
    BAT_NEXT,
    BAT_END,
    PEBT,
    UNKNOWN
}

fun parseCode(raw: String): SurveyCode =
    when (raw.trim().uppercase(Locale.ROOT)) {
        "AXE" -> SurveyCode.AXE
        "BAT-DEB" -> SurveyCode.BAT_BEGIN
        "BAT-SUIV" -> SurveyCode.BAT_NEXT
        "BAT-FIN" -> SurveyCode.BAT_END
        "PEBT" -> SurveyCode.PEBT
        else -> SurveyCode.UNKNOWN
    }

这个解析器适合初期使用。编码增多后,可以用配置表维护编码别名、规则类型和样式。建筑物开始、续点和结束的状态转换,仍由 Kotlin 规则处理。

AXE 按连续区间分段

如果先筛选出全部 AXE 点再连线,就会把中间被其他编码打断的线段连起来。应按采集顺序识别连续区间:

fun axisRuns(
    orderedPoints: List<SurveyPoint>
): List<List<SurveyPoint>> {
    val result = mutableListOf<List<SurveyPoint>>()
    val current = mutableListOf<SurveyPoint>()

    fun flush() {
        if (current.size >= 2) {
            result.add(current.toList())
        }
        current.clear()
    }

    for (point in orderedPoints) {
        if (parseCode(point.rawCode) == SurveyCode.AXE) {
            current.add(point)
        } else {
            flush()
        }
    }

    flush()
    return result
}

该函数要求输入属于同一批次,并且已经排好顺序。单独一个 AXE 点仍显示为测点,不生成折线。生成折线前,还要检查相邻顶点是否重合。

例如,以下序列应生成两条独立折线:

AXE, AXE, PEBT, AXE, AXE

建筑物用状态机收集顶点

建筑物规则需要保存当前未结束的轮廓。以下代码只处理序列,多边形拓扑校验需要另行完成:

data class BuildingStep(
    val open: List<SurveyPoint>,
    val candidate: List<SurveyPoint>? = null,
    val abandoned: List<SurveyPoint>? = null,
    val issues: List<String> = emptyList()
)

fun stepBuilding(
    open: List<SurveyPoint>,
    point: SurveyPoint,
    code: SurveyCode
): BuildingStep =
    when (code) {
        SurveyCode.BAT_BEGIN -> BuildingStep(
            open = listOf(point),
            abandoned = open.takeIf { it.isNotEmpty() },
            issues = if (open.isEmpty()) emptyList()
                     else listOf("上一轮廓缺少 BAT-FIN")
        )

        SurveyCode.BAT_NEXT ->
            if (open.isEmpty()) {
                BuildingStep(
                    open = emptyList(),
                    issues = listOf("${point.id}: 缺少 BAT-DEB")
                )
            } else {
                BuildingStep(open = open + point)
            }

        SurveyCode.BAT_END ->
            if (open.isEmpty()) {
                BuildingStep(
                    open = emptyList(),
                    issues = listOf("${point.id}: 缺少 BAT-DEB")
                )
            } else {
                BuildingStep(
                    open = emptyList(),
                    candidate = open + point
                )
            }

        else -> BuildingStep(
            open = emptyList(),
            abandoned = open.takeIf { it.isNotEmpty() },
            issues = if (open.isEmpty()) emptyList()
                     else listOf("${point.id}: 建筑物序列被中断")
        )
    }

调用方依次传入测点,把返回的 open 作为下一步的状态:

  • open 是当前草稿,可以实时预览。
  • candidate 表示已收到结束码,需要进一步校验。
  • abandoned 是被打断的轮廓,应保留为带有诊断信息的草稿。

读到当前快照末尾时,如果 open 非空,就显示“等待续点或结束码”,避免把尚未采完的轮廓误报为数据损坏。新增测点后重新计算,轮廓可以继续完成;如果结束作业时仍未完成,再提示用户处理。

闭合之前进行几何校验

Path.close() 只会补一条回到起点的边,不能据此判断建筑物轮廓是否有效。

收到 BAT-FIN 后,至少需要检查以下项目:

检查项建议处理
X、Y、Z 存在 NaN 或无穷值标记测点无效,并阻断经过它的序列
不足三个不同的平面顶点保留草稿,拒绝生成有效多边形
相邻顶点重合或边长过短给出诊断,按项目容差处理
顶点共线或面积过小标记为退化轮廓
非相邻边相交、接触或重叠按简单多边形规则判为无效
开始、续点、结束顺序错误保留测点,提示具体位置

不能简单过滤掉无效测点后继续连接,否则可能把原本隔开的两段线接在一起。

建筑物平面轮廓通常在 XY 平面上校验,同时保留 Z。面积可以用鞋带公式计算。计算前先减去附近的局部原点,有助于减少大坐标运算带来的数值误差。

长度容差和面积容差应分别设置,并明确单位,不能把同一个数值同时当作米和平方米使用。具体取值需要结合坐标单位、采集精度和业务要求确定。

如果结束测点与起点在容差内重合,可以在派生几何中去掉重复的末尾顶点,用 closed = true 表示闭合。原始测点及其来源关系仍需保留。

自交检查应覆盖共线重叠等情况。如果采用几何库,需要核实其 Android 兼容性、许可和坐标精度模型,并用实际轮廓验证行为。

保存成功后再发布新的绘图结果

新增测点时,可以按以下流程更新:

  1. 检查输入并在事务中保存。
  2. Repository 发布已提交的测点快照。
  3. 后台线程运行规则和几何校验。
  4. 主线程提交绘图结果,触发 View 重绘。

现有 SQLite Repository 可以自行提供 Flow。以后如果迁移到 Room,可以用它支持的 Flow 可观察查询;普通 SQLite 查询不会自动提供这种通知能力。参见 Room 异步查询文档。

ViewModel 中可以这样接入,类型和接口需要按项目补齐:

val drawing = repository
    .observePoints(projectId)
    .map { points ->
        engine.rebuild(points, ruleSet)
    }
    .flowOn(Dispatchers.Default)
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = DrawingResult.Empty
    )

Repository 必须把阻塞式 SQLite 操作安排到合适的后台线程,Dispatchers.Default 用于规则计算和几何处理。界面应通过生命周期感知的方式收集结果,Fragment 使用 viewLifecycleOwner。

初期采用全量重建,更容易保证结果正确。修改前面的一个 BAT-DEB,可能影响后面整个轮廓,只更新当前点容易留下错误图形。

等数据量增大后,再加入按批次或对象重建、状态检查点和几何缓存。后台任务并发运行时,需要给结果附带数据版本,防止旧结果覆盖新结果。规则配置变化后也要触发重建。

自定义 View 只绘制缓存

数据库查询、编码解析和建筑物拼装都应放在 onDraw() 之外。先生成渲染缓存,再交给 View 绘制:

data class RenderStroke(
    val path: Path,
    val paint: Paint
)

class SurveyView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : View(context, attrs) {

    private var strokes: List<RenderStroke> = emptyList()

    // 在主线程调用;提交后不再修改这些 Path 和 Paint。
    fun submit(strokes: List<RenderStroke>) {
        this.strokes = strokes.toList()
        invalidate()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        var i = 0
        while (i < strokes.size) {
            val stroke = strokes[i]
            canvas.drawPath(stroke.path, stroke.paint)
            i++
        }
    }
}

RenderStroke 属于渲染层,领域几何仍保存 Double 坐标。Android 官方的 自定义 View 性能建议 也建议减少 onDraw() 中的对象分配和不必要的失效请求。

测量坐标通常数值较大,转换为屏幕坐标时,应先用 Double 减去局部原点,再转为 Float:

screenX = (worldX - originX) * scale + offsetX
screenY = offsetY - (worldY - originY) * scale

这个公式假定平面坐标的 Y 轴向上,屏幕 Y 轴向下。工程中还要明确 X、Y 分别代表哪个坐标轴,避免导入和导出时交换东、北坐标。

性能优化可以根据实际瓶颈逐步加入:

  • 按几何包围盒剔除视口外对象。
  • 对大折线分块缓存,避免追加一个点就重建全部路径。
  • 缓存文字测量结果,缩小时减少标签。
  • 缩放和平移只更新视口变换或投影缓存,不重新解释编码。
  • 显示抽稀只作用于渲染副本,测量数据和导出几何保留原精度。

异步生成渲染缓存时,需要同时检查数据版本和视口版本。

为导出和 GNSS 保留边界

DXF、DWG 导出器应直接读取领域几何,使用工程坐标、图层和闭合状态。屏幕 Path 已经过坐标变换,不能作为测量成果的数据来源。

可以先实现 DXF,再根据选定的库或 SDK 增加 DWG。两种格式的支持范围需要分别验证,DWG 方案尤其要检查 Android 支持、离线运行、许可,以及目标 CAD 软件的读取效果。非平面建筑物轮廓中的 Z 如何表达,也需要事先约定。

GNSS 接入放在采集适配层:

接收机协议数据
    → 协议解析
    → 质量检查
    → 坐标与高程转换
    → 测点保存入口

模拟器和 GNSS 共用测点保存入口,规则引擎就无需区分测点来源。

如果接收机输出经纬度,需要转换到项目使用的平面坐标系;高程还要区分椭球高和项目采用的高程。具体协议、转换参数和改正方式取决于接收机及工程要求,不能只修改字段名称。

本地存储、编码解析、绘图和文件导出都可以离线运行。GNSS 定位精度是否依赖网络改正服务,则需要单独判断。

用固定序列验证规则

规则引擎不依赖 Android,就可以直接编写 Kotlin 单元测试。优先覆盖以下情况:

输入或操作预期结果
AXE, AXE, PEBT, AXE, AXE两条独立折线
不同批次各有一个 AXE不跨批次连接
三个有效且不共线的点依次为 BAT-DEB, BAT-SUIV, BAT-FIN一个有效闭合轮廓
单独出现 BAT-SUIV 或 BAT-FIN提示缺少开始码
BAT-DEB, BAT-SUIV 后暂时停止采集显示未完成草稿
连续出现两个 BAT-DEB前一轮廓保留为草稿,后一轮廓重新开始
自交轮廓或含无效坐标的序列产生诊断,不作为有效多边形导出
修改或删除历史测点重建结果与当前完整数据一致
相同测点和相同规则重复重建几何结果及标识保持一致

规则能否独立验证,会直接影响后续的维护成本。每新增一种编码,都应明确它如何开始、如何续接、何时结束,以及遇到异常序列时保留什么结果。

备注:内容仅供参考。