简介:这是一份基于Qt框架、在VS2010环境下用C++开发的类Simulink仿真界面小程序源码,面向具备一定C++基础、希望深入理解GUI设计与控制系统仿真的开发者。项目以Qt 4.8.5为依托,借助QGraphicsView、QGraphicsScene与QGraphicsItem构建可视化建模界面,支持拖放模块、绘制闭环控制回路并实时更新仿真结果,同时涉及数字仿真中的数值计算与微分方程求解,是学习图形界面设计与控制理论结合的实践范例。资源包共122个文件,约21.78MB,包含26个cpp源文件、18个头文件、6个ui界面文件及qrc、ts等资源与工程配置,另有exe可执行文件与docx说明文档,便于直接运行与二次开发。目前已有3632人学习下载,适合想掌握Qt绘图机制、仿真建模流程与VS工程管理的读者参考借鉴。
1. 用 Qt 手搓一个仿真画布:这个项目把 Simulink 的拖拽建模体验拆到了最小
如果你写过控制算法,大概率经历过这样的场景:算法逻辑在脑子里跑通了,但想验证一下参数变化对系统响应的影响,要么得开一个庞大的商业仿真软件,要么只能手写脚本画图,改一次参数就得重跑一遍。这个用 Qt 编写的仿真界面小程序,解决的正是这个中间地带的需求——它不追求替代任何工业级工具,而是把「拖拽模块、连线、设参数、跑仿真、看曲线」这条最小闭环用 C++ 和 Qt 完整实现了一遍。整个项目基于 Qt Widgets 的图形视图框架搭建,核心是一个可交互的画布,支持从侧边栏拖出模块、在画布上自由摆放、用连线表达信号流向,再通过一个轻量级的求解器驱动仿真。适合谁?适合正在学 Qt 图形编程、想找一个有真实交互逻辑的练手项目的人;也适合做控制或信号处理方向、需要一个可定制小型仿真环境的开发者。它不是一个玩具,而是一个骨架,你可以把自己的算法塞进模块里,把界面改成自己顺手的样式。
2. 架构拆解:图形视图框架怎么撑起一个仿真画布
2.1 为什么选 QGraphicsScene 而不是纯 QWidget 堆叠
很多刚接触 Qt 图形编程的人第一反应是用 QWidget 加绝对定位来摆放模块,每个模块是一个自定义控件,连线用 QPainter 在父窗口上画。这种做法在模块数量少的时候能跑,但一旦模块超过二三十个,拖动时的重绘闪烁、层级管理、命中检测就会变成噩梦。这个项目选择了 Qt 的 Graphics View Framework,也就是 QGraphicsScene + QGraphicsView + QGraphicsItem 这套体系。
核心优势在于场景坐标系和视图坐标系的分离。所有模块和连线都是场景里的 item,视图只负责渲染和交互映射。拖动一个模块时,只需要更新 item 在场景中的位置,视图自动处理重绘区域。连线作为独立的 QGraphicsPathItem,可以订阅模块的位置变化信号,实时更新路径。这种解耦让后续加缩放、平移、框选、对齐辅助线都变得自然。
项目里自定义了两个核心 item 类:ModuleItem 和 ConnectionItem。ModuleItem 继承自 QGraphicsObject,因为它需要发出信号——位置变了要通知连线,参数改了要通知仿真引擎。ConnectionItem 继承自 QGraphicsPathItem,持有起点和终点模块的指针,在 paint 里根据两个模块的端口位置重新计算贝塞尔曲线路径。
// ModuleItem.h 关键片段 class ModuleItem : public QGraphicsObject { Q_OBJECT public: enum { Type = UserType + 1 }; int type() const override { return Type; } QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; // 端口位置,供连线计算路径 QPointF inputPortScenePos() const; QPointF outputPortScenePos() const; signals: void positionChanged(); // 拖动时发出,连线重算 void parameterChanged(const QString &key, double value); protected: QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; private: QString m_moduleType; // "Gain" / "Integrator" / "Sum" ... QMap<QString, double> m_params; // 模块参数 QGraphicsTextItem *m_label; };这里有几个设计决策值得说。第一,用 QGraphicsObject 而不是 QGraphicsItem,是为了拿到信号槽能力。第二,itemChange 里拦截 ItemPositionHasChanged,发出 positionChanged 信号,这样连线不需要轮询。第三,模块类型用字符串标识,参数用 QMap 存储,这样新增模块类型时不需要改基类,只需要在工厂里注册。
2.2 模块工厂与注册机制
项目没有把每种模块写成一个独立的硬编码类,而是用了一个简单的工厂模式。所有模块共享 ModuleItem 的绘制和交互逻辑,差异体现在三个地方:绘制形状、参数列表、仿真步进函数。这三者通过一个 ModuleDescriptor 结构体描述,注册到一个全局的 ModuleRegistry 单例里。
// ModuleRegistry.h struct ModuleDescriptor { QString typeName; // 唯一标识 QString displayName; // 显示名 QList<ParamDef> params; // 参数定义 std::function<void(ModuleItem*, double)> stepFunc; // 仿真步进 QColor color; }; class ModuleRegistry { public: static ModuleRegistry& instance(); void registerModule(const ModuleDescriptor &desc); ModuleDescriptor* find(const QString &typeName); QList<ModuleDescriptor> allModules() const; private: QMap<QString, ModuleDescriptor> m_modules; };注册在程序启动时完成,比如增益模块的注册大概长这样:
// 在初始化函数里 ModuleDescriptor gainDesc; gainDesc.typeName = "Gain"; gainDesc.displayName = "增益"; gainDesc.params = { {"gain", 1.0} }; gainDesc.color = QColor(100, 160, 220); gainDesc.stepFunc = [](ModuleItem *item, double input) -> double { return input * item->param("gain").toDouble(); }; ModuleRegistry::instance().registerModule(gainDesc);这种做法的好处是,侧边栏的模块列表直接从注册表生成,拖拽创建时根据 typeName 查描述符,仿真引擎遍历场景时也通过描述符调用 stepFunc。新增一个模块只需要写一段注册代码,不需要动界面和引擎。常见做法是把注册代码放在一个单独的 registerBuiltinModules() 函数里,在 main 里调用一次。
2.3 仿真引擎的调度逻辑
仿真引擎本身不复杂,核心是一个定时器驱动的步进循环。每个仿真步,引擎按拓扑顺序遍历所有模块,计算每个模块的输出,然后通过连线把输出传递给下游模块的输入。拓扑排序用了一个简单的 Kahn 算法,在连线变化时重新计算。
// SimulationEngine.cpp 核心步进 void SimulationEngine::step(double dt) { // 按拓扑序计算每个模块的输出 for (ModuleItem *mod : m_sortedModules) { double input = 0.0; // 收集所有输入连线送来的值 for (ConnectionItem *conn : mod->inputConnections()) { input += conn->source()->lastOutput(); } ModuleDescriptor *desc = ModuleRegistry::instance().find(mod->moduleType()); if (desc && desc->stepFunc) { double output = desc->stepFunc(mod, input); mod->setLastOutput(output); } } // 记录示波器数据 for (ScopeItem *scope : m_scopes) { scope->recordSample(m_currentTime); } m_currentTime += dt; }dt 默认是 0.01 秒,可以在工具栏里改。定时器的间隔控制的是仿真速度,不是仿真步长——也就是说,你可以让仿真以 10 倍速跑,但内部步长仍然是 0.01,保证数值稳定性。这个区分在刚上手时容易搞混,我一开始就把定时器间隔当成了步长,结果改小间隔后仿真结果完全不对,后来才把这两个概念拆开。
3. 从零跑起来:环境配置与第一次仿真
3.1 Qt 版本选择与编译环境
项目基于 Qt 5.15 编写,理论上 Qt 6 也能编译,但需要改几个地方:QRegExp 换成了 QRegularExpression,QGraphicsScene 的某些 API 有微调。如果你用的是 Qt 6,建议先把 .pro 文件里的 QT += widgets 改成 QT += widgets gui,然后处理编译报错。
编译方式有两种。用 qmake 的话,直接在项目根目录执行:
qmake SimulinkLike.pro make -j4用 CMake 的话,项目里也带了 CMakeLists.txt:
mkdir build && cd build cmake .. -DCMAKE_PREFIX_PATH=/path/to/Qt5 make -j4Windows 下如果用 MinGW,注意把 Qt 的 bin 目录加到 PATH 里,否则运行时找不到 Qt5Core.dll 之类的动态库。Linux 下一般没这个问题,但如果你用的是发行版自带的 Qt,版本可能偏旧,建议用官方安装器装一个 5.15。
提示:编译前确认 Qt 的版本至少是 5.12,因为项目里用到了 QRandomGenerator,这个类在 5.10 才引入。
3.2 界面布局与第一次拖拽
程序启动后,主窗口分三个区域:左侧是模块面板,中间是画布,右侧是参数编辑区。模块面板里列出了所有已注册的模块,每个模块显示为一个带颜色的小方块加文字标签。拖拽的逻辑在 ModulePanel 里实现,重写 mousePressEvent 和 mouseMoveEvent,在移动时创建一个 QDrag,携带模块类型信息。
画布区域是一个 QGraphicsView,场景大小默认 4000x3000,可以通过滚轮缩放。第一次使用时,从左侧拖一个「常量」模块到画布,再拖一个「增益」模块,再拖一个「示波器」。然后从常量模块右侧的端口按住鼠标左键拖到增益模块左侧的端口,松开后自动生成一条连线。同样把增益的输出连到示波器的输入。
参数编辑区在选中某个模块时显示该模块的参数。比如选中增益模块,会看到一个「增益」字段,默认值是 1.0,改成 2.0 后按回车生效。选中示波器,可以设置采样点数,默认 500。
3.3 运行仿真与观察结果
点击工具栏的「运行」按钮,仿真引擎启动,定时器开始触发 step。示波器模块会实时绘制曲线,横轴是时间,纵轴是信号值。如果一切正常,你会看到一条水平线——因为常量模块输出恒定值,增益只是把它放大了。
想看到动态变化,可以把常量模块换成「正弦波」模块,设置频率和幅值,再运行。示波器上就会出现正弦曲线。如果曲线是一条直线或者乱跳,先检查连线方向是否正确,再检查模块参数是否合理。仿真步长太大也会导致正弦波看起来像三角波,把 dt 改小到 0.001 会好很多。
注意:仿真运行时不要拖动模块或修改连线,引擎正在遍历场景,修改拓扑会导致迭代器失效。项目里做了简单的保护——运行中禁用拖拽和连线创建,但参数修改是允许的,因为参数修改只影响下一步计算。
4. 核心模块代码逐段拆解:连线、参数与示波器
4.1 连线路径的实时计算
连线是仿真界面里视觉反馈最敏感的部分。如果连线在拖动模块时卡顿或者路径错位,整个体验就垮了。项目的 ConnectionItem 用三次贝塞尔曲线绘制,起点是源模块的输出端口,终点是目标模块的输入端口,控制点根据两点之间的水平距离动态调整。
// ConnectionItem.cpp void ConnectionItem::updatePath() { if (!m_source || !m_target) return; QPointF start = m_source->outputPortScenePos(); QPointF end = m_target->inputPortScenePos(); // 控制点偏移量,至少 50 像素,保证曲线平滑 double dx = qMax(50.0, qAbs(end.x() - start.x()) * 0.5); QPainterPath path(start); path.cubicTo(start + QPointF(dx, 0), end - QPointF(dx, 0), end); setPath(path); update(); // 触发重绘 }这段代码的关键在 dx 的计算。如果两个模块靠得很近,dx 取 50 保证曲线不会缩成一团;如果离得远,dx 取水平距离的一半,曲线看起来更自然。updatePath 在源或目标模块发出 positionChanged 信号时被调用,也在连线创建时调用一次。
连线本身不参与仿真计算,它只是一个视觉元素。仿真引擎通过模块的 inputConnections 和 outputConnections 列表来获取拓扑关系,这些列表在连线创建和删除时维护。也就是说,连线 item 和拓扑关系是两份数据,需要保持同步。项目里在 ConnectionItem 的构造函数里把自身注册到源模块的 outputConnections 和目标模块的 inputConnections,在析构函数里移除。
4.2 参数编辑与信号回传
参数编辑区是一个 QFormLayout,根据选中模块的参数定义动态生成控件。每个参数对应一个 QLineEdit 或 QDoubleSpinBox,编辑完成后通过信号回传到模块。
// ParameterPanel.cpp void ParameterPanel::showModule(ModuleItem *item) { clearForm(); m_currentItem = item; if (!item) return; ModuleDescriptor *desc = ModuleRegistry::instance().find(item->moduleType()); if (!desc) return; for (const ParamDef &def : desc->params) { QDoubleSpinBox *spin = new QDoubleSpinBox(this); spin->setRange(def.min, def.max); spin->setValue(item->param(def.key).toDouble()); spin->setSingleStep(def.step); connect(spin, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this, [this, def](double val) { if (m_currentItem) { m_currentItem->setParam(def.key, val); } }); m_formLayout->addRow(def.displayName, spin); } }这里用 lambda 捕获了 def 和 this,在值变化时直接写回模块。模块的 setParam 会发出 parameterChanged 信号,仿真引擎收到后可以在下一步使用新值。注意没有用 editingFinished 而是用了 valueChanged,这样拖动 spinbox 的上下箭头时参数实时生效,体验更流畅。
4.3 示波器的数据缓冲与绘制
示波器模块继承自 ModuleItem,但额外持有一个数据缓冲区。每次仿真步进后,引擎调用示波器的 recordSample,把当前输入值追加到缓冲区。缓冲区是一个 QVector ,超过最大长度时移除最旧的数据。
// ScopeItem.cpp 绘制部分 void ScopeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { // 先画模块本身的矩形和标签 ModuleItem::paint(painter, option, widget); // 再画波形 if (m_buffer.size() < 2) return; QRectF r = boundingRect().adjusted(4, 20, -4, -4); painter->save(); painter->setClipRect(r); // 自动缩放纵轴 double minVal = *std::min_element(m_buffer.begin(), m_buffer.end()); double maxVal = *std::max_element(m_buffer.begin(), m_buffer.end()); if (qFuzzyCompare(minVal, maxVal)) { minVal -= 1; maxVal += 1; } QPainterPath wave; for (int i = 0; i < m_buffer.size(); ++i) { double x = r.left() + r.width() * i / (m_buffer.size() - 1); double y = r.bottom() - r.height() * (m_buffer[i] - minVal) / (maxVal - minVal); if (i == 0) wave.moveTo(x, y); else wave.lineTo(x, y); } painter->setPen(QPen(Qt::green, 1.5)); painter->drawPath(wave); painter->restore(); }自动缩放纵轴是个实用细节。如果不做,当信号幅值很小时曲线会挤在中间一条线,看不出变化。min_element 和 max_element 每次重绘都遍历一遍缓冲区,500 个点的情况下开销可以忽略。如果缓冲区开到 10000 点,建议改成维护 running min/max,避免每帧 O(n) 扫描。
5. 避坑与排查:那些让我重写了两遍的细节
5.1 拖动时连线闪烁
现象:拖动模块时,连线跟着动,但路径边缘有闪烁,快速拖动时尤其明显。
原因:连线在 updatePath 里调用了 update(),但没有调用 prepareGeometryChange()。当路径的包围矩形变化时,场景不知道旧区域需要重绘,导致残影。
解决:在 updatePath 开头调用 prepareGeometryChange(),通知场景包围矩形即将变化。这个调用在 QGraphicsItem 的文档里有说明,但很容易漏掉,因为不调用也能跑,只是有视觉瑕疵。
5.2 仿真结果与预期不符
现象:搭建了一个积分器加反馈的回路,理论上应该产生振荡,但仿真结果是一条直线。
原因:拓扑排序没有处理环路。Kahn 算法要求图是有向无环图,遇到环路时排序会提前结束,部分模块没有被纳入排序列表,导致它们的 stepFunc 从未被调用。
解决:对于包含环路的系统,需要引入代数环求解或者至少一个单位延迟。项目里采取了一个简化方案:在检测到环路时,自动在环路的某条连线上插入一个单位延迟模块。这个逻辑在拓扑排序失败时触发,给用户一个提示。更严谨的做法是让用户显式添加延迟模块,但自动插入对新手更友好。
5.3 参数修改后仿真不生效
现象:运行中修改增益参数,示波器曲线没有任何变化。
原因:参数编辑区的 valueChanged 信号连接到了模块的 setParam,但仿真引擎在 step 里读取的是缓存的参数值,没有实时从模块查询。
解决:把 stepFunc 里的参数读取改成每次从模块的 m_params 里取,而不是在注册时缓存。这个改动很小,但影响很大。代价是每步都有一次 QMap 查找,对于几百个模块的规模完全可以接受。
5.4 高 DPI 屏幕下界面模糊
现象:在 4K 屏幕上运行,模块文字和连线边缘发虚。
原因:Qt 5 默认不启用高 DPI 缩放,应用程序按物理像素渲染,系统放大后导致模糊。
解决:在 main 函数里,创建 QApplication 之前设置属性:
QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling); QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);如果用的是 Qt 5.14 以上,还可以设置 QGuiApplication::setHighDpiScaleFactorRoundingPolicy 为 PassThrough,让缩放比例更精确。这个坑在笔记本外接显示器的场景下特别常见,我一开始以为是字体问题,换了好几种字体都没用,后来才发现是 DPI 缩放没开。
5.5 连线删除后拓扑未更新
现象:删除一条连线后重新运行,仿真结果仍然按照旧的拓扑计算。
原因:ConnectionItem 的析构函数里移除了模块的 inputConnections 和 outputConnections 记录,但仿真引擎的拓扑排序结果没有失效。引擎缓存了 m_sortedModules,只在连线创建时重新排序,删除时没有触发。
解决:在 ConnectionItem 的析构函数里发出一个 topologyChanged 信号,引擎收到后把 m_sortedModules 标记为脏,下次 step 时重新排序。或者更简单粗暴——每次 step 前都检查一个版本号,版本号在连线增删时递增。项目里用了后者,因为实现更简单,性能影响也可以忽略。
6. 二次开发:把自定义模块塞进注册表
这个项目最有价值的扩展点就是模块注册机制。你不需要改界面代码,不需要动仿真引擎,只需要写一个 ModuleDescriptor 并注册,就能在侧边栏看到新模块,拖出来就能用。我拿一个「饱和限幅」模块举例,说明完整的改造流程。
首先在 registerBuiltinModules() 里加一段:
ModuleDescriptor satDesc; satDesc.typeName = "Saturation"; satDesc.displayName = "饱和限幅"; satDesc.color = QColor(220, 140, 100); satDesc.params = { {"upper", 1.0, -100, 100, 0.1}, // key, 默认值, 最小值, 最大值, 步长 {"lower", -1.0, -100, 100, 0.1} }; satDesc.stepFunc = [](ModuleItem *item, double input) -> double { double upper = item->param("upper").toDouble(); double lower = item->param("lower").toDouble(); if (input > upper) return upper; if (input < lower) return lower; return input; }; ModuleRegistry::instance().registerModule(satDesc);这段代码注册了一个饱和限幅模块,输入超过上限时输出上限,低于下限时输出下限,否则直通。参数面板会自动生成两个 QDoubleSpinBox,范围 -100 到 100,步长 0.1。仿真引擎在步进时会调用这个 lambda,传入当前输入值,拿到输出值。
如果你需要更复杂的模块,比如带内部状态的积分器,stepFunc 的签名就不够用了,因为它只接收当前输入,不保留上一次的状态。解决办法是在 ModuleItem 里加一个 QVariantMap 作为状态存储,stepFunc 可以读写这个 map。比如积分器:
integDesc.stepFunc = [](ModuleItem *item, double input) -> double { double dt = 0.01; // 或者从引擎传入 double state = item->state("integral").toDouble(); state += input * dt; item->setState("integral", state); return state; };这样每个模块实例都有自己的状态,互不干扰。状态存储用 QVariantMap 的好处是序列化方便——保存项目文件时直接把 map 转成 JSON 就行。
验证新模块是否生效,最快的办法是搭一个测试回路:常量模块输出 2.0,接到饱和限幅的输入,上限设为 1.0,输出接示波器。运行后示波器应该显示 1.0 而不是 2.0。如果显示 2.0,说明 stepFunc 没被调用,检查 typeName 是否和注册时一致,以及模块是否真的被拖到了画布上。
还有一个容易翻车的地方:如果你在 stepFunc 里做了耗时操作,比如文件读写或者网络请求,整个仿真循环会被阻塞,界面卡死。仿真引擎是在主线程的定时器里跑的,任何阻塞都会反映到界面上。需要做耗时计算的模块,建议把结果缓存起来,或者用单独的线程计算、主线程只读结果。
从那以后我每次加新模块,都强制走一遍「注册 → 拖拽 → 连线 → 改参数 → 运行 → 看波形」这个完整流程,确认没有遗漏。希望这个项目能帮你省下从零搭建仿真界面的时间,把精力花在算法本身。
本文还有配套的精品资源,点击获取