ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt电力组态系统实现:图元拖拽、数据绑定与IEC104通信

Qt电力组态系统实现:图元拖拽、数据绑定与IEC104通信

简介:这是一套基于QT框架开发的电力组态系统资源包,面向电力行业监控软件开发者和QT进阶学习者。系统以图形化方式配置电力设备监控逻辑,核心绘图部分展示了QGraphicsView与QGraphicsScene在仪表盘、实时曲线、动态数据点渲染上的应用,并涉及模板化界面、脚本扩展及常见的采集、报警、历史存储等组态功能。资源共320个文件,包含122个h头文件、79个png图形资源、26个cpp实现文件,以及若干pro工程文件、ui界面文件和dll动态库,压缩包整体6.21MB,结构完整,便于对照阅读。目前已有689人学习,该资源虽仅提供绘图相关源代码,但足以帮助开发者理解QT图形渲染与电力数据绑定的实现思路,可作为学习QT组态系统设计的参考范例。

1. 电力组态系统:用 Qt 把一张电网监控画面变成可拖拽的活图纸

做电力监控系统的人,大概都经历过这种日子:用户提需求说“再画一个新间隔”,你得打开代码编辑器,把一个新的开关图元拖到画布上、连好数据点、改好坐标,重新编译再发布。这套 Qt 电力组态系统解决的就是这个痛点——把画画面这件事从程序员手里交给运行人员,画面上的开关、母线、变压器不再是画死的图片,而是绑定实时数据的活图元。拖动、连线、改属性、保存成工程文件,下次启动直接加载,双击就能切换运行态看实时值。适合正在做变电站监控、配电网调度、能耗管理系统,或者单纯想在 Qt 项目里做图形化编辑器的开发者。整个项目不是给你一个玩具 Demo,而是一套能落地的工程雏形:通信、画布、图元库、数据绑定、历史存储都齐全,拿来改一改就能接到自己的系统里。

2. 整体架构与工程目录:先看清这个项目的骨架

拿到源码第一件事,别急着点编译,先把目录结构过一遍。这个项目按功能分层组织,而不是按界面堆文件,我第一次打开目录结构的时候觉得清爽,看它如何组织确实能给同类项目提供经验。

2.1 工程目录结构:谁负责画画面,谁负责跑数据

项目顶层目录大致是这么分的:

PowerConfigSystem/ ├── PowerConfigSystem.pro # Qt 工程文件 ├── main.cpp # 程序入口 ├── MainWindow.ui / MainWindow.h / MainWindow.cpp # 主窗口 ├── scene/ # 画布与视图层 │ ├── ConfigScene.h/cpp # 组态画布,继承 QGraphicsScene │ ├── ConfigView.h/cpp # 画布视图,负责缩放与拖动 ├── items/ # 图元库 │ ├── BaseElement.h/cpp # 图元基类 │ ├── SwitchElement.h/cpp # 开关图元 │ ├── BusBarElement.h/cpp # 母线图元 │ ├── TransformerElement.h/cpp# 变压器图元 │ ├── TextElement.h/cpp # 文本标签图元 │ └── ElementFactory.h/cpp # 图元工厂 ├── comm/ # 通信层 │ ├── IEC104Client.h/cpp # 104 规约客户端 │ ├── DataPoint.h/cpp # 数据点定义 │ └── DataManager.h/cpp # 实时数据管理器 ├── property/ # 属性面板 │ ├── PropertyPanel.h/cpp # 属性表格 │ └── PropertyItem.h/cpp # 单个属性条目 ├── widgets/ # 公共控件 ├── icons/ # 图元图标资源 └── config/ # 保存的组态工程文件目录

这个分层逻辑很明确:scene和items是画布体系,comm是数据通道,property是把图元属性暴露给用户的入口。运行态和编辑态共用同一套 scene,只是切换了交互策略,这是组态系统常见的设计取舍——画面编辑和实时监控不需要两套渲染引擎。

2.2 运行时数据流:从通信报文到画面刷新的路径

整个系统的核心运行流程可以用一条链路说清:通信线程收到 104 报文,解析成带地址的遥测值,写入 DataManager;DataManager 在值变化时发信号;图元在编辑时绑定了一个数据点地址,收到 DataManager 的值变化信号后刷新自己的显示状态。

这条链路上最关键的设计决策是通信与界面解耦。comm 层跑在独立线程,通过 Qt 的信号槽机制向界面层投递数据更新,而不是直接调用图元的接口。这样即使通信卡住几分钟,界面也不会跟着阻塞。

2.3 编辑态与运行态:同一张画布两种交互模式

