ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Qt迷宫开发实战:QPainter绘图、BFS寻路与发布避坑指南

Qt迷宫开发实战:QPainter绘图、BFS寻路与发布避坑指南 简介一份面向Qt开发者的随机迷宫生成与路径获取实战项目集成了并查集、深度优先搜索与A*算法解决迷宫随机生成和最短路径查询问题适合学习图形界面编程、数据结构与算法结合的开发者。压缩包为rar格式共66个文件整体约28.14MB主要包含C源码、头文件、动态链接库、图片资源、界面描述文件、样式表、工程配置和编译中间产物目录层次清晰。目前已有353人学习。包内提供可直接运行的执行程序与Visual Studio解决方案便于阅读核心代码、复现算法过程并作二次开发。项目使用图形视图框架渲染迷宫通过并查集维护格子的连通关系用启发式搜索评估代价并规划最短路径同时支持点击格子重新规划路线呈现完整的交互式迷宫应用。整体既可作为Qt项目参考也可作为算法可视化教学案例使学习者直观理解从数据结构设计到界面落地的全过程。 很多人学 Qt 的时候最容易卡住的不是语法而是学完了 QPainter 的 drawLine、drawRect却不知道一个完整的绘图程序到底该怎么组织。官方示例又太零碎看完了好像懂了真让自己动手做一个能交互的东西往往就手足无措。随机迷宫生成与路径获取这个项目恰好能把 Qt 绘图、算法、事件处理和界面刷新这些知识点全部串起来做完之后你会对 Qt 的事件驱动模型和 QPainter 坐标系有非常直观的理解。先说结论这个项目做起来不难30x30 格子的迷宫用递归回溯生成只需要几毫秒QPainter 全量重绘也能稳定跑在 60 帧以上。但它练的是 Qt 开发里最核心的那套东西——数据怎么建模、坐标怎么换算、刷新机制怎么设计、程序发布会遇到哪些坑。这篇文章我会按照自己实际开发的顺序从环境准备、地图数据结构、生成算法、绘制、路径搜索到最后的打包发布把关键代码和一些踩过的坑完整过一遍。1. 迷宫项目到底在练什么绘图、建模与交互的完整闭环1.1 为什么我推荐用迷宫项目做 Qt 练手很多 Qt 教程会带你做计算器、文本编辑器但这类项目大部分逻辑是纯 C 的界面只是几个按钮和输入框对 QPainter 绘图几乎没有锻炼。迷宫项目不一样它天然需要两套核心机制第一是数据驱动绘制。迷宫不是预先画好的一张图片而是由格子、墙壁组成的逻辑数据界面根据数据实时绘制。这就要求你把数据和界面分离——数据结构只管哪里有墙、哪里连通绘制层只负责怎么把这个状态画出来。这个分离思想在 Qt 的任何图形项目中都会反复用到。第二是交互闭环。生成迷宫、点击设置起点终点、搜索并显示路径这一整套交互涉及鼠标事件、定时动画、动态刷新几乎覆盖了 Qt 客户端开发的全部核心环节。此外迷宫项目非常适合分段验证先生成迷宫并绘制出来能直观看到算法是否正确再跑路径搜索又能直接验证迷宫连通性的设计是否合理。这种每一步都有视觉反馈的开发方式对新手建立信心特别有帮助。1.2 环境准备版本选择与安装避坑我使用的是Qt 5.15.2它是最常用的 LTS 版本稳定、资料多配套的 Qt Creator 和编译器组合也比较成熟。下载安装时强烈建议用国内镜像源直接从 Qt 官网下载速度很不稳定会浪费大量的等待时间。# 以 5.15.2 为例镜像站路径示意 https://mirrors.tuna.tsinghua.edu.cn/qt/archive/qt/5.15/5.15.2/编译器有两个选择各自适用不同场景编译套件优点注意事项MinGW 64-bit开源免费、安装简单不需要额外装 Visual Studio发布时需要一并携带 MinGW 运行时 DLLMSVC 2019 64-bit和 Windows 生态兼容最好后续接第三方库更顺畅需要安装对应版本的 Visual Studio Build Tools且必须装对架构如果你只做这个迷宫项目用 MinGW 就够了省去很多环境配置的麻烦。但如果你之后打算介入 Halcon、OpenCV 或者 Windows 原生 API建议直接上 MSVC 套件否则后面很可能会因为库的编译器不匹配而花费大量时间处理编译错误。2. 迷宫生成算法选型逻辑与递归回溯实现2.1 三种主流生成算法的取舍迷宫生成算法有很多常见的主要是这三种递归回溯Recursive Backtracking也叫 DFS 生成法实现最简单生成的迷宫有很明显的主干路径视觉效果自然。随机 Prim从起点集合扩展生成的迷宫分支更均匀路径弯弯绕绕更多但实现稍微复杂一些。Kruskal 并查集把所有墙随机打通直到整个迷宫连通逻辑最抽象适合顺便练并查集。对于新手我强烈建议先用递归回溯。它的代码量最少、思维最直观而且生成的迷宫是完美迷宫——任意两个格子之间有且仅有一条通路。这个特性在后面做路径搜索时非常省事保证起点和终点一定有解。2.2 递归回溯的完整实现数据结构定义非常关键。每个格子需要记录四面墙是否存在以及是否被访问过struct Cell { // 顺序固定0-上1-右2-下3-左 bool wall[4] { true, true, true, true }; bool visited false; }; class MazeWidget : public QWidget { Q_OBJECT public: explicit MazeWidget(QWidget *parent nullptr); protected: void paintEvent(QPaintEvent *event) override; void mousePressEvent(QMouseEvent *event) override; private: void generateMaze(int row, int col); bool solveMaze(); int rows 30; int cols 30; int cellSize 20; QVectorQVectorCell maze; // 起点终点以及路径点 QPoint startPoint; QPoint endPoint; QVectorQPoint path; std::mt19937 rng{ std::random_device{}() }; };方向数组建议写成全局常量后面绘制和寻路都要复用const int dirRow[4] { -1, 0, 1, 0 }; // 上、右、下、左 const int dirCol[4] { 0, 1, 0, -1 };核心生成函数void MazeWidget::generateMaze(int row, int col) { maze[row][col].visited true; // 随机打乱四个方向的顺序 QVectorint dirs { 0, 1, 2, 3 }; std::shuffle(dirs.begin(), dirs.end(), rng); for (int dir : dirs) { int nr row dirRow[dir]; int nc col dirCol[dir]; if (nr 0 || nr rows || nc 0 || nc cols) continue; if (maze[nr][nc].visited) continue; // 打通当前格子与目标格子之间的墙 maze[row][col].wall[dir] false; maze[nr][nc].wall[(dir 2) % 4] false; generateMaze(nr, nc); } }每次递归都从随机打乱的方向中选取下一个格子这就保证了迷宫的随机性。(dir 2) % 4是当前方向的相反方向也就是目标格子里朝向当前格子的那面墙。我一开始经常忘记同时打通两面墙导致生成的迷宫是画出来的墙还在但逻辑上已经连通的状态绘制出来之后各种奇怪的重叠线。初始化时调用一次void MazeWidget::resetMaze() { maze.clear(); maze.resize(rows); for (int i 0; i rows; i) { maze[i].resize(cols); } startPoint QPoint(0, 0); endPoint QPoint(cols - 1, rows - 1); path.clear(); generateMaze(0, 0); update(); }2.3 关于递归深度和性能的实测我一开始天真地以为递归回溯在 500x500 的格子上也能秒开实测数据是30x30 的迷宫生成耗时不到 1 毫秒100x100 大约 12 毫秒但到了 1000x1000生成开始出现明显卡顿甚至偶尔会崩溃。原因有两个一是递归深度过大导致栈溢出二是每次std::shuffle和递归调用本身有不可忽略的开销。如果你打算做 500x500 以上的大迷宫递归的 DFS 就不太合适了建议换用随机 Prim 的迭代版本或者把递归改成显式栈。这里不展开但你需要知道这个边界在哪。3. 用 QPainter 把迷宫画出来坐标换算与绘制细节3.1 地图数据与像素坐标的换算关系这是很多新手最容易乱的地方。地图坐标系和像素坐标系是两个东西。地图坐标是第几行第几列比如maze[5][8]表示第 5 行第 8 列的格子。像素坐标是屏幕上第几个像素点。换算关系很简单像素 x 列号 * cellSize 像素 y 行号 * cellSize也就是说格子 (row, col) 的左上角像素坐标是(col * cellSize, row * cellSize)右下角是(col * cellSize cellSize, row * cellSize cellSize)。这里踩过的坑是绘制墙壁线段时drawLine(x, y, x cellSize, y cellSize)这种写法会把墙画成斜线因为你不小心把左上角到右下角连起来了。你需要的永远是横平竖直的线段——两个端点的横坐标相同或纵坐标相同。3.2 绘制主函数全量重绘其实没有性能压力Qt 的标准做法是重写paintEvent(QPaintEvent *)在其中用 QPainter 完成全部绘制void MazeWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.fillRect(rect(), QColor(#f8f6f0)); // 绘制墙体——用深灰色粗线 QPen wallPen(QColor(#3a3a3a), 2); painter.setPen(wallPen); for (int row 0; row rows; row) { for (int col 0; col cols; col) { int x col * cellSize; int y row * cellSize; const Cell c maze[row][col]; if (c.wall[0]) // 上墙 painter.drawLine(x, y, x cellSize, y); if (c.wall[1]) // 右墙 painter.drawLine(x cellSize, y, x cellSize, y cellSize); if (c.wall[2]) // 下墙 painter.drawLine(x, y cellSize, x cellSize, y cellSize); if (c.wall[3]) // 左墙 painter.drawLine(x, y, x, y cellSize); } } // 绘制起点和终点 painter.setBrush(QColor(#4caf50)); painter.setPen(Qt::NoPen); painter.drawEllipse(startPoint.x() * cellSize 4, startPoint.y() * cellSize 4, cellSize - 8, cellSize - 8); painter.setBrush(QColor(#f44336)); painter.drawEllipse(endPoint.x() * cellSize 4, endPoint.y() * cellSize 4, cellSize - 8, cellSize - 8); // 绘制路径 if (!path.isEmpty()) { QPen pathPen(QColor(#2196f3), 3); painter.setPen(pathPen); for (int i 0; i path.size() - 1; i) { int x1 path[i].x() * cellSize cellSize / 2; int y1 path[i].y() * cellSize cellSize / 2; int x2 path[i 1].x() * cellSize cellSize / 2; int y2 path[i 1].y() * cellSize cellSize / 2; painter.drawLine(x1, y1, x2, y2); } } }一个性能上的认识误区我一开始担心每次update()全量重绘 3600 条线段会不会卡实测在 30x30 格子上单次 paintEvent 的耗时在 1~2 毫秒级别完全感知不到卡顿。只有在格子数达到 200x200 以上、线段数量急剧增加之后才需要考虑局部重绘、双缓冲或者 QGraphicsView 方案。对这个项目而言update()全量重绘就是最简洁可靠的做法。3.3 起终点标记与鼠标交互让用户能自由设置起点和终点是让这个项目从演示程序变成可用工具的关键一步。在mousePressEvent里通过坐标换算取反推出格子坐标void MazeWidget::mousePressEvent(QMouseEvent *event) { if (event-button() ! Qt::LeftButton) return; int col event-pos().x() / cellSize; int row event-pos().y() / cellSize; if (row 0 || row rows || col 0 || col cols) return; // 左键设置起点右键设置终点 if (event-modifiers() Qt::ShiftModifier) endPoint QPoint(col, row); else startPoint QPoint(col, row); path.clear(); update(); }这里的一个易错点是如果获取的坐标是事件相对于其他控件的位置换算就会错位。这也是我踩过的坑——比如迷宫画在 QScrollArea 里时必须用event-pos()配合mapFromGlobal谨慎处理。这个项目里直接用event-pos()就够了因为mousePressEvent返回的坐标已经相对于当前控件。4. 路径获取BFS 最短路径与回溯展示4.1 为什么选 BFS 而不是 DFS生成迷宫的时候用的是 DFS但搜索路径我建议用 BFS。原因是DFS 找到的路径只是某一条通路不一定是最短的BFS 逐层向外扩展第一次到达终点时走过的层数一定是最少的。对于任意两格之间只有一条路径的完美迷宫其实 DFS 和 BFS 找到的路径完全一样因为路径唯一。但如果你日后改用了随机 Prim 生成法、允许迷宫存在多条路径那 BFS 的最短路径优势就凸显出来了。另一个更现实的理由是BFS 的队列逐层扩展过程非常适合做动画展示。每次从队列取出一个点就把它标记为已探索配合定时器刷新看起来就是路径搜索从起点像水波一样蔓延开来。4.2 BFS 的完整实现与路径回溯BFS 部分我建议用一个 dist 二维数组来记录起点到每个格子的最短步数既方便判断是否访问过也方便最后回溯bool MazeWidget::solveMaze() { QVectorQVectorint dist(rows, QVectorint(cols, -1)); QQueueQPoint queue; dist[startPoint.y()][startPoint.x()] 0; queue.enqueue(startPoint); while (!queue.isEmpty()) { QPoint cur queue.dequeue(); int cr cur.y(); int cc cur.x(); if (cur endPoint) break; for (int dir 0; dir 4; dir) { int nr cr dirRow[dir]; int nc cc dirCol[dir]; if (nr 0 || nr rows || nc 0 || nc cols) continue; if (maze[cr][cc].wall[dir]) // 这面墙挡住了 continue; if (dist[nr][nc] ! -1) // 已经访问过 continue; dist[nr][nc] dist[cr][cc] 1; queue.enqueue(QPoint(nc, nr)); } } if (dist[endPoint.y()][endPoint.x()] -1) return false; // 理论上完美迷宫不会出现这种情况 // 从终点回溯到起点 path.clear(); int cr endPoint.y(); int cc endPoint.x(); while (cr ! startPoint.y() || cc ! startPoint.x()) { path.prepend(QPoint(cc, cr)); for (int dir 0; dir 4; dir) { int nr cr dirRow[dir]; int nc cc dirCol[dir]; if (nr 0 || nr rows || nc 0 || nc cols) continue; if (maze[cr][cc].wall[dir]) continue; if (dist[nr][nc] dist[cr][cc] - 1) { cr nr; cc nc; break; } } } path.prepend(startPoint); update(); return true; }回溯的原理很简单起点到每个格子的步数都记在 dist 里从终点出发每次找相邻格子中 dist 比当前小 1 的格子一路走回起点。这正好保证了走的是最短路径。有个细节值得注意path.prepend()会让 path 中的点按从起点到终点的顺序排列如果你用append再手动reverse也是一样的效果。4.3 让 BFS 搜索过程动起来静态显示路径已经很有成就感了但如果能看到 BFS 的搜索过程动画理解会深得多。实现方式并不复杂把扩散过程缓存到一个访问序列里再用 QTimer 每隔一段时间向 path 里追加一个访问点并触发update()。// 伪代码思路 for (int i 0; i visitedOrder.size(); i) { QTimer::singleShot(5 * i, this, [this, i]() { processPath.append(visitedOrder[i]); update(); }); }这里的注意点是如果用户中途修改了起点终点或者重新生成了迷宫之前排队的singleShot回调仍然会触发导致画面出现幽灵路径。我的做法是在 resetMaze 里维护一个单调递增的 generation 计数回调里判断 generation 是否仍然匹配不匹配就直接返回。这个经验虽然简单但在做任何异步动画时都很值得记住。5. 发布与崩溃排查从 No Qt Platform Plugin 到栈溢出5.1 no qt platform plugin could be initialized 的完整排查链路开发调试没问题一分享给别人就白屏崩溃这是 Qt 新手最容易撞见的坑。错误信息通常是qt.qpa.plugin: Could not find the Qt platform plugin windows in This application failed to start because no Qt platform plugin could be initialized.我当时第一反应是重新安装 Qt折腾了半天没解决。后来才弄明白这个报错的本质是主程序依赖的 Qt 运行库和你提供的插件目录不匹配。正确做法是用 Qt 官方提供的windeployqt工具来完成部署windeployqt your_app.exe --release它会自动把 Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dll 等一系列运行库以及platforms/qwindows.dll这个关键的平台插件拷贝到 exe 旁边。拷贝完成后检查目录结构是否至少包含yourapp.exe Qt5Core.dll Qt5Gui.dll Qt5Widgets.dll platforms/qwindows.dll如果platforms目录不存在或者qwindows.dll版本和 exe 依赖的 Qt 版本不一致就会触发上面的报错。我见过一个特别隐蔽的情况exe 是 MSVC 编译的 release 版但 qwindows.dll 是从 MinGW 的 Qt 目录里拷贝出来的两个库的 ABI 不同直接崩溃。所以 windeployqt 必须和你编译用的 Qt 套件保持一致。5.2 编译器套件不匹配的连锁反应热词里有一大堆关于qtwindows no qt platform plugin和qt msvc 需要安装那些的搜索说明这是全行业通用的痛点。我之所以建议定好编译器就别换是因为 Qt 的库和你的代码是绑定的MinGW 编译的程序不能链接 MSVC 编译的 Qt 库反之亦然。如果你非要用 MSVC 套件Windows 上还需要安装Visual Studio Build Tools或者完整的 VS并确保安装时选择了使用 C 的桌面开发工作负载。不少人在这一步卡住是因为只装了 VS Code以为靠插件就能编译 MSVC 版本的 Qt 程序——实际上 MSVC 编译器的头文件和链接器来自 VS Build Tools缺了它就各种cannot open include file。5.3 递归栈溢出的真实崩溃场景迷宫格子数一旦调大递归回溯生成函数就可能直接让程序崩溃在 Windows 上表现为已停止工作在 Qt Creator 的调试器里看调用栈会发现几千层generateMaze压在一起。这就是典型的递归栈溢出。我的解决方案很务实如果没有做大迷宫的需求就在界面上限制最大格子数比如 200x200超出时提示用户。这样既简单又能避免崩溃。如果你确实需要支持超大迷宫就要把递归改成显式栈这是一个非常经典的练习——手动维护一个QStackQPoint来模拟递归过程代码量多十几行但完全消除栈溢出风险。还有一个小细节在使用 MSVC 编译器时Debug 模式下的递归栈更小同样的格子数在 Debug 下崩溃但 Release 下正常排查这类问题的顺序永远是先看调用栈别急着怀疑代码逻辑。最后再分享一个调试小技巧迷宫算法出问题时不要盯着代码干想直接把格子数设成 10x10然后逐格打印maze[row][col].wall[0..3]的值用 1 表示有墙、0 表示无墙一眼就能看出算法在哪里断了墙。我用这个办法找回过很多次看起来是对的但路径就是走不通的诡异 bug。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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