做工业现场HMI的人应该都有这种经历:一条包含几百个位号数据、趋势曲线和控制逻辑的监控画面,在工控机上打开慢得像在等设计图纸加载;交互还停留在鼠标点按、键盘输入的阶段,想接进来语音控制、自动化脚本甚至AI助手,简直无从下手。这篇文章记录的是我把AG-UI协议和Canvas渲染引擎结合,在一套化工厂DCS区块监控界面上做的工程化实践。核心思路其实一句话:把“怎么画”和“怎么交互”彻底分开,画的部分交给Canvas渲染引擎,语义和意图交给AG-UI协议。
AG-UI这个名字可能很多前端同学听着陌生,它是目前Web无障碍社区里比较新的一个协议方向,以“意图”为单位定义交互语义。我最初接触时以为它只跟读屏软件相关,真正丢到一个要落地、要交付、要扛住现场7x24小时运行的HMI项目里,才发现它的价值远不止无障碍,而是把界面从“给人点”升级成“可被程序理解并驱动”。这篇文章我会按项目推进顺序来写:先讲工业HMI为什么必须换交互模型,再拆AG-UI协议,然后是Canvas渲染引擎的设计,最后把两者拼接,并附上现场踩坑记录。适合正在做工控前端、智慧工厂软件、组态工具的朋友参考,也能给对Web渲染和无障碍协议感兴趣的人一些跨界启发。
1. 项目背景:工业HMI的性能与语义困境
1.1 一条控制画面就把浏览器拖垮
项目初期,现场运行的一套老组态软件生成的网页版HMI,打开一个单元设备监控页要预加载300多个点位数据,画面上散布着设备图元、报警灯、实数值、趋势曲线和操作按钮。由于整套界面是用DOM节点堆出来的,每个标签、每个数字、每个SVG图标都是一个独立节点,场景复杂时DOM数量能冲到8000到10000个。工控机配置一般,酷睿i3处理器加4GB内存,浏览器一开这个画面,滚动都掉帧,点击数值输入框的响应延迟肉眼可见。
更让人头疼的是数据刷新。OPC UA服务端每秒推送几十个变化值,画面上的数字改一次就要触发一次DOM更新,每次都带着布局计算和样式重算。现场负责人给的评价很直接:“点一个按钮,等半秒才反应,这谁敢用来操作?”性能不是一个“优化上去更好”的问题,而是HMI系统能不能被信任的基本盘。决定换技术路线,不是跟风,是被现场逼的。
1.2 渲染与语义分离的思考
界面一复杂就卡,这是Web页面做工业控制的通病。但深入看,真正的问题是两个:渲染效率低、交互语义缺。
先谈渲染效率。工业HMI的视觉元素密度并不算高,但更新频率高、区域局部化。一个电机从运行变停止,需要的只是重绘那个电机图标和状态颜色;一个温度数值变化,只影响那一小块文本。DOM将这种细粒度更新放大成了整树的布局计算,性能自然白给。如果改用Canvas自绘,天然就是“局部重绘”的思维,脏矩形、离屏分层这些手段都可以直接用上。
再谈交互语义。传统HMI的交互链条是“用户点屏幕坐标 -> 程序判断点击落在哪个图元 -> 执行事件回调”,这是给人用的。但如果希望语音说出“把3号泵的出口压力设定调高5%”、希望自动化脚本能像人一样驱动界面、希望未来的AI助手能查“现在哪个阀门是开着的”,这套模型完全不够用。因为“点坐标”本身没有语义,程序知道你在(128, 64)这个点按下,但这个点是什么、你想干什么,全靠事件回调里那几行if else猜。AG-UI协议恰好补上了这一层:把交互动作抽象为“意图”,应用声明能力,辅助设备或脚本发送意图,界面负责执行。
1.3 最终方案选型
梳理需求之后,我定下的技术方向是这样的:界面整体改用Canvas自绘渲染引擎,不用任何重型前端框架;交互层引入AG-UI协议的思路,自己做一套轻量的意图通信层;数据驱动照旧走OPC UA/MQTT,但渲染和交互彻底解耦。这样渲染层只管画,交互层只管语义,两部分之间通过统一的数据模型衔接。
这套方案听起来复杂,但落地之后对整个项目是加法而不是负担。没有引入复杂依赖,协议层核心代码也就几百行;Canvas渲染引擎按需自绘,工控机上跑得稳稳的。接下来我先把AG-UI协议的逻辑讲透,因为这是很多朋友最陌生、也是这个方案里最“值钱”的部分。
2. AG-UI协议拆解:不讲DOM事件,讲意图
2.1 传统交互模型为什么在工业现场失灵
Web生态里的操作辅助协议,从老的ARIA到现在浏览器里的事件监听,中心思想都是“监听界面发生了什么改变,然后尝试推断用户想干什么”。读屏软件靠扫描DOM树、监听焦点变化来报告“现在聚焦的是一个按钮”“页面弹出了一个对话框”。这套机制放在普通网页上够用,放在工业HMI上有两个致命问题。
第一,Canvas画出来的界面里根本没有DOM节点,ARIA那套在纯自绘场景下无从下手。我给读屏软件暴露什么?画布里的像素吗?第二,事件模型只能描述“发生了什么”的事实,不能表达“用户原本想做什么”。用户按了Tab、按了回车、用了触摸手势,这些物理动作要翻译成业务意图,得在应用代码里写一堆状态机。事实层和意图层混在一起,逻辑越来越难维护。
AG-UI协议的方向正好反过来:它规定应用先声明自己“支持哪些意图操作”,比如“调整数值”“打开菜单”“发起确认”;然后外部操作者(无论是读屏、语音助手、自动化脚本)直接把意图发给应用,应用负责把这意图映射到具体控件并执行。这有点像把图形界面的操作方式从“像素比对”升级成了“API调用”,界面变成了一个可编程的对象集。
2.2 AG-UI的核心概念:能力、意图、事件、快照
实际落地时,我简化出了四个关键概念:能力声明、意图请求、事件回报、状态快照。
能力声明是应用对外的“说明书”。一个HMI页面启动后,会暴露一份清单,说明这个画面里有哪些可操作控件、每种控件支持哪些意图。比如一个泵的图元声明自己支持“选中”“打开控制面板”“调节设定值”“确认启动”,一个趋势图声明自己支持“缩放”“移动游标”“导出数据”。外部设备拿到清单,就知道能对这个界面做些什么,不会瞎指挥。
意图请求是核心动作。一次意图不是“点击”,而是“打开泵P-03的控制面板”这样有完整语义的操作。意图请求通常带一个目标标识和参数,不携带屏幕坐标——由应用结合当前界面上下文决定目标到底是谁。这样交互就彻底脱离了“具体位置”的束缚,语音、键盘、自动化脚本可以走同一条路径。
事件回报用于状态反馈。控件执行完成一项意图后,会发布事件,比如“控制面板已打开”“参数已修改”,让外部操作者知道当前发生了什么。状态快照则是给外部拿数据用的:AG-UI协议替代DOM的一个关键点,是提供一个统一的方式来获取界面当前语义状态,比如当前画面有哪些可见控件、各自状态字段是什么,这为后续自动化和AI查询铺好了路。
2.3 一张表看清意图的使用场景
工业现场很多朋友一听到协议就觉得抽象,我整理了一张实践中常见的意图对照表,可以直观感受一下这笔“交互单词表”怎么用在具体业务上:
| 意图名 | 参数示例 | 业务场景 | 传统DOM事件写法 |
|---|---|---|---|
| select(选中) | targetId: pump-03 | 用语音/键盘将焦点移到3号泵 | 模拟click |
| setValue(调整数值) | targetId, delta: +5% | 操作员把压力设定调高 | 定位input,改value,触发change |
| openMenu(打开操作菜单) | targetId, menuLevel: 1 | 右键/语音呼出设备操作菜单 | 派发contextmenu事件 |
| confirm(确认动作) | actionId: start-pump-03 | 涉及联锁的启动操作二次确认 | 弹窗后点确定按钮 |
| navigate(切换画面) | screen: area-2-overview | 跳转到二区总览画面 | 找链接元素并click |
这轮对比做出来,项目里连测试人员都说“这个思路清楚多了”。传统DOM事件模拟要求测试脚本知道界面内部节点结构,一旦界面重排,测试代码跟着碎一地;而意图请求只关心“我想让系统做什么”,界面怎么渲染、节点怎么排列,外部一无所知,自然也就不受影响。
2.4 为什么这套协议适合工厂现场
工业现场与普通办公场景最大的差异是环境噪声和执行安全。现场有噪音,操作员语音指令会被干扰,意图协议可以把“打开3号泵控制面板”这样的指令直接映射到目标控件,不需要经过坐标和DOM定位这些容易被干扰的环节。联锁保护动作频繁,很多操作需要二次确认,意图里的confirm参数天然适配。
另外一点是智能工厂的集成需求。厂里现在都在搞数字孪生、无头浏览器控制、AI问答助手,这些系统都需要一种方式操作HMI界面。有了AG-UI协议层,外部系统不会因为界面重构而失效,接口稳定,文档清晰。我把这个协议接进项目后,后续接一个语音助理总共只花了两天,远超预期。
3. Canvas渲染引擎的设计与落地
3.1 为什么不上现成框架,自己写一套
决定上Canvas后,第一反应自然是“找个现成的渲染引擎”。市面上的Web渲染引擎不少,有做游戏精灵渲染的,有做数据可视化图表的,也有偏强交互编辑器的。但考察一圈后我发现,工业HMI的需求和游戏、数据可视化有明确的差异:画面元素类型少但业务关系复杂,常见就是设备图标、管线、数值框、按钮、趋势图、报警列表;渲染频率不低,但要求局部精准更新;运行设备落后,内存紧张。
综合这些约束,我最终选择基于Canvas 2D API自研一套轻量级渲染引擎,没有引入WebGL,也不上重型框架。可能有人觉得2024年了还用Canvas 2D是不是保守,但工业现场大多数工控机的GPU能力有限,WebGL在某些远程桌面和虚拟化环境下反而容易出问题。Canvas 2D简单、稳定、兼容性好,对代码可控性也最强。引擎核心包括场景图管理、脏矩形更新、离屏分层和交互拾取四部分,代码量控制在三千行以内。
3.2 渲染引擎的核心设计:场景图、脏矩形与离屏分层
渲染引擎最基础的是建立场景图。场景图里的每个节点对应一个可视对象,比如一台泵、一段管线、一个数字标签。节点里存的是几何信息(位置、尺寸、旋转)、绘制状态(颜色、透明度、是否选中)以及当前的业务数据值。绘制阶段从根节点向下遍历,把每个节点绘制到画布上。为了性能,我给节点做了一个“上次包围盒”缓存,如果节点状态没变化,它在脏矩形区域外,就不需要做任何改动。
脏矩形是性能的核心。数据更新时,引擎算出所有发生变化节点的最小包围盒并合并成若干矩形,下一帧只清空这些区域再重绘,而不是整画面刷新。实际操作中,一个包含600多个图元的监控页,一次数据变化通常只会触发1到2个小矩形重绘,重绘面积不到画布面积的8%。相比之前全DOM更新,帧率从12帧左右提升到50帧以上,画面滚动和弹窗交互都顺了。
离屏分层是另一个关键决策。我把画面拆成静态底图层、动态设备层、交互反馈层和弹窗层四层。底图层画管道、静态边框和背景,这层内容几乎不变,缓存在离屏Canvas中,每次主画布重绘时直接drawImage贴上去。动态设备层放电机、阀门的实时状态图元,交互反馈层放高亮边框、选中阴影、工具提示。弹窗层单独放在最顶层,保证模态框不会和底层内容纠缠。分层显著减少了每帧绘制的图元数量,也让特效处理(比如选中高亮)不需要重绘底图。
3.3 设备像素比和文字清晰的高分屏适配
工业现场设备屏幕五花八门,有老的1024x768工控屏,也有新的4K触摸一体机。如果不处理设备像素比,Canvas在高分屏上会发虚,文字像蒙了一层雾。处理方式很标准:canvas的物理尺寸按CSS尺寸乘devicePixelRatio放大,然后所有坐标都按缩放后的坐标系计算,同时为了防止模糊,文字绘制时我在独立分层里先放大再绘制。实测下来,在4K屏上汉字边缘锐利,和在普通屏上没有明显差异。
文字这块还有一个坑:Canvas里的文本排版没有DOM那么强的自适应能力。中西文混排、数字字体等宽、标签超长自动省略,这些都得手写排版逻辑。我封装了一个文本绘制工具,支持按宽度截断加省略号、按像素宽度自动缩小字号、以及固定行高的多行绘制。在需要显示大量参数值的表格区域,这套文本工具帮了大忙,单个单元格的文字更新只重绘那一个格子,性能开销非常小。
3.4 一段核心绘制代码实例
为了让后面的思路更具体,这里放一段简化后的关键代码。一个电机图元在渲染层表现为一个可绘制的场景节点,在AG-UI语义层对应为一个可交互控件:
// 渲染节点:电机图元 class MotorNode { constructor(id, bounds, initialEffects) { this.id = id; this.bounds = bounds; // 包围盒 { x, y, w, h } this.state = { running: false, feedback: false }; this.prevBounds = null; } update(data) { this.state.running = data.running; this.state.feedback = data.feedback; return this.bounds; // 返回包围盒加入脏矩形 } draw(ctx) { const { x, y, w, h } = this.bounds; const fill = this.state.running ? '#16a34a' : '#64748b'; // 绘制电机圆角矩形和状态灯 ctx.fillStyle = fill; ctx.beginPath(); ctx.roundRect(x, y, w, h, 6); ctx.fill(); // 状态灯 ctx.fillStyle = this.state.feedback ? '#fbbf24' : '#1e293b'; ctx.beginPath(); ctx.arc(x + w - 8, y + 8, 4, 0, Math.PI * 2); ctx.fill(); } }外层框架再根据这个节点的id挂上AG-UI能力声明和意图处理方法,渲染层和语义层互不干扰。节点更新数据时,只用一行脏矩形计算就能完成区域刷新,而且完全不牵动其他图元。
4. 协议与渲染引擎的对接实践
4.1 图元的三层模型:渲染、数据、语义
把AG-UI接进Canvas渲染引擎,实现上最顺的方法,是给每个图元建立三层模型。最底层是场景节点,负责几何数据和Canvas绘制;中间层是业务数据对象,绑定OPC UA点位,维护当前值和质量戳;最上层才是AG-UI语义节点,声明控件角色、能力列表和意图处理方法。三层之间靠同一个id关联,比如pump-03同时出现在场景图里作为节点id、在数据层作为数据键、在语义层作为目标id。
这样设计的好处是任何一个层级出问题,都可以独立排查。渲染层画错,去查draw方法;数据不对,去看点位订阅;意图没响应,去查AG-UI节点注册。三层之间的调用方向也很干净:数据到达时更新数据对象,并通知场景节点刷新;用户从任意通道发来意图时,语义层定位控件并调用其处理函数,处理函数只修改数据对象,场景节点再感知状态变化后重绘。没有跨层引用的历史包袱。
4.2 点击、触摸到意图的映射策略
Canvas里没有DOM事件冒泡,所有交互都得自己做命中测试。事件进入后,渲染引擎先从最上层的弹窗层开始反向遍历场景图,用点与包围盒的包含关系判断落在了哪个图元上。命中之后,并不直接触发动作,而是进入一个“意图推断器”:结合命中的控件类型、当前界面上下文、以及输入类型(单击、双击、拖拽、触摸长按)推导出一个意图对象。
比如单击一个阀门图元,推断器产出意图{ type: 'select', targetId: 'valve-07' };双击同一个图元,产出{ type: 'openMenu', targetId: 'valve-07' };在趋势图区域横向拖拽,产出{ type: 'adjustValue', targetId: 'trend-1', params: { gesture: 'pan' } }。这个推断规则写在配置表里,不用改代码就能调整交互行为,现场实施时给了我们很大灵活性。
意图对象产出后,统一交给AG-UI语义层的意图路由器。路由器检查目标控件是否声明了对应能力,如果在授权清单内,调用控件的意图处理函数;如果不在,返回一个标准事件“不支持该意图”,让调用方提示用户或回退到传统点击模拟。这套机制保证外部脚本和语音助手永远不会对一个不允许执行的操作“硬来”。
4.3 一条完整的交互链路:语音调用界面
可以看一个实际交付的链路,帮助理解三者的配合。操作员在嘈杂的现场通过耳机喊了一句“把3号泵出口压力设定提高到2.5兆帕”,整套系统的处理过程如下:
首先是语音识别模块把音频转成文本,再由本地意图解析器分析语义,识别出关键动作“提高设定值”、目标“3号泵出口压力”、数值“2.5”。解析器对照AG-UI能力清单确认当前画面里存在支持setValue意图的控件,然后发送意图请求到HMI的AG-UI运行时。
运行时校验此控件确实声明了setValue或suggestValue能力,将该意图路由到泵P-03的数值控件。数值控件的处理函数修改数据层的设定值,更新现场控制器的写请求队列,同时渲染层立刻将该数值标签的内容刷新为2.5,并画上一个“待确认”的黄色边框。语音助理在事件回报通道收到“数值已修改,待确认”后,给出语音反馈“设定已调整,等待操作台确认”,整个闭环在300毫秒内完成。
这套链路过去根本不可能用DOM实现:需要同时维护跨系统的坐标映射、状态同步、权限校验,复杂度极高。而AG-UI把交互简化成了一个“调用界面能力”的模型,语音、自动化、人工鼠标操作只是三个不同的意图来源,进入统一路由后处理逻辑完全一致。现场其他系统接入的时候只需要看懂能力清单,不需要关心界面内部长什么样。
4.4 权限、安全与多客户端协同
工业现场最不能开玩笑的是安全,这一点贯穿在意图路由的设计里。外部发来的意图请求和本地鼠标事件走同一套路由,但会在路由前多做一道权限检查:语音命令意图需要绑定操作员身份令牌,自动化脚本意图需要检查脚本在白名单里,只有plc操作台发起的confirm意图才能直接放行。权限检查与意图路由分层,业务代码不需要关心是谁调用它,只管执行并回报事件。
多客户端协同也出了很多实际问题。操作员在操作台上点开控制面板,画面上弹出模态框;同时语音助手误以为可以继续发下一条指令,调用select想切画面,结果模态框拦截到意图并产生“有未完成确认操作”事件。多亏了AG-UI的状态快照,模态层把自己登记为当前场景的“活动模态上下文”,意图路由器发现上下文不匹配时自动挂起低优先级意图,避免了一场误操作。这个机制现在已经成为我们权限体系的一部分。
5. 上线后的坑与排查实录
5.1 渲染层的三个坑:残影、缩放和文字闪烁
第一个代表性问题是脏矩形边界没含描边导致的图标残影。某个图元外围有一圈2像素的描边,但脏矩形计算用的是内容包围盒,忽略了描边宽度。状态更新时描边残留在画布上,形成一条很淡的边线,现场截图后发现,像设备周边套了个虚影。排查到原因后,我给所有节点的包围盒计算统一加上描线宽度上限的padding常量,并在绘制时用一个统一的掩膜区域参与脏矩形计算,残影问题彻底消失。经验是:包围盒必须覆盖绘制操作可能影响到的所有像素,宁多不少。
第二个坑是高DPI缩放下坐标漂移。高分屏适配时,离屏Canvas的坐标和设备像素比换算出错,导致部分图元的位置在高分屏上偏移了几像素。排查发现是一处坐标写成了物理像素坐标,而场景图逻辑用的是逻辑坐标,混用了。修正方案是把所有坐标统一在逻辑坐标系里计算,只有真正调用Canvas API做变换时才使用物理缩放。我在代码注释里加了醒目的警告,防止后续维护时再犯。
第三个坑是频繁文本重绘引发的闪烁。数值标签每秒更新5到10次,每次都是独立小矩形重绘,在部分显卡驱动下会出现轻微闪烁。优先采取的是合并绘制策略:同一区域内的多个文本标签更新时,合并成一个稍大的脏矩形,一次性重绘区域内的所有内容。闪烁随即消失,代价只是重绘面积增加2个百分点,非常划算。由此我的原则也变成:性能优化不是追求最小重绘面积,而是追求“稳定且可预测的帧耗时”。
5.2 协议层的两个坑:能力声明不一致和意图分发死循环
AG-UI接入初期,最容易出问题的是能力声明与实际实现不一致。我这边的HMI声明了某个控件支持adjustValue意图,但图元处理函数里只实现了“直接设置目标值”,而没有处理“相对增量调整”的逻辑。语音端说“提高5%”,处理函数却把值直接设成了5,差点导致一次参数误写。后来我在协议层加了一套自检工具:开发模式下启动时遍历所有能力声明,挨个往处理函数里发假的意图请求,比对回报事件是否正常,提前暴露声明和实现之间的缺口。这个自检工具现在成了每次发布前的必跑项。
另一个坑是意图分发死循环。自动化脚本发送select选中一个控件后,控件为了视觉反馈触发了高亮事件,高亮事件又被脚本监听,脚本于是再次发送select,双方互相唤醒,代CPU占用飙升。排查时通过协议日志看到同一个intentId在重复循环。解决办法是在意图路由器引用计数里加了事件来源标记:来自同一脚本的回报事件默认不作为新意图的触发条件,必须显式标记才会联动。规则很简单,但如果没有这套防护,任何接入方都可能意外把系统“锁死”。
5.3 问题排查的工具与流程
这套架构有三层,排查问题时必须有对应工具。渲染层用Performance面板配合自绘的帧耗时统计条;数据层看OPC UA日志;协议层则把我自己写的一个“意图抓包器”挂在运行时,打印每个意图的来源、目标、参数、处理耗时和返回事件。现场解决疑难问题时,最常用的动作就是打开意图抓包器,让操作员复现一次操作,然后看意图日志里的完整链路。有一次语音指令迟迟无响应,日志显示意图已经路由到正确的控件,但控件处理函数因为等待数据同步锁超时被挂起,问题定位到秒级,对照以前在黑盒里猜原因,效率天差地别。
排查流程我也总结成固定的三板斧:先看意图日志确认请求到了哪一层,再看数据层确认所需数据是否已到达,最后才看渲染层绘制是否异常。这个顺序不会漏问题,也能避免在错误层次上浪费时间。
6. 最终效果与一些心里话
项目上线后,我测了一次完整数据:同样那条600多图元的泵区监控画面,打开时间从5秒压到1.2秒;持续数据刷新场景下,帧率稳定在45到60帧之间,内存占用比原来DOM方案下降37%。现场操作员最直观的反馈是“按钮终于有手感的准头了”。而AG-UI带来的语音联动,在试运行期间帮操作员减少了高频重复操作,尤其在天冷戴手套不方便精确点触的时候,语音指令确实缓解了现场压力。
我个人在实际操作中的体会是:AG-UI和Canvas看起来是两个方向的东西,一个管语义,一个管渲染,但它们恰好反映了工业HMI下一阶段的两个刚需——界面要快,也要能被机器理解。Canvas把性能问题解决掉,AG-UI把交互模型打开,两者叠加,HMI才第一次有了“可编程操作界面”的雏形。
后面这套架构还有不少扩展空间。比如我正在尝试把AG-UI能力清单接入到知识图谱里,让AI助手可以直接理解“当前画面能做什么”,进而辅助故障诊断;再比如把这套意图路由从单体页面提升到全厂流程级别,让一个意图可以跨越多个画面协同执行。这些都是很值得继续钻的方向。如果你也在为工控界面又卡又“笨”发愁,我建议可以拿一个小画面先试试AG-UI加Canvas的组合,不要急着上一整套,先把语义层搭通,再回头看渲染,你会有和我当时一样的惊喜。