Files
microapp-vue3-interview/docs/interview-question-geofence.md

18 KiB
Raw Blame History

前端面试题:养老场景电子围栏 — 绘制 + 越界检测 + 声光报警

题目描述

在 Vue 3 + 高德地图的微前端子应用中,实现一个养老院/社区电子围栏系统:

┌──────────────────────────────────────────────┐
│              sub-app-header                  │
├──────────────────────────────────────────────┤
│  🚨 电子围栏报警!(越界时全屏覆盖)             │
│  ✅ 老人在安全区域内  (状态标签)               │
│                                              │
│       🗺️ 高德地图                             │
│   [绿色多边形围栏]  +  [👴 可拖拽老人]          │
│                                              │
├──────────────────────────────────────────────┤
│ 🗺️ 电子围栏  [🔷绘制围栏][⭕圆形围栏][🗑️清除]  │
│ 🎯 绘制提示条(顶点计数 + 闭合提示)             │
├──────────────────────────────────────────────┤
│ 👴 老人控制  [➕添加][↑←→↓方向键][🗑️移除]      │
├──────────────────────────────────────────────┤
│ 📋 报警记录(右侧浮层)                         │
└──────────────────────────────────────────────┘

核心要求:

  1. 支持多边形圆形两种围栏绘制
  2. 老人标记可拖拽 + 方向键移动,每次移动即时检测越界
  3. 越界时触发声光报警:围栏变红、全屏浮层、蜂鸣音、报警记录
  4. 老人回到围栏内自动解除报警
  5. 组件卸载时完整清理所有地图覆盖物和事件监听

架构设计

文件分层

