基于GDI的VC++公交线路查询系统:从数据建模到路径绘制
2026/9/14 15:12:14 网站建设 项目流程

简介:基于GDI技术的VC++公交线路查询系统是一份面向Windows桌面开发与C++学习者的完整工程资源,围绕公交出行场景演示了图形界面绘制与线路查询的核心实现。系统利用GDI绘制地图、站点和线路,并通过路径查找算法完成公交换乘计算,适合用于课程设计、项目实训或入门GDI编程时参考。资源共49个文件,以头文件、C++源文件及Data数据文件为主体,同时包含项目工程文件、资源描述文件和可直接运行的exe演示程序,压缩包整体约4.31MB,便于对照源码理解界面构建与算法流程。包内还内置了公交数据样例和程序说明文档,能帮助读者快速还原开发环境、梳理运行流程。目前已有201人学习下载,对希望结合图形编程与数据结构算法进行实践的人来说,是一份结构清晰、可直接上手的参考资料。

1. 基于GDI技术的VC++公交线路查询系统到底在做什么

一个压缩包里装着.dsw/.dsp工程文件和 Doc/View 架构的 C++ 源码,界面用 GDI 画出公交线网图,用户可以查某条线路经过哪些站、查某个站点有哪几路车停靠、输入起点终点后用算法算一条最少换乘方案并把路径高亮画出来——这就是“基于GDI技术的VC++公交线路查询系统”这类项目的典型形态。它看起来像课设作品,实际上是一个完整的 GUI 程序骨架:数据建模、文本解析、图搜索、GDI 绘图、鼠标命中检测、运行库部署全都覆盖到了。适合两类人:一是要拿它做课程设计需要快速搭起来的学生,二是要接手旧 MFC 系统并在上面加功能的在职开发。下面按一套可复现的实现路径,把数据结构和绘制代码串起来讲清楚,替换业务数据就能交付一个可运行的程序。

2. 先搭画布:GDI的设备上下文、画笔与坐标映射

2.1 为什么公交线路查询适合GDI而不是Direct2D

GDI(Graphics Device Interface)是 Windows 从 1990 年代延续至今的核心图形接口,负责把线条、文字、填充区域绘制到屏幕、内存位图和打印机上。公交车线网图对渲染的要求非常朴素:画若干条折线代表线路,画若干个小圆点代表站点,再用TextOut标注站名和线路号,数据规模在几百个节点以内。这个负载用 GDI 的立即模式处理绰绰有余,直接调用MoveToLineTo就能完成全部绘制任务,不需要 GPU 场景图或 GPU 硬件加速。

对比 Direct2D,虽然它自带抗锯齿、绘图效果更平滑,但在 VC++ 6.0 工程里引入 D2D1.h、初始化 COM 组件的成本明显更高;如果程序还要在虚拟机或没有独立显卡的老机器上演示,兼容性和部署复杂度都不占优势。值得一提的还有 GDI 打印机的语义:GDI 同样会把页面渲染指令发给打印驱动,所以同一套CDC绘图代码既能画屏幕也能输出到打印机设备,这个统一抽象正是 GDI 在很长一段时间里没有被淘汰的根本原因。选 GDI 不是为了追新,而是为了“最小依赖 + 最大兼容”。

2.2 设备上下文、画笔、画刷这三个核心对象

用 GDI 编程绕不开三样东西:设备上下文(DC)、画笔(Pen)、画刷(Brush)。MFC 里分别对应CDCCPenCBrush。DC 是画布的抽象,能绑定到窗口客户区,也能绑定到内存位图从而支持双缓冲;画笔决定线条的颜色、宽度和线型;画刷决定封闭区域的填充方式,比如站点圆点和图例矩形。

