简介:这是一份面向C++与Windows桌面开发学习者的MFC实战项目源码,主题为南京地铁线路查询系统,适合具备一定C++基础、希望理解MFC框架完整开发流程的初学者与进阶开发者参考。压缩包共43个文件,约2.44MB,以cpp与h源文件为核心,配合dsw、dsp工程文件、rc资源脚本、bmp地铁示意图及exe可执行文件,另含obj、pdb等编译中间产物,便于直接运行与调试。项目围绕对话框与属性页构建界面,涉及消息映射、事件处理、数据存储检索及最短路径查询算法等典型环节,读者可借此梳理从工程配置、界面搭建到查询逻辑实现的完整脉络。目前已有962人学习下载,适合作为课程设计、毕业设计或MFC入门练手的参考案例。
1. 从一张地铁线路图说起:这个MFC项目到底能跑出什么
很多做桌面端开发的朋友,尤其是还在维护老工控上位机、教学演示软件的那批人,对 MFC 的感情是复杂的。一方面它老,另一方面它稳,稳到很多需要直接调用 Win32 API 或者做串口通信的场景,绕一圈还是得回到对话框工程里。这个基于 MFC 的地铁查询系统,就是典型的“教学场景下把数据结构跑通”的项目。它不炫技,没有花哨的界面框架,核心就干三件事:把站点和线路数据读进来、根据起点终点算出换乘路径、把结果画在界面上。适合谁?适合正在学 MFC 对话框编程、想找一个完整案例把 C++ 和数据结构串起来的学生,也适合需要快速搭一个本地查询工具原型的开发者。你拿到手就能编译,改几个参数就能换成自己城市的线路数据。
2. 工程骨架拆解:对话框、文档视图还是纯手工搭
2.1 为什么这类项目通常选对话框工程
打开工程文件,大概率看到的是基于 CDialogEx 的对话框程序,而不是单文档或多文档。原因很直接:地铁查询的交互逻辑是“输入两个站点名,点按钮,出结果”,没有复杂的菜单栏和工具栏需求,对话框工程能把控件直接拖到资源编辑器里,事件响应函数用类向导一绑就完事。常见做法是主对话框上放两个 ComboBox 或者 Edit Control 用来选站点,一个 ListBox 或者 ListCtrl 显示换乘路径,再加一个静态图片控件画线路示意图。
如果你拿到的是文档视图结构,那多半是为了把线路图绘制逻辑单独放到 View 类里。两种都能跑,但对话框工程对新手更友好,因为所有代码都集中在一两个 cpp 文件里,不用去理文档模板和框架窗口的创建流程。
2.2 目录结构里哪些文件必须看
一个典型的 MFC 工程目录长这样,我按重要程度排个序:
| 文件/目录 | 作用 | 是否要改 |
|---|---|---|
| 主对话框 .cpp/.h | 界面事件、查询入口 | 必看 |
| 线路数据文件(.txt/.csv) | 站点、线路、换乘关系 | 必改 |
| 路径算法实现 .cpp | BFS 或 Dijkstra | 必看 |
| .rc 资源文件 | 控件布局、图标 | 按需 |
| stdafx/pch 预编译头 | 编译加速 | 不动 |
| res 文件夹 | 位图、图标资源 | 按需 |
数据文件一般是纯文本,每行代表一条线路上的一个站点,或者用“线路号,站点名,序号”的格式。我一般会先把这个文件打开看一遍,确认站点名称有没有空格、全角半角混用的问题,这直接决定后面字符串匹配会不会翻车。
2.3 数据加载与站点索引的建立
MFC 里读文件常用 CStdioFile 或者 CFile,配合 CString 做行解析。下面这段是加载站点数据的典型写法:
// 从 data/routes.txt 读取线路数据,构建邻接表 void CMetroQueryDlg::LoadRouteData() { CStdioFile file; // 以只读文本模式打开,路径用相对路径,方便整体拷贝 if (!file.Open(_T("data\\routes.txt"), CFile::modeRead | CFile::typeText)) { AfxMessageBox(_T("线路数据文件打开失败,请检查 data 目录")); return; } CString line; while (file.ReadString(line)) { line.Trim(); // 去掉首尾空白,防止全角空格 if (line.IsEmpty()) continue; // 约定格式:线路号|站点A|站点B|距离(可选) int p1 = line.Find(_T('|')); int p2 = line.Find(_T('|'), p1 + 1); if (p1 < 0 || p2 < 0) continue; CString route = line.Left(p1); CString staA = line.Mid(p1 + 1, p2 - p1 - 1); CString staB = line.Mid(p2 + 1); // 无向图,两个方向都要加边 m_adjMap[staA].push_back(staB); m_adjMap[staB].push_back(staA); m_stationSet.insert(staA); m_stationSet.insert(staB); } file.Close(); }逻辑说明:这里用std::map<CString, std::vector<CString>>做邻接表,key 是站点名,value 是相邻站点列表。参数方面,line.Find找分隔符位置,Mid按位置截取子串。注意Trim()那一步不能省,很多数据文件从 Excel 导出会带不可见字符,不处理的话后面Find匹配站点名永远返回 -1,这是血泪经验。
2.4 换乘路径算法的接入点
数据加载完之后,查询按钮的响应函数里调用算法。常见做法是 BFS 求最少站数路径,因为地铁场景下“换乘次数少”比“总距离短”更符合直觉。算法实现单独放一个类里,比如CPathFinder,主对话框只负责传参和取结果。
// 广度优先搜索,返回从 start 到 end 的站点序列 std::vector<CString> CPathFinder::FindPath(const CString& start, const CString& end) { std::vector<CString> path; if (start == end) { path.push_back(start); return path; } std::queue<CString> q; std::map<CString, CString> parent; // 记录前驱节点,用于回溯路径 std::set<CString> visited; q.push(start); visited.insert(start); while (!q.empty()) { CString cur = q.front(); q.pop(); auto it = m_adjMap.find(cur); if (it == m_adjMap.end()) continue; for (const CString& next : it->second) { if (visited.count(next)) continue; visited.insert(next); parent[next] = cur; if (next == end) { // 从终点回溯到起点,再反转 CString node = end; while (node != start) { path.push_back(node); node = parent[node]; } path.push_back(start); std::reverse(path.begin(), path.end()); return path; } q.push(next); } } return path; // 空表示不可达 }逻辑说明:BFS 保证第一次到达终点时经过的站数最少。parent映射用来记录每个站点是从哪个站点扩展来的,找到终点后顺着 parent 往回走就能还原整条路径。参数上,m_adjMap就是前面加载数据时构建的邻接表,直接传引用进来即可。如果要做带权最短路径,把 queue 换成 priority_queue,并在边上加权重字段,就是 Dijkstra 的改法。
3. 界面与数据联动:控件绑定、消息映射和绘制
3.1 站点下拉框的初始化时机
站点列表要在对话框显示之前就填好,所以初始化代码放在OnInitDialog()里,而不是构造函数。构造函数执行时控件还没创建,调AddString会直接崩。
BOOL CMetroQueryDlg::OnInitDialog() { CDialogEx::OnInitDialog(); LoadRouteData(); // 先加载数据,再填控件 CComboBox* pFrom = (CComboBox*)GetDlgItem(IDC_COMBO_FROM); CComboBox* pTo = (CComboBox*)GetDlgItem(IDC_COMBO_TO); for (const CString& sta : m_stationSet) { pFrom->AddString(sta); pTo->AddString(sta); } pFrom->SetCurSel(0); pTo->SetCurSel(0); return TRUE; }逻辑说明:m_stationSet是std::set<CString>,遍历时自动按字典序输出,站点名不会乱。GetDlgItem拿到控件指针后强转,这是 MFC 里最常见的控件访问方式。参数上,IDC_COMBO_FROM和IDC_COMBO_TO是资源编辑器里给 ComboBox 分配的 ID,你换成自己的编号即可。
3.2 查询按钮的消息映射
MFC 用消息映射表把按钮点击和函数关联起来,不是 Qt 那种 connect 写法。在头文件里声明afx_msg void OnBnClickedBtnQuery();,在 cpp 里写ON_BN_CLICKED(IDC_BTN_QUERY, &CMetroQueryDlg::OnBnClickedBtnQuery)。
void CMetroQueryDlg::OnBnClickedBtnQuery() { CComboBox* pFrom = (CComboBox*)GetDlgItem(IDC_COMBO_FROM); CComboBox* pTo = (CComboBox*)GetDlgItem(IDC_COMBO_TO); CString start, end; pFrom->GetLBText(pFrom->GetCurSel(), start); pTo->GetLBText(pTo->GetCurSel(), end); if (start == end) { AfxMessageBox(_T("起点和终点相同,请重新选择")); return; } CPathFinder finder(m_adjMap); std::vector<CString> path = finder.FindPath(start, end); CListBox* pResult = (CListBox*)GetDlgItem(IDC_LIST_RESULT); pResult->ResetContent(); if (path.empty()) { pResult->AddString(_T("未找到可达路径,请检查数据文件")); return; } for (size_t i = 0; i < path.size(); ++i) { CString item; item.Format(_T("%d. %s"), (int)(i + 1), path[i].GetString()); pResult->AddString(item); } }逻辑说明:GetLBText取当前选中项的文本,比GetWindowText更可靠,因为 ComboBox 可编辑时GetWindowText拿到的是编辑框内容。结果用CListBox逐条添加,带序号方便阅读。参数上,ResetContent每次查询前清空旧结果,避免累积。
3.3 线路示意图的绘制思路
如果工程里带了线路图绘制,一般是在OnPaint()里用CDC画线和圆。站点坐标可以硬编码在数组里,也可以从数据文件读。常见做法是定义一个struct StationPos { CString name; int x; int y; };,然后遍历画图。
void CMetroQueryDlg::OnPaint() { CPaintDC dc(this); // 双缓冲,避免闪烁 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, 800, 600); memDC.SelectObject(&bmp); memDC.FillSolidRect(0, 0, 800, 600, RGB(255, 255, 255)); CPen pen(PS_SOLID, 2, RGB(0, 120, 215)); memDC.SelectObject(&pen); for (const auto& seg : m_segments) { memDC.MoveTo(seg.x1, seg.y1); memDC.LineTo(seg.x2, seg.y2); } // 高亮查询结果路径 for (size_t i = 0; i + 1 < m_highlightPath.size(); ++i) { // 根据站点名找坐标,画粗线 } dc.BitBlt(0, 0, 800, 600, &memDC, 0, 0, SRCCOPY); }逻辑说明:双缓冲是 MFC 绘图防闪烁的标准操作,先把内容画到内存 DC,再一次性 BitBlt 到屏幕。参数上,CreateCompatibleBitmap的宽高要和客户区一致,否则拉伸变形。高亮路径那段需要你根据站点名查坐标表,这里省略了具体查找逻辑,思路就是遍历结果路径,把相邻站点的连线用不同颜色重画一遍。
4. 避坑与排查:编译、运行、数据三个层面的常见问题
4.1 编译报错“无法打开预编译头文件”
现象:换了一台机器或者改了工程名之后,编译直接报fatal error C1083: 无法打开预编译头文件 "pch.h"。原因通常是预编译头设置和实际文件名对不上,或者中间目录残留了旧版本。解决:在项目属性里把“预编译头”改成“不使用预编译头”先跑通,或者清理解决方案后重新生成。我一般会直接删掉 Debug 和 Release 文件夹再编译,省得跟缓存较劲。
4.2 运行后界面出来了但下拉框是空的
现象:程序能启动,ComboBox 里一个站点都没有。原因九成是数据文件路径不对。MFC 程序的工作目录默认是工程目录,不是 exe 所在目录,如果你用相对路径data\\routes.txt,调试时能读到,但直接双击 exe 就找不到。解决:把数据文件放到 exe 同级目录,或者用GetModuleFileName获取 exe 路径再拼接。更稳妥的做法是在OnInitDialog里先判断文件是否存在,不存在就弹提示。
4.3 查询结果为空但数据文件里明明有这两个站
现象:选了起点终点,点查询提示“未找到可达路径”。原因大概率是站点名里有不可见字符,比如从网页复制来的数据带了\r或者全角空格。CString::Trim()默认只去半角空格,全角空格要去掉得用Trim(_T(" \t\r\n "))显式指定。解决:在加载数据时统一做一次清洗,把所有站点名里的全角空格替换成半角,再去首尾空白。
4.4 换乘路径绕远路
现象:明明有更少站数的走法,算法却给了一条绕圈的路径。原因通常是 BFS 的 visited 标记时机不对,或者在遍历邻接表时没有去重。如果数据文件里同一条边被录入了两次(A|B 和 B|A 都写了),邻接表里就会有重复邻居,虽然不影响正确性但会拖慢速度。解决:加载时用std::set对每个站点的邻居去重,或者在插入前判断是否已存在。
4.5 发布 exe 到别的电脑报缺少 DLL
现象:在自己机器上跑得好好的,拷到别人电脑上提示缺少mfc140.dll或者vcruntime140.dll。原因是用了动态链接 MFC。解决:项目属性里把“使用 MFC”改成“在静态库中使用 MFC”,运行库改成“多线程 (/MT)”,重新编译后 exe 体积会变大但不再依赖外部 DLL。注意静态链接后如果还用了第三方库,要确认那个库也支持静态运行库。
5. 二次开发:换城市数据、加换乘权重、改成带权最短路
5.1 换成自己城市的线路数据
最直接的改造就是替换data/routes.txt。你只需要按“线路号|站点A|站点B”的格式,把相邻站点两两一行写进去。比如某条线有 A-B-C-D 四个站,就写三行:1号线|A|B、1号线|B|C、1号线|C|D。换乘站不用特殊标记,只要在不同线路的边里出现同一个站名,邻接表自然就把它们连起来了。改完数据不用动代码,重新运行即可。我一般会先拿三五条线的小数据验证,确认路径正确后再灌全量数据。
5.2 把 BFS 改成带权 Dijkstra
BFS 只算站数,不算距离。如果你想让“少换乘”优先于“少站数”,就得给边加权重。改造分三步:第一,数据文件加一列距离或时间;第二,邻接表从vector<CString>改成vector<pair<CString, int>>;第三,把 queue 换成priority_queue,每次取当前累计代价最小的节点扩展。
// 带权最短路核心片段 typedef std::pair<int, CString> Node; // first 是累计代价,second 是站点 std::priority_queue<Node, std::vector<Node>, std::greater<Node>> pq; std::map<CString, int> dist; dist[start] = 0; pq.push({0, start}); while (!pq.empty()) { Node top = pq.top(); pq.pop(); CString cur = top.second; if (top.first > dist[cur]) continue; // 过期节点跳过 for (const auto& edge : m_weightedAdj[cur]) { CString next = edge.first; int newDist = dist[cur] + edge.second; if (!dist.count(next) || newDist < dist[next]) { dist[next] = newDist; parent[next] = cur; pq.push({newDist, next}); } } }逻辑说明:dist记录起点到每个站点的当前最短代价,priority_queue按代价从小到大弹出。top.first > dist[cur]这行是跳过已经失效的旧记录,不加的话算法仍然正确但会多做无用功。参数上,edge.second就是你在数据文件里加的那一列权重,可以是站间距离,也可以是换乘惩罚值。
5.3 加一个“最少换乘”模式
如果不想动算法结构,还有一个取巧的办法:在 BFS 扩展时,优先扩展与当前线路相同的邻居。具体做法是在邻接表里额外记录每条边属于哪条线路,BFS 的 queue 改成双端队列,同线路的邻居 push_front,换线路的 push_back。这样第一次到达终点时换乘次数最少。这个改法比 Dijkstra 轻量,适合不想大改数据结构的场景。
5.4 验证改造是否正确的笨办法
改完算法最怕的是“看起来对但边界情况错”。我一般会准备一组手工验证过的用例:相邻站、跨线换乘、不可达站点、起点等于终点。每次改完算法先跑这四个用例,全过了再跑全量数据。另外可以在结果列表里把总站数和换乘次数也显示出来,方便肉眼比对。从那以后我每次改路径算法,都强制走一遍这组用例,省得在演示的时候翻车。希望帮到你。
本文还有配套的精品资源,点击获取