microapp-vue3/src/
├── composables/
│   ├── useAmap.ts          ← 地图实例管理(单例加载 JSAPI、生命周期
│   └── useGeofence.ts      ← 电子围栏核心逻辑(围栏、标记、检测、报警)
├── views/
│   └── MapView.vue         ← 视图层(布局、按钮、报警 UI
├── config/amap.ts          ← 密钥、版本、插件列表
└── vite-env.d.ts           ← AMap 类型声明Polygon/Circle/Marker/GeometryUtil...

关键设计决策:逻辑与视图分离。 useGeofence composable 封装所有地图操作,MapView.vue 只负责 UI 渲染和事件绑定。好处:

  • 围栏逻辑可在其他页面复用(如多围栏管理页)
  • 单元测试只需测 composable无需挂载 Vue 组件
  • 视图层更换 UI 框架(如 Element Plus 面板)不影响核心逻辑

核心技术点拆解

1. 围栏绘制 — 交互设计与踩坑

多边形绘制流程

点击「🔷 绘制围栏」
  → map.setStatus({ doubleClickZoom: false })   ← 关键!禁用双击缩放
  → 注册 map.on('click', onClick)
  → 光标变 crosshair

每次单击:
  ├─ 地理距离 < 30m 且 已有点 ≥ 3 → 闭环完成 ✅(点击首顶点)
  ├─ 否则 → 添加顶点
  │    ├─ new AMap.Marker编号圆点
  │    ├─ new AMap.Polyline虚线预览
  │    └─ 点数 ≥ 3→ 销毁首顶点 → 重建为红色脉冲高亮标记
  └─ 右键 → cancelDraw()

闭环(三种方式):
  ├─ 点击红色脉冲的首顶点Haversine < 30m
  ├─ 点击「✅ 闭合围栏N点」按钮
  └─ 创建 AMap.Polygon → 清理绘制中间状态 → 恢复 doubleClickZoom

圆形绘制流程

点击「⭕ 圆形围栏」
  → 第 1 次点击:确定圆心(红色标记)
      → map.on('mousemove')动态预览圆形Haversine 实时计算半径)
  → 第 2 次点击:确定半径
      → 清理预览 Circle → 创建正式 Circle 围栏
  → 右键:取消

⚠️ 踩坑记录(面试中最有区分度)

踩坑 现象 根因 修复
双击闭合 双击后地图缩放 + 多了 2 个废顶点 AMap 默认 doubleClickZoom: true 拦截双击;双击 = click+click+dblclick map.setStatus({ doubleClickZoom: false }) + 改用点击首顶点闭合
lngLatToContainer({lng,lat}) 第 4 次点击报 Pixel(NaN, NaN) AMap v2 不接受普通对象,只接受 [lng,lat]LngLat 实例 Haversine 地理距离替换像素距离判断,不再调用此方法
marker.setContent() 更新标记内容后内部状态损坏 → NaN AMap v2 的 setContent 触发重新布局,新旧内容尺寸不同时偏移计算异常 改为 destroy + new Marker 重建,确保内部状态干净
e.pixel 不可靠 偶发 NaN 某些事件回调中 e.pixel 可能未被填充 一律使用 e.lnglat + Haversine不依赖像素坐标
scoped CSS 无效 自定义 Marker 样式不生效 AMap 注入的 DOM 不带 Vue 的 data-v-xxx 属性 新增非 scoped<style> 块,用全局 class 选择器

2. 老人标记管理

// 创建HTML content + draggable + dragend 自动检测
const marker = new AMap.Marker({
  position,
  content: '<div class="geofence-person-marker">👴</div>',  // ← CSS class非 inline
  offset: new AMap.Pixel(-17, -17),   // 34×34 居中
  draggable: true,                    // 允许拖拽
  zIndex: 200,                        // 高于围栏(100)和顶点(120)
})

marker.on('dragend', () => {
  // 每次拖拽结束 → 更新位置 → 立即检测越界
  const pos = marker.getPosition()
  personPosition.value = [pos.lng, pos.lat]
  checkBoundary()
})

两种移动方式:

方式 实现 步长 适用场景
拖拽 draggable: true + dragend 事件 任意 手动测试、演示
方向键 movePerson(direction) ± 0.0005° ~30-50m 精确步进测试

为什么步长用 0.0005° 而非固定米数? 经纬度步长简单可靠,不依赖地图投影。对于养老场景的社区围栏(通常 100-500m 范围),像素级精度足够。


3. 越界检测 — 双算法兜底

function checkBoundary(): void {
  const pp = personPosition.value
  if (!pp || !fenceOverlay.value) return

  let inside: boolean

  if (fenceType.value === 'circle') {
    // 圆形AMap 内置 contains() 方法
    inside = (fenceOverlay.value as AMap.Circle).contains(pp)
  } else {
    // 多边形:优先 AMap.GeometryUtil兜底射线法
    try {
      inside = AMap.GeometryUtil.isPointInRing(
        { lng: pp[0], lat: pp[1] },
        fenceVertices.value
      )
    } catch {
      inside = isPointInPolygon(pp, fenceVertices.value)  // 纯 JS 兜底
    }
  }

  // 状态变迁检测:只在"进入→出去"时报警,"出去→进入"时解除
  const wasInside = isInside.value
  isInside.value = inside

  if (!inside && wasInside) triggerAlarm(pp)    // 刚刚越界
  if (inside && !wasInside) resetAlarm()         // 刚刚回归
}

为什么需要兜底? AMap.GeometryUtil 在某些加载顺序下可能未就绪JSAPI 异步加载)。射线法作为纯数学算法始终可用:

射线法Ray Casting从目标点向右发射一条水平线
  → 统计与多边形各边的交点数
  → 奇数 = 内部,偶数 = 外部

状态变迁检测的设计意图: 不直接用 if (!inside) alert(),而是追踪 wasInside → isInside 的变化。这样:

  • 持续在围栏外 → 不重复报警
  • 出去再进来再出去 → 每次跨越边界都报警(累计次数 + 历史记录)

4. 报警系统 — 五通道并行的设计模式

越界时同时触发 5 个通道,每个通道独立可关闭:

triggerAlarm()
  ├─ 通道 1视觉-围栏
  │    updateFenceStyle(true)
  │    绿色实线 → 红色虚线fillColor/strokeColor 切换)
  │
  ├─ 通道 2视觉-全屏浮层
  │    alarmActive = true → v-if 渲染
  │    全屏红色脉冲背景 + 震动弹窗 + 脉冲图标
  │    点击任意处 → dismissAlarm()
  │
  ├─ 通道 3视觉-状态标签
  │    fence-status--safe → fence-status--alarm
  │    "✅ 安全" → "🚨 已越界!"
  │
  ├─ 通道 4听觉-蜂鸣
  │    startAlarmSound()
  │    Web Audio API → OscillatorNode 方波 880Hz
  │    每 800ms 蜂鸣一次setInterval
  │    0.3 秒 duration + exponentialRampToValueAtTime 淡出
  │
  └─ 通道 5记录-历史
       alarmHistory.push({ time, position })
       右侧浮层实时展示

