如何在 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 兼容性、许可和坐标精度模型,并用实际轮廓验证行为。
保存成功后再发布新的绘图结果
新增测点时,可以按以下流程更新:
- 检查输入并在事务中保存。
- Repository 发布已提交的测点快照。
- 后台线程运行规则和几何校验。
- 主线程提交绘图结果,触发 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 | 前一轮廓保留为草稿,后一轮廓重新开始 |
| 自交轮廓或含无效坐标的序列 | 产生诊断,不作为有效多边形导出 |
| 修改或删除历史测点 | 重建结果与当前完整数据一致 |
| 相同测点和相同规则重复重建 | 几何结果及标识保持一致 |
规则能否独立验证,会直接影响后续的维护成本。每新增一种编码,都应明确它如何开始、如何续接、何时结束,以及遇到异常序列时保留什么结果。
备注:内容仅供参考。
