FEATURED · 精选文章

GDI绘图实战:VC++公交查询系统的双缓冲与路径高亮实现

发布时间 / 2026/9/14 15:16:43
来源 / 创域科博编辑部
栏目 / 资讯中心
GDI绘图实战:VC++公交查询系统的双缓冲与路径高亮实现 简介基于GDI技术的VC公交线路查询系统是一份面向C初/中级开发者及课程设计场景的完整项目源码包。项目利用Windows GDI绘制地图、线路和站点借助C实现起点终点输入与最优换乘线路分析涵盖图形界面、数据解析与路径搜索等关键环节并具有较好的扩展性。压缩包共49个文件、4.31MB主要包含HC源文件与头文件、dat公交与站点数据、dsp/vcproj/sln等工程配置以及可直接运行的exe和说明文档便于直接编译学习或参考改造。已有201人学习下载适合正在完成同类课设或希望掌握GDI绘图、公交查询算法和MFC界面开发细节的读者。通过阅读源码和对照数据文件可以清晰了解从线路数据存储到最短路径展示的完整实现思路并快速复用到其他地点检索类项目中。1. 这个系统真正值钱的部分不是查询而是绘制公交线路查询系统如果只做“输入起点终点输出换乘方案”那你只需一个文本数据库加几条打印语句就够了。但拿得出手的课程设计、毕业设计乃至一个小型电子站牌工具用户第一眼看到的是图站点画在什么位置、线路怎么拐弯、查询结果能不能在图上高亮一条路径。到了这一步你发现核心难点变成了“用GDI把数据画出来”而且还得画得不闪烁、不糊、能随窗口缩放。这篇文章就围绕基于GDI技术的VC公交线路查询系统把数据建模、坐标映射、双缓冲绘图、命中测试和路径高亮这一整条链路讲透。适合正在做MFC桌面程序、维护老代码或者想理解GDI绘图本质的人。2. 公交数据的组织与坐标换算2.1 用邻接表还是边数组存放公交网络公交网络本质上是一个无向带权图站点是顶点两个相邻站点之间的路段是边。常见做法是用邻接表而不是邻接矩阵因为公交站点的度通常很小一般不超过4邻接表在保存换乘信息时也更直观。定义结构体时我习惯把站点和线路分开存储站点只记录编号、名称、坐标线路通过一个数组保存它经过的站点编号序列。struct Station { int id; CString name; double lat; // 纬度真实业务中可能用GPS原始值 double lng; // 经度 }; struct Route { int routeId; CString routeName; std::vectorint stopIds; // 按行驶顺序排列的站点ID列表 }; std::mapint, Station g_stations; std::mapint, Route g_routes; std::mapint, std::vectorEdge g_adj; // 邻接表Edge至少包含toStationId和lineId这样在换乘查询时能知道“坐哪条线过去的”。邻接表适合做最短路径算法每次扩展一个站点时只需要遍历它的几个邻居而不需要遍历全部站点。公交线路查询系统在数据量上没有压力哪怕一个城市几千个公交站用最简单结构就能扛住。真正要注意的是线路方向。很多老代码把线路当作无向边但实际公交有上行和下行停靠站点序列可能不同。如果只是做查询原型可以先把同一条线路两个方向合并处理在结果里提示“请在对面站台乘车”。2.2 经纬度到屏幕坐标的线性映射公交线路图不是真实地理地图不需要精确投影。常见做法是把站点经纬度范围线性映射到窗口客户区。映射前先扫描所有站点求出最小经度、最大经度、最小纬度、最大纬度然后计算缩放比例。void CalcViewport() { minLng maxLng g_stations.begin()-second.lng; minLat maxLat g_stations.begin()-second.lat; for (auto kv : g_stations) { double lng kv.second.lng, lat kv.second.lat; if (lng minLng) minLng lng; if (lng maxLng) maxLng lng; if (lat minLat) minLat lat; if (lat maxLat) maxLat lat; } // 为边界留出5%的边距避免站点贴在窗口边缘 double padLng (maxLng - minLng) * 0.05; double padLat (maxLat - minLat) * 0.05; minLng - padLng; maxLng padLng; minLat - padLat; maxLat padLat; } POINT ToScreen(double lat, double lng, CRect rc) { POINT pt; pt.x (int)((lng - minLng) / (maxLng - minLng) * (rc.Width() - 1)); pt.y (int)((maxLat - lat) / (maxLat - minLat) * (rc.Height() - 1)); return pt; }这段代码里有几个容易踩的坑。第一纬度方向取反因为屏幕的 y 轴向下而纬度向北增大。第二比例尺是“各向独立”的如果窗口不是方形横向和纵向拉伸会改变线路的视觉形状。如果希望保持线路的大致相对位置就不能直接用宽高分别缩放而是要取一个统一的缩放因子保证 x 和 y 的缩放一致其余方向留白。第三用 double 计算完再转 int否则站点多时累加误差会导致线图失真。提示真实项目中站点坐标可能来自高德或者百度它们用的是 GCJ-02 坐标系。这种坐标之间是平偏移关系不影响相对绘制所以你不需要做纠偏直接取数值映射即可。2.3 加载数据的常见格式与解析数据结构定了接下来是加载。课程设计里很少用数据库一个纯文本文件最省事也最容易调试。# 站点表ID,名称,经度,纬度 S 101,人民广场,121.4737,31.2304 S 102,南京东路,121.4792,31.2341 # 线路表线路ID,线路名,站点ID序列逗号分隔 L 1,地铁1号线,101,102,103,104读取时用CFile或ifstream逐行处理注意编码问题。VC 6.0 下默认 ANSI如果文件是 UTF-8 中文会乱码。实用办法统一用 ANSI 保存数据文件或者用MultiByteToWideChar手动转码。很多新手在这里浪费时间说“站点名字显示为乱码”实际不是 GDI 的锅是文件编码不匹配。解析代码核心逻辑不复杂遇到 S 开头把后面四个字段拆出来存进Station遇到 L 开头先建一条Route再把 stopIds 读入。建图时可以顺便把每条相邻站点对上的一条边插入邻接表权重取两个站点之间的直线距离近似。// 建立邻接表时同一条线路相邻两站建立双向边 for (size_t i 0; i route.stopIds.size() - 1; i) { int from route.stopIds[i]; int to route.stopIds[i 1]; double dist Distance(g_stations[from], g_stations[to]); g_adj[from].push_back((Edge){to, lineId, dist}); g_adj[to].push_back((Edge){from, lineId, dist}); }3. 用GDI把线路画出来从闪烁到双缓冲3.1 GDI的基本绘图流程与设备上下文GDI 把绘图目标抽象为“设备上下文”DC。你在窗口上画图实际上是在窗口 DC 上调用LineTo、Ellipse这些函数。MFC 里最常用的是CPaintDC它封装了BeginPaint和EndPaint。一个典型的OnPaint长这样void CLineQueryDlg::OnPaint() { CPaintDC dc(this); dc.FillSolidRect(rcClient, RGB(255, 255, 255)); // 绘制线路 for (auto kv : g_routes) { DrawRoute(dc, kv.second); } // 绘制站点 for (auto kv : g_stations) { DrawStation(dc, kv.second); } }这个版本能跑但是有个致命问题每次Invalidate都会全量重绘窗口在拖动或者缩放时背景不断擦除又重画肉眼看到的就是闪烁。闪烁的根源不是 GDI 慢而是“擦除背景”和“绘制内容”之间存在时间差屏幕先被刷成白色再逐渐出现图形。解决标准办法是双缓冲。3.2 双缓冲绘图内存位图替代屏幕DC双缓冲的做法是先在内存里把图完整画好再一次BitBlt拷贝到窗口 DC 上。这样屏幕只经历一次整体更新看不到中间过程。核心代码是 CreateCompatibleDC 和 CreateCompatibleBitmap。void CLineQueryDlg::DoDoubleBuffer(CPaintDC dc) { CRect rc; GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap memBitmap; memBitmap.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); // 在memDC上画背景和所有图形 memDC.FillSolidRect(rc, RGB(255, 255, 255)); DrawAllRoutes(memDC, rc); DrawAllStations(memDC, rc); DrawSearchResult(memDC, rc); // 如果有高亮路径 // 一次性拷贝到窗口 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); }几个参数要注意CreateCompatibleBitmap必须用屏幕 DC 兼容位图的大小必须和客户区一致否则 BitBlt 以后会出现一片乱码或者闪烁不彻底。另外SelectObject用完要恢复旧位图否则 GDI 对象在析构时可能重复释放在 VC 6.0 里这种问题会表现为随机崩溃。3.3 线路绘制顺序与线宽策略绘制公交线路图和画普通连点图不同线路彼此交叉站点在交叉处必须优先显示。所以顺序是先画所有线路的边再画站点。如果先画站点线路会压在站点圆上面站点被遮挡站点名称更难看清楚。线路本身我推荐用 3 像素宽的实线颜色根据routeId从预定义数组里取。公交线路颜色是有行业习惯的比如地铁1号线红色、2号线绿色自定义线路就循环取色。为了区分同一条线路的不同方向可以在线路颜色上做些变化比如上行用深色下行用浅色但同一个 linet 颜色需要保持稳定。void DrawAllRoutes(CDC* pDC, CRect rc) { CPen pen(PS_SOLID, 3, g_routeColor[routeId]); CPen* pOldPen pDC-SelectObject(pen); for (size_t i 0; i route.stopIds.size() - 1; i) { POINT p1 ToScreen(g_stations[route.stopIds[i]].lat, g_stations[route.stopIds[i]].lng, rc); POINT p2 ToScreen(g_stations[route.stopIds[i1]].lat, g_stations[route.stopIds[i1]].lng, rc); pDC-MoveTo(p1.x, p1.y); pDC-LineTo(p2.x, p2.y); } pDC-SelectObject(pOldPen); }注意MoveTo和LineTo是一对独立调用线段是逐条绘制的不能直接传一个点数组。GDI 没有Polyline之外能带颜色的批量画点方法Polyline也可以但如果你后续要做折线高亮反而是在MoveTo/LineTo循环里嵌入颜色判断更灵活。站点绘制可以用Ellipse但站点的半径不要固定用整数。在 DPI 缩放为 125% 或 150% 的机器上固定 4 像素的站点会变得很小。常见做法是按照客户区宽度动态计算半径比如radius max(3, rc.Width() / 800)但我一般直接取 5 到 6 像素在高 DPI 下虽然小一点不影响点击命中。4. 查询交互与高亮最短路径和命中测试4.1 最短路径选用 Dijkstra 还是 BFS公交查询里“最短”有至少三种定义站数最少、时间最短、换乘最少。站数最少可以用 BFS边权为 1时间最短要用 Dijkstra边权取相邻站点行驶时间。为了让代码在 VC 6.0 环境下编译通过推荐自己写一个小根堆或者直接用数组模拟优先队列不要依赖std::priority_queue以外的复杂容器因为 VC6 的 STL 版本对模板支持比较旧。我给出一个基于std::priority_queue的 Dijkstra 实现这段代码在 VC6 下也能编译只需要包含queue和vector。std::vectorint Dijkstra(int startId, int targetId) { const int INF 0x3f3f3f3f; std::mapint, int dist; std::mapint, int prev; std::mapint, int lineUsed; // 记录从prev[id]到id使用哪条线路 for (auto kv : g_stations) { dist[kv.first] INF; prev[kv.first] -1; } dist[startId] 0; std::priority_queue std::pairint,int, std::vector std::pairint,int , std::greater std::pairint,int pq; pq.push(std::make_pair(0, startId)); while (!pq.empty()) { int d pq.top().first; int u pq.top().second; pq.pop(); if (d ! dist[u]) continue; // 旧记录跳过 if (u targetId) break; for (size_t i 0; i g_adj[u].size(); i) { Edge e g_adj[u][i]; if (dist[u] e.weight dist[e.to]) { dist[e.to] dist[u] e.weight; prev[e.to] u; lineUsed[e.to] e.lineId; pq.push(std::make_pair(dist[e.to], e.to)); } } } // 还原路径 std::vectorint path; for (int v targetId; v ! -1; v prev[v]) { path.push_back(v); if (v startId) break; } std::reverse(path.begin(), path.end()); return path; }这段代码里有两个细节值得说。第一dist[u] e.weight dist[e.to]用的是 int 比较如果你的边权是 double要改成允许一个小误差的比较否则浮点误差会导致路径反复横跳。第二lineUsed数组记录了进入某个站点时乘坐的线路最终换乘提示就是基于它生成的当lineUsed[path[i]]和lineUsed[path[i1]]不同就说明发生了换乘。4.2 鼠标点击站点时的命中测试查询结果的起点和终点不应该只靠手动输入站名更好的体验是直接点图上的站点。命中测试是典型的数学问题判断鼠标点与所有站点的屏幕坐标距离取最小且小于阈值的那一个。int HitTest(POINT mousePt, CRect rc) { int minDist 12; // 点击容差像素 int hitId -1; for (auto kv : g_stations) { POINT pt ToScreen(kv.second.lat, kv.second.lng, rc); int dx pt.x - mousePt.x; int dy pt.y - mousePt.y; int dist2 dx * dx dy * dy; if (dist2 minDist * minDist) { minDist (int)sqrt((double)dist2); hitId kv.first; } } return hitId; }在OnLButtonUp里调用这个函数如果命中成功就把它设为起点或终点。这里有个用户体验问题用户可能连续点击同一个站点结果又把刚才选中的站点取消了。我的做法是记录两个变量m_startId和m_endId第一次点击设置m_startId第二次设置m_endId第三次重新开始。这样避免增加“确定起点”按钮交互更直接。提示命中测试的容差建议用 12 像素而不是更小。站点密集时如果用 6 像素用户很难点中尤其在屏幕缩放超过 100% 时物理像素和逻辑像素的映射会导致鼠标坐标比视觉上偏半个点。4.3 将查询结果绘制到 GDI 画面有了路径就可以在DoDoubleBuffer里直接画高亮路径用更粗的画笔把路径上的所有相邻站点连线重新画一遍。为了突出结果最好把线路的原始底色降为浅灰色高亮路径用深红色或蓝色。void DrawSearchResult(CDC* pDC, CRect rc) { if (m_path.empty()) return; CPen highlightPen(PS_SOLID, 5, RGB(255, 0, 0)); CPen* pOld pDC-SelectObject(highlightPen); for (size_t i 0; i m_path.size() - 1; i) { int fromId m_path[i]; int toId m_path[i 1]; POINT p1 ToScreen(g_stations[fromId].lat, g_stations[fromId].lng, rc); POINT p2 ToScreen(g_stations[toId].lat, g_stations[toId].lng, rc); pDC-MoveTo(p1.x, p1.y); pDC-LineTo(p2.x, p2.y); } pDC-SelectObject(pOld); // 起点画绿色圆终点画红色方 DrawHighlightMark(pDC, m_startId, rc, RGB(0, 200, 0), true); DrawHighlightMark(pDC, m_endId, rc, RGB(255, 0, 0), false); }换乘文字提示可以放在对话框底部也可以用DrawText画在图例区域。我建议用TextOut把换乘信息画在一个固定区域比如左上角这样截图时能一并展示课程设计答辩时很有用。这里要特别提醒高亮路径经过的站点如果在图上已有普通圆点必须先盖一个纯白色的圆垫底再画高亮标记否则旧点会从新线下面透出来。5. 在 VC 6.0 与现代 Windows 上调试和发布的几个细节5.1 为什么用了双缓冲还是闪烁双缓冲不是万能的。如果你在OnEraseBkgnd里返回了FALSE系统仍然会擦除背景如果返回TRUE但又没有在OnPaint里画全整张客户区就会出现残影。标准做法是重写OnEraseBkgnd直接返回TRUE把背景擦除交给OnPaint里的FillRect。另一个隐性原因是InvalidateRect传了bErase TRUE。当窗口需要局部重绘时系统会先擦除该区域。如果每次点击站点都调用Invalidate()哪怕你用双缓冲也会造成局部闪烁。改成InvalidateRect(NULL, FALSE)并手动在OnPaint里重画全部比Invalidate(TRUE)更平滑。GDI 对象泄漏也是老 MFC 程序常见问题。每调用一次CreatePen、CreateSolidBrush如果不用DeleteObject删除程序在长时间运行后会变慢最终报“内存不足”。使用 MFC 对象包装类CPen时析构函数会自动删除 GDI 对象但前提是必须恢复SelectObject选出的旧对象。否则该 GDI 对象在销毁时仍在 DC 中被选中删除失败泄漏就从这里来。5.2 VC6 工程迁移到现代 Visual Studio 的常见坑VC 6.0 是 1998 年的编译器直接在新系统上编译老工程会遇到几个问题for循环变量作用域、std::min/max需要包含algorithm、CString与std::string转换。如果只是把.dsw/.dsp转成.sln用 Visual Studio 的“打开”功能会自动升级但字符集可能默认变成 Unicode导致所有SQLCHAR*或者char*参数不匹配。解决办法项目属性里把“字符集”改为“使用多字节字符集”。如果你的代码用了TCHAR和_T()宏多字节和 Unicode 都能编译如果直接写了char*字符串选 Unicode 时会出现一堆LPCWSTR类型不匹配的报错。对于公交查询系统这类小型 MFC 程序多字节字符集是最省事的。CString和std::string混用时用str.GetBuffer()和str.ReleaseBuffer()要注意缓冲区生命周期。不要把GetBuffer返回的指针保存起来因为CString在重新赋值后内部缓冲区可能被释放继续访问就是悬垂指针。5.3 发布时带上运行库目标机器才不会“缺少DLL”用 VC 6.0 编译的程序目标机器通常需要 MSVCRT.dll 或者 MFC42.dll。现代 Windows 10 自带系统运行库但精简版系统可能缺失。发布包中应包含MFC42.DLL和MSVCRT.DLL或者使用静态链接。静态链接的方法在 VC6 里是项目设置中选“使用 MFC 作为静态链接库”Use MFC in a Static Library。静态链接后 exe 体积会从几百 KB 变成 1-2 MB但部署方便拷到任何一个 Windows 机器都能跑。代价是内存占用稍高且如果同时用了多个 MFC 模块静态链接会引入重复代码。对于公交线路查询系统这种单进程程序静态链接利大于弊。如果使用新版 Visual Studio 编译则需要在目标机器装VC_redist.x86.exe运行库合集。这是微软官方发布的运行库安装包不是网上不知名的“vc运行库合集”注意分辨来源。只要你用了/MD动态链接标准库就必须在安装包中包含对应版本的 redist 安装程序。项目属性里C/C 代码生成 运行库设置为/MT可以做到完全静态连接 C/C 运行库但 MFC 仍可能需要配对的 DLL。5.4 一个提升 GDI 绘制性能的小技巧只绘制可见区域当线路数据达到数千个站点每次鼠标移动都触发全图重绘CPU 占用会明显升高。GDI 提供了裁剪区优化。在OnPaint里先获取dc.GetClipBox()然后在绘制循环里判断站点或线段是否与裁剪区相交相交才画。void DrawAllRoutes(CDC* pDC, CRect clipRect) { for (auto kv : g_routes) { Route r kv.second; for (size_t i 0; i r.stopIds.size() - 1; i) { POINT p1 ToScreen(g_stations[r.stopIds[i]].lat, g_stations[r.stopIds[i]].lng, m_rcFull); POINT p2 ToScreen(g_stations[r.stopIds[i1]].lat, g_stations[r.stopIds[i1]].lng, m_rcFull); CRect segRect(p1.x, p1.y, p2.x, p2.y); segRect.NormalizeRect(); segRect.InflateRect(1, 1); if (segRect.IntersectRect(segRect, clipRect)) { pDC-MoveTo(p1.x, p1.y); pDC-LineTo(p2.x, p2.y); } } } }这个技巧在窗口全屏显示、点击站点重绘时收益尤其明显。公交线路查询系统虽然数据量不大但养成了这个习惯以后接手地图类或拓扑图工具思路可以直接迁移。到这里你已经把 GDI 绘图的关键环节——数据映射、双缓冲、命中测试、路径高亮、裁剪优化全部走了一遍剩下的就是把这些代码组合进 MFC 的文档视图架构接好OnPaint和OnLButtonUp两个入口程序就能跑起来了。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