简介:这是一份基于MFC的南京地铁查询系统完整工程源码,面向需要上手Windows界面编程的C++学习者与课程设计开发者,有助于理解从界面搭建到查询业务落地的全过程。资源包共43个文件,约2.44MB,集合头文件、源文件、工程配置、界面资源、线路图素材以及编译后的可执行程序,可直接运行并对照代码调试。系统以对话框为主界面,覆盖站点数据管理、条件输入、结果展示等模块;通过阅读源码可掌握MFC控件布局、消息映射机制、文件或ODBC数据访问,以及最短换乘路线等查询算法的实际运用。已有958人学习浏览,资料完整、工程结构紧凑,适合希望通过真实项目提升MFC实战能力与排错能力的开发者。 做了这么多年Windows桌面开发,其实我一直觉得MFC被低估了。虽然现在一说到GUI框架,大家脑子里都是Qt、C#、Electron,但MFC这套基于C++的Windows原生界面框架,在课程设计、公司内部工具、工业控制软件这些场景里依然是绕不开的存在。前阵子我刚完成一个“南京地铁查询系统”,就是用MFC从头搭的,这里把整个设计和实现过程完整复盘一下。
这个系统说白了就是一个桌面版的南京地铁线路查询工具,核心功能包括:地铁站点信息管理、任意两站之间的最短路径计算(换乘最少)、线路图展示和站点信息检索。对于正在做MFC课程设计、毕业设计,或者想系统梳理MFC开发流程的同学,这篇东西应该能帮你少踩不少坑。已经熟悉MFC基础操作的朋友,可以重点看第三部分的算法实现和第四部分的打包发布细节。
1. 整体设计思路与方案选型
1.1 为什么用MFC而不是Qt或C#
选MFC做南京地铁查询系统,最大的理由是它跟Win32 API的天然亲和性。MFC本质上是对Windows消息机制的C++封装,你用MFC拖一个按钮、弹一个对话框,底层走的还是那套WM_CREATE、WM_COMMAND消息流转。这意味着你在学校学的Windows编程底层知识不会白费,而且MFC项目生成的原生Win32程序在Windows上跑起来资源占用极低。
Qt虽然跨平台、界面也漂亮,但它引入了自己的信号槽机制和元对象编译器,学习曲线比MFC陡不少;C#的WinForms/WPF开发效率确实高,但需要目标机器装.NET框架。对于一个要打包发给老师或同学演示的地铁查询系统,MFC直接编译成纯净的.exe,双击就能跑,不用配环境,这一点非常加分。另外VS2013到VS2022都内置MFC开发环境,不用额外装插件,对新手特别友好。
1.2 系统功能模块划分
整个系统我拆成了四个模块,各管一摊,互不干扰:
| 模块名 | 职责 | 核心实现 |
|---|---|---|
| 站点数据管理 | 读取地铁站名、线路信息、站点坐标 | C++文件读写 + 自定义数据结构 |
| 最短路径算法 | 计算两站间最短路线和换乘方案 | Dijkstra算法(邻接矩阵存图) |
| 界面交互模块 | 输入站点、展示查询结果、绘制线路 | MFC控件 + GDI绘图 |
| 结果输出模块 | 换乘方案文本展示、线路高亮 | CListBox + CStatic + OnPaint重绘 |
模块化设计的好处是后期调试特别省心。比如最短路径算法算错了,我只需要盯着一个.cpp文件查逻辑,不用满工程乱找。换乘方案显示有问题,就去查CListBox控件相关的代码,边界非常清楚。
1.3 数据结构的选定
南京地铁目前有十几条运营线路,站点数量在两百个左右。这种规模的数据量,用邻接矩阵存图最合适——因为两百个点的邻接矩阵只有四万个元素,内存占用极小,而且Dijkstra算法在稠密图上用邻接矩阵实现,代码简洁、效率也够用。
站点之间的关系我定义成这样:
struct MetroStation { int stationId; // 站点编号 CString stationName; // 站点名称 int lineId; // 所在线路编号 CPoint pos; // 在界面上的坐标(绘制线路图用) }; struct Edge { int from; // 起点站编号 int to; // 终点站编号 int weight; // 权重,默认1站 int lineId; // 位于哪条线 };换乘站的编号策略很关键。同一站名如果有多条线路经过,我在数据文件里只保留一条记录,用lineId数组记录它属于哪些线路。这样既避免了一个实体站被拆成多个节点导致路径计算错乱,又能在换乘提示时准确告诉用户“在某某站换乘几号线”。
2. 界面布局与控件使用详解
2.1 对话框程序的控件布局方案
MFC有三种界面载体:框架窗口(CFrameWnd)、文档视图(CView)、对话框(CDialog)。我这个查询系统的主界面用的是对话框。原因很简单:查询系统不需要复杂的文档编辑、撤销重做功能,对话框拖拽控件就能完成全部界面设计,开发速度最快。
主对话框的IDD_MAIN_DIALOG,我按上中下三个区域划分:
- 上方区域:两个组合框(CComboBox),分别用来选择起点站和终点站,旁边放一个“查询”按钮。
- 中间区域:一个列表框(CListBox),用来显示查询结果——包括总站数、换乘次数、具体乘车路线。
- 下方区域:一个自绘的静态区域,我用OnPaint画出了简化的南京地铁线路拓扑图。
在VS2013的对话框编辑器里,这些控件都可以直接拖拽放置。但有一点要注意:对话框编辑器默认用像素坐标定位控件,如果直接套用默认布局,在分辨率不同的屏幕上显示会跑偏。我的做法是重写对话框的OnInitDialog,在里面用GetClientRect获取客户区实际尺寸,然后动态调整各控件的坐标。比如起点组合框是居中还是靠左,宽高占比多少,全部按比例计算,这样程序在任何分辨率下都能正常显示。核心代码大概是这样的:
BOOL CMainDlg::OnInitDialog() { CDialogEx::OnInitDialog(); CRect rect; GetClientRect(&rect); int w = rect.Width(); int h = rect.Height(); // 按比例调整控件位置 m_ComboStart.MoveWindow(w * 0.05, h * 0.05, w * 0.35, h * 0.08); m_ComboEnd.MoveWindow(w * 0.45, h * 0.05, w * 0.35, h * 0.08); m_BtnQuery.MoveWindow(w * 0.85, h * 0.05, w * 0.12, h * 0.08); m_ListResult.MoveWindow(w * 0.05, h * 0.18, w * 0.9, h * 0.32); return TRUE; }2.2 控件消息与数据绑定的实践心得
MFC的控件操作,绕不开DDX(Dialog Data Exchange)机制。把组合框控件和CString变量绑定之后,代码里操作变量就能改变界面显示,反之界面操作也会同步到变量——这套逻辑虽然老,但确实好用。
// 头文件中声明控件变量和值变量 CComboBox m_ComboStart; CComboBox m_ComboEnd; CString m_strStart; CString m_strEnd; // DoDataExchange中建立绑定 DDX_Control(pDX, IDC_COMBO_START, m_ComboStart); DDX_Text(pDX, IDC_COMBO_START, m_strStart);需要注意,控件变量(CComboBox m_ComboStart)和值变量(CString m_strStart)是两条绑定线,前者用于获取选中项索引、添加条目等控件操作,后者用于快速读取当前显示文本。两者配合使用的时候,执行完m_ComboStart.SetCurSel(0)之后,必须调用UpdateData(FALSE)才能把选中项同步到m_strStart。这个细节漏掉,很容易出现界面显示正常但程序逻辑拿到的值是空的的情况。
查询按钮的消息处理函数里,我做了两轮判断:先判断起点和终点是否为空,再判断是否选了同一个站。这些校验逻辑写全了,用户即使乱点也不会让程序崩溃。
2.3 自定义按钮与样式美化的思路
原始MFC按钮是那种老式的灰色凸起风格,确实不够好看。热搜词里有“MFC自定义按钮”,这块我也折腾过。MFC按钮自绘的常见做法是继承CButton写一个CMyButton类,重写DrawItem函数。在Owner-Draw(自绘)模式下,WM_DRAWITEM消息会触发DrawItem回调,你在里面用CDC画笔自己画背景色、边框、文字,就能实现扁平化、渐变色、圆角等效果。
不过要提醒一句:自绘按钮的坑在于状态管理。鼠标悬停、按下、禁用、获得焦点,这些状态都要从DrawItem传进来的LPDRAWITEMSTRUCT里读取,然后对应画不同的视觉样式。如果你只画了正常状态,用户鼠标移上去没反馈,用起来会特别别扭。我把按钮状态判断封装成了一个工具函数,稍微有点代码量,但对提升软件完成度帮助挺大的。
3. 核心算法与数据管理实现
3.1 南京地铁线路数据的存储方式
地铁线路数据我选择了文本文件做存储,而不是数据库。因为查询系统是单机程序,数据量不大,纯文本配置文件更容易被老师和同学阅读、修改。文件格式是自定义的,用空格和逗号做分隔,每一行代表一条边:
// metro_data.txt 示例 1,2,1 // 站点1 到 站点2 属于1号线 2,3,1 3,4,1 ...文件里还单独维护了站点名称列表,让数据文件和站点编号对应起来。程序启动时,用CStdioFile逐行读取,插入到预先定义好的map<int, MetroStation>容器里。这里用STL的map而不是数组,是为了方便站点编号和站点名互查。
热搜词里提到“MFC查找MySQL”,如果你确实想用数据库存储地铁数据,思路也简单。用MySQL C API连接数据库,执行SELECT语句把结果集读取到内存即可。不过要把MySQL的lib和dll一起打包到项目里,配置起来麻烦不少。对于一个地铁查询系统,文件存储完全够用,数据库属于杀鸡用牛刀,但作为学习MySQL与MFC结合的手段,试一试也无妨。
3.2 Dijkstra最短路径算法的落地实现
两站之间的最短路径问题,经典解法是Dijkstra算法。核心思想就是贪心:每次从未访问的节点里挑一个距离起点最近的,然后把它能到达的邻居节点距离更新一遍,重复这个过程直到终点被访问。
我的实现直接用了邻接矩阵int graph[MAX_STATION][MAX_STATION],初始化为一个大数INF表示不可达。读取文件时,每条边设置graph[from][to] = 1,边权重按1站算。
void CMetroDlg::Dijkstra(int src, int dst, vector<int>& path) { int n = stationCount; vector<int> dist(n, INF); vector<int> prev(n, -1); vector<bool> visited(n, false); dist[src] = 0; for (int i = 0; i < n; i++) { int u = -1; int minDist = INF; // 找到当前未访问的距离最小的节点 for (int j = 0; j < n; j++) { if (!visited[j] && dist[j] < minDist) { u = j; minDist = dist[j]; } } if (u == -1) break; visited[u] = true; // 更新邻居距离 for (int v = 0; v < n; v++) { if (!visited[v] && graph[u][v] < INF && dist[u] + graph[u][v] < dist[v]) { dist[v] = dist[u] + graph[u][v]; prev[v] = u; } } } // 回溯路径 if (dist[dst] >= INF) return; int cur = dst; while (cur != -1) { path.push_back(cur); cur = prev[cur]; } reverse(path.begin(), path.end()); }Dijkstra算法最容易被忽略的细节是“负权边”问题。如果图的边权出现负数,贪心策略就失效了,必须改用Bellman-Ford或SPFA。好在轨道交通的站间距离永远是正数,这个坑天然不存在,从数学上保证了算法正确性。
3.3 换乘提示的实现技巧
查询线路时,光告诉用户“经过几个站”还不够,必须告诉用户在哪儿换乘。我的做法是:Dijkstra算出来的路径是站点编号序列,依次遍历这些站点,用我之前定义的换乘标记位判断相邻两段是否属于同一条线路。
具体来说,如果path[i]到path[i+1]属于线路A,path[i+1]到path[i+2]属于线路B,那path[i+1]就是换乘站。这里要特别小心多线换乘站,比如南京站同时经过1号线和3号线,把换乘站信息显示为“可在南京站换乘1号线/3号线”。
为了让结果更直白,我把换乘方案输出成类似这样的格式:
=== 线路查询结果 === 从 南京南站 到 新街口站,途经 4 站 乘车路线:南京南站 -> 安德门站 -> 中华门站 -> 三山街站 -> 新街口站 全程 1 条线路直达,无需换乘 总用时(预估):约 12 分钟如果是换乘方案:
=== 线路查询结果 === 从 南京站 到 禄口机场站,途经 15 站 乘车路线:南京站 -> ... -> 南京南站 -> ... -> 禄口机场站 在 南京南站 换乘 S1号线 总用时(预估):约 38 分钟列表控件里用InsertString逐行插入,用SetCurSel选中第一行来触发高亮,体验已经相当不错。
3.4 线路可视化绘制的GDI实现
地铁线路图我用MFC的GDI画出来了。在视图的OnPaint函数里,调用MoveTo和LineTo把每条线路用折线连起来,再在站点位置画一个实心圆作为站点标记。
void CMetroView::OnPaint() { CPaintDC dc(this); dc.SetBkMode(TRANSPARENT); // 画线路 for (int lineId = 1; lineId <= LINE_COUNT; lineId++) { CPen pen(PS_SOLID, 3, lineColors[lineId]); dc.SelectObject(&pen); for (auto& edge : edges) { if (edge.lineId == lineId) { dc.MoveTo(stations[edge.from].pos); dc.LineTo(stations[edge.to].pos); } } } // 画站点 for (auto& st : stations) { dc.Ellipse(st.pos.x - 4, st.pos.y - 4, st.pos.x + 4, st.pos.y + 4); dc.TextOut(st.pos.x + 6, st.pos.y - 6, st.stationName); } }站点坐标保存在数据文件里,前期要手动在地图背景上采集坐标。这一步比较花时间,但做完之后整个界面的完成度一下就上来了。查询完成后,我还会调用Invalidate触发重绘,同时用另一种颜色的画笔高亮最短路径上的站点和区间,视觉效果特别直观。
4. 常见问题与项目打包发布
4.1 MFC开发中常见的小问题排查
我在开发这个项目的过程中,踩过不少坑,整理了几个高频问题,新手几乎都会遇到:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 编译报错“无法打开预编译头文件” | stdafx.h没有正确包含,或预编译头设置冲突 | 右键项目 -> 配置属性 -> C/C++ -> 预编译头,选择“使用”对应stdafx.h |
| 列表框中文乱码 | 项目字符集设置为多字节,但代码用了宽字符 | 统一使用Unicode字符集,代码用CString、_T()宏包裹字符串字面量 |
| 程序退出时崩溃 | 指针没有释放,或对话框销毁时控件消息还在处理 | 析构函数里将所有new出来的对象delete,按顺序销毁控件 |
| 按钮点击没反应 | 消息映射没有绑定,或绑定的控件ID不对 | 确认BEGIN_MESSAGE_MAP里有ON_BN_CLICKED(IDC_BUTTON, &Class::OnBtnClick) |
| 绘制图形闪烁 | OnPaint里没有做双缓冲 | 用CMemDC先画到内存DC,再一次性BitBlt到屏幕上 |
特别说一下Unicode字符集问题。VS2013默认项目可能是多字节字符集,在MFC里使用中文字符串时经常会出乱码。我的习惯是,创建项目后第一件事就是把字符集改成“使用Unicode字符集”,然后所有字符串字面量都用_T("中文")宏包裹。这样无论是窄字符函数还是宽字符函数,都能正确处理中文。
双缓冲解决闪烁也要多说两句。MFC重绘控件的时候,直接往屏幕DC上画,频繁擦除和重画就会剧烈闪烁。用CMemDC在内存里画好再整体刷新,界面的流畅度会上升一个档次。
4.2 项目打包与分发必备步骤
MFC项目编译出来后是一个.exe文件,但直接拷贝到别的电脑上运行,大概率会提示“缺少MFC动态链接库”。这是因为Release版本默认使用了动态链接方式,目标机器需要安装对应的VC运行库。解决方式有两个:
方案一:静态编译(简单粗暴)
打开项目属性 -> 配置属性 -> C/C++ -> 代码生成 -> 运行库,把“多线程DLL(/MD)”改成“多线程(/MT)”。改完之后,MFC库代码会直接编译进exe里,生成的文件会变大一些,但任何Windows电脑上都能直接运行,不用另外装环境。
静态编译的缺点是exe体积会明显变大(我这个查询系统本来几百KB,静态编译后变成5MB左右),但对一个演示项目来说,换来的兼容性非常值。
方案二:带上DLL一起分发
如果你不想改静态库设置,也可以把Debug(或Release)目录下的MFC DLL和VC运行库DLL一起拷贝到目标机器。具体需要的文件可以查看编译输出日志,或者直接用Dependency Walker工具分析exe的依赖项。这个方案比较麻烦,而且MFC的发行DLL有好几个版本,对新手不友好,我更推荐方案一。
打包发布前还有几个细节:要给exe设置一个好看的图标,在项目属性 -> 链接器 -> 清单文件 -> 输入里添加.ico资源;Release版本还要把优化选项打开,保证运行效率。最后把exe、站点数据文件和程序说明文档放在同一个文件夹里,压缩成zip提交,一个完整的项目交付就完成了。
4.3 调试技巧与效率提升
MFC项目调试,最常用的手段当然是断点和单步执行。但有些逻辑性问题,比如Dijkstra算出来的路径不对,断点也很难看出来,我一般会在关键函数里用OutputDebugString输出中间结果,通过VC输出窗口观察变量变化。
另外,MFC的TRACE宏也是调试神器。在程序中加入TRACE(_T("当前经过节点:%d\n"), nodeId),运行后在输出窗口能看到日志信息,配合DebugView工具甚至可以实时查看其它进程的输出。这个调试习惯,能帮你省下大量排查时间。
5. 过程中的经验与心得
最后说一下我个人的真实感受。MFC这套框架,确实没有现在的新框架那么“时髦”,但它有一个难以替代的价值:让你真正理解Windows图形界面程序的消息驱动本质。用Qt拖拖控件、写写信号槽,很多东西是框架帮你做完了;用MFC,所有的消息循环、控件通知、DC绘制都是可见的,代码里每一个函数都能找到对应的Windows机制。
在做南京地铁查询系统的过程中,最大的收获不是那个Dijkstra算法本身,而是学会了怎么把一个实际需求拆解成模块、数据结构和界面交互。算法上稍有追求的同学,还可以把Dijkstra换成A*算法提升搜索效率,或者用Floyd算法把全站点对的最短路径预先算好存起来,查询时直接取缓存结果,响应速度会更快。
如果你也准备动手做一个MFC项目,我的建议很简单:先把数据结构和算法跑通,再碰界面,最后再考虑打包。反过来先做界面的话,你会在调试控件的过程中消耗掉大量热情。功能先能跑起来,界面的打磨和美化永远排在后面——这个顺序能让你少走非常多弯路。
本文还有配套的精品资源,点击获取