简介:面向希望以实战方式理解 Qt 图形界面与 C++ 数值仿真的开发者,这份工程实现了一个仿 Simulink 的可视化仿真界面:用户可像搭建控制回路一样组织模块、查看动态图形,并借此学习 QGraphicsView/Scene/Item 的交互式绘图机制、事件处理与界面布局。压缩包共 122 个文件,约 21.78MB,以 26 个 cpp 源文件、18 个头文件、6 个 ui 界面文件为核心,同时包含工程配置、编译中间文件、图标与说明文档等,便于在 VS2010 与 Qt4.8.5 环境中还原、编译和调试。已有 3633 人学习下载。其价值在于提供一套可运行的仿真界面原型,涵盖场景与图元管理、PID 与框图对话框、图形显示等模块,适合对照代码理解拖放建模、实时绘图和数字仿真逻辑,也可作为课程设计或 Qt 绘图项目的参考底稿。
1. 先看它能干什么:Qt 编写的图形化仿真界面小程序不是画图板
第一次打开这个 Qt 编写的图形化仿真界面小程序,很多人会把它当成画图板:拖几个方块、拉两根线、截张图就完事。真把增益、求和、示波器接起来跑一遍才发现,画布只是壳,决定它能不能算“仿真界面”的,是端口命中、连线拓扑和每个步长里的计算顺序。
它适合两类人:一类想学 Qt Graphics View 怎么把拖拽、缩放、选中、连线做成一套能用的编辑器;另一类想用 C++ 写一个轻量级框图仿真小程序,把“模块库 + 画布 + 属性面板 + 步进引擎”这条链路跑通。它不是工业级求解器,也不追求复杂数值算法,胜在结构小、链路完整,拿来做课程设计、工具原型或者二次开发底子都合适。
反直觉的地方在于,界面越像“连线搭积木”,底层越不能靠画图逻辑硬撑。模块一多,连线一交叉,删除一个节点就会带出悬空线;仿真一跑,步长和界面刷新一耦合,机器快慢还能影响结果。这个小程序真正值得拆的,是它怎么把图元对象、端口引用和仿真调度分开管。
2. 技术栈与目录结构:Qt Widgets + Graphics View 的工程骨架
这个项目的技术栈不花哨:Qt Widgets 做主窗口和面板,QGraphicsView/QGraphicsScene 做画布,QGraphicsObject 做模块图元,QTimer 做仿真节拍,C++ 写模块和引擎。它没有把界面做成 QML,原因很实际:模块、端口、连线本身就是 C++ 对象,属性面板要直接读写成员,图元事件又要和仿真引擎共享数据结构,Widgets + Graphics View 的路径更短。
2.1 技术选型:为什么用 QGraphicsScene 而不是自绘 QWidget
自绘一个画布并不难,难的是后面一连串交互:鼠标点选、框选、缩放、拖动、图元叠放、命中检测、删除后重建。QGraphicsScene 已经把这些基础能力管掉了,QGraphicsItem 自带 boundingRect、shape、zValue、ItemIsSelectable、ItemIsMovable,端口和连线只要继承对应图元,就能复用场景的命中测试和坐标变换。
我一般会把画布拆成三层:GraphScene 管图元集合和连接关系,CanvasView 管鼠标状态和视图缩放,Block/Port/Connection 管自己的绘制和局部逻辑。这样仿真引擎不需要知道鼠标怎么拖,只需要向 GraphScene 要“所有模块”和“所有连接”。如果一开始把所有逻辑塞进主窗口,后面加一个模块类型就要改三处,血泪经验。
另一个选型点是 QGraphicsObject。普通 QGraphicsItem 不带信号槽,模块参数变化时很难通知属性面板和示波器。Block 继承 QGraphicsObject 后,可以发paramsChanged,也可以接 QTimer 的槽。代价是 moc 要处理,CMake 里必须开 AUTOMOC,这个小开关漏了就是一堆 undefined reference。
2.2 目录怎么分层:block/graph/sim/ui 各管什么
源码包按职责分成四块,下面这张表是我拆这类工程时最愿意看到的结构。目录名不一定完全一样,但边界要清楚:blocks 只关心“我有哪些端口、我每个步长算什么”,graph 只关心“图元怎么画、怎么连、怎么选”,sim 只关心“按什么顺序算”,ui 只关心“窗口怎么摆、按钮怎么响应”。
| 路径 | 职责 | 关键类/内容 |
|---|---|---|
| src/ui | 主窗口、模块库、属性面板 | MainWindow、LibraryPanel、PropertyPanel |
| src/graph | 画布、端口、连线、视图事件 | GraphScene、CanvasView、Port、Connection |
| src/blocks | 模块实现与参数 | Block、GainBlock、SumBlock、ScopeBlock |
| src/sim | 仿真调度与拓扑排序 | SimulationEngine |
| resources | 图标、样式表、qrc | app.qrc |
| CMakeLists.txt | 构建入口 | Qt Widgets、AUTOMOC |
对象所有权要提前定死:GraphScene 拥有 Block 和 Connection,Block 拥有 Port,Port 不拥有 Connection,Connection 只持有 Port 指针。删除 Block 时,先让 GraphScene 找到挂在它端口上的 Connection 并删掉,再删 Port,最后删 Block。这个顺序反了,场景里就会留下指向已释放端口的线,运行时报错还不好定位。
模块库面板通常用 QListWidget 或 QTreeWidget 列出“增益、求和、示波器”,拖拽时把模块类型写成 mimeData,画布 dropEvent 里根据类型创建 Block。属性面板用 QFormLayout 动态生成控件,读取 Block::params(),修改后调 setParam()。这套结构不复杂,但每个环节都要有明确的入口,否则二次开发时加模块会变成到处补 if。
2.3 构建与启动:CMake 工程跑起来的最小步骤
工程用 CMake 组织,C++17 足够。Qt6 和 Qt5 的 CMake 包名不一样,源码里如果用了 Qt6 的新 API,就在 CMake 里锁 Qt6;想兼容 Qt5,就把 find_package 和 target_link_libraries 换成 Qt5 的写法。下面这段是 Qt6 的最小构建骨架,AUTOMOC/AUTOUIC/AUTORCC 三个开关不要省,尤其 AUTOMOC,Block 和 SimulationEngine 都有 Q_OBJECT。
cmake_minimum_required(VERSION 3.16) project(QtGraphSim LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) find_package(Qt6 COMPONENTS Widgets REQUIRED) add_executable(QtGraphSim src/main.cpp src/ui/MainWindow.cpp src/graph/GraphScene.cpp src/graph/CanvasView.cpp src/graph/Port.cpp src/graph/Connection.cpp src/blocks/Block.cpp src/blocks/GainBlock.cpp src/blocks/SumBlock.cpp src/blocks/ScopeBlock.cpp src/sim/SimulationEngine.cpp ) target_link_libraries(QtGraphSim PRIVATE Qt6::Widgets)find_package找的是 Qt 的 CMake 配置包,COMPONENTS Widgets表示只用 Widgets 模块;add_executable里把带 Q_OBJECT 的头文件和 cpp 都列进去,moc 才能生成元对象代码;target_link_libraries把 Qt Widgets 的库和 include 路径一起带进来。如果本机只有 Qt5,把两处 Qt6 改成 Qt5 即可,其他源码不用动。
配置和编译命令如下。-S指定源码目录,-B指定构建目录,-DCMAKE_BUILD_TYPE=Debug方便断点;--build执行编译,-j并行。跑起来之前确认 Qt 的动态库在系统路径里,桌面端如果提示缺平台插件,就把 Qt 的 bin 目录加进环境变量,或者用 Qt 自带的部署工具把依赖拷到可执行文件旁边。
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --build build -j ./build/QtGraphSim注意:第一次构建失败先看 CMake 输出里的 Qt 包名和编译器路径,不要急着改源码。Qt6 和 Qt5 混用、AUTOMOC 没开、qrc 没加进 add_executable,是这类工程最常见的三类构建问题。
3. 核心模块拆解:模块、端口、连线三件套怎么落到 C++ 类
界面里拖出来的每个方块,最后都要回答三个问题:它有哪些端口、端口连到谁、每个步长输出什么。Block 负责第一个和第三个,Port 负责“我是谁、我被谁连着”,Connection 负责“从哪个端口到哪个端口、线怎么画”。这三类对象拆清楚,画布才不会变成一堆坐标判断。
3.1 Block 基类:统一输入输出端口和参数
Block 是所有模块的父类,它不关心自己是增益还是求和,只规定每个模块必须有端口、必须能执行一步、必须能读写参数。下面这段头文件把接口压到很小:inputs()/outputs()给连线和仿真用,step(double dt)给引擎调,params()/setParam()给属性面板用。子类只需要在构造函数里加端口,再实现 step。
// src/blocks/Block.h #pragma once #include <QGraphicsObject> #include <QVariantMap> #include <QVector> class Port; class QPainter; class Block : public QGraphicsObject { Q_OBJECT public: enum class Kind { Gain, Sum, Scope, Unknown }; explicit Block(Kind kind, QGraphicsItem* parent = nullptr); Kind kind() const { return m_kind; } QRectF boundingRect() const override; void paint(QPainter* p, const QStyleOptionGraphicsItem* opt, QWidget* w) override; const QVector<Port*>& inputs() const { return m_inputs; } const QVector<Port*>& outputs() const { return m_outputs; } Port* input(int i) const; Port* output(int i) const; virtual void step(double dt) = 0; virtual QVariantMap params() const { return {}; } virtual void setParam(const QString& key, const QVariant& value) { Q_UNUSED(key); Q_UNUSED(value); } protected: Port* addInput(const QString& name); Port* addOutput(const QString& name); void setOutputValue(int index, double v); double inputValue(int index) const; private: Kind m_kind; QVector<Port*> m_inputs; QVector<Port*> m_outputs; };Kind用来在工厂里区分模块类型,也方便示波器模块被引擎识别;m_inputs/m_outputs存的是 Port 指针,端口对象由 Block 创建并设置父项,生命周期跟着 Block 走;step是纯虚函数,每个模块必须给出自己的计算;params返回 QVariantMap,属性面板不用知道具体模块类就能生成编辑框。addInput/addOutput里一般会 new Port(this, direction, index),再把 Port 加到场景里,这一步放在 Block 的构造函数或初始化函数中。
inputValue(int index)不是简单返回成员变量,它要沿着输入端口找到上游 Connection,再从上游 Block 的输出缓存里取值。这样模块之间不直接互相引用,只通过端口和连接解耦。setOutputValue把结果写进 Block 自己的输出缓存,同时通知示波器或属性面板刷新。这个“输入拉、输出写”的约定要在基类里定死,否则每个模块各写一套,拓扑排序也救不了。
3.2 Port 与 Connection:命中检测和贝塞尔连线
Port 是画布上最小的可点击对象,通常画成小圆点。它至少要有 owner、direction、index 和当前连接引用。输入端口一般只允许接一条线,输出端口可以扇出多条,这个规则在 commit 时检查,比在仿真时检查更早暴露问题。Port 的scenePos()直接作为连线端点,不要用 Block 的坐标加偏移硬算,否则模块一移动线就歪。
| 字段/方法 | 类型 | 作用 |
|---|---|---|
| owner | Block* | 端口所属模块 |
| direction | Input/Output | 输入还是输出 |
| index | int | 同方向端口序号 |
| connection | Connection* | 当前连接,输入端口只允许一个 |
| scenePos() | QPointF | 连线路径端点 |
Connection 继承 QGraphicsPathItem,画一条三次贝塞尔曲线。控制点按水平方向拉开,视觉上像信号从左往右流;setZValue(-1)让线压在模块下面,选中时再抬高。commit里做方向检查和输入端口占用检查,失败返回 false,由视图负责删掉临时线。下面这段是路径更新和提交的节选。
// src/graph/Connection.cpp(节选) Connection::Connection(Port* from, Port* to, QGraphicsItem* parent) : QGraphicsPathItem(parent), m_from(from), m_to(to) { setZValue(-1); setPen(QPen(QColor("#4a6fa5"), 2)); setFlag(QGraphicsItem::ItemIsSelectable, true); updatePath(to ? to->scenePos() : from->scenePos()); } void Connection::updatePath(const QPointF& endScenePos) { if (!m_from) return; const QPointF a = m_from->scenePos(); const QPointF b = endScenePos; const qreal dx = qMax<qreal>(40.0, qAbs(b.x() - a.x()) * 0.5); QPainterPath path(a); path.cubicTo(a + QPointF(dx, 0), b - QPointF(dx, 0), b); setPath(path); } bool Connection::commit(Port* target) { if (!target || target->direction() == m_from->direction()) return false; if (target->connection()) return false; m_to = target; target->setConnection(this); m_from->addConnection(this); updatePath(m_to->scenePos()); return true; }a是起点端口的场景坐标,b是当前鼠标或目标端口的场景坐标;dx至少 40,保证短连线也有弧度,不会缩成一条直线;cubicTo的两个控制点分别从起点向右、终点向左拉,方向感更接近信号流。commit里先判断方向不同,再判断目标输入端口是否已被占用;输出端口允许多条线,所以只在m_from上登记。线选中后按 Delete 删除时,要同时清掉两端 Port 的 connection 引用,不然下次连线会被“已占用”挡住。
注意:Connection 的路径全部用场景坐标计算,不要再给 Connection 设一个偏移父项。父项坐标叠加一次,缩放后就可能出现线头不贴端口的现象。
3.3 拖拽建模块与连线:鼠标事件里的状态机
画布交互最怕写成“按下、移动、释放各写一堆 if”。更稳的做法是把鼠标状态收敛成几个成员:是否正在拖模块、是否正在拉线、临时 Connection 指针。模块拖动交给 QGraphicsItem 的 ItemIsMovable,视图只处理“从端口拉线”这一种特殊状态。按下时命中 Port 就创建临时线,移动时更新终点,释放时找目标端口并提交,失败就删线。
// src/graph/CanvasView.cpp(节选) void CanvasView::mousePressEvent(QMouseEvent* e) { if (e->button() == Qt::LeftButton) { auto* port = qgraphicsitem_cast<Port*>(itemAt(e->pos())); if (port) { m_pending = new Connection(port, nullptr); scene()->addItem(m_pending); m_pending->updatePath(mapToScene(e->pos())); e->accept(); return; } } QGraphicsView::mousePressEvent(e); } void CanvasView::mouseMoveEvent(QMouseEvent* e) { if (m_pending) { m_pending->updatePath(mapToScene(e->pos())); e->accept(); return; } QGraphicsView::mouseMoveEvent(e); } void CanvasView::mouseReleaseEvent(QMouseEvent* e) { if (m_pending) { auto* target = qgraphicsitem_cast<Port*>(itemAt(e->pos())); if (!m_pending->commit(target)) { scene()->removeItem(m_pending); delete m_pending; } m_pending = nullptr; e->accept(); return; } QGraphicsView::mouseReleaseEvent(e); }itemAt(e->pos())用的是视图坐标,e->pos()不能直接塞给场景坐标的updatePath,所以移动时要mapToScene。m_pending为空表示普通选择/拖动,非空表示正在拉线,状态只有两种,排查时看这一个指针就够了。释放时commit失败要removeItem再 delete,不能只 delete,否则场景里还留着一个已释放图元的索引,后面刷新就翻车。qgraphicsitem_cast依赖 Port 里重写的type(),这个函数别忘了。
4. 仿真执行链路:从界面参数到一步步进计算
画布上连好线只是静态图,仿真引擎要把图变成执行顺序。增益的输出接到求和的输入,求和再接到示波器,计算时必须先算增益、再算求和、最后取样,顺序反了读到的是上一步的旧值。这个项目用拓扑排序解决依赖,用固定步长推进时间,用 QTimer 只负责刷新界面,三者分开,结果才不会跟机器快慢挂钩。
4.1 仿真引擎接口:拓扑排序与离散步长
SimulationEngine 不直接操作图元坐标,只向 GraphScene 要 blocks 和 connections。启动时先 buildOrder(),用入度表做 Kahn 拓扑排序:入度为 0 的模块先入队,每弹出一个模块,就把它的所有下游连接对应模块入度减一,减到 0 再入队。排序结果存进 m_order,每个 tick 按这个顺序调 step。图里有环时排序数量对不上,直接报错,不要硬跑。
// src/sim/SimulationEngine.h(节选) class SimulationEngine : public QObject { Q_OBJECT public: explicit SimulationEngine(QObject* parent = nullptr); void setGraph(GraphScene* scene); bool buildOrder(); void start(int refreshMs, double dt); void stop(); signals: void stepped(double t, const QVector<double>& samples); private slots: void onTick(); private: GraphScene* m_scene = nullptr; QVector<Block*> m_order; QTimer m_timer; double m_t = 0.0; double m_dt = 0.01; };m_scene是图数据入口,m_order是排序后的执行序列,m_timer只控制界面刷新间隔,m_t是仿真时间,m_dt是每次计算推进的步长。start(refreshMs, dt)里把 m_dt 设成 dt,定时器间隔设成 refreshMs。refreshMs 可以 16 毫秒,dt 可以 0.01 秒,两者不是一回事。下面这段是排序实现。
bool SimulationEngine::buildOrder() { m_order.clear(); QHash<Block*, int> indeg; for (auto* b : m_scene->blocks()) indeg[b] = 0; for (auto* c : m_scene->connections()) { if (!c->from() || !c->to()) continue; indeg[c->to()->owner()]++; } QQueue<Block*> ready; for (auto it = indeg.begin(); it != indeg.end(); ++it) { if (it.value() == 0) ready.enqueue(it.key()); } while (!ready.isEmpty()) { Block* b = ready.dequeue(); m_order.append(b); for (auto* c : m_scene->connectionsFrom(b)) { Block* next = c->to()->owner(); if (--indeg[next] == 0) ready.enqueue(next); } } return m_order.size() == m_scene->blocks().size(); }indeg记录每个模块还缺几个上游输入,ready是当前可以计算的模块队列。第一轮把所有入度为 0 的模块入队,后面每处理一个模块,就把它的下游入度减一。connectionsFrom(b)只返回起点属于 b 的连接,避免遍历全图。返回值用来判断有没有环:排序结果数量和模块总数一致才算成功。这个方法在每次连线变化后重新调用,不要在仿真中途改图。
4.2 一个增益模块的完整计算例子
增益模块最能说明“界面参数怎么进到计算里”。它在构造函数里加一个输入、一个输出,m_gain默认 1.0;属性面板改的是m_gain,params()把它暴露成"gain";step里从输入端口拉当前值,乘完写到输出缓存。没有状态,所以不需要 dt,但接口统一保留,换成积分模块就能用上。
// src/blocks/GainBlock.cpp(节选) GainBlock::GainBlock() : Block(Kind::Gain) { addInput("u"); addOutput("y"); } void GainBlock::step(double dt) { Q_UNUSED(dt); setOutputValue(0, inputValue(0) * m_gain); } QVariantMap GainBlock::params() const { return {{"gain", m_gain}}; } void GainBlock::setParam(const QString& key, const QVariant& value) { if (key == "gain") { m_gain = value.toDouble(); emit paramsChanged(); } }inputValue(0)沿着第 0 个输入端口找到上游连接,再读上游模块的输出缓存;setOutputValue(0, ...)写第 0 个输出。params()返回的 key 要和属性面板里的字段名一致,否则面板生成了控件也写不回去。setParam里改完发paramsChanged,示波器或属性面板可以据此刷新。增益不需要 dt,但求和、积分、一阶惯性都需要,所以 step 的签名统一带 dt,子类按需使用。
4.3 运行控制与波形输出:定时器、数据缓冲
onTick 里不要只算一步就发信号,最好用累加器把“界面刷新间隔”换算成“仿真步数”。比如 refreshMs 是 16 毫秒,dt 是 0.01 秒,每次刷新大约跑 1 到 2 步,具体由累加器决定,而不是由机器实际耗时决定。这样同一张图在不同机器上跑,时间序列一致,示波器曲线不会因为机器快慢变形。下面这段是简化的 tick 逻辑。
void SimulationEngine::onTick() { const int steps = qMax(1, qRound(m_timer.interval() / 1000.0 / m_dt)); QVector<double> samples; for (int i = 0; i < steps; ++i) { for (Block* b : m_order) b->step(m_dt); m_t += m_dt; } for (Block* b : m_order) { if (b->kind() == Block::Kind::Scope) samples << b->lastValue(); } emit stepped(m_t, samples); }steps由定时器间隔和 dt 算出来,最少跑一步;内层循环按拓扑顺序调 step,外层推进 m_t;最后只把示波器模块的最新值收集成 samples 发出去。示波器自己维护一个固定长度的环形缓冲,比如 2000 个点,收到新样本就 push,超过长度就丢最旧的。不要在 paintEvent 里无限 append,否则跑几分钟内存就上去了。
提示:仿真步长 dt 和界面刷新间隔分开配置,属性面板里可以放两个输入框。把 dt 写死成 16 毫秒,再换成 1 毫秒刷新,曲线形状会变,这是很多人以为“算法不稳”的玄学来源。
5. 避坑与排查:图形化仿真界面最容易翻车的 5 个点
这类小程序功能不多,但坑很集中:坐标、生命周期、时间、参数、持久化。每个点单看都不难,凑在一起就会表现成“线乱飞、程序崩、曲线不对、保存后再打开全乱”。下面五条是我拆这类工程时最愿意先检查的地方,按现象、原因、解决拆开写,照着排查能省不少时间。
5.1 连线坐标错位:缩放后线头不贴端口
现象很直观:刚连好时线是贴的,滚轮一缩放,线头偏到模块外面;拖动模块时线跟着动,但端点像漂移。原因通常是把视图坐标、场景坐标、图元局部坐标混用了。mouseMoveEvent里的e->pos()是视图坐标,直接塞给updatePath就会偏;Port 的pos()是相对父项 Block 的局部坐标,不是场景坐标。另一个原因是给 Connection 设了父项,又在路径里叠加了父项偏移。
解决方式是把所有路径计算统一到场景坐标:起点用port->scenePos(),临时终点用mapToScene(e->pos()),Connection 的父项设为空或场景根,setPath里不要再加pos()。模块移动时,在 Block 的itemChange(ItemPositionHasChanged)里遍历自己端口上的连接,调updatePath(other->scenePos())。如果线还是偏,打印一次起点和终点的 scenePos,和端口圆心的 scenePos 对一下,基本一眼能看出来是哪层坐标没转。
5.2 删除模块后悬空连接:场景里留了一条“幽灵线”
现象是删掉一个模块,画布上还留着半截线,点它还崩;或者重新连到同一个输入端口时提示“已占用”。原因是 Block 删除时只删了端口,没有通知 Connection;Connection 里的m_from/m_to变成悬空指针,Port 的connection字段还指着已删除的线。QGraphicsItem 不是 QObject,不能靠 QPointer 自动置空,必须手动维护关系。
解决方式是在 GraphScene 里提供removeBlock(Block*):先收集这个 Block 所有端口上的 Connection,逐个removeItem并 delete,同时清掉两端 Port 的 connection 引用;再删 Port;最后删 Block。删除 Connection 时也要走一个disconnect()方法,把m_from、m_to置空,清掉 Port 的登记。不要直接delete block,更不要只从 scene 里 remove 不 delete,后者会漏内存,前者会留下野指针。
5.3 仿真步长与界面刷新耦合:机器快慢影响结果
现象是同一张图,在开发机上跑出来的曲线和同事机器上不一样;拖动窗口大小、切换焦点,曲线形状还会变。原因多半是把 QTimer 的实际触发间隔当成了仿真步长,或者每帧只算一步,机器快就多算,机器慢就少算。图形界面刷新是“尽力而为”,本来就不稳定,拿它当积分时钟,结果必然飘。
解决方式是把仿真时间推进和界面刷新拆开:dt 固定,比如 0.01 秒;定时器只负责“多久刷新一次界面”;每次 tick 用累加器算这次应该跑几步,再按固定 dt 循环。累加器可以写成m_acc += elapsed; while (m_acc >= dt) { step(dt); m_acc -= dt; }。如果图很大,单次 tick 步数上限要设一个阈值,避免界面卡死。排查时在状态栏显示仿真时间 m_t 和实际墙钟时间,两者偏差持续变大就说明 tick 逻辑有问题。
5.4 属性面板改参数不生效:界面改了,计算还用旧值
现象是属性面板里把增益从 1 改成 2,画布上的模块标签变了,但仿真输出还是原来的幅值;或者要重新点一下模块才生效。原因通常是属性面板只改了控件值,没有写回 Block;或者写回了params()返回的临时 QVariantMap,没有改成员变量;还有一种是把参数缓存在 step 的局部静态变量里,setParam 改不到。
解决方式是让属性面板只做两件事:读block->params()生成控件,控件valueChanged时调block->setParam(key, value)。Block 子类在 setParam 里改自己的成员,改完发paramsChanged,需要重绘就update(),需要重算就标记引擎重建顺序。不要在面板里直接reinterpret_cast成具体模块类,也不要把参数存在 QVariantMap 成员里再让 step 去查,查表开销不说,键名写错很难发现。
5.5 保存/加载后端口 ID 对不上:一打开图全乱
现象是保存工程再打开,模块位置对了,连线却连到别的端口,或者干脆消失。原因是保存时存了 QGraphicsItem 指针、内存地址或者端口在 QVector 里的下标,重新加载后对象重建,地址和下标都变了。还有一种是把 Connection 的起点终点存成场景坐标,加载后模块位置一变,线就飘。
解决方式是给每个 Block 一个稳定的字符串 ID,端口用blockId + direction + index标识;保存分两阶段:先存所有 Block 的类型、位置、参数,再存所有 Connection 的两端端口标识。加载时也分两阶段:先创建 Block 并建端口索引,再根据连接记录找回端口并 commit。ID 用 QUuid 生成,不要用自增 int 跨文件合并时会撞。保存格式用 JSON 就够,字段名写清楚,后面加模块类型也好扩展。
6. 二次开发:把“能连线”改成“能扩展模块库”的改造顺序
二次开发最怕一上来改主窗口,把画布、模块、仿真搅在一起。更稳的顺序是先把模块工厂抽出来,再按“继承 Block、注册类型、补属性、跑三步验证”的固定套路加新模块。主窗口只负责菜单和布局,模块库面板从工厂拿类型列表,画布 dropEvent 从工厂创建对象,这样加一个模块不用改画布代码。
新增模块的最小代码如下。构造函数里声明端口,step 里读输入写输出,params/setParam 暴露参数。积分模块比增益多一个状态 m_y,step 里用 dt 累加,正好验证仿真步长有没有真正传进来。
class IntegratorBlock : public Block { public: IntegratorBlock() : Block(Kind::Unknown) { addInput("u"); addOutput("y"); } void step(double dt) override { m_y += inputValue(0) * dt; setOutputValue(0, m_y); } QVariantMap params() const override { return {{"initial", m_y}}; } private: double m_y = 0.0; };注册时用工厂映射
本文还有配套的精品资源,点击获取