ConfigScene里维护一个枚举状态,EditMode和RunMode。编辑态下,选中的图元有虚线边框、吸附网格、可以拖拽缩放、右键打开属性面板;运行态下,图元的选中框消失,鼠标点击图元触发的是“操作”,比如点击开关图元会下发遥控命令。

两种模式的切换逻辑非常直观:

void ConfigScene::setMode(SceneMode mode) { m_mode = mode; // 让所有图元感知模式变化 for (auto *item : items()) { BaseElement *ele = dynamic_cast<BaseElement *>(item); if (ele) { ele->setEditable(mode == EditMode); } } // 运行态下关闭网格吸附,保留拖拽平移 if (mode == RunMode) { setItemIndexMethod(QGraphicsScene::BspTreeIndex); // 运行态用加速索引 } }

注意setItemIndexMethod,编辑态图元频繁移动,用线性索引反而快;运行态图元基本不移动,但刷新频率高,BspTreeIndex 能加速碰撞检测。这是一个很小的细节,但对大画布的性能体感影响很明显。

3. 核心实现拆解:一条测点从 104 报文到画面刷新的完整链路

如果只看目录结构就止步,那你只拿到了骨架。这一章把代码的关键路径抽出来讲,这部分才是这类项目真正的价值所在,也是值得深入学习的地方。

3.1 图元基类:一个电力图元的自我修养

所有电力图元(开关、母线、变压器、文本)都继承自BaseElement,这个基类封装了三件事:几何外形绘制、属性字典、数据点绑定。

class BaseElement : public QGraphicsItem { public: BaseElement(); // 子类必须实现绘制 virtual void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; virtual QRectF boundingRect() const override; // 绑定数据点,地址类似 "101.0.13":装置地址.组号.点号 void bindDataPoint(const QString &pointAddress); void onDataUpdated(const DataPoint &point); // 属性字典,暴露给属性面板 QMap<QString, QVariant> properties() const { return m_properties; } void setProperty(const QString &key, const QVariant &value); protected: QMap<QString, QVariant> m_properties; // 图元属性 QString m_pointAddress; // 绑定的数据点 bool m_isEditable = true; // 是否处于编辑态 };

paint是虚函数,每个子类画自己的图形。开关画一个矩形加一条斜杠,断开时斜杠旋转 45 度;母线画一条粗线;变压器画两个圆。绘制的时候不是直接用裸坐标,而是基于一个统一的boundingRect()换算比例,这样才能在缩放时不变形。

properties字典是图元和属性面板之间的桥梁。属性面板列出字典里的所有键,用户改了值之后调setProperty更新。这样添加新图元的属性时,不需要改属性面板的代码,只要在子类里往字典塞键值对就行。

3.2 图元工厂:从面板拖一个新开关到画布

图元不能直接在画布上new,得经过工厂。左侧的图元面板是一个QListWidget,每一项的data里存了图元类型字符串。拖拽时把类型字符串编码进 MIME,画布的dropEvent里取出来交给工厂创建。

void ConfigScene::dropEvent(QGraphicsSceneDragDropEvent *event) { if (event->mimeData()->hasFormat("application/x-element-type")) { // 从拖拽数据中取图元类型 QByteArray typeData = event->mimeData()->data("application/x-element-type"); QString typeStr = QString::fromUtf8(typeData); QPointF scenePos = event->scenePos(); // 注意这里是场景坐标 // 工厂创建图元实例 BaseElement *element = ElementFactory::createElement(typeStr); if (!element) { event->ignore(); return; } // 图元默认中心是(0,0),要平移到鼠标位置 element->setPos(scenePos); addItem(element); // 选中新图元,方便立刻调整属性 clearSelection(); element->setSelected(true); event->accept(); } }

一个常见的翻车点是坐标问题。拖拽事件里的pos()是视图坐标,必须先mapToScene转成场景坐标,否则图元会出现在和鼠标差一截的位置。上面代码里用event->scenePos()直接拿场景坐标,省掉了这一层转换。

ElementFactory::createElement本质上是一个if-else链或者QMetaType注册表,按类型字符串返回不同的图元实例。项目里用静态注册表的方式,新加图元只需要在工厂里登记一行,不必改拖拽逻辑。

3.3 104 报文解析:从字节流里捞出实时数据

通信层是这个项目通信的基座,里面实现了 IEC 60870-5-104 规约的基本收发能力。104 规约是电力行业用得最多的远动规约,报文结构是启动字符 0x68 + 长度 + 控制域 + 地址 + 信息体。解析的核心方法是解析一帧遥测报文。

QList<DataPoint> IEC104Client::parseMeasurandFrame(const QByteArray &frame) { QList<DataPoint> points; if (frame.size() < 12) return points; if (frame[0] != 0x68) return points; // 启动字符校验 quint8 length = frame[1]; // APDU 长度 int pos = 2; // 跳过启动字符和长度 quint16 commonAddr = frame[pos] | (frame[pos + 1] << 8); // 公共地址 pos += 2; // 跳过 3 字节控制域(对应 1 类型帧的 S 或 I 帧) pos += 3; quint8 type = frame[pos++]; // 类型标识,0x0D 表示遥测 quint8 sq = frame[pos++] & 0x80; // 信息体地址是否连续 quint8 num = frame[pos++] & 0x7F; // 信息体数量 if (sq) { // 地址连续:信息体地址寄存器模式 quint32 infoAddr = frame[pos++] | (frame[pos + 1] << 8) | (frame[pos + 2] << 16); pos += 3; for (quint8 i = 0; i < num; ++i) { // 遥测值是 2 字节短浮点(归一化值) qint16 rawVal = frame[pos] | (frame[pos + 1] << 8); pos += 2; // 品质描述符 quint8 quality = frame[pos++]; DataPoint p; p.address = QString("%1.%2.%3").arg(commonAddr).arg(infoAddr + i); p.value = rawVal / 32768.0; // 归一化到 -1~1 之间 p.qualityOk = !(quality & 0x01); // 最低位是有效性标记 points.append(p); } } // 地址非连续的情况需要逐条解析,逻辑类似,略 return points; }

这段代码体现了 104 规约解析的核心思路:报文就是一段有固定格式的字节流,解析就是按协议文档里的字段偏移量去切。归一化的rawVal / 32768.0是 104 规约对遥测的标准做法,原始值除以满量程得到标准化数值,再通过量程信息换算成实际工程值。

实际工程里最常踩的坑是公共地址不匹配——报文里的commonAddr和装置配置的地址不一致,导致收上来的数据全在过滤器里被丢弃,画面上一个数都不动。这个问题通常要抓报文日志才能发现。

3.4 数据绑定与反向刷新:图元怎么知道开关状态变了

图元从 DataManager 接收数据更新,DataManager 在数据变化时遍历所有已绑定的图元,然后逐个通知。更细的做法是让 DataManager 内部维护一个QMultiHash<QString, BaseElement*>,地址到图元的映射,这样值变化时能直接找到关注这个点的图元。

void DataManager::registerElement(BaseElement *ele, const QString &address) { m_mapping.insert(address, ele); // 新注册的图元,立刻拿当前值刷一次,否则画面上是空的 DataPoint pt = getCurrentValue(address); if (pt.isValid) { ele->onDataUpdated(pt); } } void DataManager::onDataReceived(const DataPoint &point) { // 写入实时缓存,历史线程会定期读取这个缓存落库 m_valueCache[point.address] = point; // 反向刷新界面 auto range = m_mapping.equal_range(point.address); for (auto it = range.first; it != range.second; ++it) { it.value()->onDataUpdated(point); } }

registerElement里那个“立刻刷一次当前值”的细节值得给个提示:

提示:新图元拖到画布并绑定数据点后,如果数据没有变化,图元会一直显示默认状态。注册时主动推一次当前值,可以避免这种“看着像没连上”的假象。

4. 开发环境与工程运行:从源码拿到屏幕上的画面

代码看得再明白,跑不起来也等于零。这一章把从零开始到系统跑起来的完整流程过一遍,包括工具链、第三方依赖、配置文件,以及第一次运行时容易出问题的点。

4.1 工具链与依赖清单

这个项目基于 Qt 5.15 开发,编译器用 MinGW 64 位,C++ 标准 C++11 以上。依赖只有三块:Qt 5(Core / GUI / Widgets / Network 模块)、SQLite 3(负责组态画面和历史数据的持久化)、编译器自带的 OpenSSL(104 规约的 TCP 连接不做加密,但项目保留了 TCP keepalive 的配置)。

版本建议:

组件版本建议说明
Qt5.15.x 或 6.5.x 均可6.x 需要把QRegExp换QRegularExpression
编译器MSVC2019 64bit 或 MinGW 8.1Qt 6 不提供 MinGW 版本就换 MSVC
数据库SQLite 3项目里不需要单独安装服务端,嵌入式直接读文件

项目里没有使用 QML,整个界面基于 Widgets。如果你日常主力是 QML,切过来可能需要适应一下QGraphicsView的坐标体系,但思路是相通的。

4.2 编译与启动步骤

在 Qt Creator 里打开PowerConfigSystem.pro,选择构建套件,直接构建即可。如果编译报缺少模块,大概率是 Kit 里没有勾选 Network 或 Sql 模块。构建完成后直接运行,主窗口会加载默认的空白画布。

如果使用命令行构建,对应流程是:

mkdir build && cd build qmake ../PowerConfigSystem.pro make -j4 ./PowerConfigSystem

4.3 配置文件详解:设备地址、协议参数和画面加载

项目首次启动会生成config/system.ini,内容大概是这样:

[Communication] enabled=true deviceAddress=127.0.0.1 devicePort=2404 commonAddr=1 useTcpKeepAlive=true [Database] path=./config/history.db autoSaveInterval=5000 [Scene] defaultFile=./config/default.cfg gridEnabled=true gridSize=10

commonAddr就是上面提到的那一个关键配置。模拟调试时deviceAddress填127.0.0.1,配合一个 104 模拟器往 2404 端口发包,就能在没有真实装置的情况下把画面跑起来。自动保存间隔控制数据落库频率,建议不要短于 3 秒,否则 SQLite 写入会成为瓶颈。

4.4 第一次运行必做的三件验证

系统起来后先做一个冒烟测试:从左侧图元面板拖一个开关到画布,右键打开属性面板,数据点地址填1.0.1,切换到运行态。如果此时有模拟器在发数据,开关的状态应该随模拟器变化而变化。

第二个验证看遥测值。添加一个文本图元,属性面板绑定1.0.13,运行态下文本标签应显示从 104 报文里解析出的归一化数值。

第三个验证看数据落库。运行 5 分钟后,查看config/history.db,用 SQLite 工具打开确认history_data表里有数据。这一条验证通过,历史曲线模块就具备扩展条件了。

5. 常见问题与避坑记录:这些坑不解决就白编译一个下午

编译级错误通常最好解决,运行期的坑才真是让人血压升高的地方。整理几条最典型的,都是实际踩过的,按现象、原因、解决来写。

5.1 拖拽图元后,图元位置和鼠标落点不一致

现象:拖新图元到画布,图元出现在鼠标右下方一段距离,且离得不是固定像素。

原因:图元定义坐标系时以自身中心为原点,而QGraphicsItem的setPos设置的是 item 坐标系原点的位置。新建图元时在图元构造函数里设置了setPos(0,0)附近的默认坐标,拖拽创建时直接把事件坐标赋给了图元位置,导致中心对不上。

解决:创建后做一次位置校正,将图元内所有子图形相对于基准点平移。更省事的做法是图元基类里约定所有子类绘制时都以(0,0)为几何中心,再用一条element->setPos(scenePos)保证图元的中心点落在鼠标位置。

5.2 跨线程更新 UI 时图元刷新不及时甚至崩溃

现象:通信线程收到报文后直接调用图元的update(),偶发段错误,或者画面刷新频率极低。

原因:通信线程和主界面线程是不同的QThread,直接跨线程调用QGraphicsItem::update()不是线程安全的。Qt 虽然让信号槽跨线程自动切换队列连接,但前提是信号槽必须通过信号机制调用,而不是直接调函数指针。

解决:DataManager 接收报文后通过信号valueChanged(DataPoint)发射,主线程里的ConfigScene槽函数负责转发给图元。代码上 3.4 节已经展示了这部分逻辑,关键是信号槽里的QueuedConnection模式。检查代码时留意有没有跨线程直接连接对象方法的情况。

5.3 104 模拟器能连上,但是画面数据全是 0

现象:TCP 连接成功,模拟器一直在发数据,但画面上绑定数据点的图元毫无反应。

原因:收到的报文能解析,但地址换算错位了。常见于信息体地址偏移处理不对——有些厂家报文里信息体地址从 0 开始,有些从 1 开始,代入commonAddr和组号做匹配时出现错位。

解决:在 104 解析函数里打日志,输出解析后的p.address字符串。对照实际变电站的装置参数表,看commonAddr、组号、点号三者的组合是否一致。如果站点只有一个装置,把commonAddr写死为 1 最稳妥。调通了再改回配置。

5.4 图元闪烁严重,拖动时画面撕裂

现象:画布上有几百个图元时,运行态数据刷新导致整个画面闪烁,拖动起来不跟手。

原因:每次任一图元收到数据都调用scene->update(),更新整块画布,这是性能浪费。图元多的时候裸奔式全量刷新必然出问题。

解决:把DataManager::onDataReceived里的通知逻辑从scene->update()改成element->update(),只更新被数据影响到的图元。另外一个配套的习惯是把ConfigView的viewport()->setAttribute(Qt::WA_OpaquePaintEvent)设置上,减少背景重绘。

5.5 缩放画布后图元虚线边框变成实线,并且变粗

现象:编辑态下缩放画布到 150% 以上,图元的选中虚线框变成实线,图元的边框也跟着变粗,整体图形发虚。

原因:默认的ItemCoordinateSystem下,线宽是跟随视图缩放系数变化的。视觉期望是图元的图形跟随缩放整体变大,但边框线宽保持 1 像素,这个期望冲突了。

解决:重写图元基类的QGraphicsItem::paint时,在 painter 里做一次反向缩放,让线宽不受视图缩放影响。常见写法是:

void BaseElement::paint(...) { painter->save(); qreal scaleFactor = painter->transform().m11(); // 当前缩放系数 QPen pen(Qt::blue, 1.0 / scaleFactor); // 线宽除以缩放系数,保持视觉恒定 painter->setPen(pen); // ... 绘制图形逻辑不变 painter->restore(); }

这样一个画布从 50% 缩放到 300%,图形整体缩放,但线条视觉宽度恒定,虚线也不会在缩放中被涂成实线。

6. 二次开发落地:从添加一个自旋电机图元到画面绑定

这一章提供一个改造案例,把整套流程走通:定义新图元、注册到工厂、属性面板自动识别、绑定数据点。按这个模式做,一周内就能把项目里的图元库翻一倍。

6.1 新图元类的骨架代码

假设场景是加一个“自旋电机”图元,它在运行态下通过旋转角度反映负载率。新建MotorElement.h和.cpp,继承BaseElement:

class MotorElement : public BaseElement { public: QRectF boundingRect() const override { return QRectF(-30, -30, 60, 60); // 以中心为原点 } void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override { // 画一个圆形电机,再画一个指针表示负载率 painter->setBrush(Qt::lightGray); painter->drawEllipse(-25, -25, 50, 50); // 绑定数据点数值,归一化到 -1~1 映射为指针角度 double loadRatio = m_currentValue.toDouble(); // 如 0.6 表示 60% 负载 double angle = loadRatio * 180.0; // 满幅 180 度 painter->save(); painter->rotate(angle); painter->drawLine(0, 0, 0, -20); // 指针 painter->restore(); } void onDataUpdated(const DataPoint &point) override { m_currentValue = point.value; update(); // 只刷新自己 } };

这里最关键的是onDataUpdated只调update(),不要碰场景、不要碰通信,保证新图元不会破坏既有链路。

6.2 工厂注册:一行代码的扩展点

在ElementFactory::createElement里加一行分支,或者在静态注册表里插一条记录:

BaseElement *ElementFactory::createElement(const QString &type) { if (type == "switch") return new SwitchElement(); if (type == "busbar") return new BusBarElement(); if (type == "transformer") return new TransformerElement(); if (type == "motor") return new MotorElement(); // 新增 return nullptr; }

同时别忘记图元面板QListWidget里加一个条目,拖拽数据里带上"motor"类型字符串,剩下的拖拽创建、坐标校正、属性面板展示都是自动适配的。

6.3 属性面板的零侵入适配

新的电机图元需要“额定电压”“负载率上限”两个属性。在MotorElement的构造函数里往m_properties塞值即可:

MotorElement::MotorElement() { m_properties["额定电压(kV)"] = 10.0; m_properties["负载率上限(%)"] = 120.0; }

属性面板不用改一行代码——它本来就是遍历properties()字典生成表单的。设计这一步时,如果你决定扩展这个项目,记住这个套路:图元自己定义属性,面板只做展示和回写,扩展永远发生在图元这一侧。

6.4 部署验证清单

二次开发完成后不要只点一次运行就完事。按这个顺序验证:

  1. 从图元面板拖一个“电机”到画布,确认位置不偏移。
  2. 绑定数据点,切到运行态,确认模拟器发数后指针转动。
  3. 把画布缩放从 25% 到 200% 各看一遍,确认线宽和文字不变形。
  4. 保存组态文件,重启程序,确认电机图元正确反序列化回来。

套用这套流程,遇到 QGraphicsItem 相关的改动,都不必再走一次漫长的调试过程。我在一次改造里加过超声波传感器图元,差一点在属性面板里硬编码新属性,后来发现直接往字典塞值就能生效,省了全局搜索属性面板的时间。从那以后,每次设计新图元我都会强制过一遍这个清单。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进