GDI对象MFC包装类作用生命周期注意事项
设备上下文CDC/CClientDC窗口或位图的绘制入口栈对象自动释放,不要手动 delete
画笔CPen画线、画轮廓选入 DC 后才能生效,用完恢复旧画笔
画刷CBrush填充圆形、矩形区域需要和旧画刷配对切换
位图CBitmap双缓冲的后备存储与内存 DC 成对创建和释放

使用时的固定套路是:创建对象、SelectObject选入 DC、绘制、再SelectObject把旧的换回来。如果画了一个CPen选入 DC 后不恢复就直接让临时CPen析构,GDI 对象计数会在任务管理器里持续上涨,程序跑几个小时才崩溃,排查起来非常隐蔽。MFC 的CView::OnDraw里传入的pDC由框架创建和释放,不需要也不能手动清理。

2.3 坐标映射模式:把城市地图放进逻辑坐标系

GDI 默认的MM_TEXT映射模式原点在窗口左上角,y 轴向下,单位为像素。直接按像素坐标写线路图不是不行,但换一套城市数据就要改坐标常量,维护体验很差。更合理的做法是把城市地图抽象在一个逻辑坐标系里,再让 GDI 负责从逻辑坐标换算到窗口像素坐标。

我来用一段可运行的代码说明映射模式参数的实际意义。假设城市范围被规范化为 1200 宽、900 高,坐标原点在左上角:

