
1. 工业现场为什么需要一套自己的界面协议1.1 从一块“卡死”的触摸屏说起三年前我在一个汽车零部件厂的焊装车间做产线数字化改造现场有十几台工控一体机跑的是某组态软件做的监控界面。问题出在换型的时候每次切换车型界面要重新加载几百个控件屏幕会卡住三到五秒操作工以为死机了就反复戳屏幕结果触发了一堆误操作。产线停一次线损失按分钟算这个锅我们背不起。那次之后我开始认真思考一个问题工业现场的界面到底和互联网界面差在哪后来总结出三条硬约束。第一是确定性界面刷新必须在固定时间内完成不能因为数据量波动就卡顿第二是可预测操作工按下一个按钮界面反馈的时序必须是稳定的不能这次快下次慢第三是可离线车间网络经常抖动界面不能依赖云端渲染。这三条约束直接决定了工业界面不能照搬 Web 那一套。浏览器渲染管线里样式计算、布局、绘制、合成这几步的耗时是动态的DOM 节点一多就崩。而工业现场动辄几百个测点、几十条报警、实时曲线用 DOM 硬扛就是给自己挖坑。1.2 AG-UI 协议到底解决的是什么问题AG-UI 这个名字听起来像某个大厂的产品其实它是一类面向工业场景的界面描述协议的统称。核心思路是把“界面长什么样”和“界面怎么渲染”彻底分开。界面用一套声明式的 DSL 描述渲染引擎负责把这套描述翻译成实际的绘制指令。我最初接触这个概念是在一个做数控机床面板的项目里。当时甲方要求同一套界面逻辑要能跑在三种设备上工控机上的大屏、手持终端的七寸屏、还有嵌在设备上的小尺寸触摸屏。如果每个平台写一套 UI维护成本直接爆炸。AG-UI 协议的价值就在这里一次描述多端渲染。它的 DSL 通常长这样这是我实际项目里简化过的结构screen: id: welding_line_01 size: [1920, 1080] layers: - id: background type: rect fill: #1a1a2e - id: realtime_curve type: chart bind: sensor.temperature refresh: 100ms - id: alarm_panel type: list bind: alarms.active max_items: 20这套描述里没有任何渲染细节没有说用 Canvas 还是 SVG没有说用哪个字体引擎。渲染引擎拿到这份描述自己决定怎么画。这就是协议层的抽象价值。1.3 Canvas 渲染引擎在工业场景的独特优势为什么选 Canvas 而不是 DOM 或者 SVG这个问题我被问过很多次。答案要从工业界面的数据特征说起。工业界面的数据密度极高。一条产线可能有 500 个测点每个测点要显示实时值、状态色、趋势箭头。如果用 DOM就是 500 个 div 在频繁更新浏览器的重排重绘开销根本扛不住。SVG 好一点但节点数量上去之后属性更新的开销同样可观。Canvas 是立即模式渲染没有节点树的概念。每一帧就是把当前状态画一遍。500 个测点的更新在 Canvas 里就是 500 次绘制调用耗时是可预测的。我实测过同样 500 个动态元素DOM 方案在低端工控机上帧率掉到 15fpsCanvas 方案能稳在 55fps 以上。还有一个关键点是离屏渲染。工业现场经常需要把界面截图存档或者把某个区域单独渲染到另一个屏幕上。Canvas 的离屏画布机制天然支持这个需求DOM 方案要做同样的事就非常别扭。1.4 这套组合适合谁参考如果你在做工业 HMI、SCADA 界面、设备面板、产线看板这类东西并且遇到了性能瓶颈或者多端适配的麻烦这套方案值得认真看看。如果你只是做普通的后台管理系统那 DOM 方案完全够用没必要上这套。另外做数字孪生、工业仿真可视化的同学也可以参考。这类场景的数据量和刷新频率比传统 HMI 还高Canvas 渲染引擎几乎是必选项。2. 协议层与渲染层的职责划分2.1 为什么要把协议和渲染拆开我见过太多项目把界面逻辑和渲染代码揉在一起最后变成一坨谁都不敢动的泥巴。改一个按钮颜色要翻三个文件加一个数据绑定要动渲染循环。这种代码在项目初期跑得挺欢一旦需求变更就开始还债。AG-UI 协议和 Canvas 渲染引擎拆开之后职责边界非常清晰。协议层只关心界面是什么有哪些元素、元素之间什么关系、数据怎么绑定。渲染层只关心界面怎么画用什么绘制 API、怎么优化绘制顺序、怎么管理帧率。这个拆分带来的直接好处是可测试性。协议层可以单独做单元测试验证 DSL 解析、数据绑定、条件逻辑是否正确。渲染层可以单独做性能测试用模拟数据压帧率。两层之间的接口就是一份渲染指令列表非常稳定。2.2 DSL 设计的关键取舍设计 DSL 的时候最大的纠结是表达力和简洁性的平衡。表达力太强DSL 就变成了一门编程语言学习成本高解析器也复杂。表达力太弱稍微复杂一点的界面就描述不了最后还是要写代码。我的经验是工业界面的 DSL 应该覆盖 80% 的常见场景剩下 20% 用逃生舱机制处理。所谓逃生舱就是允许在 DSL 里嵌入一小段自定义绘制代码。比如某个特殊的仪表盘指针DSL 描述不了就写一个自定义绘制函数注册到渲染引擎里DSL 里引用这个函数的 ID。具体来说DSL 的语法我建议用 YAML 或者 JSON不要自己发明语法。原因很简单现成的解析器成熟稳定工具链完善团队上手快。我试过自己设计一套类 CSS 的语法解析器写了一周bug 修了一个月最后发现还不如直接用 YAML。DSL 的元素类型我建议控制在十种以内。矩形、圆形、文本、图片、折线、路径、分组、图表、列表、自定义。这十种基本能覆盖工业界面的所有需求。元素多了渲染引擎的 switch 分支就爆炸维护成本直线上升。2.3 渲染引擎的分层架构渲染引擎我习惯分成四层。最底层是绘制原语层封装 Canvas 的 2D API提供画矩形、画线、画文字这些基础能力。这一层要处理设备像素比、坐标变换、裁剪这些细节。往上是元素渲染层每种 DSL 元素对应一个渲染器。矩形渲染器、文本渲染器、图表渲染器等等。这一层负责把元素的属性翻译成绘制原语的调用序列。再往上是场景管理层负责管理元素树、处理脏标记、决定哪些元素需要重绘。工业界面里大部分元素是静态的只有少数元素在变。场景管理层要能识别出变化的部分只重绘脏区域而不是每帧全量重绘。最上层是帧调度层负责控制刷新节奏。工业界面的刷新率不需要很高10 到 30 fps 足够。关键是稳定不能忽快忽慢。帧调度层要能根据数据更新频率动态调整数据没变就不重绘省 CPU。2.4 数据绑定与响应式更新工业界面的数据绑定和前端框架的响应式不太一样。前端框架追求的是开发体验工业场景追求的是确定性。一个测点值变了界面必须在下一个刷新周期内更新不能有延迟累积。我的做法是显式脏标记。数据层更新的时候主动标记哪些绑定失效了。渲染引擎在每帧开始的时候收集所有脏标记计算出需要重绘的区域然后执行绘制。这个过程是同步的没有微任务队列没有异步调度时序完全可控。具体实现上每个数据绑定维护一个版本号。数据更新时版本号加一。渲染引擎记录上次渲染时的版本号对比发现不一致就标记为脏。这个机制简单可靠实测在 1000 个绑定的情况下脏标记检查耗时不到 1ms。注意不要用 Proxy 或者 Object.defineProperty 做自动依赖收集。工业场景的数据更新路径是明确的手动标记比自动收集更可控性能也更好。3. 从零搭建渲染引擎的核心环节3.1 画布初始化与设备像素比处理工控机的屏幕五花八门有 1080p 的有 4K 的还有各种奇怪分辨率的触摸屏。Canvas 如果不处理设备像素比在高分屏上就是糊的。处理方式很简单但坑不少。function setupCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }这段代码看起来简单但有个坑scale 会累积。如果你在 resize 的时候重复调用 setupCanvasscale 会叠加界面越放越大。正确做法是每次 resize 都重置变换矩阵或者重新创建 context。另一个坑是触摸坐标转换。触摸事件给的坐标是 CSS 像素但 Canvas 的绘制坐标是设备像素。如果做了 scale触摸坐标也要相应转换。我一般封装一个toCanvasCoord函数统一处理这个转换避免在每个事件处理里重复写。3.2 脏矩形计算与局部重绘全量重绘在元素少的时候没问题元素一多就扛不住。脏矩形机制是性能优化的关键。核心思路是只重绘发生变化的区域。计算脏矩形有两种策略。一种是包围盒策略把脏元素的包围盒合并得到一个或多个矩形区域。另一种是网格策略把画布分成固定大小的网格标记脏网格重绘脏网格覆盖的区域。包围盒策略适合元素少、变化集中的场景。网格策略适合元素多、变化分散的场景。工业界面通常是后者几百个测点分散在屏幕上每个都在独立更新。我一般用 64x64 的网格实测下来效果不错。class DirtyGrid { constructor(width, height, cellSize 64) { this.cols Math.ceil(width / cellSize); this.rows Math.ceil(height / cellSize); this.cellSize cellSize; this.dirty new Uint8Array(this.cols * this.rows); } markRect(x, y, w, h) { const c0 Math.floor(x / this.cellSize); const r0 Math.floor(y / this.cellSize); const c1 Math.floor((x w) / this.cellSize); const r1 Math.floor((y h) / this.cellSize); for (let r r0; r r1; r) { for (let c c0; c c1; c) { if (r 0 r this.rows c 0 c this.cols) { this.dirty[r * this.cols c] 1; } } } } getDirtyRects() { // 合并相邻脏网格为矩形减少 clip 调用次数 // 具体合并算法略核心是扫描线合并 } }脏网格的粒度需要根据实际场景调。太小了脏矩形数量多clip 调用开销大。太大了重绘面积大浪费绘制时间。64 像素是我试过比较平衡的值。3.3 文本渲染的性能陷阱工业界面里文本特别多测点名称、数值、单位、报警信息全是文字。Canvas 的 fillText 性能其实不差但有几个陷阱要注意。第一个陷阱是字体加载。如果用了自定义字体字体没加载完就绘制会 fallback 到默认字体然后字体加载完再重绘界面会闪一下。正确做法是等document.fonts.ready之后再开始渲染。第二个陷阱是频繁测量文本宽度。measureText是个相对昂贵的操作如果在渲染循环里对每个文本都调用性能会明显下降。我的做法是缓存测量结果key 是文本内容加字体配置value 是宽度。工业界面的文本内容变化不频繁缓存命中率很高。第三个陷阱是文本对齐。Canvas 的 textAlign 和 textBaseline 会影响绘制位置如果每个文本都单独设置状态切换开销不小。我一般按对齐方式分组绘制先画所有左对齐的再画所有居中的减少状态切换。实操心得如果界面里有大量数字在快速变化比如实时值显示可以考虑用位图字体。把 0-9 和小数点预渲染成精灵图绘制的时候直接 drawImage比 fillText 快一个数量级。代价是字体样式不灵活但工业界面通常不需要花哨的字体。3.4 图表渲染的降采样策略实时曲线是工业界面的标配。一条曲线可能有几千个数据点如果每个点都画Canvas 的路径绘制会非常慢。降采样是必须的。最简单的降采样是等间隔抽样每隔 N 个点取一个。但这样会丢失尖峰工业场景里尖峰往往是最重要的信息。更好的做法是极值抽样把数据分成若干区间每个区间取最大值和最小值这样尖峰不会丢。function downsample(data, targetCount) { if (data.length targetCount) return data; const bucketSize data.length / targetCount; const result []; for (let i 0; i targetCount; i) { const start Math.floor(i * bucketSize); const end Math.floor((i 1) * bucketSize); let min Infinity, max -Infinity; for (let j start; j end; j) { if (data[j] min) min data[j]; if (data[j] max) max data[j]; } result.push(min, max); } return result; }降采样的目标数量取决于曲线的像素宽度。一条 800 像素宽的曲线最多需要 1600 个点每个像素列两个点。超过这个数量的点画出来也看不出区别纯属浪费。3.5 离屏画布与图层合成工业界面经常有静态背景和动态前景。背景可能是厂区平面图、设备示意图这些内容不变但每次重绘都要画一遍很浪费。离屏画布可以解决这个问题。把静态内容画到一个离屏 canvas 上每帧只需要drawImage一次把离屏画布贴到主画布上。这个操作在现代浏览器里是 GPU 加速的非常快。class Layer { constructor(width, height) { this.canvas document.createElement(canvas); this.canvas.width width; this.canvas.height height; this.ctx this.canvas.getContext(2d); this.dirty true; } render(drawFn) { if (!this.dirty) return; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); drawFn(this.ctx); this.dirty false; } composite(targetCtx) { targetCtx.drawImage(this.canvas, 0, 0); } }图层的划分要合理。背景一层数据层一层报警层一层交互层一层。每层独立管理脏标记只有脏了的层才重新渲染。实测下来这种分层结构能把 CPU 占用降低 60% 以上。4. 工业现场踩过的坑与排查实录4.1 触摸事件与渲染帧的时序问题工业触摸屏的触摸采样率和渲染帧率往往不一致。触摸屏可能是 60Hz 采样渲染是 30fps。如果触摸事件处理里直接触发重绘会出现一帧内多次重绘浪费性能。我的做法是事件缓冲。触摸事件只记录状态不直接触发重绘。渲染帧开始的时候读取当前触摸状态统一处理。这样一帧内无论来多少次触摸事件只处理一次。还有一个坑是触摸抖动。工业现场电磁干扰大触摸屏偶尔会报错误的坐标。如果直接响应界面会跳。我一般加一个简单的滤波连续三次触摸坐标都在一个小范围内才认为是有效触摸。4.2 内存泄漏的常见来源Canvas 应用的内存泄漏比 DOM 应用更隐蔽因为 Canvas 本身不占多少内存泄漏往往发生在 JavaScript 对象上。最常见的泄漏源是事件监听器。组件销毁的时候忘了移除监听器监听器持有组件的引用组件就回收不了。我一般用统一的销毁函数组件销毁时把所有注册的监听器、定时器、动画帧都清理掉。第二个泄漏源是离屏画布。每次创建离屏画布都占内存如果频繁创建不释放内存会持续增长。离屏画布应该复用不要每次渲染都新建。第三个泄漏源是数据缓存。文本测量缓存、路径缓存这些如果不设上限会无限增长。我一般用 LRU 策略缓存超过一定数量就淘汰最久未使用的。4.3 不同浏览器的渲染差异工业现场的工控机浏览器版本往往很老可能是 Chromium 70 甚至更早。不同版本的 Canvas 实现有差异踩过的坑包括老版本对ctx.filter支持不好模糊效果要用离屏画布手动实现老版本的Path2D性能差大量路径绘制要用beginPath加moveTo/lineTo老版本的requestAnimationFrame在后台标签页会暂停工业场景要确保界面始终在前台注意如果工控机是 Windows 7 系统浏览器最高只能到 Chromium 109。这个版本对现代 Canvas API 的支持已经不错但还是要做特性检测不要假设所有 API 都可用。4.4 常见问题速查表现象可能原因排查方向解决方案界面卡顿全量重绘检查脏矩形是否生效启用脏网格只重绘变化区域文字模糊设备像素比未处理检查 canvas.width 与 style.width 比例按 DPR 缩放画布触摸无响应事件坐标未转换检查触摸坐标与绘制坐标是否一致统一坐标转换函数内存持续增长监听器未清理用内存快照对比组件销毁时清理所有资源曲线绘制慢数据点过多统计每帧绘制点数极值降采样到像素宽度的两倍界面闪烁字体未加载完检查 document.fonts.ready等字体加载完再渲染帧率不稳数据更新频率波动统计每帧数据变更量固定刷新周期数据缓冲4.5 性能优化的几个实用技巧第一个技巧是批量绘制。相同样式的元素攒到一起画减少状态切换。比如所有红色报警文本先收集起来设置一次 fillStyle然后循环 fillText。第二个技巧是避免浮点坐标。Canvas 绘制时如果坐标是浮点数浏览器会做抗锯齿处理性能比整数坐标差。我一般用Math.round把坐标取整视觉上几乎看不出区别性能有提升。第三个技巧是合理使用 willReadFrequently。如果频繁调用getImageData创建 context 的时候要加{ willReadFrequently: true }否则浏览器会把画布放在 GPU 上每次读取都要从 GPU 拷回 CPU非常慢。第四个技巧是控制画布尺寸。画布越大绘制开销越大。如果界面只有一部分在变化可以考虑把变化部分单独放到一个小画布上静态部分用大画布。这样每帧只需要重绘小画布。5. 这套方案的实际效果与扩展方向5.1 实测数据与收益在焊装车间的项目里这套方案上线后界面切换时间从 3 到 5 秒降到了 200ms 以内。操作工的误操作率下降了 70% 以上。CPU 占用从平均 40% 降到了 12% 左右工控机的风扇都不怎么转了。另一个项目是数控机床面板同一套 DSL 描述跑在三种尺寸的屏幕上界面代码量减少了 60%。以前改一个界面要改三遍现在改一遍 DSL 就行。维护成本大幅下降。5.2 后续可以扩展的方向第一个方向是协议层的可视化编辑器。现在写 DSL 还是手写 YAML对非技术人员不友好。做一个拖拽式的编辑器生成 DSL能进一步降低使用门槛。第二个方向是渲染引擎的 WebGL 后端。现在用的是 Canvas 2D对于更复杂的 3D 可视化场景可以加一个 WebGL 后端。DSL 层不用变渲染层根据场景自动选择后端。第三个方向是协议层的版本管理。工业现场的界面配置需要审计谁在什么时候改了什么要能追溯。给 DSL 加版本号配合配置管理系统可以实现完整的变更审计。5.3 一些个人体会这套方案不是银弹。它的复杂度比直接用 DOM 高前期投入不小。如果项目规模小界面简单用 DOM 加一些性能优化就够了。只有当界面复杂度到一定程度多端适配和性能优化成为刚需的时候这套方案的价值才体现出来。另外DSL 的设计要克制。我见过一些项目DSL 越设计越复杂最后变成了一门图灵完备的语言解析器比渲染引擎还大。这就本末倒置了。DSL 的目的是描述界面不是写程序。遇到 DSL 表达不了的场景用逃生舱机制不要硬往 DSL 里塞。最后说一个细节工业现场的界面刷新率不要追求高。30fps 足够了人眼对工业数据的刷新不敏感。刷新率越高CPU 占用越大工控机的散热压力越大。稳定比流畅重要。