23 KiB
23 KiB
机器人运动监控组件详细分析
1. 组件架构概述
1.1 核心组件结构
movement-supervision.vue
是机器人运动监控的主要组件,负责实时显示机器人在场景中的位置和状态。
// 组件核心属性
type Props = {
sid: string; // 场景ID
id?: string; // 机器人组ID(可选)
};
1.2 依赖服务架构
- EditorService: 基于Meta2D的场景编辑器服务
- WebSocket服务: 提供实时数据通信
- 场景API服务: 处理场景数据的增删改查
2. 组件生命周期详解
2.1 组件初始化流程
onMounted(async () => {
await readScene(); // 步骤1: 加载场景数据
await editor.value?.initRobots(); // 步骤2: 初始化机器人
await monitorScene(); // 步骤3: 建立WebSocket监控
});
步骤1: readScene() - 场景数据加载
const readScene = async () => {
const res = props.id ? await getSceneByGroupId(props.id, props.sid) : await getSceneById(props.sid);
title.value = res?.label ?? '';
editor.value?.load(res?.json);
};
关键问题点: 每个页面实例都独立调用API获取场景数据,可能导致:
- 不同时间点获取的数据版本不一致
- 网络延迟造成的数据获取时差
- 场景数据在获取期间被其他页面修改
步骤2: initRobots() - 机器人初始化
public async initRobots(): Promise<void> {
await Promise.all(
this.robots.map(async ({ id, label, type }) => {
const pen: MapPen = {
...this.#mapRobotImage(type, true),
id,
name: 'robot',
tags: ['robot'],
x: 0, // 关键: 初始位置固定为(0,0)
y: 0, // 关键: 初始位置固定为(0,0)
width: 74,
height: 74,
lineWidth: 1,
robot: { type },
visible: false, // 关键: 初始状态为不可见
text: label,
textTop: -24,
whiteSpace: 'nowrap',
ellipsis: false,
locked: LockState.Disable,
};
await this.addPen(pen, false, true, true);
}),
);
}
问题分析:
- 所有机器人初始位置都设为
(0,0)
- 初始状态为
visible: false
,需要WebSocket数据才能显示 - 如果WebSocket连接延迟,不同页面的机器人可能长时间处于不可见状态
步骤3: monitorScene() - WebSocket监控建立
const monitorScene = async () => {
client.value?.close(); // 关闭之前的连接
const ws = await monitorSceneById(props.sid); // 创建新连接
if (isNil(ws)) return;
ws.onmessage = (e) => {
const { id, x, y, active, angle, path, ...rest } = <RobotRealtimeInfo>JSON.parse(e.data || '{}');
if (!editor.value?.checkRobotById(id)) return; // 验证机器人存在
editor.value?.updateRobot(id, rest); // 更新机器人基本信息
if (isNil(x) || isNil(y)) {
// 关键逻辑: 无位置信息时隐藏机器人
editor.value.updatePen(id, { visible: false });
} else {
// 关键逻辑: 有位置信息时更新位置并显示
editor.value.refreshRobot(id, { x, y, active, angle, path });
}
};
client.value = ws;
};
3. 机器人实时移动机制深度分析
3.1 WebSocket消息处理流程
每当接收到WebSocket消息时,会执行以下处理逻辑:
- 消息解析: 将JSON字符串解析为
RobotRealtimeInfo
对象 - 机器人验证: 调用
checkRobotById(id)
验证机器人是否存在 - 基本信息更新: 调用
updateRobot(id, rest)
更新电量、状态等信息 - 位置处理: 根据坐标是否存在进行不同处理
3.2 位置更新核心逻辑: refreshRobot()
public refreshRobot(id: RobotInfo['id'], info: Partial<RobotRealtimeInfo>): void {
const pen = this.getPenById(id);
const { rotate: or, robot } = pen ?? {};
if (!robot?.type) return;
// 获取当前机器人位置
const { x: ox, y: oy } = this.getPenRect(pen!);
// 解析新的位置信息(默认值为37,37是机器人中心点)
const { x: cx = 37, y: cy = 37, active, angle, path: points } = info;
// 关键坐标转换: 从中心点坐标转换为左上角坐标
const x = cx - 37; // 机器人宽度74,中心偏移37
const y = cy - 37; // 机器人高度74,中心偏移37
const rotate = angle ?? or; // 角度更新
// 路径坐标转换
const path =
points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? // 新路径相对于机器人中心
robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); // 旧路径坐标调整
const o = { ...robot, ...omitBy({ active, path }, isNil) };
if (isNil(active)) {
// active为null时,只更新位置不改变图标
this.setValue(
{ id, x, y, rotate, robot: o, visible: true },
{ render: true, history: false, doEvent: false }
);
} else {
// active有值时,同时更新图标状态(运行/停止状态图标不同)
this.setValue(
{ id, ...this.#mapRobotImage(robot.type, active), x, y, rotate, robot: o, visible: true },
{ render: true, history: false, doEvent: false }
);
}
}
3.3 机器人图标映射逻辑
#mapRobotImage(
type: RobotType,
active?: boolean,
): Required<Pick<MapPen, 'image' | 'iconWidth' | 'iconHeight' | 'iconTop'>> {
const theme = this.data().theme;
const image = import.meta.env.BASE_URL +
(active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`);
return {
image,
iconWidth: 34,
iconHeight: 54,
iconTop: -5
};
}
3.4 机器人绘制函数
function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void {
const theme = sTheme.editor;
const { lineWidth: s = 1 } = pen.calculative ?? {};
const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {};
const { active, path } = pen.robot ?? {};
if (!active) return; // 关键: 非活跃状态不绘制路径
const ox = x + w / 2; // 机器人中心X坐标
const oy = y + h / 2; // 机器人中心Y坐标
ctx.save();
// 绘制机器人本体(椭圆)
ctx.ellipse(ox, oy, w / 2, h / 2, 0, 0, Math.PI * 2);
ctx.fillStyle = get(theme, 'robot.fill') ?? '';
ctx.fill();
ctx.strokeStyle = get(theme, 'robot.stroke') ?? '';
ctx.stroke();
// 绘制运动路径
if (path?.length) {
ctx.strokeStyle = get(theme, 'robot.line') ?? '';
ctx.lineCap = 'round';
ctx.lineWidth = s * 4;
ctx.setLineDash([s * 5, s * 10]); // 虚线样式
ctx.translate(ox, oy);
ctx.rotate((-deg * Math.PI) / 180); // 根据机器人角度旋转
// 绘制路径线条
ctx.beginPath();
ctx.moveTo(0, 0);
path.forEach((d) => ctx.lineTo(d.x * s, d.y * s));
ctx.stroke();
// 绘制路径终点箭头
const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {};
const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {};
const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI;
ctx.setLineDash([0]);
ctx.translate(ex1 * s, ey1 * s);
ctx.beginPath();
ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10);
ctx.lineTo(0, 0);
ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10);
ctx.stroke();
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
ctx.restore();
}
4. 多页面位置不一致问题深度分析
4.1 根本原因:缺乏全局状态同步机制
每个页面实例都是完全独立的,具体表现为:
-
独立的EditorService实例
- 每个页面创建独立的
new EditorService(container.value!)
- 各自维护独立的机器人状态映射
#robotMap
- 无法共享机器人位置信息
- 每个页面创建独立的
-
独立的WebSocket连接
- 每个页面调用
monitorSceneById(props.sid)
创建独立连接 - 服务器可能向不同连接推送不同时间点的数据
- 网络延迟导致消息到达时间不同
- 每个页面调用
4.2 具体问题场景分析
场景1: 初始化时间差异
// 页面A在时间T1执行
onMounted(async () => {
await readScene(); // T1时刻的场景数据
await initRobots(); // 创建机器人,位置(0,0),visible:false
await monitorScene(); // T1+100ms建立WebSocket
});
// 页面B在时间T2执行(T2 > T1)
onMounted(async () => {
await readScene(); // T2时刻的场景数据(可能已更新)
await initRobots(); // 创建机器人,位置(0,0),visible:false
await monitorScene(); // T2+80ms建立WebSocket
});
结果: 两个页面获取的初始场景数据可能不同,机器人列表或配置存在差异。
场景2: WebSocket消息时序差异
// WebSocket消息处理逻辑
ws.onmessage = (e) => {
const { id, x, y, active, angle, path, ...rest } = JSON.parse(e.data || '{}');
if (isNil(x) || isNil(y)) {
// 关键问题: 无坐标消息会隐藏机器人
editor.value.updatePen(id, { visible: false });
} else {
editor.value.refreshRobot(id, { x, y, active, angle, path });
}
};
问题分析:
- 页面A先收到有坐标的消息,机器人显示在位置(100, 200)
- 页面B后收到无坐标的消息,机器人被隐藏
- 页面C收到旧的坐标消息,机器人显示在位置(80, 150)
场景3: 坐标转换精度问题
// refreshRobot中的坐标转换
const { x: cx = 37, y: cy = 37, active, angle, path: points } = info;
const x = cx - 37; // 默认值37导致的问题
const y = cy - 37;
// 当服务器发送的坐标为null/undefined时
// cx和cy都会使用默认值37,导致机器人位置为(0,0)
问题: 不同页面接收到的消息中坐标字段可能为null
、undefined
或有效数值,默认值处理导致位置计算不一致。
场景4: 机器人状态检查差异
if (!editor.value?.checkRobotById(id)) return;
// checkRobotById实现
public checkRobotById(id: RobotInfo['id']): boolean {
return this.#robotMap.has(id);
}
问题: 不同页面的#robotMap
内容可能不同,导致某些页面忽略特定机器人的更新消息。
4.3 路径绘制不一致问题
// 路径坐标转换逻辑
const path =
points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? // 新路径处理
robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); // 旧路径处理
问题分析:
- 新路径使用
p.x - cx, p.y - cy
进行坐标转换 - 旧路径使用
p.x + ox! - x, p.y + oy! - y
进行坐标转换 - 两种转换方式在特定情况下可能产生不同结果
- 不同页面可能处于新旧路径的不同阶段
4.4 渲染状态不同步
// setValue方法的渲染参数
this.setValue(
{ id, x, y, rotate, robot: o, visible: true },
{ render: true, history: false, doEvent: false }, // 立即渲染,不记录历史
);
问题:
render: true
表示立即重新渲染- 不同页面的渲染时机不同步
- 可能出现某个页面正在渲染时收到新消息的情况
5. 解决方案详细设计
5.1 方案一: 全局状态管理器
/**
* 全局机器人状态管理器
* 单例模式,确保所有页面共享同一份状态
*/
class GlobalRobotStateManager {
private static instance: GlobalRobotStateManager;
// 存储所有机器人的最新状态
private robotStates = new Map<string, RobotRealtimeInfo>();
// 订阅者列表,用于通知状态变化
private subscribers = new Set<(robotId: string, info: RobotRealtimeInfo) => void>();
// 连接管理,避免重复连接
private connections = new Map<string, WebSocket>();
static getInstance(): GlobalRobotStateManager {
if (!this.instance) {
this.instance = new GlobalRobotStateManager();
}
return this.instance;
}
/**
* 订阅机器人状态变化
*/
subscribe(callback: (robotId: string, info: RobotRealtimeInfo) => void): () => void {
this.subscribers.add(callback);
// 立即推送当前所有机器人状态
this.robotStates.forEach((info, robotId) => {
callback(robotId, info);
});
// 返回取消订阅函数
return () => this.subscribers.delete(callback);
}
/**
* 更新机器人状态并通知所有订阅者
*/
updateRobotState(robotId: string, info: RobotRealtimeInfo): void {
// 合并状态更新
const currentState = this.robotStates.get(robotId) || ({} as RobotRealtimeInfo);
const newState = { ...currentState, ...info };
this.robotStates.set(robotId, newState);
// 通知所有订阅者
this.subscribers.forEach((callback) => {
try {
callback(robotId, newState);
} catch (error) {
console.error('机器人状态更新回调执行失败:', error);
}
});
}
/**
* 获取或创建WebSocket连接(复用连接)
*/
async getOrCreateConnection(sceneId: string): Promise<WebSocket | null> {
// 检查现有连接
const existingConnection = this.connections.get(sceneId);
if (existingConnection && existingConnection.readyState === WebSocket.OPEN) {
return existingConnection;
}
try {
const ws = await monitorSceneById(sceneId);
if (!ws) return null;
// 设置消息处理
ws.onmessage = (e) => {
try {
const robotInfo = JSON.parse(e.data || '{}') as RobotRealtimeInfo;
this.updateRobotState(robotInfo.id, robotInfo);
} catch (error) {
console.error('WebSocket消息解析失败:', error);
}
};
// 设置连接关闭处理
ws.onclose = () => {
this.connections.delete(sceneId);
};
// 存储连接
this.connections.set(sceneId, ws);
return ws;
} catch (error) {
console.error('创建WebSocket连接失败:', error);
return null;
}
}
}
5.2 方案二: 改进的组件实现
// 改进后的movement-supervision.vue核心逻辑
<script setup lang="ts">
import { GlobalRobotStateManager } from '@/services/global-robot-state';
const props = defineProps<Props>();
const globalStateManager = GlobalRobotStateManager.getInstance();
// 移除原有的monitorScene函数,改用全局状态管理
const initializeMonitoring = async () => {
// 确保WebSocket连接存在
await globalStateManager.getOrCreateConnection(props.sid);
// 订阅机器人状态变化
const unsubscribe = globalStateManager.subscribe((robotId, robotInfo) => {
if (!editor.value?.checkRobotById(robotId)) return;
// 更新机器人基本信息
const { id, x, y, active, angle, path, ...rest } = robotInfo;
editor.value.updateRobot(id, rest);
// 处理位置更新
if (isNil(x) || isNil(y)) {
editor.value.updatePen(id, { visible: false });
} else {
editor.value.refreshRobot(id, { x, y, active, angle, path });
}
});
// 组件卸载时取消订阅
onUnmounted(() => {
unsubscribe();
});
};
onMounted(async () => {
await readScene();
await editor.value?.initRobots();
await initializeMonitoring(); // 使用改进的初始化方法
});
</script>
5.3 方案三: EditorService增强
// 为EditorService添加状态缓存和同步机制
export class EditorService extends Meta2d {
// 添加状态缓存
private robotStateCache = new Map<string, RobotRealtimeInfo>();
/**
* 改进的坐标转换方法
*/
private normalizeCoordinates(info: Partial<RobotRealtimeInfo>): { x: number; y: number } | null {
const { x: cx, y: cy } = info;
// 严格的坐标验证
if (typeof cx !== 'number' || typeof cy !== 'number' || isNaN(cx) || isNaN(cy) || cx < 0 || cy < 0) {
return null; // 返回null表示无效坐标
}
// 坐标转换:从中心点转换为左上角
return {
x: cx - 37, // 机器人宽度74,中心偏移37
y: cy - 37, // 机器人高度74,中心偏移37
};
}
/**
* 改进的refreshRobot方法
*/
public refreshRobot(id: RobotInfo['id'], info: Partial<RobotRealtimeInfo>): void {
const pen = this.getPenById(id);
const { rotate: or, robot } = pen ?? {};
if (!robot?.type) return;
// 使用改进的坐标转换
const coords = this.normalizeCoordinates(info);
// 无效坐标处理
if (!coords) {
this.setValue({ id, visible: false }, { render: true, history: false, doEvent: false });
return;
}
const { x, y } = coords;
const { active, angle, path: points } = info;
const rotate = angle ?? or;
// 路径处理优化
let path: Point[] | undefined;
if (points && Array.isArray(points)) {
// 新路径:相对于机器人中心的坐标
path = points.map((p) => ({
x: (p.x || 0) - (info.x || 37),
y: (p.y || 0) - (info.y || 37),
}));
} else if (robot.path) {
// 保持原有路径,但需要调整坐标
const { x: ox, y: oy } = this.getPenRect(pen!);
path = robot.path.map((p) => ({
x: p.x + ox - x,
y: p.y + oy - y,
}));
}
const robotState = { ...robot, ...omitBy({ active, path }, isNil) };
// 根据active状态决定渲染方式
if (typeof active === 'boolean') {
// 有明确的活跃状态,更新图标
this.setValue(
{
id,
...this.#mapRobotImage(robot.type, active),
x,
y,
rotate,
robot: robotState,
visible: true,
},
{ render: true, history: false, doEvent: false },
);
} else {
// 无活跃状态信息,只更新位置
this.setValue(
{ id, x, y, rotate, robot: robotState, visible: true },
{ render: true, history: false, doEvent: false },
);
}
}
}
6. 性能优化建议
6.1 渲染优化
- 使用
requestAnimationFrame
批量处理渲染更新 - 实现视口裁剪,只渲染可见区域的机器人
- 添加机器人状态变化的diff检测,避免无效渲染
6.2 内存管理
- 定期清理过期的机器人状态缓存
- 使用WeakMap存储临时状态,避免内存泄漏
- 在组件卸载时正确清理WebSocket连接和事件监听器
6.3 网络优化
- 实现WebSocket连接池,复用连接
- 添加消息压缩,减少网络传输量
- 使用心跳机制检测连接状态
7. 总结
机器人运动监控组件的多页面位置不一致问题主要源于:
- 架构设计缺陷: 缺乏全局状态管理,每个页面独立维护状态
- WebSocket连接独立性: 多个连接可能接收到不同时间点的数据
- 初始化时序问题: 不同页面的初始化时间不同,导致状态基线不一致
- 坐标转换逻辑: 默认值处理和坐标转换在边界情况下存在问题
- 状态验证不足: 缺乏对接收数据的有效性验证
通过实施全局状态管理、WebSocket连接复用、状态缓存机制和坐标转换优化等解决方案,可以有效解决这些问题,确保多页面间机器人位置的一致性。
8. 自动门点光圈功能扩展
8.1 功能概述
在机器人光圈绘制的基础上,新增了自动门点的光圈绘制功能。当WebSocket推送自动门点状态数据时,系统会根据设备状态自动绘制相应颜色的光圈。
8.2 数据结构
WebSocket推送的自动门点数据格式:
{
"gid": "",
"id": "172.31.57.55-502-17", // 设备ID,用于匹配地图中的自动门点
"label": "AutoD01",
"type": 99, // 标识为自动门点
"deviceStatus": 0, // 设备状态:0=关门,1=开门
"active": true,
// ... 其他字段
}
8.3 实现细节
主题颜色配置
在 editor-dark.json
和 editor-light.json
中添加自动门点颜色配置:
"autoDoor": {
"stroke-closed": "#FF4D4F99", // 关门状态边框(红色)
"fill-closed": "#FF4D4F33", // 关门状态填充(红色)
"stroke-open": "#1890FF99", // 开门状态边框(蓝色)
"fill-open": "#1890FF33" // 开门状态填充(蓝色)
}
数据模型扩展
在 MapPointInfo
接口中新增字段:
interface MapPointInfo {
// ... 现有字段
deviceStatus?: number; // 设备状态(0=关门,1=开门)
active?: boolean; // 是否激活状态,控制光圈显示
}
绘制函数修改
在 drawPoint
函数中添加自动门点光圈绘制逻辑:
// 为自动门点绘制光圈
if (type === MapPointType.自动门点 && pointActive && deviceStatus !== undefined) {
const ox = x + w / 2;
const oy = y + h / 2;
const haloRadius = Math.max(w, h) / 2 + 10;
ctx.ellipse(ox, oy, haloRadius, haloRadius, 0, 0, Math.PI * 2);
// 根据设备状态选择颜色
if (deviceStatus === 0) {
// 关门状态 - 红色
ctx.fillStyle = get(theme, 'autoDoor.fill-closed') ?? '#FF4D4F33';
ctx.strokeStyle = get(theme, 'autoDoor.stroke-closed') ?? '#FF4D4F99';
} else {
// 开门状态 - 蓝色
ctx.fillStyle = get(theme, 'autoDoor.fill-open') ?? '#1890FF33';
ctx.strokeStyle = get(theme, 'autoDoor.stroke-open') ?? '#1890FF99';
}
ctx.fill();
ctx.stroke();
}
状态更新方法
新增 updateAutoDoorByDeviceId
方法:
public updateAutoDoorByDeviceId(deviceId: string, deviceStatus: number, active = true): void {
// 查找匹配的自动门点
const autoDoorPoint = this.data().pens.find(
(pen) => pen.name === 'point' &&
(pen as MapPen).point?.type === MapPointType.自动门点 &&
(pen as MapPen).point?.deviceId === deviceId
) as MapPen | undefined;
if (!autoDoorPoint?.id || !autoDoorPoint.point) return;
// 更新自动门点状态
this.updatePen(autoDoorPoint.id, {
point: {
...autoDoorPoint.point,
deviceStatus,
active,
},
}, false);
}
WebSocket数据处理
修改运动监控组件的WebSocket处理逻辑:
ws.onmessage = (e) => {
const data = JSON.parse(e.data || '{}');
// 判断数据类型:type=99为自动门点,其他为机器人
if (data.type === 99) {
// 自动门点数据处理
const { id: deviceId, deviceStatus, active = true } = data;
if (deviceId && deviceStatus !== undefined) {
latestAutoDoorData.set(deviceId, { deviceId, deviceStatus, active });
}
} else {
// 机器人数据处理
const robotData = data as RobotRealtimeInfo;
latestRobotData.set(robotData.id, robotData);
}
};
8.4 使用方式
- 地图编辑:在场景编辑器中创建自动门点,并设置对应的设备ID
- 状态监控:系统自动接收WebSocket推送的自动门点状态数据
- 视觉反馈:
- 关门状态(deviceStatus=0):显示红色光圈
- 开门状态(deviceStatus=1):显示蓝色光圈
- 无状态数据时:不显示光圈
8.5 技术优势
- 复用机器人架构:充分利用现有的渲染和状态管理机制
- 高性能处理:采用相同的时间分片和数据缓冲策略
- 类型安全:完整的TypeScript类型支持
- 主题适配:支持深色和浅色主题
- 实时性:与机器人监控相同的实时更新能力
这种设计展现了系统架构的可扩展性,为未来支持更多设备类型(如电梯、传感器等)奠定了良好的基础。