1. 工业现场为什么需要一套自己的 UI 协议1.1 从一块卡死的触摸屏说起三年前我在一个汽车零部件厂的装配车间做设备数据采集项目现场有 27 台工控一体机跑的是某组态软件做的监控界面。问题出在换型的时候每次产线切换产品型号界面上的工位布局、参数阈值、报警提示都要跟着变而原来的组态方案改一次界面要重新下发整个工程文件平均耗时 40 分钟期间操作工只能看着黑屏等。这件事让我开始认真思考一个问题工业现场的 UI 到底应该怎么组织传统组态软件把界面、逻辑、数据绑死在一个工程里改一处动全身而纯前端方案又太软面对 PLC 毫秒级的点位刷新、断网重连、多屏同步这些硬需求时经常掉链子。后来我们尝试了一条新路子用一套自定义的 UI 协议我们内部叫 AG-UI来描述界面用 Canvas 渲染引擎来画界面变更通过 JSON Patch 增量下发。这套方案在后面的三个项目里跑了两年多最长的设备连续运行 400 多天没重启过界面进程。这篇就把这套东西的设计思路、踩过的坑和实操细节完整讲一遍。1.2 AG-UI 协议到底解决什么问题先说清楚 AG-UI 是什么。它不是某个开源框架而是我们在项目里约定的一套面向工业场景的界面描述协议本质是一份 JSON Schema 约束下的 DSL。一份典型的 AG-UI 描述长这样{ version: 1.2, scene: assembly_line_03, canvas: { width: 1920, height: 1080, bg: #0d1117 }, nodes: [ { id: tank_01, type: tank, rect: [120, 200, 160, 320], bind: { level: PLC1.DB1.DBD0, unit: L }, style: { fill: #1f6feb, warn: 0.85, alarm: 0.95 } }, { id: valve_01, type: valve, pos: [320, 240], bind: { open: PLC1.DB1.DBX4.0 }, rotate: { open: 90, close: 0 } } ] }这份描述里没有一行绘制代码全是画什么、绑什么数据、什么条件下变色的声明。渲染引擎拿到它之后负责把它变成屏幕上的像素。这样做的好处很直接界面与逻辑解耦工艺工程师改布局只动 JSON不碰渲染代码增量更新天然友好节点有稳定 id改一个阀门状态只需要下发一个 patch跨端一致同一份描述在工控机、平板、大屏上渲染结果一致只是缩放策略不同。注意AG-UI 这个名字是我们内部约定不是行业标准。你在自己项目里完全可以叫别的名字关键是这套声明式描述 增量更新 Canvas 渲染的组合思路。1.3 谁适合参考这套方案如果你正在做下面这几类事情这套思路大概率能帮上忙工业组态、SCADA、MES 的现场看板点位多、刷新频繁需要频繁换型、界面随工艺配置动态变化的产线多块屏幕工位屏 线体大屏 移动端要显示同一套数据但布局不同对启动速度、内存占用有硬要求的嵌入式或低配工控机。反过来说如果你的界面就是几十个静态按钮、一年改不了两次那用现成的组态软件或者普通前端框架就够了没必要上这套维护成本不划算。技术选型永远要看场景别为了架构而架构。2. 协议设计与渲染引擎选型的取舍2.1 为什么是 Canvas 而不是 DOM这是被问得最多的问题。工业看板上动辄几百上千个图元如果每个图元是一个 DOM 节点浏览器要维护的样式计算、布局树、合成层会迅速膨胀。我实测过一个 800 图元的看板DOM 方案在工控机的低端集显上全量刷新时帧率掉到 12fps 左右鼠标拖动有明显拖影换成 Canvas 单画布后稳定在 55fps 以上。但 Canvas 不是没有代价它把命中测试、事件分发、文本排版、无障碍这些浏览器白送的能力全收回去了你得自己实现。所以选型时要算清楚这笔账维度DOM/SVG 方案Canvas 方案图元数量上限几百个开始吃力几千个仍流畅事件处理浏览器原生省心需自己做命中测试文本渲染自动排版、可选中手动测量、不可选中增量更新改属性即可需重绘脏区内存占用随节点数线性增长基本恒定调试便利性开发者工具直接看需自建调试层我们的结论是图元超过 300 个、刷新频率高于 5Hz 的看板优先 Canvas交互复杂、图元少的配置界面用 DOM 更划算。很多项目其实是混合的——主看板 Canvas弹窗和表单 DOM各取所长。2.2 渲染引擎的三层结构我们最终的引擎分成三层这个分层是踩了很多坑之后定下来的第一层是场景图Scene Graph。它维护所有节点的树形结构负责节点的增删改查、层级排序、可见性裁剪。场景图不碰像素只维护逻辑状态。这一层的关键设计是节点 id 与业务点位解耦——节点 id 是界面概念点位地址是数据概念两者通过 bind 字段关联这样同一个点位可以驱动多个节点同一个节点也能绑多个点位。第二层是脏区管理与调度。每次数据变化或 patch 应用后引擎标记受影响的节点为 dirty然后按帧合并重绘。这里有个重要优化不是所有变化都要全画布重绘。我们给每个节点算一个包围盒多个脏节点的包围盒合并成若干脏矩形只重绘这些矩形区域。实测在典型看板上脏区重绘比全量重绘省 60% 到 80% 的绘制时间。第三层是渲染后端。这一层才是真正调ctx.fillRect、ctx.drawImage的地方。我们把它抽象成接口方便替换。有意思的是不同平台的 Canvas 实现差异很大——桌面浏览器、移动端 WebView、某些嵌入式浏览器的 Canvas 后端完全不同有的走 GPU 合成有的纯 CPU 光栅化。这也是为什么impeller 渲染引擎原理这类话题最近很热大家开始关心底层光栅化到底怎么跑的。2.3 DSL 的字段设计原则AG-UI 的字段设计我们改了三版最后沉淀出几条原则分享出来供参考原则一几何与样式分离。rect、pos、rotate是几何fill、stroke、opacity是样式。分开的好处是换肤、换主题时只动样式层几何层不动patch 体积小。原则二绑定用路径字符串不用嵌套对象。早期我们写成{bind: {source: PLC1, area: DB1, offset: 0}}结果 patch 一个点位要改三层。后来统一成PLC1.DB1.DBD0这种点分路径patch 直接替换字符串简单可靠。原则三状态用枚举不用布尔堆叠。一个阀门有开、关、故障、维护四种状态早期用isOpen、isFault、isMaintain三个布尔组合出 8 种状态有 4 种是非法组合。改成state: open | close | fault | maintain 之后非法状态从类型层面就杜绝了。原则四预留扩展位。每个节点留一个ext对象放厂商自定义字段。渲染引擎遇到不认识的字段直接忽略不报错。这个设计让协议可以渐进演进老引擎跑新描述不会崩。3. JSON Patch 增量更新怎么落地3.1 为什么选 JSON Patch 而不是全量下发全量下发一份 200 节点的场景描述大概 40KB 到 80KB如果每秒下发一次一个车间 30 块屏就是 1.2MB/s 到 2.4MB/s 的持续流量。工业现场的交换机很多是百兆的还要跑视频和 PLC 数据这个量级扛不住。JSON PatchRFC 6902的思路是只下发变化的部分。一个阀门从关到开patch 就一行[ { op: replace, path: /nodes/12/state, value: open } ]几十字节搞定。一天下来流量能降两三个数量级。而且 patch 是幂等的、可重放的断线重连时补发丢失的 patch 就能追上状态不用重新拉全量。3.2 patch 生成端的设计patch 从哪来我们的做法是在数据源侧做 diff。服务端维护每个场景的已下发快照当业务数据变化时把新状态和快照对比生成最小 patch 集合再广播给订阅了该场景的客户端。这里有个关键细节diff 的粒度要按节点算不能按字段算。早期我们按字段 diff一个节点改了 5 个字段就生成 5 条 patch网络包碎得厉害。后来改成按节点聚合一个节点的所有变化合并成一条replace整个节点对象的 patch包数量降了 70%。// 按节点聚合 diff 的简化实现 function diffScene(oldScene, newScene) { const patches []; const oldMap new Map(oldScene.nodes.map(n [n.id, n])); newScene.nodes.forEach((node, idx) { const old oldMap.get(node.id); if (!old) { patches.push({ op: add, path: /nodes/${idx}, value: node }); } else if (!shallowEqual(old, node)) { patches.push({ op: replace, path: /nodes/${idx}, value: node }); } }); return patches; }提示shallowEqual只比一层节点内部的bind、style对象如果引用变了但内容没变会误判为变化。生产环境建议用结构化比较或者给节点算哈希。3.3 客户端 patch 应用与渲染触发客户端收到 patch 后先应用到本地场景图再触发脏区标记。这里最容易出问题的是patch 顺序和并发。如果两条 patch 都改同一个节点乱序应用会导致状态错乱。我们的解法是给 patch 加单调递增的序号客户端维护lastAppliedSeq收到序号不连续的 patch 就缓存等待或者直接请求全量重同步。应用 patch 之后不要立刻重绘而是标记 dirty 并交给下一帧统一处理。原因很简单一秒钟可能来几十条 patch如果每条都触发重绘GPU 会被打爆。合并到帧级别一帧最多重绘一次这是流畅度的关键。class SceneStore { constructor(engine) { this.engine engine; this.scene null; this.pendingSeq 0; this.buffer new Map(); } applyPatch(seq, patches) { if (seq ! this.pendingSeq 1) { this.buffer.set(seq, patches); return; } this._doApply(patches); this.pendingSeq seq; // 尝试消费缓存的后续 patch while (this.buffer.has(this.pendingSeq 1)) { this._doApply(this.buffer.get(this.pendingSeq 1)); this.buffer.delete(this.pendingSeq 1); this.pendingSeq; } this.engine.markDirty(); } _doApply(patches) { patches.forEach(p applyPatchToScene(this.scene, p)); } }3.4 断线重连的状态追赶工业现场网络抖动是常态断线重连必须处理好。我们的策略是客户端重连后先发一个sync请求带上自己的lastAppliedSeq服务端如果还留着这个序号之后的 patch 历史就补发如果历史已经滚掉了比如断线太久就下发全量快照。patch 历史我们保留最近 5 分钟或者 10000 条取先到的那个。这个窗口是根据现场网络恢复时间定的——实测 95% 的断线在 30 秒内恢复5 分钟窗口足够覆盖。4. 工业现场的实操细节与性能调优4.1 点位刷新与渲染帧率的解耦工业点位刷新频率差异极大温度可能 1Hz电机转速可能 50Hz急停信号要求毫秒级响应。如果每个点位变化都直接触发渲染帧率会被高频点位拖垮。我们的做法是数据层和渲染层之间加一个节流缓冲。点位数据进来到一个环形缓冲渲染引擎按自己的节奏通常 30fps 或 60fps从缓冲取最新值。这样无论点位多快渲染频率恒定。急停这类安全信号不走这条路直接走独立的高优先级通道绕过缓冲立即响应。class PointBuffer { constructor() { this.latest new Map(); // pointPath - value this.dirty false; } update(path, value) { this.latest.set(path, value); this.dirty true; } // 渲染帧调用 flush() { if (!this.dirty) return null; const snapshot new Map(this.latest); this.latest.clear(); this.dirty false; return snapshot; } }4.2 文本渲染的坑Canvas 的文本渲染是重灾区。fillText每次调用都要做字体解析、字形查找、光栅化几百个文本标签能把帧率拉下来。我们做了三件事第一文本缓存成离屏 Canvas。数值不变的标签比如温度这种固定文字只画一次缓存成小图之后drawImage贴上去。数值变化的标签才重新fillText。第二限制字体种类。现场看板最多用两种字体、三档字号。字体种类越多字形缓存命中率越低。第三数字用等宽字体。否则数值从 9 变到 10 时宽度跳变标签会抖动操作工看着难受。注意某些嵌入式浏览器的 Canvas 文本渲染有 bugmeasureText返回的宽度和实际绘制宽度对不上。上线前一定要在目标机型上实测别信开发机的结果。4.3 多屏同步与缩放策略同一个场景要在 1920x1080 的工位屏和 3840x2160 的线体大屏上显示缩放策略很关键。我们的方案是逻辑坐标系 视口变换。场景描述里的坐标永远是逻辑坐标我们定的是 1920x1080 基准渲染时根据实际画布尺寸算一个变换矩阵。function computeViewport(canvasW, canvasH, logicalW, logicalH) { const scale Math.min(canvasW / logicalW, canvasH / logicalH); const offsetX (canvasW - logicalW * scale) / 2; const offsetY (canvasH - logicalH * scale) / 2; return { scale, offsetX, offsetY }; }这样一份描述在所有屏上都能正确显示只是留白不同。如果某些屏需要不同的布局比如大屏要显示更多信息那就用不同的场景描述但共享同一套点位绑定。4.4 内存与长稳运行工业设备要求 7x24 运行内存泄漏是致命的。我们踩过的坑包括离屏 Canvas 缓存无上限文本缓存越攒越多跑一周内存涨到 2GB。后来加了 LRU 淘汰上限 500 张。事件监听器未解绑场景切换时旧的监听器没清每次切换泄漏一点。现在统一用 AbortController 管理。patch 缓冲无限增长断线时 patch 一直缓存内存爆掉。加了上限超过就丢弃并标记需要全量同步。长稳测试我们跑的是 72 小时连续运行 每 10 分钟一次场景切换观察内存曲线是否平稳。这个测试比任何单元测试都管用。5. 常见问题排查与避坑清单5.1 现场高频问题速查现象可能原因排查方向界面卡顿、帧率骤降脏区计算错误导致全量重绘打印每帧重绘面积看是否接近全画布数值不更新patch 序号断档缓冲卡住检查 lastAppliedSeq 与收到的 seq断线重连后状态错乱patch 重放顺序问题核对服务端补发历史与客户端 seq文本模糊画布未按 devicePixelRatio 缩放检查 canvas.width 与 CSS 宽度比内存持续上涨缓存或监听器泄漏用内存快照对比两次场景切换前后触摸点击无响应命中测试坐标系未变换检查点击坐标是否经过视口逆变换5.2 命中测试的正确姿势Canvas 没有原生命中测试得自己算。最容易错的是忘记做视口逆变换。用户点击的是屏幕坐标而节点存的是逻辑坐标中间差一个变换矩阵。正确做法是先把点击坐标逆变换回逻辑坐标再和节点包围盒比较。function hitTest(scene, screenX, screenY, viewport) { const x (screenX - viewport.offsetX) / viewport.scale; const y (screenY - viewport.offsetY) / viewport.scale; // 从上层往下层找第一个命中的就是目标 for (let i scene.nodes.length - 1; i 0; i--) { const n scene.nodes[i]; if (n.rect pointInRect(x, y, n.rect)) return n; } return null; }对于不规则图形比如管道、阀门包围盒命中会误判。我们的做法是给这类节点额外存一个简化的多边形轮廓用射线法做精确命中。精度和性能之间取平衡轮廓点控制在 16 个以内。5.3 我踩过的三个大坑坑一以为 Canvas 一定比 DOM 快。早期有个看板只有 50 个图元我硬上 Canvas结果因为要自己实现文本选中、滚动、无障碍开发成本翻了三倍性能还没 DOM 好。教训是图元少就别用 Canvas。坑二patch 里塞了整个节点对象。一个节点对象可能几百字节改一个状态字段却下发整个对象流量白白浪费。后来改成细粒度 patch只下发变化的字段路径。但要注意细粒度 patch 的 diff 计算更复杂得权衡。坑三忽略了 devicePixelRatio。开发机是 1x 屏现场是 2x 屏结果所有文字和线条都糊。修复很简单画布尺寸乘以 dpr再用ctx.scale(dpr, dpr)但发现这个问题花了两天。5.4 上线前的检查清单每次新项目上线前我都会过一遍这个清单在目标工控机型号上实测帧率不看开发机数据模拟断网 30 秒再恢复确认状态能追上连续运行 72 小时看内存曲线切换场景 100 次看是否有泄漏用真实工艺数据压测点位数量按实际峰值的 1.5 倍算检查所有文本在目标分辨率下是否清晰确认急停等安全信号走独立通道不经过节流缓冲。这套 AG-UI 加 Canvas 的方案说到底核心就三句话界面用声明式 DSL 描述变更用 JSON Patch 增量下发渲染用 Canvas 加脏区管理。每一块单独看都不新鲜难的是把它们组合起来适配工业现场那些又硬又碎的需求。我在实际项目里最大的体会是工业软件的性能问题往往不在算法而在对现场真实工况的理解——你得知道操作工怎么用、网络怎么抖、设备怎么老才能把技术方案落到地上。