恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
QT Widgets实战:开发旅行模拟查询系统全流程
首页
资讯中心
/
QT Widgets实战:开发旅行模拟查询系统全流程
QT Widgets实战:开发旅行模拟查询系统全流程
发布时间:2026/10/10 19:16:16
最近有个朋友找我帮忙说想做一个旅行模拟查询系统当课程设计要求必须有图形界面最好能把城市路线、费用估算这种事直观地展示出来。我第一反应是这东西用QT来做最合适。QT本身就是为桌面图形界面而生的跨平台框架配C做业务逻辑跑起来流畅界面也专业不像网页版还得折腾服务器和浏览器兼容。折腾了几天把项目完整跑通之后我决定把整个实现过程整理出来——从功能设计、技术选型、架构拆分到环境搭建、核心算法、界面绘制再到我踩过的一堆坑。无论你是正在做类似课设的学生还是想熟悉QT Widgets开发流程的开发者这篇文章都能帮你少走不少弯路。我会把关键代码、编译配置、排查思路都放出来你可以直接照着搭一个属于自己的版本。1. 项目拆解这个旅行模拟查询系统到底要解决什么问题1.1 用户场景与功能边界先别急着写代码。我在接手任何项目之前都习惯先把这个系统给谁用、用来干什么这件事理清楚。旅行模拟查询系统的核心用户场景很明确用户想知道从A城市到B城市大概多远、要花多少钱、中间能不能路过其他景点系统要快速给出路线和费用明细。基于这个场景我把功能定义成四个模块城市与路线管理维护城市节点、道路连接、两地距离。这是所有查询的基础数据。路线查询输入出发地和目的地计算最短路径或推荐路线输出途经城市列表。费用模拟根据路程、单价、固定成本估算单程总费用并生成明细。界面展示用图形界面展示城市关系、选中路线并在表格中呈现计算结果。选择的范围我刻意控制在模拟级别——不接真实地图API不做实时交通数据用本地JSON或配置维护。这样既能把QT和C的核心知识点都覆盖到又不会因为数据获取而把项目复杂度推高到不合理的水平。说白了这是一个非常适合练手和展示的图形界面应用项目。1.2 模块划分别把逻辑全写在MainWindow里很多刚开始做QT的人容易犯一个毛病所有代码都塞在MainWindow类里。界面控件直接操作数据结构查询逻辑也写在按钮的槽函数里。短期内能跑但后续加功能、换界面、修Bug会非常痛苦。我这次按三层去划分数据层Data负责城市、道路、路线结果的存储与读写。对外提供纯净的数据结构比如City、Road、TripResult。业务层Logic负责最短路径计算、费用估算、推荐排序等规则。这一层完全不依赖QT的UI组件纯C逻辑。界面层UI负责窗口、控件、绘制、信号槽连接。只调用业务层接口不直接改动核心数据。这样的好处是任何一个环节出问题都可以单独测试。比如我想验证最短路径算法对不对写一个控制台测试就行不需要启动整个图形界面。后面我会在每一层的具体实现里展示这个划分是怎么落地的。2. 选型为什么是QT Widgets C以及环境怎么搭2.1 QT版本选择5.15.2还是6.x我在做这个项目之前先对比过QT 5.15.2和QT 6.x。这里有个真实的背景5.15.2是QT 5系列的最后一个LTS版本稳定性和资料丰富度都很好网上能搜到的教程、博客几乎都基于这个版本。而QT 6.x对CMake集成更友好底层改动也大但如果跟着老教程写代码偶尔会遇到一些函数签名变化。最终我选了QT 5.15.2。原因有三大量课设、比赛项目的参考代码都是5.x写法遇到问题好查。5.15.2对Windows的兼容性最好尤其是配合VS2019/VS2022的工具链。我的项目不需要最新特性5.15.2完全够用。如果你是从零开始、希望长期跟进新特性选6.x也可以。但如果你像我一样目标是快速做出一个能跑的完整项目5.15.2是最稳妥的选择。2.2 VS2022 Qt VS Tools还是Qt Creator开发环境我推荐两条路各有选择理由VS2022 Qt VS Tools适合已经习惯Visual Studio的C开发者。集成度高调试体验好还能直接用到VS的众多插件。这个组合有一个关键点安装的QT组件必须和VS的编译器匹配。我用的是msvc2019_64这套QT库对应VS2019/2022的64位MSVC编译器完全没有兼容问题。Qt CreatorQT官方IDE配置更简单用CMake或qmake构建都很方便。新手不容易踩环境坑缺点是代码补全和调试没有VS那么强大。我这次实际用的是VS2022 Qt VS Tools。不是为了别的就是因为VS更像上班的工具而且它可以无缝处理我后面要写的很多C测试代码。这里有一个非常关键的配置点在VS里装了Qt VS Tools扩展之后必须去扩展 - Qt VS Tools - Qt Versions里把你的QT路径添加进去。如果不配这个VS就不知道去哪里找QT的头文件和库文件编译必然报错。这个坑我后面专门讲。2.3 环境搭建步骤和踩坑记录环境搭建我按以下步骤走你可以直接照抄打开清华源镜像QT官方下载太慢国内几乎都用清华源找到qt/5.15.2/目录下载对应平台的安装包。Windows就下qt-opensource-windows-x86-5.15.2.exe。安装时勾选组件msvc2019_64必选这是VS编译器的64位库Qt Debug and Release Tools选上Sources建议选方便调试时跳转查看源码。打开Visual Studio Installer安装Qt Visual Studio Tools扩展市场里直接搜并确认VS已经装了C桌面开发工作负载。在VS里打开扩展 - Qt VS Tools - Qt Versions点击加号添加QT安装路径系统会自动识别版本号。新建项目时选择Qt Widgets Application模板然后在项目属性里确认Qt Installation已经指向我们添加的版本。我在这里卡住过一件事第一次建QT项目后编译直接抛出一个长长的报错-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets does not exist.这个报错看起来特别吓人其实根因很简单Qt VS Tools 的版本路径配置里QT路径写错了或者包含中文、空格导致解析失败。检查了一圈发现我安装QT时居然把路径放在了带空格的目录里。解决方法是重新配置Qt Versions里的路径放到一个纯英文无空格的目录比如D:\Qt\5.15.2\msvc2019_64清理解决方案再重新编译。所有时间都花在路径这两个字上真不夸张。3. 数据模型与核心算法让查询有据可依3.1 数据模型结构体还是类数据层是整个系统的基础。我设计了三个核心结构体City、Road、TripResult。struct City { int id; // 城市唯一ID QString name; // 城市名称 double x, y; // 坐标用于界面绘制 QString desc; // 景点描述 }; struct Road { int fromId; // 起点城市ID int toId; // 终点城市ID double distance; // 公路里程公里 double toll; // 该路段通行费元 }; struct TripResult { QVectorint path; // 途经城市ID序列 double totalDistance; // 总里程 double totalCost; // 总费用 QString detailText; // 明细文本用于展示 };这里用struct而不是class是因为它们本质上是纯数据容器没有复杂的行为直接公开成员变量反而更方便。QT自带的数据类型QString、QVector在这里远比我手搓std::string和std::vector好用主要是它们能无缝对接UI层和JSON序列化。City里坐标x、y是我特意加的。因为界面要绘制城市关系图后期做可视化的时候直接通过坐标把城市画在画布上省了二次转换的麻烦。3.2 路线查询算法Dijkstra的QT实现路线查询本质上是图的最短路径问题。城市是节点道路是带权边权重就用距离。我在业务层封装了一个RoutePlanner类负责构建图和执行查询。图我用邻接表存储结构是QHashint, QVectorRoad。为什么要用QHash因为城市ID不一定是连续的用哈希表根据城市ID直接索引邻接列表查找效率是常数级。邻接表比邻接矩阵省空间对稀疏图尤其友好——很多城市之间并没有直接道路。核心算法我选了经典的Dijkstra用贪心策略每次选当前距离最短的未访问节点扩展适合这种无负权边的场景。QT 5.15.2的QPriorityQueue可以直接用省了自己写堆排序的功夫struct NodeState { int cityId; double dist; bool operator(const NodeState other) const { return dist other.dist; } }; QVectorint RoutePlanner::shortestPath(int fromId, int toId) { QHashint, double dist; QHashint, int prev; QHashint, bool visited; QPriorityQueueNodeState, QVectorNodeState, std::greaterNodeState pq; dist[fromId] 0; pq.push({fromId, 0}); while (!pq.isEmpty()) { NodeState cur pq.top(); pq.pop(); if (visited.value(cur.cityId, false)) continue; visited[cur.cityId] true; if (cur.cityId toId) break; for (const Road road : adjList.value(cur.cityId)) { int next road.toId; double newDist cur.dist road.distance; if (!dist.contains(next) || newDist dist[next]) { dist[next] newDist; prev[next] cur.cityId; pq.push({next, newDist}); } } } // 回溯路径 QVectorint path; QVectorint reversed; int cur toId; while (prev.contains(cur)) { reversed.append(cur); cur prev[cur]; } reversed.append(fromId); std::reverse(reversed.begin(), reversed.end()); return reversed; }初学的时候容易把visited判断漏掉那会导致同一个节点反复入队在大图里性能急剧下降。还有一个小细节当fromId和toId之间没有任何路径时prev里不会包含toId这时候回溯出来的路径只有一个孤立节点必须在查询前先检查dist.contains(toId)避免界面显示一条假路线。3.3 费用估算别只算一个总价费用模拟的关键是让用户看得懂这个钱是怎么算出来的。我把费用拆成三部分里程费用总里程 * 每公里单价比如1.2元/公里模拟油费加损耗。路段通行费每段道路的toll累加模拟高速过路费。基础服务费固定值比如20元模拟系统服务成本。TripResult RoutePlanner::planTrip(int fromId, int toId) { TripResult result; result.path shortestPath(fromId, toId); result.totalDistance 0; result.totalCost 20.0; // 基础服务费 QStringList lines; lines QString(出发地%1).arg(cityName(fromId)); for (int i 0; i result.path.size() - 1; i) { int a result.path[i]; int b result.path[i 1]; Road road findRoad(a, b); double segCost road.distance * 1.2 road.toll; result.totalDistance road.distance; result.totalCost segCost; lines QString( 途经 %1里程 %2 km费用 %3 元) .arg(cityName(b)) .arg(QString::number(road.distance, f, 1)) .arg(QString::number(segCost, f, 1)); } result.detailText lines.join(\n); return result; }这里我犯过一个低级错误直接用double做字符串拼接导致中文和数字混在一起完全没法看。后来统一用QString::number(value, f, 1)保留一位小数。如果你在界面上看到费用显示成1.200000这种长尾巴就是格式化这一步偷懒了。double转字符串在QT里是有讲究的toQString的f参数表示定点格式第二个参数是小数位数不写默认是6位够你喝一壶的。3.4 数据持久化JSON读写数据不能每次启动都重新手填我把城市和道路数据写进JSON文件程序启动时加载。QT的QJsonDocument用起来很顺手bool DataStore::loadFromFile(const QString path) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) return false; QJsonDocument doc QJsonDocument::fromJson(file.readAll()); QJsonObject root doc.object(); QJsonArray cities root.value(cities).toArray(); for (const QJsonValue v : cities) { QJsonObject obj v.toObject(); City c; c.id obj.value(id).toInt(); c.name obj.value(name).toString(); c.x obj.value(x).toDouble(); c.y obj.value(y).toDouble(); c.desc obj.value(desc).toString(); cityList.append(c); } // roads 类似构建邻接表 return true; }JSON数据一条条的格式简单、可读性强而且不容易被误改。我建了一份示例数据8个城市北京、天津、济南、南京、上海、杭州、武汉、西安大概模拟了旅行场景的几条主线道路。你完全可以根据自己需要替换成任何城市集合。4. 界面落地多页面切换、表格展示与路线绘制4.1 主窗口框架与多页面管理界面层我的整体结构是一个QMainWindow作为外壳中间放一个QStackedWidget做页面切换。两个页面分别是查询主页面和线路总览页面。这种多页面布局的好处是业务功能与视觉组织解耦——用户在一个窗口内完成所有操作不需要弹多个零散对话框。查询主页面的核心控件有这些QComboBox两个一个选出发城市一个选目的城市。QPushButton一个开始查询。QTextEdit一个只读模式显示路线明细和费用说明。QTableView一个把途经城市列表结构化展示出来。主窗口的菜单栏我加了文件 - 退出和帮助 - 关于虽然功能简单但一个像样的桌面应用必须有个壳状态栏显示就绪和查询耗时用户操作起来会有完整应用的感觉。4.2 路线绘制QPainter从零画一张拓扑图可视化是旅行系统最出彩的部分。我用QPainter在一个自定义的RouteWidget上绘制城市节点和道路连线。思路非常简单城市节点坐标由City::x和City::y决定映射到控件的宽度和高度区间城市之间的道路用直线连接查询出的最短路径用红色加粗绘制普通道路用灰色细线绘制。void RouteWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); // 计算坐标映射 double minX, maxX, minY, maxY; // ... 遍历所有城市求边界 double scaleX (width() - 40) / (maxX - minX); double scaleY (height() - 40) / (maxY - minY); // 绘制所有道路 QPen normalPen(QColor(160, 160, 160), 2); for (const Road road : allRoads) { painter.setPen(normalPen); QPoint p1 cityPoint(road.fromId, scaleX, scaleY); QPoint p2 cityPoint(road.toId, scaleX, scaleY); painter.drawLine(p1, p2); } // 绘制所有城市 for (const City c : cityList) { QPoint p cityPoint(c.id, scaleX, scaleY); painter.setBrush(QColor(70, 130, 180)); painter.setPen(Qt::NoPen); painter.drawEllipse(p, 8, 8); painter.setPen(Qt::black); painter.drawText(p QPoint(-20, 20), c.name); } }绘制有一个容易忽略的细节paintEvent里不能直接操作业务数据因为绘制可能会因为窗口缩放、遮挡恢复等原因被多次调用。我是在查询动作发生时才更新m_path这个成员变量然后调用widget-update()请求重绘。千万不要在paintEvent里去做复杂计算那会造成界面卡顿。4.3 查询结果表格化QTableView QStandardItemModel把查询结果放进QTableView是为了让用户更直观地看到每个途经节点的信息。我用QStandardItemModel来做表格数据模型void MainWindow::showResult(const TripResult result) { QStandardItemModel* model new QStandardItemModel(this); model-setHorizontalHeaderLabels({序号, 城市, 到达里程(km), 累计费用(元)}); model-appendRow({ new QStandardItem(1), new QStandardItem(cityName(result.path[0])), new QStandardItem(0), new QStandardItem(QString::number(20.0, f, 1)), }); double cumCost 20.0; double cumDist 0.0; for (int i 0; i result.path.size() - 1; i) { Road road findRoad(result.path[i], result.path[i 1]); cumDist road.distance; cumCost road.distance * 1.2 road.toll; model-appendRow({ new QStandardItem(QString::number(i 2)), new QStandardItem(cityName(result.path[i 1])), new QStandardItem(QString::number(cumDist, f, 1)), new QStandardItem(QString::number(cumCost, f, 1)), }); } tableView-setModel(model); }刚开始我图省事直接往QTableWidget里塞控件确实方便但在数据量大一点、或者想动态改变列内容的时候就暴露问题了。用View/Model分离的方式后续想加过滤、排序、导出Excel功能都容易得多。这个选择在项目不大时看着笨重但养成的习惯很重要。5. 实操踩坑从编译错误到中文乱码的完整排查链路5.1 依赖路径错误-1: error: dependent 到底是谁的问题这个报错我上面提到过是很多QT新手第一次用VS2022 Qt的时候会遇到的第一道坎。这里我把完整排查链路写出来你以后遇到可以照方抓药。报错形态-1: error: dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwidgets does not exist.注意那串..\..\..的路径看起来像是从项目目录往上层找。这其实是Qt VS Tools 在构建时尝试通过相对路径定位头文件目录失败的表现。为什么是相对路径因为Qt VS Tools在生成项目的时候会把QT头文件目录写进去但这个路径解析是基于你这个QT库所在的位置、和你添加Qt Version时填的路径来做拼装的。排查步骤打开扩展 - Qt VS Tools - Qt Versions确认路径是否指向D:\Qt\5.15.2\msvc2019_64这一层注意是包含include和lib的那一层不是再往下一层。检查路径中是否有中文、空格、特殊符号。哪怕中间夹一个空格VS都有可能解析成两段路径报错行为非常随机。项目文件.vcxproj右键 - 重新加载项目让扩展重新写入属性。清理解决方案删除.vs缓存文件夹重新生成。我实测下来90%的情况是第1和第2步的问题。你想想一个路径问题整得报错像系统崩溃一样多劝退。5.2 中文乱码MSVC下的编码策略MSVC默认按系统本地代码页GBK解析源码文件而QT源码我习惯用UTF-8保存。结果就是界面上的中文变成鍖椾含娴﹀……这种乱码。这几乎是每个从VS转QT开发的C程序员都会撞上的墙。解决标准方案是在编译选项加一个参数在项目属性 - C/C - 命令行 - 附加选项 里加/utf-8或者代码文件顶部加#pragma execution_character_set(utf-8)加了/utf-8之后编译器统一按UTF-8解读源码从源头上避免乱码。还有一个更稳妥的习惯所有用户可见的字符串统一用QStringLiteral或tr()包装。QT会把这些字符串当作编码已知的字符串字面量处理运行时不易出问题。我这次是两者都用编译选项加/utf-8代码里写中文时尽量用QString::fromUtf8(北京)显式转换。不要嫌麻烦这个习惯能救你很多次。5.3 信号槽连接失败重载函数与Lambda捕获信号槽是QT的命脉。这个项目里最常碰到的坑有两个第一个坑QComboBox的currentIndexChanged信号有重载版本一个是传int一个是传QString。直接写connect(combo, QComboBox::currentIndexChanged, this, MainWindow::onComboChanged)会报编译错因为编译器不知道你要哪个版本的函数指针。解决方法是做一个有参lambdaconnect(combo, QComboBox::currentIndexChanged, this, [this](int index) { updateCitySelect(index); });第二个坑lambda默认捕获this后如果窗口在事件到达前已经销毁程序直接崩。处理方式是确保连接建立在稳定的生命周期对象上。比如信号连接时接收者传入thisQT在this销毁时会自动断开连接不会有问题。但如果你把lambda传给一个长期存活的单例对象就必须小心了。5.4 QPainter重绘闪烁与坐标映射还有一个让我折腾了一晚上的事——画路线图时窗口大小变化导致控件疯狂闪烁。原因是paintEvent里执行了太多的坐标计算和重复绘制。解决方法是三管齐下在RouteWidget构造函数里设setAttribute(Qt::WA_OpaquePaintEvent)告诉系统这个控件自己处理全部绘制不擦背景。这能显著减少闪烁。坐标映射的结果缓存到成员变量里只在数据发生变化时更新不在paintEvent里反复算。用update()而不是repaint()请求重绘把多次绘制请求合并成一次。闪烁看起来是小问题但演示项目如果一拖拽窗口就闪个不停给人印象非常差。这三个措施做完之后界面就非常稳了。6. 从能跑到好用小优化与后续扩展方向6.1 搜索联想与城市过滤现在两个QComboBox是把城市列表直接塞进去如果城市数量多了要滚动半天才能找到目标城市。我给出发城市下拉框加了一个QCompleter补全功能QCompleter* completer new QCompleter(cityNameList, this); completer-setCaseSensitivity(Qt::CaseInsensitive); departCombo-setCompleter(completer);用户只要输入北就自动补全北京体验立刻上了一个台阶。这个小功能不费多少代码却能体现一个完整应用的细节感。6.2 历史记录缓存与最近查询我在Logic层加了一个QSettings缓存把用户的查询记录保存到注册表/配置文件里。下次打开程序可以一键重放最近一次查询。这个功能最直观的价值不是方便而是展示你对桌面应用生命周期管理的理解——程序关闭后数据还能保留这是用户对完整软件的朴素认知。void HistoryManager::save(const TripResult result) { QSettings settings(MyTourApp, History); settings.setValue(lastQuery, QDateTime::currentDateTime().toString()); settings.setValue(lastPath, result.detailText); }6.3 性能注意数据量变大的时候如果你的城市和路线数据远超我的8个节点Dijkstra的原始版本性能会明显下滑。到时候可以考虑用堆优化的A*算法或者对图做预处理剪枝提前排除明显不在同一区域的节点。另外如果用户反复查询同一对城市可以加一个结果缓存QHashQPairint,int, TripResult存一下同样的查询直接返回而不重新计算。这种以空间换时间的思路在数据量大时是必要的。6.4 扩展方向从模拟走向真实这个系统做到现在已经是标准的模拟查询系统。如果以后想继续往深做我认为有三个方向很有意思接入外部地图数据用QT的网络模块QNetworkAccessManager去请求真实地图API把返回的坐标数据解析后绘制在地图上。这比我现在手动维护城市坐标真实得多。多模式查询除了最短距离还可以按最少费用最少时间来规划路线。本质上都是图算法里的单源最短路径变体只是边的权重定义不同。把UI升级为QMLQT 6对QML的支持越来越好如果你想做一个更炫的界面QML的动态动画和特效比Widgets强得多。我计划在下一个版本里试试QML和C混合编程把核心算法保留在C层界面完全用QML重写。我自己实操下来的体会是QT这个框架最值钱的地方不是某个具体控件而是它把窗口、事件、数据存储、网络、绘制这些桌面应用该有的零件都整整齐齐摆好了让你可以专心把业务逻辑写好。对做旅行模拟查询这种入门级的图形界面项目来说C保证了性能底线QT保证了开发效率。把这个流程完整走一遍你对桌面应用从设计到交付的理解会完全不一样。最后再分享一个我的小习惯每个模块做完之后先写一个不带界面的控制台测试把核心逻辑跑通了再接UI。这样定位问题非常快至少能避免一半的明明没报错但结果不对的谜之事故。