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

397 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 前端面试题:养老场景电子围栏 — 绘制 + 越界检测 + 声光报警
## 题目描述
在 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. 老人标记管理
```ts
// 创建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. 越界检测 — 双算法兜底
```ts
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` 的兼容处理:**
```ts
// AMap 不同版本可能没有 setOptions兜底用 Object.assign
poly.setOptions?.(style) || Object.assign(poly as any, style)
```
---
### 5. 响应式设计 — `shallowRef` 的正确用法
```ts
// ✅ 正确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` | 派生状态(`hasFence``hasPerson` | 自动缓存 |
---
### 6. 生命周期清理 — 避免内存泄漏的完整链条
```ts
onUnmounted(() => {
destroyGeofence()
stopAlarmSound() // 清理 setInterval + Oscillator
clearFence() // map.remove(polygon/circle) + 重置状态
removePerson() // map.remove(marker) + 停止报警
cancelDraw() // 清理预览线/顶点/事件监听 + 恢复 doubleClickZoom
})
```
**事件监听的清理机制:**
```ts
// 绑定:将 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 踩坑** | `doubleClickZoom``lngLatToContainer` 参数格式、`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 半径),精度完全够用。
### 追问 3`alarmActive` 为什么用 `ref` 而不用 `shallowRef`
**期望回答:** `alarmActive` 是布尔值,不是对象。`ref(true)``shallowRef(true)` 对原始值行为完全相同——Vue 对原始类型不会深度追踪。但用 `ref` 语义更清晰("这是一个需要响应式的值")。
### 追问 4如果老人恰好在围栏边界线上`isPointInRing` 返回什么?
**期望回答:** 取决于浮点精度和算法实现。射线法在边界线上行为不确定(可能 true 也可能 false。生产环境应该在边界 ±2m 加一个模糊区间hysteresis避免在边界反复触发报警/解除。
### 追问 5`startAlarmSound` 中为什么要 try-catch
**期望回答:**
1. 部分浏览器(如 iOS Safari要求 AudioContext 必须在用户手势中创建
2. 某些企业环境可能禁用 Web Audio API
3. setInterval 可能被浏览器节流(后台标签页降至 1s
4. 静默失败比页面崩溃更优雅——视觉报警仍然工作
### 追问 6为什么不在 `drag` 事件中检测,而是 `dragend`
**期望回答:** `drag` 事件每像素触发一次,拖拽 100px 就触发 100 次 `checkBoundary`,其中 99 次是浪费。`dragend` 只触发一次,刚好够。"边界穿越"在拖拽中段发生还是在终点发生,对报警结果无影响——报警只需要知道"最终位置是否在围栏外"。