updateFenceStyle 的兼容处理:

// AMap 不同版本可能没有 setOptions兜底用 Object.assign
poly.setOptions?.(style) || Object.assign(poly as any, style)

5. 响应式设计 — shallowRef 的正确用法

// ✅ 正确shallowRef — 只在引用替换时触发更新
const fenceOverlay = shallowRef<AMap.Polygon | AMap.Circle | null>(null)
const personMarker = shallowRef<AMap.Marker | null>(null)

// ❌ 错误ref — 会深度追踪 AMap 实例的数千个内部属性
// const fenceOverlay = ref<AMap.Polygon | null>(null)
// → setCenter/setZoom 等操作触发深度 diff → 性能灾难
存储方式 适用场景 代价
shallowRef 地图实例、覆盖物(内部状态由 AMap 自行管理) 无性能损耗
ref 简单值(坐标数组、布尔、计数) 需要响应式追踪
computed 派生状态(hasFencehasPerson 自动缓存

6. 生命周期清理 — 避免内存泄漏的完整链条

onUnmounted(() => {
  destroyGeofence()
    ├─ stopAlarmSound()      // 清理 setInterval + Oscillator
    ├─ clearFence()          // map.remove(polygon/circle) + 重置状态
    ├─ removePerson()        // map.remove(marker) + 停止报警
    └─ cancelDraw()          // 清理预览线/顶点/事件监听 + 恢复 doubleClickZoom
})

事件监听的清理机制:

// 绑定:将 handler 引用存储到 map 实例的自定义属性上
;(map as any)._geofenceClick = onClick
map.on('click', onClick)

// 清理:通过引用精确 off避免误删其他监听器
const m = map as any
if (m._geofenceClick) {
  map.off('click', m._geofenceClick)
  delete m._geofenceClick
}

完整调用链路图

用户操作 → 电子围栏响应
═══════════════════════════════════════════

┌─ 绘制 ─────────────────────────────────────┐
│ [🔷绘制围栏] → startDrawPolygon()            │
│   → map.setStatus({ doubleClickZoom:false }) │
│   → 逐一点击 → onClick(e)                   │
│     → 每点new Marker + new Polyline       │
│     → ≥3点首顶点变红脉冲                   │
│   → 点击首顶点 / [✅闭合] → finishDraw()     │
│     → finishPolygonDraw()                   │
│       → new AMap.Polygon(path, FENCE_STYLE) │
│       → cleanupDrawing(map)                 │
│       → map.setStatus({ doubleClickZoom:true})│
└────────────────────────────────────────────┘

┌─ 老人 ─────────────────────────────────────┐
│ [➕添加老人] → addPerson()                   │
│   → new AMap.Marker({draggable:true})       │
│   → marker.on('dragend', checkBoundary)     │
│   → checkBoundary()  // 初始检测             │
│                                              │
│ [↑↓←→] / 拖拽 → movePerson()                │
│   → marker.setPosition([lng, lat])          │
│   → checkBoundary()  // 每次移动立即检测      │
└────────────────────────────────────────────┘

┌─ 检测 ─────────────────────────────────────┐
│ checkBoundary()                             │
│   ├─ 无围栏 → isInside = true (skip)       │
│   ├─ 圆形 → Circle.contains(point)          │
│   └─ 多边形 → GeometryUtil.isPointInRing()  │
│              └─ 失败 → isPointInPolygon()   │
│   └─ wasInside && !inside → triggerAlarm()  │
│   └─ !wasInside && inside → resetAlarm()    │
└────────────────────────────────────────────┘

┌─ 报警 ─────────────────────────────────────┐
│ triggerAlarm(position)                      │
│   ├─ alarmCount++                           │
│   ├─ alarmHistory.push({time, position})    │
│   ├─ updateFenceStyle(true) // 围栏变红虚线  │
│   ├─ alarmActive = true   // 浮层出现       │
│   └─ startAlarmSound()    // 蜂鸣开始       │
│                                              │
│ dismissAlarm() / resetAlarm()               │
│   ├─ alarmActive = false                    │
│   ├─ updateFenceStyle(false) // 恢复绿色     │
│   └─ stopAlarmSound()     // 蜂鸣停止       │
└────────────────────────────────────────────┘

考察点汇总

层级 考察点 难度
架构设计 Composable 模式:逻辑/视图分离、单一职责
响应式 shallowRef vs ref vs computed 的选择理由
几何算法 射线法Ray Casting原理 + Haversine 公式
地图 SDK 踩坑 doubleClickZoomlngLatToContainer 参数格式、setContent 副作用
交互设计 三种闭环方式(点击首顶点/按钮/右键取消)的 UX 考量
事件管理 动态事件绑定/解绑、闭包捕获、内存泄漏防范
Web Audio API OscillatorNode + GainNode + exponentialRampToValueAtTime
生命周期 onUnmounted 中 4 步清理链(音效→围栏→标记→绘制态)
CSS 隔离 scoped vs 全局样式在 AMap 自定义标记中的应用
状态机 wasInside/inside 变迁检测避免重复报警

加分项

  • 能解释为什么不用 AMap.MouseTool 而手动处理 click 事件 — 更细粒度的交互控制(首顶点高亮、实时顶点计数、三通道闭环)
  • 能指出 setContent 的替代方案 — destroy + new Marker 重建,或使用 setOptions(如果 SDK 支持)
  • 能说明 lngLatToContainer 的参数格式陷阱 — AMap v2 只接受 [lng, lat] 数组或 LngLat 实例,不接受 {lng, lat} 普通对象
  • 能提出改进方向
    • 支持多个围栏 + 多个老人(一对多/多对多关系)
    • 使用 watchPosition 替代 getCurrentPosition 实现实时 GPS 追踪
    • 报警信息通过 WebSocket 推送到护工端
    • 围栏数据持久化localStorage/后端)+ 编辑已有围栏的顶点
    • 使用 AMap 的 PolygonEditor 插件支持围栏二次编辑
  • 能分析 movePerson 的步长选择 — 0.0005° 约 30-50 米的经纬度换算

