基于GDI的VC++公交线路查询系统:从数据建模到路径绘制
简介基于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而不是Direct2DGDIGraphics Device Interface是 Windows 从 1990 年代延续至今的核心图形接口负责把线条、文字、填充区域绘制到屏幕、内存位图和打印机上。公交车线网图对渲染的要求非常朴素画若干条折线代表线路画若干个小圆点代表站点再用TextOut标注站名和线路号数据规模在几百个节点以内。这个负载用 GDI 的立即模式处理绰绰有余直接调用MoveTo、LineTo就能完成全部绘制任务不需要 GPU 场景图或 GPU 硬件加速。对比 Direct2D虽然它自带抗锯齿、绘图效果更平滑但在 VC 6.0 工程里引入 D2D1.h、初始化 COM 组件的成本明显更高如果程序还要在虚拟机或没有独立显卡的老机器上演示兼容性和部署复杂度都不占优势。值得一提的还有 GDI 打印机的语义GDI 同样会把页面渲染指令发给打印驱动所以同一套CDC绘图代码既能画屏幕也能输出到打印机设备这个统一抽象正是 GDI 在很长一段时间里没有被淘汰的根本原因。选 GDI 不是为了追新而是为了“最小依赖 最大兼容”。2.2 设备上下文、画笔、画刷这三个核心对象用 GDI 编程绕不开三样东西设备上下文DC、画笔Pen、画刷Brush。MFC 里分别对应CDC、CPen、CBrush。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_ANISOTROPICx 和 y 可以独立缩放地图会铺满窗口但圆形会变形。公交线路图通常用等比模式因为站点之间的相对方位必须忠实于真实地理关系。注意SetupMap必须在任何绘制前调用并且要在窗口最小化时跳过——客户区宽或高为 0 时SetViewportExt会失败坐标换算结果无法预期。2.4 在OnDraw里跑通第一条折线MFC 单文档框架中所有绘制逻辑集中在CView::OnDraw。窗口首次显示、被其他窗口遮挡后重新露出、改变大小时系统都会触发重绘所以OnDraw必须是无状态、完全由数据驱动的函数不能在这里读文件或做换乘搜索。在SetupMap之后绘制线路只需要MoveTo加若干次LineTovoid 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章建立的坐标系 CArrayint, int lines; // 经过本站的线路编号冗余存储用于反查 }; struct BUS_LINE { int nID; // 线路编号 CString strName; // 线路名称如1路 CArrayint, int stops; // 按运行顺序保存的站点索引 };这里的CArrayint, int是 MFC 的模板容器第一个参数是元素类型第二个参数是函数参数传递类型。在 VC 6.0 的工程里使用CArray能与 CString、调试器可视化配合得更好STL 的vector也完全可行但如果整个工程都是 MFC 风格混用两套容器会让代码不统一我一般坚持只用一套。BUS_STOP::lines和BUS_LINE::stops里存放的都是下标或编号不是对象本身这样既节省内存又方便索引。3.2 文本读取的两个坑编码和空格分隔数据文件建议写成自定义的分节文本例如站点段和线路段分开。VC 6.0 默认按 ANSI 编码处理字节因此数据文件必须存成 ANSI 或 GBK如果存成 UTF-8CString读进来就是乱码。解析行内字段时虽然 CString 自带Find和Mid但逐字段手写切割容易错位稳妥的方式是用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站名 2x坐标 3y坐标 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, CArrayint, int routeStops) { const int nTotal m_nStopCount m_nLineCount; vectorint dist(nTotal, -1); vectorint pre(nTotal, -1); queueint q; dist[nStart] 0; q.push(nStart); while (!q.empty()) { int cur q.front(); q.pop(); CArrayint, 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_adj是CArrayint,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 算法与渲染层完全解耦这是它最大的迁移价值。如果想让线路图更精致把CDC、CPen换成 GDI 的Graphics、PenOnDraw里的大部分逻辑可以直接复用抗锯齿和渐变填充能让站点标注清晰很多。如果目标是跨平台把 CString 换成 std::string、CArray 换成 vector绘图逻辑搬到 QPainter 的 paintEvent 里查询算法部分几乎可以逐行平移。数据结构和算法是能带走的资产MFC 和 GDI 只是这套系统在 Windows 时代留下的外壳。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →