1. 工业现场为什么需要 AG-UI 协议加 Canvas 渲染引擎
工业现场的人机界面,和我们在手机上刷到的 App、在浏览器里打开的网页,完全是两个世界的东西。手机 App 卡一下,用户骂两句就过去了;工业现场的画面卡一下,可能意味着一条产线的操作员错过了某个关键报警,或者中控室里的人没能及时看到设备状态的跳变。这个压力,做过 SCADA 系统的人应该都懂。
我最早接触 SCADA 组态图的时候,用的还是传统的 DOM 方案。一个画面里几百个设备图标、管道、阀门、仪表盘,全部用 div 加 CSS 拼出来。刚开始跑得还行,画面一复杂,浏览器就开始喘了。尤其是那种带实时数据刷新的场景,每秒几十个点位更新,DOM 的重排和重绘直接把主线程堵死。后来换成 Canvas 渲染,情况才好转。但新的问题又来了:Canvas 是一张画布,里面画了什么、哪个元素被点了、哪个设备该响应鼠标事件,全得自己算。这时候,一套清晰的 UI 协议就显得特别重要。
AG-UI 协议就是在这个背景下进入我视野的。它本质上是一套描述“界面应该长什么样、怎么交互”的约定,把界面元素、属性、事件、数据绑定这些概念抽象出来,让渲染层可以按照统一的规则去画。Canvas 渲染引擎则负责把这些描述变成实际的像素。两者结合,在工业现场这种对实时性、稳定性、画面复杂度都有极高要求的场景里,算是一个比较务实的组合。
这篇文章适合谁看?如果你正在做 SCADA 组态、工业 HMI、或者任何需要在 Canvas 上构建复杂交互界面的项目,尤其是对实时数据刷新和画面性能有要求的场景,那接下来的内容应该能给你一些可以直接抄作业的思路。我会从整体设计、核心细节、实操过程、问题排查几个方面展开,尽量把踩过的坑和验证过的方案都讲清楚。
2. 整体架构设计与技术选型思路
2.1 为什么不用 DOM 而选 Canvas
先说一个最根本的问题:工业组态画面到底该用 DOM 还是 Canvas?这个问题我被人问过不下几十次。我的答案一直是:看场景。如果画面元素少、交互简单、不需要频繁刷新,DOM 完全够用,开发效率还高。但工业现场的画面往往不是这样。
一个典型的中控 SCADA 画面,可能有几百个设备图元、几十条管道连线、多个实时曲线、报警列表、趋势图。这些元素里,相当一部分需要根据实时数据变化颜色、位置、数值。用 DOM 做,每个元素都是一个节点,数据一变就要改属性,浏览器要重新计算布局、重新绘制。元素一多,帧率就往下掉。Canvas 不一样,它是一张位图,所有绘制指令直接作用在像素上,没有布局计算这一层。同样的画面,Canvas 的渲染开销通常比 DOM 低一个数量级。
但 Canvas 也有代价。它没有 DOM 那种天然的命中检测和事件冒泡机制。你画了一个阀门,用户点上去,Canvas 本身不知道用户点的是哪个阀门。你得自己维护一套图元列表,自己算坐标,自己做命中检测。这就是为什么需要 AG-UI 协议——它把图元的描述、层级、事件绑定这些信息结构化,让渲染引擎有据可依。
还有一个容易被忽略的点:Canvas 在高分屏上的表现。工业现场的中控大屏往往是 4K 甚至更高分辨率,Canvas 如果不做设备像素比适配,画出来的线条和文字会发虚。DOM 在这方面省心很多,浏览器自动处理。Canvas 就得自己乘上 devicePixelRatio,还要注意缩放后的坐标换算。这个后面实操部分会细说。
2.2 AG-UI 协议到底解决了什么问题
AG-UI 协议这个名字听起来有点抽象,其实你可以把它理解成一份“界面说明书”。它规定了界面由哪些元素组成、每个元素有哪些属性、元素之间怎么嵌套、事件怎么绑定、数据怎么关联。渲染引擎拿到这份说明书,就知道该怎么画。
在工业场景里,这份说明书的价值体现在几个方面。第一是解耦。组态工程师只需要关心“这里放一个泵,绑定哪个点位,点击弹什么窗口”,不需要关心底层是用 Canvas 还是 SVG 还是别的什么渲染。第二是复用。同一个协议描述,可以在不同分辨率的屏幕上渲染,可以在 Web 端和桌面端共用,甚至可以为不同的渲染引擎提供统一的输入。第三是可维护。画面逻辑和数据逻辑分开,改画面不影响数据采集,改数据采集不影响画面。
我自己的项目里,AG-UI 协议的描述通常是一个 JSON 结构。每个图元有类型、位置、尺寸、样式、数据绑定、事件列表。渲染引擎遍历这个结构,按层级顺序绘制。命中检测的时候,反向遍历图元列表,从最上层开始判断坐标是否落在图元范围内。这个思路和浏览器的事件模型是反过来的,但实现起来很直接。
2.3 Canvas 渲染引擎的核心模块划分
一个能用在工业现场的 Canvas 渲染引擎,我一般会把它拆成几个核心模块。第一个是图元管理模块,负责维护图元列表、层级顺序、增删改查。第二个是绘制模块,负责把图元画到 Canvas 上,包括形状、文字、图片、渐变等。第三个是事件模块,负责命中检测、事件分发、拖拽、缩放等交互。第四个是数据绑定模块,负责把实时数据映射到图元属性上,触发重绘。第五个是性能优化模块,包括脏矩形、离屏 Canvas、分层渲染等。
这几个模块里,数据绑定和性能优化是最容易出问题的。数据绑定如果做得太粗,每个点位更新都触发全量重绘,性能很快就崩了。性能优化如果做得太激进,又容易出现画面撕裂或者状态不一致。后面会详细讲我是怎么平衡的。
2.4 技术选型的几个关键取舍
选型的时候有几个点我纠结过很久。第一个是 Canvas 2D 还是 WebGL。Canvas 2D 的 API 更友好,文字渲染和路径绘制都很方便,适合图元数量在几千以内的场景。WebGL 性能更强,但文字渲染和复杂路径处理要自己写 shader,开发成本高。工业组态画面通常图元数量不会特别夸张,Canvas 2D 够用,而且调试方便。第二个是自研渲染引擎还是用现成的库。现成的库比如 Konva、Fabric.js 都挺成熟,但工业场景有一些特殊需求,比如自定义图元、精确的坐标系统、和 SCADA 数据层的深度集成,用现成库反而容易被框架限制。我最后选了自研核心渲染,部分工具函数参考了开源实现。
第三个取舍是渲染频率。工业数据刷新频率从几百毫秒到几秒不等,不是越快越好。渲染频率太高,CPU 占用上去了,风扇呼呼转,工控机受不了。渲染频率太低,操作员感觉画面卡顿。我的经验是,把数据更新和画面渲染解耦,数据来了先存着,渲染按固定帧率走,比如 30fps 或者 60fps,具体看画面复杂度。这样既能保证画面流畅,又不会因为数据突发导致渲染压力过大。
3. 核心细节解析与实操要点
3.1 图元描述结构的设计
图元描述结构是整个协议的基础。我用的结构大概是这样:每个图元有一个唯一的 id,一个 type 表示类型,一个 rect 表示位置和尺寸,一个 style 表示样式,一个 dataBindings 表示数据绑定,一个 events 表示事件列表,一个 children 表示子图元。这个结构看起来简单,但设计的时候有几个细节要注意。
id 必须唯一,而且在图元生命周期内不变。命中检测、事件分发、数据更新都靠 id 来定位图元。如果 id 会变,那维护起来就是灾难。type 决定了渲染引擎用哪个绘制函数来画这个图元。工业场景常见的类型有矩形、圆形、多边形、路径、文字、图片、仪表盘、趋势图等。每种类型有自己的绘制逻辑和属性集。
rect 我用的是相对坐标加绝对坐标的组合。相对坐标是相对于父图元的位置,绝对坐标是相对于画布的位置。这样嵌套结构里,子图元跟着父图元移动,不需要重新计算所有子图元的绝对坐标。但命中检测的时候,需要把鼠标坐标转换成图元的局部坐标,这个转换链要维护好。
dataBindings 是一个数组,每个绑定项包含数据点 id、目标属性、转换函数。比如一个泵的图元,颜色属性绑定到运行状态点位,状态为 1 时绿色,为 0 时灰色。转换函数可以是一个简单的映射,也可以是一段脚本。工业场景里,我倾向于用配置化的映射,而不是让组态工程师写脚本,降低出错概率。
3.2 命中检测的精度与性能平衡
Canvas 的命中检测是个老生常谈的问题。最简单的做法是包围盒检测,判断鼠标坐标是否在图元的矩形范围内。这个方法快,但不精确。一个圆形图元,包围盒是正方形,点四个角也会被判定为命中。对于工业场景,精度要求没那么高,包围盒通常够用。但如果图元密集,包围盒重叠严重,就会出现点 A 却选中 B 的情况。
我的做法是分级检测。第一级用包围盒快速筛选,排除掉大部分不可能命中的图元。第二级对候选图元做精确检测,矩形用坐标范围判断,圆形用距离判断,多边形用射线法或者叉积法。第三级处理重叠,按层级从上到下,第一个命中的就是目标。这个分级策略在几千个图元的场景下,实测响应时间在几毫秒以内,操作员感觉不到延迟。
还有一个细节是命中检测的容差。工业现场的触摸屏,手指点击的精度不如鼠标,有时候点偏几个像素很正常。我会给命中检测加一个容差值,比如 5 个像素。这样用户点在图元边缘附近也能选中,体验好很多。但容差不能太大,否则相邻图元容易误选。这个值需要根据实际屏幕和操作方式调整。
3.3 数据绑定与增量渲染
数据绑定是工业 SCADA 的核心。实时数据从采集层过来,要映射到图元属性上,触发画面更新。如果每个数据点更新都触发全量重绘,图元一多,性能就崩了。我的方案是增量渲染,只重绘受影响的区域。
具体做法是,每个图元维护一个脏标记。数据更新时,找到绑定了这个数据点的图元,标记为脏。渲染循环里,只重绘脏图元所在的区域。这个区域可以是图元的包围盒,也可以稍微扩大一点,避免边缘抗锯齿问题。重绘之前,先用背景色或者保存的背景图填充这个区域,然后再画脏图元。这样每次渲染的工作量只和变化的图元数量相关,和总图元数量无关。
但增量渲染有个坑:图元重叠。如果脏图元下面还有别的图元,只重绘脏图元会把下面的图元盖掉。解决办法是,重绘区域时,把和这个区域相交的所有图元都重绘一遍,按层级顺序。这样虽然多画了一些,但保证了画面正确。实际项目里,我会维护一个空间索引,快速找到和指定区域相交的图元,避免遍历全部图元。
3.4 高分屏适配与坐标换算
工业现场的中控大屏,分辨率往往很高,而且很多是高分屏。Canvas 如果不做适配,画出来的线条和文字会模糊。适配的核心是 devicePixelRatio。Canvas 的 width 和 height 属性要乘以 devicePixelRatio,CSS 的 width 和 height 保持逻辑尺寸不变。然后 ctx.scale(devicePixelRatio, devicePixelRatio),这样绘制时用的还是逻辑坐标,但实际渲染的像素更多,画面就清晰了。
坐标换算也是个容易出错的地方。鼠标事件的坐标是相对于 Canvas 元素的,单位是 CSS 像素。绘制时用的是逻辑坐标,经过 scale 之后,逻辑坐标和 CSS 像素是对应的。但如果 Canvas 有缩放或者平移,就需要额外的变换矩阵。我的做法是维护一个视图变换矩阵,鼠标坐标先经过逆矩阵变换,得到世界坐标,然后再做命中检测。这个矩阵在缩放、平移操作时更新,其他时候不变。
还有一个细节是文字渲染。Canvas 的文字在缩放后容易模糊,尤其是小字号。我的经验是,文字尽量用整数坐标绘制,避免半像素偏移。如果必须缩放,考虑用离屏 Canvas 预渲染文字,或者用位图字体。工业场景里,文字通常不大,但要求清晰可读,这个细节不能忽略。
4. 实操过程与核心环节实现
4.1 环境搭建与基础渲染循环
先说一下基础环境。我用的技术栈是原生 JavaScript 加 Canvas 2D,没有用框架。构建工具用 Vite,开发体验好,打包也快。项目结构大概是 src 下面分 core、renderer、events、data、utils 几个目录。core 放协议解析和图元管理,renderer 放绘制逻辑,events 放事件处理,data 放数据绑定,utils 放工具函数。
渲染循环用 requestAnimationFrame 驱动。每一帧做几件事:处理待更新的数据,更新脏图元,执行增量渲染,处理待分发的事件。这个循环要保持稳定,不能因为某一帧任务太多就卡住。我的做法是给每帧设一个时间预算,比如 16 毫秒,超出的任务放到下一帧。这样即使数据突发,画面也不会完全卡死。
初始化的时候,先创建 Canvas 元素,设置宽高和 devicePixelRatio 适配。然后加载 AG-UI 协议描述,解析成图元树。接着建立数据连接,订阅需要的点位。最后启动渲染循环。这个过程听起来简单,但实际项目里,协议描述的加载和解析可能耗时较长,尤其是画面复杂的时候。我的做法是分帧解析,避免阻塞主线程。
4.2 图元绘制函数的实现要点
每种图元类型对应一个绘制函数。以矩形为例,绘制函数接收 ctx、图元对象、视图变换矩阵几个参数。先根据图元的 rect 和变换矩阵计算出屏幕坐标,然后设置填充色、描边色、线宽等样式,最后调用 ctx.fillRect 或者 ctx.strokeRect。如果是圆角矩形,用路径绘制。如果是带渐变的,创建渐变对象再填充。
圆形图元的绘制类似,用 ctx.arc。多边形用 ctx.beginPath 加 moveTo、lineTo、closePath。路径图元稍微复杂一点,需要解析路径描述,可能是 SVG 路径字符串,也可能是自定义的指令数组。工业场景里,管道、连线这些常用路径,我会预编译成绘制指令,避免每次重绘都解析字符串。
文字图元的绘制要注意对齐和换行。Canvas 的 fillText 不支持自动换行,需要自己算。我的做法是,文字图元有一个 maxWidth 属性,超过就按字符或者单词换行。对齐方式支持左对齐、居中、右对齐。垂直对齐支持顶部、中部、底部。这些在组态的时候都要能配置,因为工业画面里文字的位置和对齐要求很精确。
图片图元用 ctx.drawImage。工业场景里,设备图标通常是 PNG 或者 SVG。SVG 需要先转成 Image 对象才能画到 Canvas 上。这个转换是异步的,要注意加载完成的时机。我的做法是,图片图元在加载完成前画一个占位符,加载完成后再重绘。如果图片加载失败,画一个错误标记,方便排查。
4.3 事件系统的搭建与分发
事件系统是 Canvas 交互的关键。我在 Canvas 元素上监听 mousedown、mousemove、mouseup、click、wheel 等原生事件,然后转换成自定义的图元事件。转换的过程包括坐标换算、命中检测、事件分发。
坐标换算前面说过了,鼠标坐标经过视图变换矩阵的逆矩阵,得到世界坐标。命中检测找到目标图元。事件分发按照图元的事件列表,依次调用处理函数。如果图元没有处理,事件冒泡到父图元。这个冒泡机制和 DOM 类似,但需要自己实现。
拖拽是工业画面里常用的交互。实现方式是,mousedown 时记录起始位置和目标图元,mousemove 时计算偏移量,更新图元位置,标记为脏,mouseup 时结束拖拽。拖拽过程中要注意边界限制,不能让图元拖出画布或者拖到不允许的区域。还有吸附功能,拖拽到网格线或者对齐线附近时自动吸附,这个在组态编辑里很有用。
缩放和平移通常用滚轮和拖拽空白区域触发。缩放时以鼠标位置为中心,调整视图变换矩阵的缩放因子。平移时调整矩阵的平移分量。这两个操作都要限制范围,缩放不能太大也不能太小,平移不能超出画面边界。工业现场的操作员通常不需要复杂的缩放平移,但组态工程师在编辑画面时很需要。
4.4 数据绑定的实现与优化
数据绑定模块负责把实时数据映射到图元属性。我用的方式是在图元上维护一个 bindings 数组,每个绑定项包含 pointId、property、transform。数据层收到点位更新时,遍历所有图元的绑定项,找到匹配的,计算新值,更新属性,标记脏。
这个遍历如果每次数据更新都做,图元多了会很慢。优化方式是建立反向索引,从 pointId 到图元列表的映射。数据更新时,直接查索引找到相关图元,不需要遍历全部。这个索引在图元增删时维护,成本很低,但查询效率提升明显。
transform 函数负责把原始数据转换成图元属性值。比如点位值是 0 到 100 的百分比,图元颜色需要根据这个值在红黄绿之间插值。transform 可以是一个配置化的映射表,也可以是一个简单的表达式。工业场景里,我倾向于用配置化映射,因为组态工程师不一定懂编程,配置化更友好。但有些复杂逻辑,比如多个点位组合计算,还是需要表达式或者脚本。
数据更新的频率控制也很重要。有些点位变化很快,每秒几十次,如果每次都触发重绘,性能受不了。我的做法是给每个绑定项加一个节流阈值,比如变化超过一定幅度才更新,或者限制更新频率。这样既能反映数据变化,又不会过度渲染。
5. 常见问题与排查技巧实录
5.1 画面卡顿的排查思路
画面卡顿是工业现场最常见的问题。排查的时候,我一般按这个顺序来。先看帧率,用 requestAnimationFrame 的时间戳算实际帧率,如果低于 30fps,说明渲染压力大。然后看每帧的耗时,把渲染循环里的各个阶段打点,看是数据更新慢、命中检测慢、还是绘制慢。定位到具体阶段后,再进一步分析。
如果是绘制慢,看图元数量。几千个图元在 Canvas 2D 上绘制,如果每个都画得很复杂,确实会慢。优化方式包括:合并简单图元、用离屏 Canvas 缓存静态图元、减少渐变和阴影的使用、避免频繁的 save 和 restore。如果是数据更新慢,看绑定项数量和更新频率。优化方式是加索引、加节流、减少不必要的绑定。
还有一个容易被忽略的点是内存。Canvas 的离屏缓存如果太多,内存占用上去,垃圾回收频繁,也会导致卡顿。我的做法是限制离屏 Canvas 的数量,用 LRU 策略淘汰不常用的。还有事件监听器,如果绑定后不解除,图元删除后监听器还在,也会造成内存泄漏。这个在开发时要特别注意。
5.2 命中检测不准的常见原因
命中检测不准,通常有几个原因。第一个是坐标换算错误。视图变换矩阵的逆矩阵算错了,或者鼠标坐标没有减去 Canvas 的偏移量。这个用调试工具打点就能发现。第二个是图元层级顺序不对。命中检测从上层开始,如果层级顺序和视觉顺序不一致,就会选错。第三个是包围盒和实际形状差异太大。比如一个细长的斜线,包围盒很大,点旁边也会命中。这种情况需要更精确的检测算法。
还有一个坑是缩放后的命中检测。如果视图有缩放,鼠标坐标换算后,图元的尺寸也要相应缩放。如果检测时用的是原始尺寸,就会偏。我的做法是,命中检测统一在世界坐标系里做,图元的坐标和尺寸都是世界坐标,鼠标坐标也换算成世界坐标,这样就不受缩放影响。
触摸屏上的命中检测还有额外问题。手指点击的面积比鼠标大,而且有抖动。我的做法是加一个触摸容差,并且对触摸事件做防抖处理。如果连续两次点击位置很近,认为是双击,而不是两次单击。这个在工业触摸屏上很实用。
5.3 数据更新导致画面闪烁的处理
画面闪烁通常是因为全量重绘或者重绘区域计算错误。如果每帧都清空整个画布再重绘,图元多的时候,清空和重绘之间会有短暂的空窗,看起来就是闪烁。解决办法是用增量渲染,只重绘变化区域。但增量渲染如果区域算错了,比如脏图元的包围盒没算上描边宽度,重绘后边缘会有残留或者缺口。
还有一个原因是双缓冲没做好。Canvas 的绘制是直接作用在显示缓冲区上的,如果绘制过程中有异步操作,比如图片加载,可能会导致中间状态被显示出来。我的做法是,复杂的绘制先在离屏 Canvas 上完成,然后一次性 drawImage 到主 Canvas。这样绘制过程不可见,只有最终结果呈现,不会闪烁。
数据更新频率和渲染频率不匹配也会导致闪烁。如果数据更新比渲染快,有些中间状态会被跳过,看起来就是跳变。如果数据更新比渲染慢,画面会一顿一顿的。我的做法是,数据更新先存到缓冲区,渲染时从缓冲区取最新值。这样渲染频率稳定,数据也不会丢。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 画面卡顿 | 图元过多、绘制复杂、数据更新频繁 | 打点测帧率、测各阶段耗时 | 增量渲染、离屏缓存、数据节流 |
| 命中不准 | 坐标换算错误、层级顺序不对、包围盒过大 | 打点看坐标、检查层级、可视化包围盒 | 修正矩阵、调整层级、精确检测 |
| 画面闪烁 | 全量重绘、脏区域计算错误、双缓冲缺失 | 观察重绘范围、检查脏标记 | 增量渲染、修正区域、离屏缓冲 |
| 文字模糊 | 高分屏未适配、半像素绘制 | 检查 devicePixelRatio、坐标取整 | 适配像素比、整数坐标 |
| 内存泄漏 | 事件监听未解除、离屏缓存过多 | 内存快照、监听器计数 | 及时解绑、LRU 淘汰 |
| 数据不同步 | 绑定索引未更新、节流过度 | 检查索引、调整节流阈值 | 维护索引、合理节流 |
这个表是我在实际项目里总结的,基本上覆盖了八成以上的常见问题。遇到新问题的时候,先对照这个表排查,能省不少时间。
5.5 几个容易踩的坑和实操心得
第一个坑是 Canvas 的尺寸限制。不同浏览器对 Canvas 的最大尺寸有限制,超过之后绘制会失败或者白屏。工业现场的大屏,如果分辨率很高,Canvas 尺寸可能超过限制。我的做法是分块渲染,把大画面拆成多个小 Canvas,或者用离屏 Canvas 分块绘制再合成。这个在项目初期就要考虑,不然后期改起来很麻烦。
第二个坑是字体加载。Canvas 绘制文字时,如果字体还没加载完,会 fallback 到默认字体,导致文字样式不对。解决办法是用 FontFace API 显式加载字体,加载完成后再重绘。或者用系统自带字体,避免加载延迟。工业场景里,我倾向于用系统字体,稳定可靠。
第三个坑是事件穿透。Canvas 上的图元如果不需要交互,但覆盖在需要交互的图元上面,会挡住事件。解决办法是在命中检测时跳过不需要交互的图元,或者在图元上标记 pointerEvents 属性。这个和 CSS 的 pointer-events 类似,但需要自己实现。
第四个坑是数据精度。工业数据有时候是浮点数,直接用来计算坐标或者颜色,可能会有精度问题。比如颜色插值,浮点数算出来是 127.999,取整后变成 127,和预期差一点。我的做法是,关键计算用整数或者定点数,避免浮点误差累积。
第五个坑是跨浏览器兼容。不同浏览器对 Canvas API 的支持有差异,尤其是文字渲染和路径绘制。我的做法是,核心功能用标准 API,边缘功能做特性检测,不支持就降级。测试的时候,主流浏览器都要覆盖,不能只测一个。
6. 性能优化的几个实战手段
6.1 分层渲染与离屏缓存
分层渲染是我用得最多的优化手段。把画面分成几层:背景层、静态图元层、动态图元层、交互层。背景层和静态图元层变化少,可以缓存到离屏 Canvas,每帧直接 drawImage。动态图元层和交互层每帧重绘。这样大部分绘制工作被缓存了,每帧只需要处理变化的部分。
离屏缓存的更新策略要设计好。静态图元如果变了,比如组态编辑时移动了位置,缓存要失效重建。我的做法是给每个缓存加一个版本号,图元变化时版本号加一,渲染时对比版本号,不一致就重建。重建的成本虽然高,但频率低,可以接受。
分层渲染的另一个好处是,不同层可以用不同的渲染策略。背景层可以用低分辨率缓存,放大后虽然有点模糊,但工业画面背景通常是大色块,不明显。动态层用高分辨率,保证清晰。这样在性能和画质之间取得平衡。
6.2 脏矩形合并与渲染批次
脏矩形是增量渲染的基础。每个脏图元产生一个脏矩形,如果脏图元很多,脏矩形也很多,逐个重绘效率低。我的做法是合并脏矩形。如果两个脏矩形相交或者距离很近,合并成一个大矩形。合并后虽然多画了一些区域,但减少了重绘次数,整体效率更高。
合并算法我用的是简单的迭代合并。先把脏矩形按位置排序,然后遍历,能合并的就合并,不能合并的保留。合并的阈值可以配置,比如距离小于 10 个像素就合并。这个算法复杂度不高,但效果明显。实测下来,脏矩形数量能减少一半以上。
渲染批次是另一个优化点。Canvas 的绘制调用有开销,如果每个图元都单独设置样式、单独绘制,调用次数多了也慢。我的做法是,把相同样式的图元合并到一个批次里,一次性设置样式,然后连续绘制。比如所有红色填充的矩形,可以一起画。这个需要图元按样式排序,排序本身有开销,但绘制调用减少的收益更大。
6.3 数据节流与渲染降帧
数据节流前面提过,这里展开说一下。工业数据的更新频率差异很大,有的点位每秒变几十次,有的几分钟才变一次。如果所有更新都触发渲染,高频点位会把渲染压力拉满。我的做法是给每个绑定项配置节流策略。可以是时间节流,比如最多每 100 毫秒更新一次;可以是变化节流,比如变化超过 1% 才更新;也可以是混合策略。
渲染降帧是另一个手段。如果画面复杂,60fps 跑不满,可以降到 30fps。人眼对 30fps 的动画基本能接受,工业画面通常不需要 60fps 的流畅度。降帧的方式是,在渲染循环里判断时间间隔,不够一帧的时间就跳过。这样 CPU 占用降下来,工控机的风扇也不那么吵了。
但降帧要小心,不能降得太低。低于 20fps,操作员会感觉明显卡顿,影响操作。我的经验是,静态画面可以降到 10fps 甚至更低,动态画面保持 30fps,有动画效果的保持 60fps。这个可以根据画面内容动态调整。
6.4 内存管理与垃圾回收优化
内存管理在长时间运行的工业系统里特别重要。系统可能连续运行几个月不重启,内存泄漏会累积,最终导致崩溃。我的做法是,所有对象池化,避免频繁创建和销毁。图元对象、事件对象、脏矩形对象,都用对象池管理。需要的时候从池里取,用完还回去。这样减少了垃圾回收的压力,也避免了内存碎片。
事件监听器要严格管理。图元删除时,相关的监听器要解除。数据订阅取消时,回调要移除。这些如果忘了,内存就泄漏了。我的做法是,图元销毁时调用一个 cleanup 方法,统一清理所有资源。这个方法里遍历图元的绑定项、事件列表、缓存引用,逐一释放。
还有一个细节是闭包。JavaScript 的闭包容易造成意外的引用保持。比如在事件处理函数里引用了图元对象,图元删除后,闭包还持有引用,内存就回收不了。我的做法是,事件处理函数尽量不直接引用图元对象,而是通过 id 查找。这样图元删除后,闭包里的 id 只是个字符串,不持有对象引用。
7. 工业现场部署的注意事项
7.1 工控机环境适配
工业现场的工控机,配置通常比办公电脑低,而且很多是集成显卡。Canvas 渲染对 GPU 有一定要求,如果显卡太弱,帧率上不去。我的做法是,在项目初期就在目标工控机上测试,根据实际性能调整渲染策略。如果 GPU 太弱,就多用离屏缓存,减少每帧的绘制量。
工控机的操作系统也五花八门,有 Windows 7、Windows 10、各种 Linux 发行版。浏览器的版本也参差不齐。我的做法是,尽量用标准 API,避免新特性。如果必须用新特性,做特性检测和降级方案。测试的时候,目标环境一定要覆盖,不能只在开发机上测。
还有一个问题是工控机的显示设置。有些工控机默认缩放不是 100%,可能是 125% 或者 150%。这会影响 Canvas 的坐标换算。我的做法是,在初始化时读取实际的 devicePixelRatio 和缩放比例,动态调整。不能假设缩放是 100%。
7.2 网络延迟与数据断线处理
工业现场的网络环境复杂,延迟和断线是常态。数据层如果直接依赖网络,网络一抖,画面就卡住或者数据不更新。我的做法是,数据层加缓冲和重连机制。数据先存到本地缓冲区,渲染从缓冲区读。网络断了,缓冲区还有数据,画面不会立刻空白。重连成功后,缓冲区同步最新数据。
断线的时候,画面要有提示。操作员需要知道数据是不是最新的。我的做法是,在画面上加一个状态指示器,显示数据连接状态。断线时变红,重连时变黄,正常时变绿。这个指示器要显眼但不干扰操作,通常放在角落。
数据恢复后,要处理数据跳变。断线期间的数据可能缺失,恢复后直接跳到最新值,画面上的曲线会有一个突变。我的做法是,对关键数据做插值或者平滑处理,让跳变看起来自然一些。但报警相关的数据不能平滑,必须如实反映。
7.3 操作日志与故障回溯
工业现场的系统,操作日志和故障回溯很重要。出了事故,要能查到当时谁操作了什么、画面是什么状态、数据是什么值。我的做法是,在渲染引擎里加日志钩子,关键操作和状态变化都记录。日志存到本地,定期上传或者导出。
故障回溯的时候,日志要能还原当时的画面。我的做法是,日志里记录图元的状态快照,包括位置、样式、数据绑定值。回溯时,用这些快照重建画面。这个功能在排查偶发问题时特别有用,因为现场问题往往难以复现。
日志的量要控制。如果每个数据更新都记,日志会爆炸。我的做法是,只记关键操作和异常状态,正常的数据更新不记。关键操作包括画面切换、参数修改、报警确认等。异常状态包括数据断线、渲染错误、命中检测失败等。
7.4 安全与权限控制
工业现场的操作权限要严格控制。不同角色的操作员,能看到的画面、能操作的元素不一样。我的做法是,在 AG-UI 协议里加权限标记,每个图元可以配置可见性和可操作性。渲染引擎根据当前用户的权限,决定是否绘制和是否响应事件。
权限控制要在渲染层做,不能只在数据层做。如果只在数据层做,画面上的按钮还在,只是点了没反应,操作员会困惑。渲染层直接不画,操作员看不到,就不会误操作。这个在安全要求高的场景里很重要。
还有一个是操作确认。关键操作比如开关阀门、修改参数,要二次确认。我的做法是,在事件处理里加确认弹窗,用户确认后才执行。确认弹窗也要走 AG-UI 协议,保证风格一致。确认记录要写日志,方便追溯。
8. 后续扩展方向与个人体会
这套 AG-UI 协议加 Canvas 渲染引擎的方案,我在几个工业项目里用过,整体表现稳定。后续还可以往几个方向扩展。一个是三维渲染,工业现场越来越多地用到三维模型,把 Canvas 2D 扩展到 WebGL 或者混合渲染,能支持更丰富的画面。另一个是移动端适配,操作员用平板或者手机查看画面,需要响应式布局和触摸优化。还有一个是智能化,结合数据分析,在画面上直接给出操作建议或者异常预警。
我个人在实际操作中的体会是,工业现场的软件开发,稳定性和可维护性比新特性重要得多。一个花哨但三天两头出问题的系统,不如一个朴素但稳定运行几年的系统。所以在技术选型上,我倾向于成熟、简单、可控的方案。Canvas 2D 虽然不如 WebGL 强大,但胜在稳定、调试方便、兼容性好。AG-UI 协议虽然需要自己设计,但胜在灵活、可控、能深度贴合业务需求。
最后再分享一个小技巧:在开发阶段,给渲染引擎加一个调试模式,可以显示图元的包围盒、命中检测的结果、脏矩形的范围、帧率和各阶段耗时。这个调试模式在排查问题时特别有用,能省下大量猜测的时间。上线时关掉调试模式,不影响性能。这个习惯我从第一个 Canvas 项目保持到现在,强烈推荐。