简介这是一份面向C与Windows桌面开发学习者的MFC实战项目源码主题为南京地铁线路查询系统适合刚接触MFC框架、希望借助完整工程理解消息映射与对话框编程的初学者和进阶开发者。压缩包共43个文件约2.44MB以cpp与h源码文件为核心辅以dsw、dsp工程配置、rc资源脚本、bmp地铁示意图及exe可执行文件另含obj、pdb等编译调试产物便于直接运行与断点调试。已有962人学习下载。项目覆盖MFC应用入口、对话框界面搭建、按钮与输入框事件响应、基于location.dat.txt的站点数据读取以及地图展示与路径查询逻辑读者可据此梳理从界面到数据的完整调用链理解消息驱动机制与异常处理写法并在此基础上尝试替换数据源或优化查询算法积累Windows应用开发的工程经验。1. 从一张地铁线路图说起这个MFC项目到底能跑出什么很多做桌面端开发的朋友第一次接触 MFC 都是被逼的——课程设计要交、上位机要写、老系统要维护。我拿到这个「基于MFC的南京地铁查询系统」的时候第一反应也是这年头还有人用 MFC但真把源码跑起来、把换乘逻辑拆开看了一遍之后我改主意了。它不是一个玩具 Demo而是一个把「图论最短路 Windows 原生界面」串起来的完整小系统输入起点站和终点站程序会算出最短路径、最少换乘方案还能把经过的线路和站点按顺序列出来。适合谁适合正在做 C 课程设计的学生、需要给工控上位机加路径规划模块的开发者以及想找一个「能跑、能改、逻辑清晰」的 MFC 练手项目的人。下面我按「它是什么 → 怎么跑起来 → 核心代码怎么写的 → 怎么改成你自己的」这条线把它拆一遍。2. 项目定位与技术选型为什么是 MFC 图算法2.1 这个系统解决的是什么问题地铁查询本质上是一个带权图的最短路径问题。把每个站点看作图的一个顶点相邻站点之间的乘车区间看作一条边边的权重可以是「站数」「时间」或者「换乘次数」。用户输入起点和终点系统要做的就是在这样一张图上跑一遍最短路算法然后把路径还原成人类能看懂的「几号线 → 哪站换乘 → 几号线」的形式。这个项目把这件事封装成了一个 Windows 桌面程序。界面部分用 MFC 的对话框框架数据部分用邻接表或邻接矩阵存储线路图算法部分自己实现 Dijkstra 或者 BFS 变种。它不依赖任何第三方图论库所有逻辑都在工程内部这也是它适合拿来改的原因——你想换成别的城市、想加时间权重、想加票价计算改的都是自己看得懂的代码。2.2 技术栈拆解层次技术选型说明开发语言C主体逻辑兼容 C11 及以上界面框架MFC对话框模式基于 CDialogEx控件用 Edit Control List Control开发环境Visual Studio需要安装「使用 C 的桌面开发」工作负载数据存储内存数据结构 可选文本文件站点和线路信息硬编码或从 txt 读取核心算法Dijkstra / BFS最短路径与最少换乘两套逻辑MFC 在这里的角色其实很纯粹它负责把用户输入接进来、把结果画出来。真正有含量的部分在算法层。我见过不少人一上来就纠结 MFC 的消息映射、DDX/DDV 这些机制结果算法部分草草了事。这个项目的正确打开方式是先把图建好再把算法跑通最后才去调界面。2.3 工程目录结构一个典型的 MFC 对话框工程目录大概长这样SubwayQuery/ ├── SubwayQuery.sln // VS 解决方案文件 ├── SubwayQuery/ │ ├── SubwayQuery.h // 主对话框类声明 │ ├── SubwayQuery.cpp // 主对话框类实现界面逻辑 │ ├── SubwayQueryDlg.h // 对话框头文件 │ ├── SubwayQueryDlg.cpp // 对话框实现核心交互 │ ├── Graph.h // 图结构定义 │ ├── Graph.cpp // 图的构建与算法实现 │ ├── Station.h // 站点数据结构 │ ├── stdafx.h / pch.h // 预编译头 │ └── res/ // 资源文件图标、对话框模板 └── x64/Debug/ // 编译输出目录核心文件就三个Graph.h/cpp管数据结构和算法SubwayQueryDlg.cpp管界面交互。你把这两个文件读透整个项目就没有黑匣子了。提示不同版本的 VS 生成的预编译头文件名不一样老版本叫stdafx.h新版本叫pch.h编译报错找不到头文件时先检查这里。3. 跑起来再说环境配置与首次编译3.1 环境准备你需要一台 Windows 机器装 Visual Studio 2019 或 2022 都行。安装的时候务必勾选「使用 C 的桌面开发」这个工作负载里面包含了 MFC 组件。如果你装的时候没勾可以打开 Visual Studio Installer 单独补装「适用于最新 v143 生成工具的 C MFC」。常见做法是直接用 VS 打开.sln文件然后右键解决方案 → 重新生成。如果一切正常你会看到输出窗口打印出一串编译信息最后显示「生成: 成功 1 个」。3.2 编译时最容易翻车的三个点第一个坑是字符集。MFC 项目默认用 Unicode 字符集如果你的代码里混用了char*和CString编译会报一堆类型不匹配。解决办法是在项目属性 → 配置属性 → 高级 → 字符集中确认选的是「使用 Unicode 字符集」然后把字符串统一成CString或std::wstring。第二个坑是 MFC 的使用方式。项目属性里有一个「MFC 的使用」选项必须选「在共享 DLL 中使用 MFC」或者「在静态库中使用 MFC」。如果选成「使用标准 Windows 库」链接阶段会报一堆LNK2019找不到符号。第三个坑是平台工具集。如果你拿到的工程是老版本 VS 创建的打开时提示「需要重定向项目」点确定让它升级就行。但升级之后可能有些 API 签名变了比如CWnd::MessageBox的参数类型这个要具体看报错信息。3.3 运行后的界面长什么样编译成功后直接 F5 运行会弹出一个对话框。界面上通常有这几个控件两个下拉框或编辑框一个选起点站一个选终点站一个「查询」按钮一个列表控件或只读编辑框显示查询结果可能还有一个「清空」按钮你选两个站点查询结果区域就会列出路径。如果点了没反应八成是消息映射没绑上或者控件变量没关联。这个后面排查章节会细说。3.4 数据从哪来站点和线路数据一般有两种存法。一种是直接硬编码在Graph.cpp的初始化函数里用数组或std::vector把每条线的站点按顺序塞进去。另一种是从同目录下的 txt 文件读格式大概是每行「线路名 站点1 站点2 ...」。硬编码的好处是跑起来不依赖外部文件坏处是改线路要重新编译。我一般会把它改成读文件的方式方便二次开发时换城市数据。// 硬编码方式初始化线路数据示例 void Graph::InitLines() { // 1号线站点序列 std::vectorCString line1 { _T(迈皋桥), _T(红山动物园), _T(南京站), _T(新模范马路), _T(玄武门), _T(鼓楼), _T(珠江路), _T(新街口) // ... 后续站点省略 }; AddLine(_T(1号线), line1); // 2号线站点序列 std::vectorCString line2 { _T(油坊桥), _T(雨润大街), _T(元通), _T(奥体中心), _T(兴隆大街), _T(集庆门大街), _T(云锦路), _T(莫愁湖), _T(汉中门), _T(上海路), _T(新街口) // ... 后续站点省略 }; AddLine(_T(2号线), line2); }这段代码的逻辑很直白把每条线的站点按运营顺序放进一个vector然后调用AddLine把这条线注册到图结构里。AddLine内部会做两件事——把每个站点加入站点集合以及把相邻站点之间连一条边。参数就是线路名和站点序列改线路只需要改这个vector的内容。注意站点名称必须和查询时输入的名称完全一致包括空格和标点。建议在AddLine里做一次去空格处理否则用户输入「新街口 」带个尾空格就查不到了。4. 核心模块拆解图结构、最短路与换乘逻辑4.1 图结构的定义这个项目用的是邻接表。每个站点对应一个节点节点里存一个列表列表的每一项是「相邻站点 所属线路」。为什么不用邻接矩阵因为地铁站点数量不多但连接稀疏邻接表省空间遍历邻居也快。// Station.h - 站点与边的数据结构 struct Edge { int toStationId; // 相邻站点的编号 CString lineName; // 这条边属于哪条线路 int weight; // 权重可以是1站数或换乘代价 }; struct Station { int id; // 站点唯一编号 CString name; // 站点名称 std::vectorEdge neighbors; // 邻接边列表 }; class Graph { public: std::vectorStation stations; // 所有站点 std::mapCString, int nameToId; // 站名到编号的映射加速查找 void AddLine(const CString lineName, const std::vectorCString stops); int FindStation(const CString name); // ... 算法接口 };Edge里的weight是关键。如果你只关心最少站数所有边的权重都设成 1。如果你关心最少换乘那就要在换乘的时候给一个额外的代价。常见做法是同一条线内的边权重为 1换乘边的权重设为一个较大的值比如 100这样算法会优先选换乘少的路径。4.2 Dijkstra 算法的实现Dijkstra 是这个项目最核心的算法。它的思路是从起点开始每次选出当前距离最小的未访问节点然后松弛它的所有邻居。用优先队列可以把时间复杂度压到 O(E log V)。// Graph.cpp - Dijkstra 最短路 std::vectorint Graph::Dijkstra(int startId, int endId, int totalCost) { int n stations.size(); std::vectorint dist(n, INT_MAX); // 起点到各点的最短距离 std::vectorint prev(n, -1); // 记录前驱节点用于还原路径 std::vectorbool visited(n, false); // 标记是否已确定最短路 // 小顶堆pair距离, 节点编号 std::priority_queuestd::pairint,int, std::vectorstd::pairint,int, std::greater pq; dist[startId] 0; pq.push({0, startId}); while (!pq.empty()) { auto [d, u] pq.top(); pq.pop(); if (visited[u]) continue; // 已经确定过的节点跳过 visited[u] true; if (u endId) break; // 提前终止不用跑完所有点 for (const Edge e : stations[u].neighbors) { int v e.toStationId; int newDist dist[u] e.weight; if (newDist dist[v]) { dist[v] newDist; prev[v] u; pq.push({newDist, v}); } } } totalCost dist[endId]; // 从终点回溯到起点还原路径 std::vectorint path; for (int cur endId; cur ! -1; cur prev[cur]) { path.push_back(cur); } std::reverse(path.begin(), path.end()); return path; }逐段说明一下。dist数组存的是从起点到每个站点的当前已知最短距离初始化为INT_MAX表示不可达。prev数组是「后悔药」——它记录了每个站点是从哪个站点走过来的最后靠它把路径倒推出来。优先队列里存的是「距离 站点编号」每次弹出距离最小的那个。visited数组防止同一个节点被重复处理。松弛操作是算法的灵魂如果从起点经过 u 再到 v 的距离比当前已知的到 v 的距离更短就更新dist[v]并把 v 压入队列。e.weight就是前面说的边权改这个值就能改变算法的偏好。参数方面startId和endId是站点编号通过nameToId映射从站名转换而来。totalCost是输出参数返回最短路径的总代价。返回值是路径上的站点编号序列。4.3 换乘逻辑的处理光有最短路还不够用户要的是「在哪换乘、换几号线」。这需要在路径还原之后再遍历一遍路径对比相邻两条边的lineName如果不一样就说明发生了换乘。// 把站点编号路径转换成带换乘提示的文字描述 CString Graph::FormatPath(const std::vectorint path) { CString result; if (path.size() 2) return _T(路径无效); CString currentLine _T(); result stations[path[0]].name; for (size_t i 1; i path.size(); i) { // 找到连接 path[i-1] 和 path[i] 的边取出线路名 CString edgeLine GetEdgeLine(path[i-1], path[i]); if (edgeLine ! currentLine) { // 线路变了说明要换乘 if (!currentLine.IsEmpty()) { result _T( → 换乘 ) edgeLine; } else { result _T( → 乘坐 ) edgeLine; } currentLine edgeLine; } result _T( → ) stations[path[i]].name; } return result; }GetEdgeLine是一个辅助函数在stations[u].neighbors里找到指向 v 的那条边返回它的lineName。currentLine记录当前乘坐的线路一旦发现下一条边的线路和当前不同就插入一个「换乘」提示。这样输出的结果就是「新街口 → 乘坐 1号线 → 鼓楼 → 换乘 4号线 → ...」这种人类可读的形式。提示如果两条线路共用一个换乘站但站点编号不同比如 1号线的「新街口」和 2号线的「新街口」被当成两个节点那就要在AddLine里做站点合并——同名站点复用同一个 id。这个细节没处理好换乘逻辑会直接失效。5. 避坑与排查编译、运行、算法三类问题5.1 编译报错 LNK2019 找不到符号现象链接阶段报一堆LNK2019提示__imp_?xxx之类的符号找不到。原因MFC 的使用方式没配对或者字符集不一致导致函数签名对不上。解决项目属性 → 配置属性 → 常规 → 「MFC 的使用」改成「在共享 DLL 中使用 MFC」同时确认「高级」里的字符集是 Unicode。改完清理解决方案再重新生成。5.2 点查询按钮没反应现象界面正常显示选好站点后点「查询」结果区域一片空白。原因按钮的点击消息没有映射到处理函数或者控件变量没有正确绑定。解决打开资源视图双击对话框上的查询按钮VS 会自动生成OnBnClickedButtonQuery函数并在消息映射表里注册。如果没生成手动在.h里声明afx_msg void OnBnClickedButtonQuery();在.cpp的BEGIN_MESSAGE_MAP和END_MESSAGE_MAP之间加ON_BN_CLICKED(IDC_BUTTON_QUERY, CSubwayQueryDlg::OnBnClickedButtonQuery)。控件变量则通过「添加变量」向导绑定或者用GetDlgItemText直接取值。5.3 查询结果路径明显不是最短现象查出来的路径绕了一大圈明明有更短的走法。原因边权设置有问题或者图里存在重复边导致算法选了错误的那条。解决检查AddLine里有没有对同一条边重复添加。如果两条线共用一个区间比如某些共线运营的区段要确保不会插入两条指向同一站点的边。另外确认weight的初始值——如果所有边权重都是 1那 Dijkstra 退化成 BFS结果应该是最少站数如果你想要最少换乘就要把换乘代价调大。5.4 站点名带空格导致查不到现象手动输入站名能查到但从下拉框选或者从文件读入的站名查不到。原因字符串首尾有不可见字符或者全角半角不一致。解决在FindStation里对输入做一次Trim处理去掉首尾空格。如果是文件读取注意文件编码——UTF-8 带 BOM 的文件读进来第一个站名前面会多一个不可见字符建议用CStdioFile按 Unicode 方式读或者把文件转成 ANSI 编码。5.5 程序运行一段时间后崩溃现象反复查询多次后程序闪退。原因路径还原时prev数组出现环导致for循环死循环或者越界访问。解决在Dijkstra里加一个防御——如果prev[cur]指向的节点已经被访问过说明图里有负权边或者数据有环直接 break。地铁图正常情况下不会有负权出现环基本是数据录入错误检查AddLine里有没有把同一个站点重复加入同一条线。6. 二次开发换成你自己的城市数据与算法调优6.1 把数据从硬编码改成配置文件原项目把线路数据写在InitLines里改起来要重新编译。我一般会把它改成读lines.txt格式如下1号线,迈皋桥,红山动物园,南京站,新模范马路,玄武门,鼓楼,珠江路,新街口 2号线,油坊桥,雨润大街,元通,奥体中心,兴隆大街,集庆门大街,云锦路,莫愁湖,汉中门,上海路,新街口读取的代码用CStdioFile逐行读按逗号分割第一段是线路名后面是站点序列。这样换城市只需要换一个 txt 文件不用动代码。void Graph::LoadFromFile(const CString filePath) { CStdioFile file; if (!file.Open(filePath, CFile::modeRead | CFile::typeText)) { AfxMessageBox(_T(线路数据文件打开失败)); return; } CString line; while (file.ReadString(line)) { line.Trim(); if (line.IsEmpty()) continue; // 按逗号分割 std::vectorCString parts; int pos 0; CString token line.Tokenize(_T(,), pos); while (!token.IsEmpty()) { token.Trim(); parts.push_back(token); token line.Tokenize(_T(,), pos); } if (parts.size() 2) continue; CString lineName parts[0]; std::vectorCString stops(parts.begin() 1, parts.end()); AddLine(lineName, stops); } file.Close(); }Tokenize是CString自带的分割函数第二个参数是引用传参的起始位置每次调用会返回下一个片段。注意分割完之后每个片段都要Trim防止空格残留。AddLine内部会处理站点去重和边的建立。6.2 加入时间权重和票价计算如果你想让查询结果更实用可以把边权从「1」改成「两站之间的行驶时间秒」。数据可以从公开的线路信息里估算比如平均每站 2 分钟换乘站加 5 分钟步行时间。改法就是在AddLine里给每条边赋不同的weightDijkstra 会自动按总时间最短来算。票价计算则是在路径确定之后根据经过的站数或者里程来算。常见做法是站数 ≤ 6 收 2 元7 到 10 站收 3 元以此类推。这个逻辑放在FormatPath之后单独算不影响最短路本身。6.3 界面改造的几个方向MFC 的界面确实不好看但改起来也不难。几个低成本的方向把List Control换成RichEdit显示带颜色的路径文本加一个「交换起点终点」按钮把站点选择从下拉框改成可搜索的编辑框。这些都是在SubwayQueryDlg.cpp里加消息处理函数的事不涉及算法层。我自己的习惯是每次改完算法层先用几个固定用例跑一遍——比如「新街口到南京站」「迈皋桥到油坊桥」——确认路径和换乘次数没变再去动界面。算法和界面混着改出了问题你都不知道是哪层的锅。从那以后我每次拿到这类图算法项目都强制先把数据加载和算法跑通再碰 UI。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?