
简介这是一份基于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的控件操作绕不开DDXDialog 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逐行读取插入到预先定义好的mapint, 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, vectorint path) { int n stationCount; vectorint dist(n, INF); vectorint prev(n, -1); vectorbool 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[i1]属于线路Apath[i1]到path[i2]属于线路B那path[i1]就是换乘站。这里要特别小心多线换乘站比如南京站同时经过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项目我的建议很简单先把数据结构和算法跑通再碰界面最后再考虑打包。反过来先做界面的话你会在调试控件的过程中消耗掉大量热情。功能先能跑起来界面的打磨和美化永远排在后面——这个顺序能让你少走非常多弯路。本文还有配套的精品资源点击获取