简介:这是一份基于Qt框架、在Visual Studio 2010与Qt 4.8.5环境下开发的类Simulink仿真界面小程序源码,面向具备一定C++基础、希望深入理解GUI设计与控制系统仿真的开发者。项目通过QGraphicsView、QGraphicsScene与QGraphicsItem构建可拖放、可配置的交互式仿真画布,涵盖闭环控制回路绘制、PID模块、图形对话框及实时仿真结果动态更新等核心功能,帮助读者掌握将Qt图形界面与数值计算结合以模拟连续时间系统离散化行为的完整思路。资源包共122个文件,以cpp与h源码、obj中间文件、tlog日志、ui界面文件为主,另含qrc资源、ico图标、sln工程与vcxproj项目文件,整体约21.78MB,目录结构清晰,便于在VS2010中直接编译、调试与二次开发。目前已有3632人学习下载,适合作为学习Qt绘图框架、数字仿真算法与工程化项目组织的实践参考。
1. 从零手搓一个 Qt 仿真界面:为什么我不建议你直接套现成框架
去年帮一个学弟看毕设,题目是「基于 Qt 的图形化仿真环境搭建」,他上来就想找一个开源仿真框架改改交差。我让他先想清楚一件事:Simulink 那套东西的核心到底是什么?不是求解器,不是算法库,而是画布上的模块拖拽、连线、参数编辑、仿真调度这一整套交互闭环。你如果只是要交一份课设或毕设,真正值钱的部分恰恰是这套交互骨架,而不是底层跑什么微分方程。
这个项目要解决的需求很具体:用 Qt 和 C++ 做一个类似 Simulink 的仿真界面小程序,支持从左侧模块库拖拽模块到画布、用连线连接模块端口、双击模块编辑参数、点击运行后按拓扑顺序执行仿真并输出结果。适合正在做 Qt 上位机、图形编辑器、仿真工具类毕设的学生,也适合想练手 Qt Graphics View 框架的 C++ 开发者。下面这套方案,从需求拆到验收,你可以直接照着搭。
2. 需求拆解与技术选型:Graphics View 框架 + 自定义图元体系
2.1 功能边界怎么划
先把「类似 Simulink」这个模糊描述拆成可交付的功能点。一个能过答辩、能演示的最小闭环包含五块:
- 模块库面板:左侧列出可用模块(增益、求和、常量、示波器等),支持拖拽到画布。
- 画布编辑区:模块可选中、移动、删除,端口位置随模块移动自动更新。
- 连线系统:从输出端口拖到输入端口生成贝塞尔曲线,连线随模块移动实时重绘。
- 参数编辑:双击模块弹出属性对话框,修改增益值、常量值等参数。
- 仿真执行:点击运行,按模块连接关系做拓扑排序,依次计算每个模块的输出,结果送到示波器模块显示。
这五块里,前四块是交互,第五块是逻辑。很多人把精力全砸在第五块,结果界面卡成 PPT,答辩时老师一拖模块就露馅。我的建议是交互占七成精力,仿真逻辑占三成,因为演示效果决定第一印象。
2.2 为什么选 QGraphicsView 而不是 QWidget 拼
Qt 里做图形编辑器有两条路:一是用 QWidget 自己拼,每个模块一个 widget,连线用 QPainter 在父 widget 上画;二是用 Graphics View 框架,场景(QGraphicsScene)+ 视图(QGraphicsView)+ 图元(QGraphicsItem)。
我一般会直接上 Graphics View,原因有三。第一,它自带坐标变换和碰撞检测,模块移动时不用自己算重绘区域。第二,图元可以设置 ItemIsMovable、ItemIsSelectable 标志,选中和拖动几乎零代码。第三,连线作为独立图元加入场景,模块移动时只需调用连线的 updatePosition(),不用手动触发整个画布重绘。
用 QWidget 拼的方案,模块一多,移动时的重绘闪烁和坐标换算能把你折磨到怀疑人生。Graphics View 的坑主要在坐标系理解上,这个后面避坑章节细说。
2.3 模块与连线的数据结构设计
在动手写图元之前,先把数据模型定下来。我习惯把「数据」和「显示」分开:数据层用纯 C++ 类描述模块和连接关系,显示层用 QGraphicsItem 子类负责画。这样仿真调度时只碰数据层,不依赖 Qt 的图形对象。
// 模块数据模型:与图形显示解耦 struct ModuleData { int id; // 模块唯一标识 QString type; // 模块类型:"gain" / "sum" / "constant" / "scope" QPointF position; // 画布坐标 QMap<QString, double> params; // 参数表,如 {"gain": 2.0} QVector<int> inputIds; // 输入端口连接的模块 id QVector<int> outputIds; // 输出端口连接的模块 id }; // 连接关系:记录从哪个模块的哪个端口连到哪个模块的哪个端口 struct ConnectionData { int fromModuleId; int fromPortIndex; int toModuleId; int toPortIndex; };ModuleData里用QMap存参数而不是固定字段,是为了新增模块类型时不用改结构体。inputIds和outputIds存的是模块 id 而非指针,避免模块删除后的悬空指针问题——这是血泪经验,早期用裸指针存连接关系,删一个模块程序直接崩。
仿真调度时,遍历所有ModuleData,根据inputIds做拓扑排序,得到执行顺序,再依次调用每个模块的计算函数。计算函数根据type分发:增益模块输出 = 输入 × gain,求和模块输出 = 所有输入之和,常量模块输出 = 常量值,示波器模块把输入存进缓冲区。
3. 画布与图元实现:模块拖拽、端口吸附与贝塞尔连线
3.1 自定义模块图元
每个模块在画布上是一个 QGraphicsItem。我一般继承 QGraphicsObject 而不是 QGraphicsItem,因为前者自带信号槽,端口位置变化时能发信号通知连线更新。
class ModuleItem : public QGraphicsObject { Q_OBJECT public: ModuleItem(const ModuleData &data, QGraphicsItem *parent = nullptr); QRectF boundingRect() const override; // 返回模块外框矩形 void paint(QPainter *painter, ...) override; // 绘制模块外观 QPointF inputPortPos(int index) const; // 输入端口中心坐标 QPointF outputPortPos(int index) const; // 输出端口中心坐标 int moduleId() const { return m_data.id; } protected: QVariant itemChange(GraphicsItemChange change, const QVariant &value) override; private: ModuleData m_data; static constexpr qreal WIDTH = 80.0; static constexpr qreal HEIGHT = 50.0; };boundingRect()返回模块的矩形范围,Qt 用它做碰撞检测和重绘裁剪,必须准确,否则会出现拖动残影。paint()里画圆角矩形加模块名,端口画成小圆点。itemChange()是关键:当模块位置变化(ItemPositionHasChanged)时,遍历所有关联连线,调用连线的更新函数重算路径。
端口坐标的计算要统一。我习惯把输入端口放在模块左侧,输出端口放在右侧,垂直方向按端口数量均分。inputPortPos(index)返回的是场景坐标,用mapToScene()转换,这样连线图元拿到的是绝对坐标,不用关心父项变换。
3.2 拖拽创建:从模块库到画布
左侧模块库用 QListWidget,每一项设置自定义 MIME 数据。画布所在的 QGraphicsView 重写 dragEnterEvent 和 dropEvent。
void CanvasView::dropEvent(QDropEvent *event) { const QMimeData *mime = event->mimeData(); if (!mime->hasFormat("application/x-module-type")) return; QString type = QString::fromUtf8(mime->data("application/x-module-type")); QPointF scenePos = mapToScene(event->pos()); // 视图坐标转场景坐标 emit moduleDropped(type, scenePos); // 通知主窗口创建模块 event->acceptProposedAction(); }这里有个容易翻车的点:event->pos()是视图坐标,必须用mapToScene()转成场景坐标,否则模块会落在错误位置,尤其是画布滚动或缩放后偏移更明显。emit moduleDropped把创建逻辑交给主窗口,保持视图类职责单一。
3.3 连线图元与实时更新
连线继承 QGraphicsPathItem,路径用三次贝塞尔曲线。控制点根据起点和终点的水平距离动态计算,距离越远弧度越大,视觉上更像 Simulink。
void ConnectionItem::updatePath() { QPointF start = m_fromItem->outputPortPos(m_fromPort); QPointF end = m_toItem->inputPortPos(m_toPort); qreal dx = qAbs(end.x() - start.x()) * 0.5; // 控制点水平偏移量 QPainterPath path(start); path.cubicTo(start + QPointF(dx, 0), // 第一控制点 end - QPointF(dx, 0), // 第二控制点 end); setPath(path); }dx取水平距离的一半,保证曲线平滑且不会自交。连线创建流程:鼠标在输出端口按下时记录起点模块和端口,移动时画一条临时线跟随鼠标,松开时检测落点是否在某个输入端口上,是则创建 ConnectionItem 并写入数据模型。检测落点用scene()->items(pos)拿到该位置所有图元,遍历找端口。
连线更新靠信号槽:ModuleItem 位置变化时发positionChanged信号,ConnectionItem 槽函数调用updatePath()。注意连线本身要设置setZValue(-1),让它画在模块下面,否则连线会盖住模块文字。
4. 仿真调度与参数编辑:拓扑排序与属性对话框
4.1 拓扑排序确定执行顺序
仿真执行的核心是确定模块的计算顺序。有向无环图用 Kahn 算法做拓扑排序:先统计每个模块的入度(即输入连接数),入度为 0 的入队,出队时把其输出连接的模块入度减一,减到 0 再入队。
QVector<int> Scheduler::topologicalSort(const QVector<ModuleData> &modules) { QHash<int, int> inDegree; for (const auto &m : modules) inDegree[m.id] = m.inputIds.size(); QQueue<int> queue; for (auto it = inDegree.begin(); it != inDegree.end(); ++it) if (it.value() == 0) queue.enqueue(it.key()); QVector<int> order; while (!queue.isEmpty()) { int id = queue.dequeue(); order.append(id); // 找到该模块,遍历其输出连接的目标模块 for (int nextId : findModule(modules, id).outputIds) { if (--inDegree[nextId] == 0) queue.enqueue(nextId); } } if (order.size() != modules.size()) qWarning() << "检测到环路,仿真无法执行"; // 有环时排序结果不完整 return order; }排序结果order就是执行序列。如果order长度小于模块总数,说明存在环路(比如两个增益模块互相连接),这时要弹提示而不是硬跑,否则仿真会死循环。每个模块的计算函数签名统一为double compute(const QVector<double> &inputs),调度器按顺序调用,把结果缓存起来供下游模块读取。
4.2 参数编辑对话框
双击模块触发mouseDoubleClickEvent,弹出 QDialog。对话框内容根据模块类型动态生成:增益模块一个 QDoubleSpinBox 编辑增益值,常量模块一个编辑框,求和模块选择符号。用 QFormLayout 布局,确定后把值写回ModuleData.params并刷新模块显示。
void ModuleItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { ParameterDialog dlg(m_data.type, m_data.params); if (dlg.exec() == QDialog::Accepted) { m_data.params = dlg.parameters(); // 取回修改后的参数 update(); // 触发重绘,模块上显示新参数 } QGraphicsObject::mouseDoubleClickEvent(event); }参数写回后调用update()重绘模块,把关键参数显示在模块下方,答辩时老师一眼能看到参数变化对结果的影响。示波器模块的结果显示用 QChart 或自绘折线,我倾向自绘,依赖少、可控性强。
4.3 仿真结果可视化
示波器模块维护一个QVector<double>缓冲区,每次仿真步进把输入值追加进去。仿真结束后,示波器窗口用 QPainter 画折线图,横轴时间步、纵轴数值,自动缩放坐标范围。如果要动态显示,用 QTimer 每 50ms 刷新一次,模拟实时波形效果,演示时观感更好。
5. 避坑与常见问题排查:坐标系、内存与重绘的三个大坑
5.1 模块拖动后连线错位
现象:拖动模块,连线端点不跟随,或者跟随但偏移一个固定距离。
原因:端口坐标计算时混用了视图坐标和场景坐标。event->pos()是视图坐标,QGraphicsItem::pos()是父项坐标,只有mapToScene()才是场景坐标。连线图元在场景中,必须用场景坐标。
解决:统一所有端口坐标返回场景坐标,在inputPortPos()内部用mapToScene(本地端口坐标)转换。连线图元直接用返回值,不再做任何坐标变换。
5.2 删除模块后程序崩溃
现象:选中模块按 Delete 删除,再点运行或拖动其他模块,程序闪退。
原因:数据模型里用裸指针存连接关系,模块删除后指针悬空,仿真调度或连线更新时访问野指针。
解决:数据层用 id 而非指针关联,删除模块时同步清理所有涉及该 id 的连接记录,并删除对应的 ConnectionItem。图形层删除用scene->removeItem()后手动delete,或者设置父项让 Qt 自动管理。
5.3 模块数量多时拖动卡顿
现象:画布上超过二三十个模块后,拖动一个模块整个界面卡顿。
原因:itemChange()里对每个位置变化都触发全场景重绘,或者连线更新时遍历了所有图元。
解决:只更新关联连线,不要调scene->update()全刷。给 ModuleItem 设置setCacheMode(QGraphicsItem::DeviceCoordinateCache),Qt 会缓存模块的绘制结果,移动时直接复用位图。另外把boundingRect()返回值收紧到实际范围,减少重绘区域。
5.4 连线端口吸附不准
现象:鼠标明明落在端口上,却创建不了连线。
原因:端口检测用图元的boundingRect()判断,而端口小圆点的实际范围比 boundingRect 小,或者检测时没考虑图元的 Z 值顺序。
解决:端口检测单独用一个圆形区域判断,以端口中心为圆心、半径 8 像素内都算命中。用scene->items(scenePos)拿到候选图元后,遍历判断是否为端口图元,而不是判断整个模块图元。
5.5 仿真结果全为零
现象:点击运行,示波器没有任何波形,或者全是零。
原因:拓扑排序后执行时,模块读取输入值用的是初始值而非上游模块的计算结果,或者常量模块没被正确初始化。
解决:调度器维护一个QHash<int, double> outputCache,每个模块计算前先从缓存取输入值,计算后把结果写入缓存。常量模块入度为 0,最先执行,其输出写入缓存供下游读取。调试时在计算函数里打印模块 id 和输出值,一眼能看出哪一环断了。
6. 验收与答辩:怎么演示才能让老师点头
验收这件事,很多人栽在「功能都有但演示翻车」上。我一般会按下面的清单提前走一遍,确保答辩时不出岔子。
| 验收项 | 操作步骤 | 合格标准 |
|---|---|---|
| 模块拖拽 | 从左侧拖三个不同模块到画布 | 模块落在鼠标位置,无偏移 |
| 连线创建 | 从输出端口拖到输入端口 | 生成平滑曲线,端点吸附准确 |
| 模块移动 | 拖动中间模块 | 连线实时跟随,无残影 |
| 参数编辑 | 双击增益模块改增益值 | 对话框弹出,修改后模块显示更新 |
| 仿真运行 | 搭建「常量→增益→示波器」链路,运行 | 示波器显示放大后的波形 |
| 异常处理 | 搭建环路,运行 | 弹出环路提示,不崩溃 |
| 删除模块 | 删除中间模块 | 关联连线一并删除,程序稳定 |
演示流程我建议按「搭一个完整链路」来讲,而不是逐个功能点。比如先拖一个常量模块设值为 1,再拖一个增益模块设增益为 3,连线,再拖示波器,连线,点运行,示波器显示一条值为 3 的直线。然后现场改增益为 5,再运行,波形变成 5。这一套下来,交互、参数、仿真、可视化全展示到了,比干巴巴说「我实现了拖拽功能」有说服力得多。
答辩时老师最爱问的三个问题,提前准备好答案:一是「你的拓扑排序怎么处理环路」,答 Kahn 算法检测入度,排序结果不完整就报错;二是「模块和连线怎么关联的」,答数据层用 id 关联,图形层用信号槽更新;三是「为什么用 Graphics View 不用 QWidget」,答坐标变换和碰撞检测现成,模块多了性能更好。
从那以后我每次做图形编辑器类的项目,都强制自己先把数据模型和图形显示分开设计,再动手写图元。这个习惯帮我省了至少三次重构。希望这套方案能帮到你,需要完整工程源码和模块清单的,可以按项目标题去找对应的资源包,里面连参数对话框的 UI 文件都配齐了。
本文还有配套的精品资源,点击获取