
工业现场做HMI人机界面做多了你迟早会遇到同一个问题画面复杂到一定程度传统那套“控件 事件回调”的组合方式就开始卡、开始乱报警弹窗叠加半天操作员戳一下屏幕不知道到底是点中了按钮还是误碰了空白区。这几年我在几个改造项目里尝试了一套组合方案用AG-UI 协议管交互意图用Canvas 渲染引擎管画面绘制整体跑下来效果比较稳这篇文章把思路、架构、实测数据和踩过的坑都整理出来给正在做工业现场Web化HMI、SCADA前端或者组态软件替换的朋友一个参考。先说清楚这套东西解决什么问题工业现场有大量实时数据要上屏也有大量操作指令要下发给PLC而浏览器页面天然擅长排版却不擅长高频局部刷新。AG-UI 协议相当于在人机交互平面上加了一条“语义通道”不再传“你点击了坐标(320, 180)”而是传“操作员要打开阀门 V-1201”Canvas 渲染引擎则负责把几千个图元稳定地画出来两条线合在一起既保住了操作语义的可靠性也撑住了画面的性能。这套方案适合的目标读者是想用 Web 技术做工业HMI、又不想被 Web 性能瓶颈卡住的人新手可以先看架构部分老手可以直接跳到实操和问题排查。1. 为什么工业现场需要一条“交互平面”协议1.1 现场交互的固有矛盾数据实时性与操作可靠性工业现场其实同时存在两张“网”。一张是数据平面PLC、传感器、OPC UA 服务器不停地把温度、压力、液位、设备状态送上来另一张是人机交互平面操作员要看趋势、要确认报警、要修改设定值、要开阀启泵这些动作需要稳定、可追踪、防误触的语义。数据平面的实时性要求很高但交互平面的要求不一样它要求的是“每一次操作都能被明确解释、被准确执行、被完整记录”。传统组态软件把这两个平面搅在一起——一个控件既绑定数据变化、又绑定鼠标事件大量状态轮询把 UI 线程拖死最后场景复杂以后很难排查。我在这套方案里做的第一个决定就是把交互独立出来所有用户操作不直接改数据而是先翻译成一条 AG-UI 意图消息。这条消息表达“用户想完成什么目标”而不是“用户动了哪个控件”。这样不管是鼠标点击、触摸长按、键盘快捷键甚至未来的语音指令和AR眼镜手势落到底层执行的都是同一套意图。1.2 AG-UI 协议到底长什么样意图优先而不是控件优先很多没接触过 AG-UI 的人会问这跟 WebSocket 发个 JSON 有啥区别区别在于协议结构的设计原则。传统做法是前端发{btnId: btn_start_pump}后端收到后执行startPump()。这个思路在设备少的时候能用但一旦同一套画面跑在不同厂家、不同尺寸、不同交互方式的触摸屏上就会出问题按钮可能改位置、可能被遮住、可能操作员换用了快捷键控件层一变逻辑层就跟着崩。AG-UI 的核心理念是意图优先一条消息里带着目标、对象、动作和上下文比如{ protocol: ag-ui, version: 1.2, intent: Actuate, target: V-1201, action: open, priority: high, correlationId: a3f9c2e1-8b4d-4f0a-9c65-1e884d7f5a02, source: HMI-03, operator: shift-leader, touchPressure: 0.42, timestamp: 1718012345123 }这条消息里没有任何“坐标”“控件ID”“DOM节点”之类的信息只有业务语义。下发侧收到这条意图后会做三件事先根据operator和source做权限校验再根据target找到对应的PLC点位表最后把intent翻译成具体的 Modbus/OPC UA 写指令执行。为什么要带correlationId因为现场出事故后要复盘从 UI 操作到设备动作全链路必须能串起来这就是贯穿协议层的追踪ID。AG-UI 协议同时对上行和下行做了区分上行是操作员产生的人机意图下行是系统反馈的指令结果和状态确认。上行通道强调“强确认”一个高优先级操作必须等到设备侧 ACK 之后才能闭合下行通道强调“合并推送”各种状态改变不必每一条都立刻画出来可以合并成批次。2. 渲染引擎选型为什么是 Canvas 而不是 DOM / SVG / WebGL2.1 画得动、控得住Canvas 2D 的性能边界工业HMI 画面有个典型特征图元数量大、局部变化频繁、整体布局相对稳定。一张炼化装置总览图可能有 2000 个阀门符号、500 个文本标签、几十条管线路径还有报警闪烁动画。这种负载下DOM 的短板就非常明显——每个图元都是一个 DOM 节点布局、样式、合成树的更新都要花时间元素一多帧率就往下掉。我自己实测过一个对比同样绘制 1200 个矩形并每秒更新其中 300 个DOM 方案在 Chrome 里 CPU 占用轻松干到 45% 以上操作明显迟滞SVG 方案稍好但节点一多照样吃力Canvas 2D 因为走的是“直接绘制”路线没有 DOM 节点和样式计算的负担同样场景下 CPU 占用可以压到 20% 以内帧率稳定在 60。但 Canvas 不是银弹。一个容易踩的误区是不管什么变化每次都把整个画布重新画一遍。这等于把一个包含 3000 个图元的场景重复清屏重绘drawcall 一多引擎照样崩。正确的做法是分层 缓存这套我在第 3 节详细讲。先给个结论Canvas 2D 的上限很高前提是要学会控制重绘范围和复用静态内容。2.2 为什么不用 HTML in Canvas也不用 WebGL搜索“canvas 绘图”时经常看到一个方向把 HTML 标签直接嵌入 Canvas或者拿 Canvas 去解析渲染 HTML 内容。听起来很诱人但落地到工业现场问题很多。一来 HTML 的排版引擎依赖字体、样式、外链资源离线部署或者内网弱环境下一不小心字体就掉二来嵌入的 DOM 层和 Canvas 层是两个渲染体系事件命中、遮挡关系、缩放适配都存在奇奇怪怪的不一致问题。我试过一版最后维护成本太高放弃了。那 WebGL 呢Impeller 这类渲染后端确实很强现代浏览器底层越做越复杂但工业现场的工控机很多还是老核显、老驱动、老系统WebGL 初始化失败、GPU 进程崩溃、驱动黑名单的问题多到数不清。Canvas 2D 的好处恰恰是兼容性好、兜底容易、性能下限高——即使 GPU 加速不可用软件渲染也能勉强跑不会直接白屏。所以最终选择非常务实底层渲染交给浏览器引擎应用层只依赖 Canvas 2D 标准 API。这里也回应一下热词里提到的“html in canvas 示例页面”和“Cursor Canvas”那种把多张画布、AI生成控件拼到一张图里的工具形态适合做设计稿展示不适合做工业实时交互。工业现场需要的是可控、可测、可回放任何“黑盒”渲染都要尽量避免。3. 架构设计AG-UI 协议与 Canvas 引擎怎么协同3.1 三端结构采集层 / 协议层 / 渲染层整体架构我拆成三层每层只管一件事采集层C 或者 Node 服务对接 OPC UA / Modbus TCP把 PLC 里的变量实时读出来按点位表整理成结构化状态。协议层AG-UI 桥接服务接收上行的意图消息、下发设备指令同时把实时状态变更包装成 UI 状态增量推送。渲染层浏览器里的 Canvas 页面负责把状态增量画出来同时监听用户操作并转成意图消息。为什么要单独拉一个协议层而不是让渲染层直接连 OPC UA因为 OPC UA 的数据模型和操作语义并不适合直接暴露给浏览器而且权限控制、消息记录、QoS 这些能力都适合在协议层统一做。采集层只保证“数据准”协议层保证“交互稳”渲染层保证“画得快”职责分离以后每层的改动能控制在很小的范围。3.2 指令流转全链路一套完整交互的流程大概是这样的操作员触摸屏幕上“开启阀门 V-1201”的热区。渲染层根据触点坐标做图元命中检测确定用户点的是哪个业务对象不是直接上报坐标。渲染层生成 AG-UI 意图消息通过 WebSocket 发送到协议层。协议层校验操作员权限、目标是否存在、当前设备状态是否允许该操作比如“阀门已开启”时不允许重复 open。校验通过后协议层把意图翻译成 OPC UA 写命令发给采集层。PLC 执行完成返回 ACK协议层把 ACK 转成状态增量下推渲染层。渲染层收到增量后只更新阀门状态对应的图标颜色并弹出操作成功或失败提示。整条链路的关键点在于前端任何操作都是一个“任务闭环”不是点了就完事。步骤 6 没有完成画面上就必须保留“执行中”的状态不能提前把按钮置灰更不能假装成功。3.3 用“双缓冲 脏矩形”控制重绘成本Canvas 2D 和游戏引擎不一样没有原生双缓冲概念但我们可以用离屏 Canvas 模拟出同样的效果静态背景图元管道、网格、背景边框只画一次渲染到离屏 canvas 上动态图元阀门状态、数值文本、报警闪烁单独一层每次重绘先把离屏背景贴上去再只更新动态部分。配合脏矩形机制每次状态变化不是“全屏重绘”而是先计算哪些区域真的变了把这些区域合并成矩形集合然后只重绘这些区域。逻辑示意// 渲染帧循环先收集脏区再执行局部绘制 function frame(timestamp) { const dirty state.collectDirtyRects(); if (dirty.length 0) { // 合并相交矩形减少 drawcall const merged mergeRects(dirty); renderer.beginFrame(); for (const rect of merged) { renderer.blitBackground(rect); // 贴静态背景 renderer.drawDynamicLayer(rect); // 只画该区域内的动态图元 } renderer.endFrame(); } requestAnimationFrame(frame); }我还会给每帧分配固定预算3ms 给业务逻辑和指令处理10ms 给绘制3ms 给缓冲和GC预留。超出预算就优先丢弃低优先级的动画效果比如报警闪烁降频保留核心数值刷新。这套预算机制在弱配置工控机上尤其管用。4. 工业现场的硬约束从协议设计到渲染实现4.1 老工控机与核显性能预算怎么定工业现场的设备往往不像办公电脑那么新。我在项目里见过大量配置是i3 三代处理器、4GB 内存、核显 HD4000/HD4600、Windows 7 或 Windows 10 精简版。在这种硬件上做 Canvas 渲染必须先把性能预算定死。我的经验值是这样单画面图元总量控制在 3000 以内动态变量控制在 500 以内常态帧率只要求 30fps不需要 60fps。为什么不是越高越好工业现场动画刷太快反而容易造成视觉疲劳而且高帧率会持续占用 GPU 和 CPU老机器供电和散热都是问题。要保证的是“该动的时候动得流畅、不该动的时候不浪费时间”。实测下来几个有效手段静态背景全部缓存到离屏 canvas动态图元不建复杂 path文本绘制优先使用等宽数字字体并关闭抗锯齿管道连线用预构建路径缓存而不是每次重算。这样在 HD4000 核显上一个 2500 图元的画面也就吃到 20%~28% 的 CPU长时间跑不烫不卡。4.2 操作安全与权限意图协议防误触工业现场最怕的就是误操作。DCS 或 SIS 系统里按错一个按钮轻则废一批料重则引发设备联锁停机。而触摸屏上的误触概率比键盘鼠标高一个数量级尤其操作员戴手套、有水渍、或者正在快速巡查时。AG-UI 协议在这个环节把“物理操作”和“意图确认”分成了两级第一级渲染层做热区防误触要求高优先级操作必须长按 0.3 秒以上才生成意图消息触发的瞬间在画面上显示震动反馈和高亮描边第二级协议层做权限校验不是前端判断“你看得见按钮就允许点”而是协议层根据操作员角色、工位、目标区域、操作类型四个维度综合判定。举个例子普通巡检员可以查看阀门状态但没有 “Actuate” 权限班长可以操作但启动大型电机这种高风险动作必须带priority: critical协议层会额外触发二次确认消息由现场第二个人在另一个确认终端上确认。整个链路的每条意图消息都会落到审计日志就是这套方案的日常运行记录。4.3 长时间稳定运行与内存控制工业系统要求 7×24 小时连续运行这跟 Web 应用“跑几天崩一次无所谓”的容忍度完全不是一个级别。Canvas 页面最怕的就是内存泄漏我整理过常见的泄漏点离屏 canvas 反复创建不释放、事件监听器挂在 window 上不清理、动态文本每次绘制都重新创建字体对象、缓存数组无限增长。我的做法是三层防线第一层所有业务对象在页面生命周期内只创建一次图元管理器负责复用第二层动态数据推送采用“合并 限量”策略协议层缓存最近状态渲染层每秒最多处理 30 条合并状态超过的丢弃第三层每 10 分钟主动检查一次内存指标如果 JS 堆超过 300MB 触发页面级看门狗自动重置渲染上下文并恢复现场状态。工业现场还有个特殊功能需要有操作回放。出事故后需要复盘所以我让协议层循环存储最近 5000 条 AG-UI 消息渲染层每分钟存一张截图画到滚动目录便于事后回溯。这个功能一开始觉得“没啥用”后来一次客户端排查时靠它定位到了一个操作员误触问题直接节省了好几天的扯皮。5. 实操过程从零搭一套可落地的原型5.1 渲染引擎的最小实现一个能撑住工业画面的 Canvas 引擎核心就是图元管理器和帧循环。图元管理器维护一张对象表每个图元有 id、类型、位置、显隐、动态绑定表达式比如const valveFigure renderer.createFigure({ id: valve_V1201, type: valve, rect: { x: 120, y: 280, w: 64, h: 40 }, bind: { value: state.pump.V1201, alarm: alarm.V1201 } });帧循环里面我维护一个 dirtyRect 集合任何状态变化都往里塞脏区。一旦有报警变更就只重绘报警对应的区域而不是全部重来。离屏缓存方面静态背景用一次drawImage贴合管线路径用Path2D缓存旋转动画只有在变化时才重新构造路径。这套引擎做过一次性能摸底在普通笔记本上撑住了 4200 个图元、同时 700 个动态值实时刷新CPU 稳定在 38% 左右到工控机上缩到 2500 图元后效果足够顺滑。5.2 AG-UI 桥接模块的最小实现协议层的桥接模块我用 Node.js WebSocket 做了个最小实现核心逻辑是上行消息解析、下行指令下发和权限校验。上行消息接收的简化版示意ws.on(message, (raw) { const msg JSON.parse(raw); if (!protocol.validate(msg)) return; const allowed auth.check(msg.operator, msg.target, msg.intent); if (!allowed) { ws.send(protocol.reject(msg.correlationId, permission_denied)); return; } const opcCmd translateToOpcUa(msg); opcClient.write(opcCmd) .then((ack) { ws.send(protocol.complete(msg.correlationId, ack)); }); });这里有个容易被忽略的点权限校验的结果要能被审计所以 reject 和 complete 都要带上原始 correlationId并且记录一份到本地日志。另外桥接层要维护心跳和离线重连现场网络抖动是常态WebSocket 断了以后前端不能傻等本地队列撑住最近 200 条意图重连后按序补发。5.3 现场联调清单原型做出来后我建议按下面的清单去现场摸底大部分问题都是这个阶段暴露的检查项预期结果说明CPU 占用稳态 35%老工控机标准GPU 进程稳定性24小时无崩溃留意显卡驱动版本触摸反馈延迟指令确认 300ms从手指抬起开始计时多屏不同 DPI画面不错位按物理分辨率重算断线重连画面状态恢复本地队列补发正确长时间老化内存不持续增长观察 JS 堆 2 小时权限超时无操作自动退出防止他人误操作这个清单可以打印出来直接带到现场逐项打勾比泛泛地“测试一下”高效很多。6. 常见问题与排查技巧实录6.1 白屏/黑屏与 GPU 进程崩溃这是老工控机上最常见的事故。现象页面跑着跑着突然整块 Canvas 白屏但页面其他区域还在响应。排查路径一般是打开诊断面板看 GPU 进程内存和 GPU 进程是否退出再试一下--disable-gpu参数后是否还复现。如果是驱动问题那就做“软件渲染兜底”检测到 GPU 进程退出后自动重启渲染上下文并切到软件渲染同时告警提示维护人员更新驱动。代价是软件渲染更耗 CPU所以只在崩溃后降级使用平时保持硬件加速。6.2 触控反馈延迟与误触触控延迟不一定是网络问题很多时候是事件处理顺序问题。比如touchstart立即触发了意图手指还没离开屏幕就判断成一次点击或者touchend之后又有click事件重复触发。我们的处理方式统一在touchend之后用按下时长判定意图——按下时间超过 0.3 秒识别为确认操作小于 0.15 秒忽略同时给每个意图消息带上按下时间长度供协议层做二次决策。这样既避免了快速滑动误触也消除了双击触发两次开启的错误。6.3 不同电脑绘制效果不一致生产环境的浏览器版本、系统字体、显卡色彩管理都不同再好的代码也架不住环境差异。我遇过的典型同一套代码在 A 机器的 i5 独显上显示正常在 B 机器的三代 i3 核显上字体发虚、颜色偏灰。处理办法是内置字体子集不用系统字体颜色在启动时通过标准色板校准高分屏 DPI 缩放按物理分辨率重算。另外每台机器固定一个浏览器版本或 CEF 版本禁止随意升级因为底层渲染引擎一变画面表现就可能跟着变。6.4 协议队列阻塞报警风暴来临时大量状态通知塞进同一条 AG-UI 通道操作员点击确认都被挤在后面现场会反馈“屏幕点了没反应”。我最后的方案是把队列拆成两个控制指令优先队列负责意图消息容量小、优先级高状态通知批处理队列负责状态增量容量大、允许合并丢包。控制指令 100ms 内必须被处理状态通知最多每 50ms 合并推送一次。分层之后报警再多也不影响基本的开阀停机操作。这轮项目做完我最大的体会是Canvas 渲染引擎只是“画得快”真正让工业现场放心的其实是那层 AG-UI 协议。只要所有操作都带意图、带身份、带追踪后续的权限管控、审计回溯、防误触设计才有地方落。最后再分享一个小技巧协议层里一定要保留一条Confirm意图任何高优先级执行前走一次人工确认表面上是多了一步实际上在事故分析时能救你无数次。