首页 / 资讯中心 / 文章详情

GDI公交查询系统解析:MFC绘图与换乘算法从原理到实战

GDI公交查询系统解析:MFC绘图与换乘算法从原理到实战 ★ FEATURED ARTICLE
简介一份基于GDI技术的VC公交线路查询系统完整源码包面向Windows桌面应用开发、数据结构与算法课程的C学习者尤其适合作为课程设计或毕业设计参照。系统使用Windows GDI接口绘制公交地图、站点与线路支持输入起点和终点借助路径查找算法展示可选公交方案界面采用设备无关的图形渲染保证不同分辨率下显示一致。压缩包共49个文件以15个头文件与14个C源文件为主体分别承担界面框架、站点管理、线路解析和路径计算等模块并附带bus_demo.exe可运行程序以及多个dat公交数据文件便于直接运行体验与二次调试。包体约4.31MB轻量清晰。目前已有201人浏览学习。研读源码可掌握GDI绘图基本调用、C面向对象工程组织、公交数据存储格式以及最短路径算法的落地写法是理解图形界面与算法结合实践的优质素材。1. 解压一个“GDI公交查询系统”压缩包后你要面对的是三件事拿到一份「基于GDI技术的VC公交线路查询系统.rar」解压后通常是一堆这样的东西早期 VC 风格的工程文件、几个 .cpp/.h、一个写死了站点坐标的数据文件运气好还带一个编译好的 exe。把它打开窗口里画着歪歪扭扭的公交线路选起点终点能算出一条换乘方案。这个标题听起来像课程设计但它实际上在考三件事第一能不能用 MFC 把窗口程序搭起来第二能不能用 GDI 这种最底层的绘图接口把数据画出来第三能不能用图算法回答“怎么坐车”这种查询。三件事放在一起才是这个压缩包真正的价值。它适合两类人一类是要复现并改造这个项目的学生另一类是想借这个小系统把 Windows 桌面绘图的套路捡起来、以后去做上位机或小型 GIS 原型的开发者。别因为它“老”就小看它GDI 的思路至今还在很多工业软件里跑着。2. 先打通 MFC 窗口与 GDI 绘制通路这类系统为什么几乎都长一个样2.1 单文档视图还是对话框绘图类系统先选对宿主窗口打开压缩包里的工程最先要认清的一件事是这个系统的界面是建立在哪种窗口上的。常见做法无非两种一种是 MFC 对话框把绘图区域放一个 Picture 控件里另一种是文档视图架构用 CView 派生类作为画布。如果是课程设计里的公交查询系统很多早期版本为了省事会用对话框但做线路绘制、做鼠标点选站点对话框方案会越改越别扭因为 Picture 控件的刷新、坐标换算、消息响应都要自己做一层转包。我一般拿到这类工程第一件事不是看查询逻辑而是看它是 CView 框架还是对话框框架。视图框架天然有一个独立的 OnDraw 入口窗口任何区域失效、被遮挡后恢复、大小改变系统都会自动触发重绘所有绘制代码只需要集中在 OnDraw 里这比对话框里手动处理 WM_PAINT 要干净得多。如果压缩包里又是对话框又是自绘控件绘制通路会比较绕改造前先想清楚是否要平移到一个新的视图框架里。公交查询这种“数据 地图 交互”的结构用文档视图承载是更省心的路。2.2 绘图入口与最小线段代码OnDraw 里到底发生了什么MFC 里 CView 的 OnDraw 是 GDI 绘制的主战场。它接收一个 CDC 指针所有画线、画圆、写文字都通过这个指针完成。窗口收到 WM_PAINT 消息时框架会准备好设备上下文交给 OnDraw 执行你在查询按钮里改了结果只需要调用 Invalidate() 通知窗口“内容失效了”系统下一个空闲时刻就会重绘。理解这条链路后面加高亮路径、加换乘标注就都顺着这个思路走。cppvoid CTransitView::OnDraw(CDC* pDC) { // 1. 创建一条 3 像素宽的蓝色实线画笔用来画某条公交线路 CPen penRoute(PS_SOLID, 3, RGB(16, 110, 190)); // 2. SelectObject 把新画笔选入 DC同时拿到旧画笔的指针 CPen* pOldPen pDC-SelectObject(penRoute);// 3. 从站点 A 画到站点 B再画到站点 C pDC-MoveTo(80, 200); pDC-LineTo(240, 160); pDC-LineTo(360, 240); // 4. 画完恢复旧画笔让 DC 回到进入时的状态 pDC-SelectObject(pOldPen);}这段代码的关键是第 2 步和第 4 步。SelectObject 返回旧画笔指针绘制结束后立刻选回去这叫“GDI 状态恢复”。如果画完不恢复DC 里残留的画笔状态会影响后续的站点绘制、文字绘制。另一方面CPen 是栈对象出了 OnDraw 自动析构不会泄露 GDI 句柄。真正的泄漏点往往出现在堆对象上这点后面专门讲。2.3 GDI 对象生命周期栈对象自动释放为什么还不够GDI 对象是系统级资源进程里能创建的句柄数量有上限用完不释放程序会越来越卡最后直接画不出东西。很多初版公交查询系统都会有这个问题尤其是“画完一条线路换一个颜色换一次就 new 一个画笔”这种写法。检查是否泄漏有个土办法任务管理器里看进程的 GDI 对象数静止不动时这个数如果还在涨基本就是泄漏了。GDI 对象常见创建写法释放方式画笔CPen 栈对象 / CreatePen 堆对象栈对象析构自动释放堆对象必须 DeleteObject画刷CBrush 栈对象 / CreateSolidBrush 堆对象同上位图CBitmap CreateCompatibleBitmapDeleteObject注意先选回旧位图字体CFont CreateFontIndirectDeleteObject设备上下文CDC / CClientDC栈对象自动释放CreateCompatibleDC 创建的要用 DeleteDC绘制公交线路时最典型的一个错误示范是在循环里用 CreatePen 创建画笔SelectObject 进 DC画完直接 next从不 DeleteObject。每重绘一次就泄漏一个画笔窗口每次切换、每次遮挡恢复都累积几分钟后界面明显变卡。正确做法是优先用栈对象一条线路一个 CPen 局部变量如果线路数量事先不确定必须用堆对象那么把 HPEN 存下来在绘制结束后统一 DeleteObject。还有一点容易被忽略当你把一个 GDI 位图选入内存 DC 再选择别的对象时位图是删不掉的必须先把旧位图 SelectObject 回来再 DeleteObject。这个顺序错了会报“对象还在 DC 里删不掉”的错属于 GDI 编程里很经典的坑。2.4 老式工程编译不过的先手检查字符集与工作区文件这类压缩包里的工程文件往往是老式工作区格式直接双击打开先碰到的问题多半不是绘图而是编译环境不认工程格式。遇到这种情况常见做法是用新版开发环境打开时选择“转换”或者干脆新建一个 MFC 工程把 .cpp/.h 和资源文件按照原有目录结构加进去。模块越小的项目后者反而越快省得旧工程转换后带着一堆迁移配置问题。真正容易卡住的是字符集不统一。老工程常用多字节字符集新环境默认可能是 Unicode代码里 CString 与 char* 混用编译直接报错。项目属性里把字符集统一成 Unicode 或关闭“使用 Unicode 字符集”改回多字节通常能解决大半。这个决定要趁早做因为后面站名读写、文件解析都依赖它。建议一律走 Unicode去兼容现代 Windows 的中文环境更稳。3. 公交线路查询的算法内核先建模再谈最短路径与换乘3.1 站点与线路的数据模型查询结果最终是一串站点 ID公交查询系统里站点和线路是两类核心实体。每个站点有名字和坐标每条线路有名字和按行驶方向排列的站点序列。路线有两个含义一个是“地图上怎么画”另一个是“算法里怎么走”。为了让 GDI 只负责画、算法只负责算我会先把这两类实体用结构体定义清楚。cppstruct Station { int id -1; CString name; // 站点名显示在图上 double x 0.0; // 经度或地图系横向坐标 double y 0.0; // 纬度或地图系纵向坐标 vector lineList; // 经过该站点的所有线路 id int screenX 0; // 换算后的屏幕坐标绘制时直接用 int screenY 0; };struct Route { int id -1; CString name; // 线路名如 “K12 路” vector stationList; // 按行驶顺序排列的站点 id这是画线依据 COLORREF color RGB(16, 110, 190); };stationList 是画线顺序的唯一依据相邻两个站点之间才连一条线段。很多翻车现场就是忽略了“顺序”两个字站点集合是对的顺序乱了画出来的线在图上交叉跳跃。算法层最后输出一个 vector 里面是依次经过的站点 IDGDI 只需要按这个 ID 列表去画高亮线不需要知道算法怎么算的。数据从文件读入时我一般用分隔符文本而不是数据库课程设计阶段的站点量用文本文件最省事。每一行存一条线路站点名用竖线分隔读完按顺序填入 Route::stationList同时把站点的 lineList 和坐标信息补全。注意 CString 的 Tokenize 是按分隔符拆串但两次 Tokenize 的位置参数要分开维护不然容易出现“第一个字段丢一半”的怪问题。3.2 最少换乘与最短耗时是两套算法别用一套 Dijkstra 硬解所有问题用户说“查公交”他心里其实有两种问法一种是“最少换乘怎么坐”另一种是“最快怎么到”。这两个问题对应不同算法。最少换乘本质是站在“线路”的角度做广度优先从起点站能上的所有线路出发每换乘一次走一层第一次出现终点站所在的线路时换乘次数就是答案。这个模型里节点是线路边是“两条线路有共同站点”。cpp// linesOfStation[i] 存放经过站点 i 的所有线路 id // stationsOfLine[j] 存放线路 j 途经的所有站点 id int MinTransfer(int srcStation, int dstStation) { vector dist(MAX_LINE, -1); // 线路编号到换乘次数的映射 queue q;// 起点直接能上的线路第一次上车不算换乘 for (int line : linesOfStation[srcStation]) { if (dist[line] -1) { dist[line] 0; q.push(line); } } while (!q.empty()) { int cur q.front(); q.pop(); for (int st : stationsOfLine[cur]) { // 这条线路上出现了终点站直接返回当前换乘次数 if (st dstStation) return dist[cur]; // 从这个站点继续扩散到其他线路代表一次换乘 for (int nextLine : linesOfStation[st]) { if (dist[nextLine] -1) { dist[nextLine] dist[cur] 1; q.push(nextLine); } } } } return -1; // 不可达}这里的 dist 数组下标是线路 ID不是站点 ID这是 BFS 的最少换乘与普通图的 BFS 最大的区别。每次从“当前线路上的每个站点”跳到“经过该站点的其他线路”就意味着换乘了一次。初版代码最容易犯的错是忘了 dist 初始化成 -1导致 dist 为 0 的线路被重复入队出现“换乘次数负值”这类诡异结果。3.3 最短耗时用 Dijkstra朴素 O(n²) 在几百个站点内足够最少换乘不等于总时间最短因为还要考虑每条线路上行驶的时间和等车时间。如果项目需求是“查询耗时最短方案”就得引入带权最短路径。站点规模几百个以内朴素 Dijkstra 完全够用没必要上优先队列优化。写起来也直白评委看着也舒服。cpp// graph[u] 中每条边记录邻近站点编号、属于哪条线路、行驶耗时 struct Edge { int to; int lineId; int cost; }; vector graph[MAX_STATION];vector Dijkstra(int src, int dst) { vector dist(MAX_STATION, INT_MAX); vector prev(MAX_STATION, -1); vector used(MAX_STATION, false);dist[src] 0; for (int k 0; k MAX_STATION; k) { int u -1; for (int v 0; v MAX_STATION; v) if (!used[v] (u -1 || dist[v] dist[u])) u v; if (u -1 || dist[u] INT_MAX) break; used[u] true; for (const Edge e : graph[u]) if (!used[e.to] dist[u] e.cost dist[e.to]) { dist[e.to] dist[u] e.cost; prev[e.to] u; } } vectorint path; for (int v dst; v ! -1; v prev[v]) path.push_back(v); reverse(path.begin(), path.end()); return path;}这里的邻接边里带着 lineId 字段是为了回溯路径时能告诉用户“从 A 站坐 K12 路到 B 站换乘 K3 路”。只存站点前后关系就丢掉了换乘提示这个重要信息。另外图数据的构建有个枯燥但很关键的步骤把每条线路的相邻站点一对一对地加进 graph。如果你发现查询结果里有跨线乱跳的路径十有八九是建图时把不相邻站点也连了边。3.4 查询与绘制解耦算法输出站点序列GDI 只负责画这类系统的工程结构里最容易腐化的地方是把路径计算直接写在 OnDraw 里窗口一重绘就重新算一遍全图。站点少时看不出来站点一多拖动窗口都卡。我的习惯是查询按钮按下后只做一件事算出结果并保存为成员变量比如 vector m_queryPath然后调用 Invalidate()。OnDraw 里只读取这个变量把结果路径高亮一下。查询和绘制彻底分离后后续加记忆功能、加历史记录都方便不需要动绘图代码。提示算法返回的路径如果为空OnDraw 里不要做任何假设直接跳过高亮绘制。还要把 m_queryPath 清空否则上一次的红色路径会残留在地图上。4. 把线路画到窗口上GDI 双缓冲与坐标映射的四个关键参数4.1 经纬度坐标换算屏幕坐标保留纵横比给站名留边距公交数据文件里存的坐标通常是经纬度或地图投影坐标但窗口的单位是像素且屏幕 Y 轴向下增大纬度向上增大直接把数值当像素用地图要么倒过来要么被拉伸变形。所以绘制前要做一次坐标映射先求所有站点的经纬度范围再按窗口客户区大小算缩放比例。参数含义取值建议minLon / maxLon横向坐标范围从站点数据里扫描得到minLat / maxLat纵向坐标范围从站点数据里扫描得到margin四周留白4060 像素给站名文字留位置scale每单位坐标对应的像素数取横向和纵向比例中的较小值保持纵横比cppvoid BuildScreenCoord(vector stations, const CRect rc) { if (stations.empty()) return;double minLon stations[0].x, maxLon stations[0].x; double minLat stations[0].y, maxLat stations[0].y; for (const auto s : stations) { minLon min(minLon, s.x); maxLon max(maxLon, s.x); minLat min(minLat, s.y); maxLat max(maxLat, s.y); } double rangeX maxLon - minLon; double rangeY maxLat - minLat; int margin 50; // 站名标注预留空间 double scaleX (rc.Width() - 2.0 * margin) / rangeX; double scaleY (rc.Height() - 2.0 * margin) / rangeY; double scale min(scaleX, scaleY); // 取较小值避免变形 for (auto s : stations) { s.screenX (int)((s.x - minLon) * scale margin); s.screenY (int)((maxLat - s.y) * scale margin); // 纬度翻转 }}这段代码是 GDI 公交图绘制的核心窗口拉大、站点数据更换时只要重新调用一次它所有屏幕坐标都会跟着更新。注意 screenY 的计算里用的是“maxLat - s.y”这就是纬度翻转做对这一步地图才不会上下颠倒。4.2 双缓冲绘制线路一多窗口就闪先画到内存再贴屏窗口重绘时如果直接往窗口 DC 上画每画一条线屏幕就刷新一次线路多了或者窗口被拖动时肉眼看到的就是闪烁。解决思路是把所有内容先画到一张内存位图上画完再一次 BitBlt 贴到窗口。这就是双缓冲。cppvoid CTransitView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(rc); if (rc.IsRectEmpty()) return;CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 所有真实绘制都画到 memDC 上 memDC.FillSolidRect(rc, RGB(245, 245, 245)); DrawMap(memDC); // 画底图、线路、站点、站名 DrawRoute(memDC); // 画查询结果高亮线 pDC-BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp);}位图大小用的是窗口客户区实时大小窗口尺寸一变下一帧重绘自然会生成新大小的画布。Write 一个注意点CreateCompatibleBitmap 的宽高不要传 0窗口最小化时客户区可能为空所以开头先判 IsRectEmpty。双缓冲的代价是额外一块和窗口等大的内存公交站点图这种量级的内存占用完全可以忽略。4.3 站点与站名绘制圆点、文字和透明背景的一个组合画站点通常用 Ellipse 画一个实心小圆再在圆圈旁边用 TextOut 输出站名。站名显示有个容易忽略的细节默认背景是白色文字会和底图互相盖住绘制前要设置 SetBkMode(TRANSPARENT)让文字背景透明。cppvoid DrawStations(CDC* pDC, const vector stations, CFont* pFont) { CBrush brush(RGB(200, 30, 30)); pDC-SelectObject(brush);CFont* pOldFont pDC-SelectObject(pFont); pDC-SetBkMode(TRANSPARENT); // 文字背景透明不遮挡底图 for (const auto s : stations) { // 以站点为中心画一个半径 5 的实心圆 pDC-Ellipse(s.screenX - 5, s.screenY - 5, s.screenX 5, s.screenY 5); // 站名放在圆点右下方稍微错开 pDC-TextOut(s.screenX 8, s.screenY 4, s.name); } pDC-SelectObject(pOldFont);}这里 font 建议作为视图成员变量在构造函数里一次性创建不要在 DrawStations 里反复 CreateFontIndirect。重复创建字体即便有栈对象自动释放也会平白拉高重绘耗时。站名位置如果重叠严重可以在后面进阶里做文字避让。4.4 鼠标点站查询命中测试为什么能不用任何 API查询交互通常有两种入口下拉框选站或者鼠标在地图上点站。鼠标点站的实现不复杂关键是重写 OnLButtonDown把鼠标点按的客户区坐标与所有站点的屏幕坐标逐一比对距离小于阈值就算命中。不需要调用复杂的 API一个矩形判断就够。cppbool HitTestStation(const CPoint pt, const vector stations, int hitId) { const int R 12; // 命中半径单位是屏幕像素 for (const auto s : stations) { if (abs(pt.x - s.screenX) R abs(pt.y - s.screenY) R) { hitId s.id; return true; } } return false; }OnLButtonDown 里拿到 CPoint 直接传给 HitTestStation命中后把 hitId 填进起点或终点编辑框再调用 Invalidate 刷新高亮。这个 R 值别设太小否则手指粗的鼠标操作很难点中也别设太大不然站点密集区域会同时命中好几个站。这个方案在站点几百个时速度完全够用要处理几千站再考虑四叉树索引现阶段属于过度设计。5. 避坑GDI 公交查询系统最常见的 5 个翻车现场5.1 窗口拉大后线路错位、站名飞出窗口现象程序第一次启动图是正常的把窗口最大化线路变得歪歪扭扭站点和站名散得到处都是。原因屏幕坐标只在初始化时算了一次窗口变大了客户区尺寸变了旧坐标还按老尺寸贴着上边缘画。解决把 BuildScreenCoord 放到 OnDraw 里每次重绘前都基于当前 GetClientRect 重新计算坐标。站点几百个的情况下重算一次的开销可以忽略。不要用固定 800×600 的初始窗口尺寸去硬撑所有画面用户一旦最大化必然露馅。5.2 程序越跑越卡GDI 对象数持续上涨现象功能正常但运行五六分钟后拖动窗口明显卡顿最后干脆画面空白。原因绘制线路时循环里用 CreatePen/CreateBrush 创建了 GDI 对象却没有 DeleteObject。每次重绘都泄漏几个句柄系统句柄表被慢慢塞满。解决打开任务管理器在“详细信息”里给进程加上 GDI 对象列静止不动时观察数值。如果数值一直涨优先排查所有 Create 开头的 GDI 调用改成栈对象或者成对补上 DeleteObject。画完线路后如果用了 SelectObject记得先选回旧对象再删除。5.3 站名显示成问号乱码现象站点名字在数据文件里是正常的界面上却显示成“??”或一整串乱码。原因项目字符集是 Unicode但 CStdioFile.ReadString 读取时按多字节把文件内容读进来CString 里存的是错误解码的字节或者反过来源文件编码和系统代码页不一致。解决先检查项目属性里字符集是否统一再用记事本打开数据文件另存为 UTF-8 或 ANSI 里和工程一致的一种。读取站点名后在内存里断点确认 CString 内容是否正常。字符集问题在这类老工程里出现的频率最高解决后一劳永逸。5.4 点击查询按钮界面假死半分钟现象站点少时查询秒回站点一多点击“查询”按钮窗口直接变白鼠标转圈过很久才恢复。原因查询算法在 UI 线程里跑OnDraw 同时也在同一线程重绘算法不结束窗口就进不了消息循环。常见的是建图时把线路所有站点两两相连导致边数爆炸Dijkstra 复杂度从 O(V²) 恶化到 O(V³) 以上。解决一边查建图代码确认只有相邻站点才连边一边把 Dijkstra 里的循环体检查一遍dist 更新条件别漏。课程设计阶段不允许用多线程的话至少把站点量控制在千级以内查询会在百毫秒级完成不卡顿。如果非要大数据量再考虑把算法丢到工作线程PostMessage 回 UI 线程刷新。5.5 线路跨站乱连图面一团乱麻现象画出来的线路网像蜘蛛网线路明明是一条直线走向画完却横跨了半个地图。原因绘制时没有按照 Route::stationList 的顺序逐对连线而是把线路上所有站点按 ID 排序后连了一遍ID 顺序和行驶顺序混在一起。解决画线只认 stationListLineTo 的下一个点永远是 stationList 里的下一个、且仅下一个站点。数据加载后加一段 assert检查 stationList 里是否连续两个站点在坐标上离得离谱如果突然出现几十倍的跳变说明数据文件里的顺序有问题或坐标解析错位了。6. 进阶把课程设计级的查询系统打磨成演示级结果展示方面可以给查询路径加一层高亮OnDraw 里遍历 Dijkstra 返回的站点 ID 序列用一条 4 像素宽的红色画笔把相邻站点依次连起来在换乘站点处画一个空心方框旁边用 TextOut 标“换乘 K3 路”。这样演示时观众一眼就能看出起点、换乘点、终点而不是盯着一条红线上看不出门道。性能验证方面养成一个习惯在 OnDraw 开头结尾用 GetTickCount 记录耗时把双缓冲开和关各跑一次。你大概率会看到直接绘制被拖动窗口时帧耗时几十毫秒甚至上百毫秒双缓冲后稳定在个位数毫秒。用数据说话比嘴上解释“双缓冲能消除闪烁”有说服力得多。我的习惯是这类演示项目定稿前一定做三件事把窗口尺寸从最小化拉到最大化来回拖几趟确认坐标映射稳定把数据文件里站名改一个生僻字确认字符集没问题把查询路径固化成一组已知答案核对高亮结果。这三件事全过一遍心里才有底。这三个问题我都在不同项目里见过真实翻车前两个属于典型“玄学”问题其实背后全是对象生命周期和坐标变换这种确定性原因。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站