基于Qt Graphics View的Simulink式仿真画布核心设计与实现
简介基于Qt 4.8.5与Visual Studio 2010环境这份源码包打造了一个风格接近MATLAB Simulink的可视化仿真界面小程序适合正在学习Qt图形视图框架、C面向对象设计以及控制系统仿真的开发者参考。RAR压缩包内共122个文件约21.78MB除cpp/h源码外还包含ui界面文件、qrc资源、jpg/png图标图片以及exe、obj、pdb等编译产物和VS2010工程配置docx说明文档、tlog构建日志可帮助理解编译流程exe文件也能直接运行查看界面效果。项目利用QGraphicsScene、QGraphicsView、QGraphicsItem实现场景、视图与图元的三层结构支持模块拖拽、参数配置、图形化建模和实时刷新控制回路完整展示了从界面搭建、绘图交互到数值仿真计算落地的路径对想将Qt GUI与仿真算法结合的中高级学习者很有价值。目前已有3631人学习浏览包内目录结构清晰便于按模块查阅、定位问题与二次开发。1. 一个用Qt画出来的Simulink式仿真画布仿真工具做到一定程度都会遇到同一个坎业务方和算法工程师不想对着几百行参数文件调模型他们想要一个能拖、能连、能看的界面。这个基于Qt与C开发、界面形态类似Simulink的小程序就是冲着这个需求去的——把信号流图搬到屏幕上模块用鼠标拖进去端口用线连起来点一下运行就能看到数据在节点之间流动。它不是要复刻Simulink的体量而是把图形化建模这条主链路做通拖拽、连线、参数面板、仿真调度、波形显示。对想研究Qt图形视图框架做节点编辑器、或者想给自己的仿真内核套一层可视化壳的开发者来说这个项目是很好的起步样本。2. 技术选型与工程结构为什么用Qt图形视图框架撑起整张画布2.1 图形视图框架Graphics View是这张画布的地基做这种拖拽式仿真界面绕不开一个问题画布上的每一个模块、每一条连线到底用普通控件还是自绘图形用控件方案拖动和缩放会非常吃力用纯QPainter自绘又要自己处理命中检测和状态管理。这个项目选的是Qt的Graphics View框架也就是QGraphicsSceneQGraphicsViewQGraphicsItem这套组合。每个仿真模块是一个QGraphicsItem子类场景负责管理这些图元的空间关系视图负责渲染和交互。这个选型的好处很直接拖拽、缩放、图元选中、碰撞检测都是框架内置能力不用自己造轮子。第二个好处是图元的坐标系统是浮点精度放大缩小不丢精度这对仿真画布来说很重要——模型大了以后缩放到细节处不能出现明显的坐标锯齿。第三个好处是QGraphicsItem本身支持父子关系一个模块内部的小部件比如端口可以作为子图元挂上去这样移动父图元时子图元自动跟随。2.2 工程目录的模块划分这个项目的源码组织方式比较常规按功能边界拆目录不是把所有东西堆在几个大文件里。常见的一种布局是这样的simulink-like/ ├── src/ │ ├── core/ # 仿真内核不依赖UI │ │ ├── simulation_engine.h/cpp │ │ ├── node_base.h/cpp │ │ └── data_types.h │ ├── items/ # 场景图元 │ │ ├── module_item.h/cpp │ │ ├── port_item.h/cpp │ │ └── wire_item.h/cpp │ ├── scene/ # 场景与视图 │ │ ├── simulation_scene.h/cpp │ │ └── simulation_view.h/cpp │ ├── widgets/ # 右侧属性面板、底部输出面板 │ │ ├── property_panel.h/cpp │ │ └── output_panel.h/cpp │ └── main.cpp ├── modules/ # 内置仿真模块的注册定义 │ ├── constant_node.h/cpp │ ├── gain_node.h/cpp │ └── adder_node.h/cpp └── CMakeLists.txt核心思路是界面层和仿真层分离。core目录下的仿真引擎不知道界面上发生了什么它只接收一个“节点图”节点列表 连接关系然后按调度策略执行计算把结果返回给界面层。items目录下的图元类只负责显示和交互不承载业务计算。这样做的好处是以后想换一套渲染方案或者想把这个内核嵌到命令行工具里仿真部分可以原样拿走。2.3 数据流的设计界面操作如何驱动仿真运行界面和内核之间的数据流是这个项目的一个看点。用户在画布上拖进来一个“增益模块”本质上是在场景里创建了一个ModuleItem这个图元通过data(int role)接口关联到一个GainNode实例。当用户把两个模块的端口连起来WireItem内部记录的是“源端口ID → 目标端口ID”的映射关系。仿真启动时界面层调用simulation_engine传入一个动态构建的拓扑描述引擎先做拓扑排序再按顺序执行每个节点的计算函数。数据在节点之间传递时用的是统一的SignalData结构包含时间戳、数值数组、维度信息。这个结构设计得比较简单但足够支撑一维信号流的仿真演示也方便以后扩展成矩阵或者总线数据类型。提示数据流设计是这类项目的关键决策点。如果一开始就把节点的输入输出定义成QVariant后面扩展会非常痛苦定义一个轻量的信号结构体所有节点统一用它传递数据仿真执行层才能做到与具体节点类型解耦。3. 核心模块拆解从一个模块拖进画布到数据流跑通3.1 ModuleItem一个仿真模块在画布上是如何被“画”出来的仿真模块在画布上不是一个简单的矩形。一个典型的ModuleItem包含几个部分模块名称标签、若干个输入端口、若干个输出端口以及一个表示模块类型的图标或背景色。这个项目里ModuleItem继承自QGraphicsObject这样它能使用Qt的信号槽机制属性面板修改参数时可以直接通过信号通知图元刷新。// items/module_item.h class ModuleItem : public QGraphicsObject { Q_OBJECT public: ModuleItem(const QString moduleType, QGraphicsItem* parent nullptr); // 返回模块类型标识用于和core层Node建立映射 QString moduleType() const { return m_moduleType; } // 添加一个输入/输出端口位置由调用方指定 PortItem* addPort(const QString portName, bool isInput, const QPointF localPos); // 获取端口在世界坐标系中的位置连线时要用 QPointF portScenePos(PortItem* port) const; // 从core层读到的数值刷新到图元显示上 void setOutputValue(int portIndex, double value); signals: void moduleMoved(ModuleItem* item); void portClicked(PortItem* port); protected: QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override; private: QString m_moduleType; QVectorPortItem* m_inputPorts; QVectorPortItem* m_outputPorts; QVectordouble m_outputValues; // 最新一轮仿真结果 };boundingRect()返回的是整个模块包括端口伸出去的部分的矩形范围这个值决定了场景的刷新区域——如果返回的矩形比实际绘制范围小会出现图元拖拽时留下残影的问题。paint()里负责画圆角矩形、画端口小圆点、显示输出值。端口位置的锚点计算是个容易出错的地方端口作为子图元它的scenePos()是经过父图元变换后的世界坐标连线时要拿这个坐标不能直接在paint()里算否则缩放视图后线端点和端口会对不上。3.2 WireItem连线不是一条直线那么简单连线是这个项目里交互细节最密的地方。用户在源端口按下鼠标拖到目标端口松开WireItem需要在这之间画一条路径。常见做法是画一条三次贝塞尔曲线控制点根据两个端口的相对位置动态计算让线看起来有“从输出端流出、流入输入端”的方向感。// items/wire_item.cpp 中路径更新逻辑 void WireItem::updatePath() { QPainterPath path; QPointF start m_startPort-scenePos(); QPointF end m_endPort-scenePos(); // 水平方向的偏移量用于生成控制点 qreal dx qMax(50.0, qAbs(end.x() - start.x()) * 0.5); QPointF c1(start.x() dx, start.y()); QPointF c2(end.x() - dx, end.y()); path.moveTo(start); path.cubicTo(c1, c2, end); setPath(path); // 更新端点装饰箭头等 updateArrow(); }这里的dx计算是画布体验的一个细节两个端口距离很近时dx会取50.0的保底值保证曲线不会退化成一条直线距离很远时dx按水平距离的一半缩放让曲线的弧度看起来自然。如果直接写死一个固定偏移量近距离连线时会看到明显的折返远距离时曲线又会显得“塌”。updateArrow()是在线的终点画一个小三角用来标识信号方向。很多类似的Qt仿真小工具会忽略箭头装饰实际跑起来后用户根本分不清哪边是输入哪边是输出。3.3 仿真引擎拓扑排序驱动的模块执行内核部分是这个项目技术含金量的核心。画布上的连线关系在内核里被抽象为一个有向图节点和端口是顶点连线是有向边。仿真执行的第一步是对这个图做拓扑排序得到一个节点执行顺序。// core/simulation_engine.cpp 拓扑排序核心逻辑 bool SimulationEngine::buildExecutionOrder() { m_executionOrder.clear(); // 统计每个节点的入度有几条输入线还没被满足 QHashQString, int inDegree; QQueueQString readyQueue; for (auto* node : m_nodes) { inDegree[node-id()] node-inputPorts().size(); if (inDegree[node-id()] 0) { readyQueue.enqueue(node-id()); } } while (!readyQueue.isEmpty()) { QString id readyQueue.dequeue(); m_executionOrder.append(m_nodes[id]); // 当前节点执行完其下游节点的入度减一 for (const QString downstreamId : m_adjacencyList[id]) { if (--inDegree[downstreamId] 0) { readyQueue.enqueue(downstreamId); } } } // 如果最终执行序列长度小于节点总数说明图里有环 return m_executionOrder.size() m_nodes.size(); }这段逻辑有几个关键点。第一入度的初始值用的是节点的输入端口数量而不是“有几条连线指向它”——如果两个上游节点连到同一个目标端口的同一根线这个端口的入度只算一次所以这里用一个去重后的集合来判断更稳妥代码里可以用QSet包一层避免重复计数。第二m_adjacencyList在构建时就要做端口到端口的映射不能只按节点ID连边因为一个节点可能有多个输入端口信号源不同、数据类型也不同。拓扑排序完成后引擎按顺序调用每个节点的compute()方法。这一步是单线程顺序执行没有做并行化。想深入了解仿真性能的同学可以想想模型大了以后拓扑排序分出来的层级里同层节点其实没有数据依赖是天然可以并行执行的——这是后续优化std::async或者线程池的一个突破口。3.4 动态反馈仿真结果如何回到画布上内核执行完一轮仿真后每个节点持有的输出值已经更新。这时候需要把数据推送到界面SimulationEngine发出valuesUpdated(QHashQString, QVectordouble)信号SimulationScene接收到这个信号后遍历所有ModuleItem调用setOutputValue()更新图元上显示的数值。这里有一个性能取舍每帧都全量刷新所有图元在模块数量少的时候没有任何问题模块多了以后paint()调用会变频繁。常见优化策略是只在节点的输出值变化超过一定阈值时才触发更新或者只对可见区域内的图元做刷新。这个项目可以用QGraphicsScene::invalidate()配合区域参数做局部刷新避免整个场景重绘。4. 编译与运行实战从拉取源码到跑通第一个仿真模型4.1 环境准备与构建参数这个项目是标准的CMake工程依赖项主要是Qt 5.15以上版本的Core、Gui、Widgets模块。没有第三方数学库节点计算都是自己写的简单公式。构建方式很常规mkdir build cd build cmake .. -DCMAKE_PREFIX_PATH/path/to/qt/5.15.2/gcc_64 make -j4 ./simulink_likeCMAKE_PREFIX_PATH要指向你本机安装Qt的编译器套件目录这个路径不设置对的话CMake会找不到Qt5Widgets等包。如果用的是Qt 6CMakeLists里需要把find_package(Qt5 ...)改成find_package(Qt6 ...)注意QGraphicsObject相关的头文件路径在Qt 6里没有变化但QVector相关接口建议换成QList因为Qt 6里QVector只是QList的别名了。跑起来以后左侧会有一个模块库面板右边是画布底部是输出面板。左侧的“常量”和“增益”模块可以直接拖进画布——这是这个项目比较细致的交互拖拽模块是从库面板里按鼠标拖到画布上放下不是点击再在画布上放置。实现上用的是QDrag和QMimeData携带一个moduleType字符串画布在dropEvent里解析这个字符串创建对应的ModuleItem实例。4.2 搭一个最简单的信号流演示在画布左侧拖入两个模块“常量”模块和“增益”模块。把常量模块的输出端口拖到增益模块的输入端口上松开会生成一条带箭头的连线。双击增益模块右侧属性面板出现增益系数Gain输入框默认值是1.0改成2.5。点底部运行按钮增益模块的输出端口旁边会显示计算结果。信号流向是常量节点产生一个固定数值比如5.0经过增益节点乘以2.5变为12.5最终在模块底部显示。把鼠标悬停在连线上会看到这条连线关联的是哪个端口到哪个端口——这个信息对后面排查模型接错线的场景很有用。如果搭的模型里有环比如把增益模块的输出绕回去接到自己的输入上点击运行后会弹出“检测到循环依赖”的提示这就是拓扑排序返回false时触发的错误提示路径。4.3 避坑指南把这些坑踩过一遍你就懂这个项目了坑一图元拖拽后有残影或显示断裂现象模块移动后原来的位置留了一小条“尾巴”或者模块边缘闪烁。 原因boundingRect()返回的范围比实际绘制区域小场景在局部刷新时没有覆盖到整个图元。 解决boundingRect()里把端口延伸出去的部分也算进去宁大勿小。调试时可以临时在paint()里画一个红色边框把实际绘制边界可视化一目了然。坑二连线的端点和端口没对齐现象缩放画布后连线端点不跟着端口走端口到线的连接处出现缝隙。 原因拿到端口坐标用错了坐标系把局部坐标当成场景坐标用了。 解决统一用port-scenePos()获取端口在世界坐标系中的位置路径的updatePath()每次在WireItem的positionChanged信号触发时重新计算。坑三仿真运行时界面卡死特别是连线较多的模型现象点击运行后界面无响应几秒CPU占用率拉满。 原因仿真引擎里用了QThread::sleep()模拟计算耗时或者在某处用了一个长时间阻塞的循环。 解决检查是否有死循环比如拓扑排序的邻接表里出现了自环以及是否把数值实时刷新全量堆在了UI线程。至少把引擎执行放到QtConcurrent::run里跑完发信号回主线程更新UI。坑四属性面板修改参数后图元上的显示值没变现象双击模块打开属性面板改了增益系数运行后模块上显示的数值还是旧值。 原因属性面板的信号没有连到ModuleItem的更新槽上或者更新槽只改了内部变量没有调用update()触发重绘。 解决在PropertyPanel里参数修改后发射propertyChanged(QString moduleId, QString propName, QVariant value)场景接收到后找到对应图元调用setPropertyValue()这个函数内部除了改数据还要调用update()。提示调试这类图形界面问题学会用QGraphicsScene的itemAt()方法配合QDebug输出可以快速定位某个坐标位置到底叠着哪些图元。比盲目猜测高效得多。5. 二次开发实战三个上手改造的好入手方向5.1 新增一个自定义仿真模块新增模块是这个项目最核心的扩展方式。以加一个“正弦波发生器”为例要做的事分三块第一块是写一个SineWaveNode继承NodeBase实现compute()方法// modules/sine_wave_node.h class SineWaveNode : public NodeBase { public: SineWaveNode(); // 重置内部相位 void reset() override; // 核心计算输出当前时间步的正弦值 void compute() override; private: double m_phase; // 当前相位 double m_frequency; // 频率外部可调 double m_amplitude; // 幅值外部可调 };SineWaveNode在compute()里做的事很简单从输入端口读不到任何东西它是信号源直接按时间步推进相位把sin值写到输出端口。关键点是reset()方法——每次仿真重新开始时需要把内部相位归零否则上次仿真结束时的相位残留会导致这次仿真的起始波形是跳跃的。很多人在扩展模块时忘了这一点结果模型跑出来的波形始终带着一个未知的偏移量查半天查不出原因。第二块是写一个SineWaveItem继承ModuleItem定义它的输入输出端口布局和默认大小。第三块是在模块库的注册表里加上这个类型这样拖拽面板里才会出现“正弦波”这一项。注册表的关键代码如下// modules/module_registry.cpp void ModuleRegistry::registerBuiltinModules() { registerModule(SineWave, [](const QString id) { auto* node new SineWaveNode(id); return node; }); }模块注册表用的是工厂函数映射表字符串键对应一个创建函数。这样做的直接好处是SimulationScene在dropEvent里拿到模块类型字符串时不需要写一长串if-else判断直接查表创建即可。后续想加更多模块只需要新增两个类Node和Item、在注册表里加一行完全不需要改动场景或引擎代码。5.2 从单步执行升级到批量仿真当前项目的仿真逻辑是“点一次运行跑一轮”适合教学演示。如果你的目标是做参数扫描或者和外部数据对接可以把SimulationEngine的执行循环扩展成批量模式。常见做法是在引擎里增加一个runBatch(int steps)接口内部循环调用stepOnce()但每步更新一次UI从性能角度考量是浪费的——通常的做法是每N步才发一次valuesUpdated信号刷新画布或者在批量模式里完全不刷新UI只在跑完后一次性把全部结果发出来。配合批量模式的改造SineWaveNode这类时间相关节点的计算逻辑也要跟着调整需要支持“当前仿真时间”作为compute()的输入参数而不是自己在内部维护一个步计数器。这样批量跑和单步跑才能共用同一套节点代码。5.3 自定义节点是衡量这个项目扩展性的标尺作为一个Qt仿Simulink的界面项目衡量它好坏的标准不是它内置了多少种节点而是新增一种节点的成本有多高。这个项目目前的设计新增节点可以做到“不碰画布代码、不碰仿真引擎”这是架构分层带来的红利。反过来说如果你拿到手后发现加节点需要改动多处代码那说明它的模块注册、图元创建、信号路由这三条链路还没有彻底解耦——这恰恰是拿来练手阅读代码的一个切入点。实际开发中我一般会先把ModuleRegistry和NodeBase的接口读一遍确认这两处的扩展点是否稳定再看Scene里的拖拽创建路径是否走的是注册表。如果这两块是通的后续加再多模块都是体力活。这个项目在这点上做得比较扎实。跑起来试过才知道把一个正弦波发生器从加类到拖进画布跑通整个流程二十分钟内能走完——这样的扩展体验才是图形化仿真工具该有的样子。把那以后我每次扩展新模块都强制走一遍“注册表加一行 → 拖拽测试 → 连出信号流跑仿真”的三步流程确认新模块在画布上拖拽、连线、计算、显示全链路通畅才收工。希望帮到你。本文还有配套的精品资源点击获取