追问方向

追问 1如果围栏有 100 个顶点,isPointInPolygon 的性能如何?

期望回答: 射线法时间复杂度 O(n)n 为顶点数。100 个顶点约 0.01ms。但养老场景围栏通常 4-20 个顶点,性能完全不是瓶颈。如果真有数千顶点,可以先用 bounding box 做粗筛。

追问 2圆形围栏的 contains() 精度取决于什么?

期望回答: AMap.Circle.contains() 基于球面几何计算,精度受限于:

  1. 地球半径常量AMap 使用 6378137m
  2. 高纬度地区 Mercator 投影变形
  3. 极小的圆(<1m可能受浮点精度影响 对于养老场景(通常 100-1000m 半径),精度完全够用。

追问 3alarmActive 为什么用 ref 而不用 shallowRef

期望回答: alarmActive 是布尔值,不是对象。ref(true)shallowRef(true) 对原始值行为完全相同——Vue 对原始类型不会深度追踪。但用 ref 语义更清晰("这是一个需要响应式的值")。

追问 4如果老人恰好在围栏边界线上isPointInRing 返回什么?

期望回答: 取决于浮点精度和算法实现。射线法在边界线上行为不确定(可能 true 也可能 false。生产环境应该在边界 ±2m 加一个模糊区间hysteresis避免在边界反复触发报警/解除。

追问 5startAlarmSound 中为什么要 try-catch

期望回答:

  1. 部分浏览器(如 iOS Safari要求 AudioContext 必须在用户手势中创建
  2. 某些企业环境可能禁用 Web Audio API
  3. setInterval 可能被浏览器节流(后台标签页降至 1s
  4. 静默失败比页面崩溃更优雅——视觉报警仍然工作

追问 6为什么不在 drag 事件中检测,而是 dragend

期望回答: drag 事件每像素触发一次,拖拽 100px 就触发 100 次 checkBoundary,其中 99 次是浪费。dragend 只触发一次,刚好够。"边界穿越"在拖拽中段发生还是在终点发生,对报警结果无影响——报警只需要知道"最终位置是否在围栏外"。