void CBusQueryView::SetupMap(CDC* pDC, const CRect& rcClient) { // 设置等比缩放映射,x/y 方向比例保持一致 pDC->SetMapMode(MM_ISOTROPIC); // 逻辑坐标系的跨度:站点坐标都落在 0~1200 和 0~900 范围内 pDC->SetWindowExt(1200, 900); // 视口范围跟随窗口客户区大小 pDC->SetViewportExt(rcClient.Width(), rcClient.Height()); }

SetWindowExt定义的是“逻辑世界”的大小,SetViewportExt定义的是“设备输出”的大小。MM_ISOTROPIC会强制按两轴中最小的比例统一缩放,保证圆形站点不会在窗口拉伸后变成椭圆;如果改用MM_ANISOTROPIC,x 和 y 可以独立缩放,地图会铺满窗口但圆形会变形。公交线路图通常用等比模式,因为站点之间的相对方位必须忠实于真实地理关系。注意SetupMap必须在任何绘制前调用,并且要在窗口最小化时跳过——客户区宽或高为 0 时SetViewportExt会失败,坐标换算结果无法预期。

2.4 在OnDraw里跑通第一条折线

MFC 单文档框架中,所有绘制逻辑集中在CView::OnDraw。窗口首次显示、被其他窗口遮挡后重新露出、改变大小时系统都会触发重绘,所以OnDraw必须是无状态、完全由数据驱动的函数,不能在这里读文件或做换乘搜索。在SetupMap之后,绘制线路只需要MoveTo加若干次LineTo

void CBusQueryView::OnDraw(CDC* pDC) { CRect rcClient; GetClientRect(&rcClient); // 先建立逻辑坐标映射,后面的坐标全部按逻辑值输入 SetupMap(pDC, rcClient); // 创建蓝色实线画笔,线宽3 CPen penRoute(PS_SOLID, 3, RGB(0, 0, 255)); CPen* pOldPen = pDC->SelectObject(&penRoute); // 用两个拐点模拟一条有弯折的公交线路 pDC->MoveTo(100, 100); // 起点站 pDC->LineTo(300, 250); // 拐点一 pDC->LineTo(600, 400); // 终点站 // 恢复旧画笔,让 penRoute 安全析构 pDC->SelectObject(pOldPen); }

代码里要注意一个隐含顺序:GetClientRect返回的尺寸在被映射模式影响前应视为像素值,但要等到SetupMap执行完,MoveTo接收的才是逻辑坐标。在 MFC 中一个容易忽视的细节是OnDraw并不保证 DC 的映射模式是上一次设置的,因为 DC 状态在重绘之间可能被重置,所以每次绘制都要重新调用SetupMap,不能只在初始化时设置一次。

3. 线路数据建模与最少换乘查询在VC++里的实现

3.1 用结构体而不是数据库

公交数据量很小,几十条线路、几百个站点,为它引入 ODBC 或 ADO 是典型的过度设计;这类 MFC 项目最常见的数据方案是解析纯文本文件,把结果存进内存里的结构体数组。线路与站点是多对多关系:一个站点被多条线路经过,一条线路按顺序关联多个站点。在设计结构体时,不仅要顺向保存“线路包含哪些站点”,还要冗余保存“站点被哪些线路经过”,后者是换乘搜索里使用频率最高的反向索引。

struct BUS_STOP { CString strName; // 站点名称,MFC下使用CString处理中文 int x, y; // 逻辑坐标,对应第2章建立的坐标系 CArray<int, int> lines; // 经过本站的线路编号,冗余存储用于反查 }; struct BUS_LINE { int nID; // 线路编号 CString strName; // 线路名称,如"1路" CArray<int, int> stops; // 按运行顺序保存的站点索引 };

这里的CArray<int, int>是 MFC 的模板容器,第一个参数是元素类型,第二个参数是函数参数传递类型。在 VC++ 6.0 的工程里使用CArray能与 CString、调试器可视化配合得更好;STL 的vector也完全可行,但如果整个工程都是 MFC 风格,混用两套容器会让代码不统一,我一般坚持只用一套。BUS_STOP::linesBUS_LINE::stops里存放的都是下标或编号,不是对象本身,这样既节省内存又方便索引。

3.2 文本读取的两个坑:编码和空格分隔

数据文件建议写成自定义的分节文本,例如站点段和线路段分开。VC++ 6.0 默认按 ANSI 编码处理字节,因此数据文件必须存成 ANSI 或 GBK,如果存成 UTF-8,CString读进来就是乱码。解析行内字段时,虽然 CString 自带FindMid,但逐字段手写切割容易错位,稳妥的方式是用AfxExtractSubString配合空格分隔符:

BOOL CBusQueryDoc::LoadBusData(LPCTSTR lpszFilePath) { CStdioFile file; if (!file.Open(lpszFilePath, CFile::modeRead | CFile::typeText)) return FALSE; CString strLine, strField[4]; while (file.ReadString(strLine)) { if (strLine.IsEmpty() || strLine[0] == _T('#')) continue; // 跳过空行和注释段 // 按空格拆分字段:0=站点ID 1=站名 2=x坐标 3=y坐标 for (int i = 0; i < 4; i++) { if (!AfxExtractSubString(strField[i], strLine, i, _T(' '))) break; } int nID = _ttoi(strField[0]); // 存入 m_stops 数组,并记录名称与坐标 // ... } file.Close(); return TRUE; }

AfxExtractSubString的第三个参数是字段索引,第四个是分隔符,它会自动跳过连续分隔符。_ttoi是 ANSI/Unicode 通用的字符串转整数宏。这里没有对字段数量做严谨的校验,实际工程中应根据文件行是否以特定前缀开头来决定解析逻辑,否则数据文件格式错误时程序可能越界访问数组。还有一点:CStdioFile以文本模式打开会自动把\r\n去掉,这是用它而不是CFile读文本行的主要理由。

3.3 站点反查线路:用冗余索引避免全表扫描

查询“某个站点有哪几路车”时,最直接的方式是循环所有线路,检查每条线路的站点序列里是否包含该站。这个做法在线路规模小的时候没问题,但换乘搜索的内部循环会反复执行这种查找,每次线性扫描全线路列表就会让算法退化到 O(线路数 × 每线站点数)。用BUS_STOP::lines这个反向索引可以做到 O(1) 定位:解析线路数据时每读取一个站点,就把它所属的线路编号追加到站点对应结构的lines数组里。代价是解析阶段多做几次Add,换来的是查询和搜索阶段成倍的性能提升。

3.4 最少换乘的BFS实现

最少换乘问题可以抽象为无权图最短路径。如果把“同一线路上相邻两站”连边、用站点 BFS 求最短路径,得到的是“经过站数最少”的方案,它可能导致用户在一个极少有车经过的方向兜圈,换乘次数反而多。更贴合真实需求建模是把线路也当作图节点:站点节点连向经过它的线路节点,线路节点再连回线路上所有站点节点。这样 BFS 每经过两个站点节点之间必然夹着一个线路节点,步数衡量的是“乘车段数”的累积,还原出来的方案就是换乘次数最少的路径。实现如下:

// 图节点编号规则:站点节点占前 nStop 个,线路节点从 nStop 开始 // 邻接表 adj 在数据加载时构建 bool FindBestRoute(int nStart, int nEnd, CArray<int, int>& routeStops) { const int nTotal = m_nStopCount + m_nLineCount; vector<int> dist(nTotal, -1); vector<int> pre(nTotal, -1); queue<int> q; dist[nStart] = 0; q.push(nStart); while (!q.empty()) { int cur = q.front(); q.pop(); CArray<int, int>& nbs = m_adj[cur]; for (int i = 0; i < nbs.GetSize(); i++) { int next = nbs[i]; if (dist[next] != -1) continue; dist[next] = dist[cur] + 1; pre[next] = cur; if (next == nEnd) { // 回溯 pre 数组得到站点序列,略 return TRUE; } q.push(next); } } return FALSE; // 无连通路径 }

m_adjCArray<int,int>的数组,解析一行“1路 1 2 3”时,把线路节点nStop + lineID和站点 1、2、3 互相连边。代码里的vector来自 STL,在 VC++ 6.0 中需要#include <vector>#include <queue>,工程设置里头文件路径指向 VC 的标准包含目录即可。BFS 结束后pre数组记录的是完整的前驱链,还原路径时从终点一路回退到起点,连续两个站点节点之间的线路节点就是要换乘的线路编号。

4. 站点与线路讲清楚:GDI绘图顺序、线路配色和命中检测

4.1 先画线还是先画点:绘制顺序决定视觉层次

绘制顺序决定了遮挡关系。正确的顺序分四层:先画浅色背景,再画所有线路折线,然后在每个站点位置画圆点,最后在圆点旁写站名和线路号。把站点圆点画在线路之后,站点就能遮挡住线路的端点,视觉上更接近地图软件的呈现方式。顺序错乱会出现线路压在站名上面、文字被折线穿过的糟糕效果。

这里的效率上限取决于MoveTo/LineTo的调用次数。一条有 30 个站的线路会被拆成 29 次线段绘制,全部线路加起来可能有上千次 GDI 调用。一个关键优化是用Polyline一次画完整条折线:先构建CPoint数组,再传入Polyline。它和逐段画线在结果上一致,但 GDI 内部一次调用完成整条路径的绘制,API 调用次数从几十次降为一次,VC++ 6.0 程序在慢速机器上的重绘体验差别非常明显。

4.2 线路自动配色的取模策略

线路数量多时人工指定每条线路的颜色难以维护。常见做法是按线路编号对固定调色板取模:

COLORREF GetLineColor(int nLineID) { static COLORREF arrColor[6] = { RGB(0, 0, 255), // 蓝 RGB(255, 0, 0), // 红 RGB(0, 128, 0), // 绿 RGB(255, 128, 0), // 橙 RGB(128, 0, 128), // 紫 RGB(0, 128, 128) // 青 }; return arrColor[nLineID % 6]; }

取模方案的关键是“绘制结果可复现”:同一条线路在每次重绘时颜色稳定,不会因为系统时间或随机数变化而闪烁变色。它的缺点是线路编号一旦超过 6 就会重复,此时可以在调色板里再增加深色系变体,或者按线路首末站点坐标的角度生成 HSV 色相值再转 RGB。VC++ 6.0 没有现成的 HSV 转换函数,需要自己写三分段换算,课程设计通常不需要做到这一步,理解取模的局限即可。

4.3 站名文字绘制的位置避让

TextOut可以直接在站点坐标旁边绘制文字,站点密集时文字会互相覆盖。最简单有效的位置策略是依据站点坐标的相对方位决定文字偏移:站点偏上时文字画在下方,偏左时画在右侧。这个规则不需要复杂的碰撞检测,只要在绘制函数里根据站点在整个坐标系中的相对位置选择一个固定的偏移方向,即可让大部分站名清晰可读。

文字字体建议显式创建CFont并指定“宋体”,而不是依赖 DC 的默认字体。MFC 的 MBCS 工程中 CString 的长度是字节数,但英文、数字和中文混合时用str.GetLength()作为TextOut的字符数参数在纯 ANSI 下是可行的;如果工程开了 Unicode,则必须统一使用宽字符版本,避免字体绘制出乱码。

4.4 鼠标命中检测:点到线段的最短距离

GDI 绘制只是把像素画到了窗口上,画完的图形不保留任何对象信息,用户点击“某条线路”时必须自己做几何运算。站点命中用两点间欧氏距离即可,线路命中则不能直接求点到直线的距离,否则点击在线段延长线上也会被误判为命中该线路。正确的做法是计算点到线段的最短距离:

double DistToSegment(int px, int py, int ax, int ay, int bx, int by) { double vx = bx - ax, vy = by - ay; double wx = px - ax, wy = py - ay; double c1 = vx * wx + vy * wy; if (c1 <= 0) // 投影在线段起点外侧 return sqrt((px - ax) * (px - ax) + (py - ay) * (py - ay)); double c2 = vx * vx + vy * vy; if (c2 <= c1) // 投影在线段终点外侧 return sqrt((px - bx) * (px - bx) + (py - by) * (py - by)); double t = c1 / c2; double projX = ax + t * vx; double projY = ay + t * vy; return sqrt((px - projX) * (px - projX) + (py - projY) * (py - projY)); }

判定命中时设定一个阈值(比如 15 个逻辑单位)。命中优先级上应先判站点再判线路,因为站点是用户更精确的意图;如果某坐标同时命中了站点和某条线路,程序优先显示站名和经过线路列表,而不是线路信息。

绘制内容GDI对象命中算法用户反馈
线路折线CPen(PS_SOLID, 3, color)点到线段距离 < 阈值状态栏显示线路名、起终点
站点圆点CBrush填充椭圆两点距离 < 阈值状态栏显示站名、经过线路
换乘高亮高亮色粗画笔由查询结果决定地图上突出换乘路径

4.5 鼠标消息处理与坐标转换

在视图类重写OnLButtonDown,把鼠标点击的屏幕像素坐标转换为逻辑坐标后再做命中检测。关键在于 DC 的映射模式必须和绘制时一致,否则DPtoLP换算出来的坐标是错的。代码骨架如下:

void CBusQueryView::OnLButtonDown(UINT nFlags, CPoint point) { CClientDC dc(this); CRect rcClient; GetClientRect(&rcClient); SetupMap(&dc, rcClient); // 先建立一样的映射模式 CPoint ptLog = point; dc.DPtoLP(&ptLog); // 设备坐标转逻辑坐标 int nHit = HitTestStop(ptLog); // 命中站点检查 if (nHit >= 0) { m_nSelectStop = nHit; InvalidateRect(NULL, FALSE); // 只重绘,不擦背景,减少闪烁 } CView::OnLButtonDown(nFlags, point); }

DPtoLP是把设备坐标转换为逻辑坐标的系统函数,它依赖 DC 当前的映射模式、窗口范围和视口范围。这里容易出的问题是在SetupMap之前调用DPtoLP,得到的结果等于没转换。InvalidateRect的第二个参数FALSE表示重绘时不触发背景擦除,配合内存 DC 双缓冲能有效避免连续重绘时的闪烁感;如果是拖动鼠标进行临时高亮预览,还可以配合SetCapture把鼠标捕获住,防止移出窗口时丢失WM_MOUSEMOVE

5. 从能跑到能交付:运行库、GDI对象泄漏、调试和渲染替换

5.1 运行库依赖和“绿版exe”的取舍

VC++ 6.0 编译出的 MFC 程序如果采用动态链接,会依赖 MFC42.dll 和 MSVCRT.dll,目标机器缺失时程序双击后没有任何反应。最省事的做法是安装网上流传的“VC++ 运行库合集”,但这会把几十个版本的运行时全部装进用户机器,对单个小工具来说污染太大。在“Project Settings → General → Microsoft Foundation Classes”里选择静态链接 MFC,编译产物是一个只有几 MB 的独立 exe,拷到任何 Windows 上直接运行。代价是体积变大,且系统补丁升级后需要重新编译,对公交查询这种不频繁更新的工具完全没有影响。

5.2 量化GDI对象泄漏

GDI 对象泄漏在 VC++ 6.0 里非常阴险。程序界面正常、不崩溃,但任务管理器里 GDI 对象数随着每次窗口重绘上涨几十个。排查时可以在OnDraw开头和结尾分别取样:

DWORD dwGdiStart = GetGuiResources(::GetCurrentProcess(), GR_GDIOBJECTS); // ... 原有绘制代码 ... DWORD dwGdiEnd = GetGuiResources(::GetCurrentProcess(), GR_GDIOBJECTS); TRACE(_T("GDI delta = %d\n"), dwGdiEnd - dwGdiStart);

GetGuiResources是 Win32 函数,GR_GDIOBJECTS返回进程当前的 GDI 句柄数。把这段代码加进去后连续拉伸窗口,如果 delta 持续增长,就说明有画笔或画刷创建后没有释放。最常见的原因不是忘了delete对象,而是SelectObject之后没有恢复旧对象就直接让临时对象析构,导致 DC 里残留悬空句柄。

5.3 运行效率和DLL调试的两条经验

运行效率的瓶颈通常不在 GDI 本身,而在反复创建对象。线路图重绘时每次都重新 new 一个CPoint数组再Polyline,在几百个站点的规模下拉扯窗口不至于卡顿;但如果在OnDraw里重新解析数据文件或每次都重新计算换乘路径,卡顿就是必然的。这类项目通常把数据加载放在CDocument::OnOpenDocument,把换乘结果缓存到成员变量,OnDraw只负责把缓存的站点序列画出来。

如果换乘算法被拆到独立 DLL 里编译,VC++ 6.0 的调试方法是在 DLL 工程的 Project Settings 里设置“Executable for debug session”为主程序 exe,把 DLL 工程设为启动项目,F5 后系统启动宿主 exe,在 DLL 源码里下的断点就能命中。需要提前把生成的 DLL 拷贝到 exe 同目录,并保证两个工程的字符集设置一致,否则调试器里看到的字符串全是乱码。

5.4 渲染层替换:GDI+ 和 Qt 的延伸方向

公交查询系统的数据模型和 BFS 算法与渲染层完全解耦,这是它最大的迁移价值。如果想让线路图更精致,把CDCCPen换成 GDI+ 的GraphicsPenOnDraw里的大部分逻辑可以直接复用;抗锯齿和渐变填充能让站点标注清晰很多。如果目标是跨平台,把 CString 换成 std::string、CArray 换成 vector,绘图逻辑搬到 QPainter 的 paintEvent 里,查询算法部分几乎可以逐行平移。数据结构和算法是能带走的资产,MFC 和 GDI 只是这套系统在 Windows 时代留下的外壳。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询