简介:基于GDI技术的VC++公交线路查询系统是一份完整可运行的Windows图形界面项目,面向学习C++、Windows API及GDI绘图的中高级开发者,演示了如何用VC++实现公交线路的查询与可视化。系统可输入起终点并借助路径算法展示线路,界面中的地图、站点和路线均通过GDI绘制,适合作为课程设计或图形编程入门参考。压缩包共包含49个文件,以C++头文件与源文件(h/cpp)为主,另有工程配置文件、公交站点线路数据文件(dat)、使用说明和可直接启动的exe,整体约4.31MB,结构清晰便于按模块阅读。已有201人学习下载。读者可从源码中掌握GDI绘图流程、对话框界面搭建、公交数据解析与最短路径算法集成等关键实现,同时通过随附的可执行文件快速验证效果,是理解Windows桌面应用开发流程的有价值实例。
1. 基于GDI的VC++公交线路查询系统:一张能画出来的线路图,为什么比控制台版值钱
“基于GDI技术的VC++公交线路查询系统”这个标题,做过C++课设的人多半不陌生。它不是一个只会把“中央公园→会展中心”打印成文本的黑匣子,而是打开程序就能看到站点圆点和线路走向的桌面地图。GDI是Windows图形设备接口,在VC++里通过设备上下文完成画线、画点、写字。公交线路查询系统靠它把站点、线路、换乘关系画到屏幕上,算是没有第三方图形库时最贴近真实桌面应用的做法。
这类项目适合谁?新手能借此熟悉MFC消息映射、视图重绘和GDI对象管理;熟手也能在经典架构里找回“数据与显示分离”的从容。它不需要数据库,一个文本文件加一个视图类就能跑。下面按我落地这类小系统的顺序展开:先搭工程与数据模型,再画地图,再写查询,最后把翻车率最高的几个坑集中讲一遍。
2. 搭建MFC工程与公交数据模型:先解决“地图从哪来”
2.1 为什么选MFC文档视图而不是对话框
公交线路查询系统有两种常见落地法:对话框程序或单文档视图程序。如果目标只是“输入两个站名,输出换乘方案”,对话框最直接,堆三个编辑框加一个按钮就行。但一旦涉及地图缩放、查询结果高亮、打印导出,对话框就要自己造滚动和重绘机制,得不偿失。我一般会直接选MFC单文档工程,基类用CView或CScrollView,让文档类保存数据,视图类负责画图。
文档视图带来的最大好处是OnDraw按需调用。窗口被遮挡、拖大、滚动时,系统自动触发重绘,视图类只要保证OnDraw能根据当前数据产出完整画面。查询模块产生的变化也只体现在成员变量上,比如高亮线路ID数组,再Invalidate一下即可。对话框程序则要在消息里区分“是用户点了查询”“还是窗口在重绘”,消息路由一乱,画出来的地图经常被编辑框盖住。这是我看过很多人用对话框做同类系统后翻车的主要原因。
新建工程时,向导会生成应用类、框架类、视图类、文档类四个类。公交数据我放在文档类里,作为成员变量;视图类通过GetDocument()访问。这样后续做文件读写和菜单命令,消息分发路径最短。如果选CScrollView,还要额外处理滚动偏移,初学阶段先做整图缩放到客户区,功能跑通后再加滚动。
2.2 站点和线路的数据结构:先定清楚,后面少改
公交线路查询系统的数据规模通常不大,几十条线路、几百个站点足够演示。但数据结构仍要遵守一个原则:站点全局唯一,线路只存站点ID的序列。有人图省事,把站名和坐标直接写在线路结构里,结果同一个“中央公园”在不同线路中坐标不一致,换乘判断只能靠字符串匹配,性能差还容易匹配上同名的分站。我一般在文档类里维护两个数组:
// 站点定义:全局唯一ID、站名、坐标(城市坐标或经纬度) struct BusStop { int id; // 站点ID,从0开始 CString name; // 站点名,例如“中央公园” double x; // 横坐标(或经度) double y; // 纵坐标(或纬度) }; // 线路定义:线路ID、线路名、按行驶方向排列的站点ID struct BusRoute { int id; // 线路ID CString routeNo; // 线路名,例如“K1” CArray<int,int> stopIds; // 顺序即行车方向 };逻辑说明:站点id是检索的键,线路stopIds里的每一个整数都指向某个站点。这样做有几个直接好处:换乘查询只要比较“一个站点ID是否在另一条线路的stopIds里”,不用逐字比较站名;坐标修正也只需要改站点结构,所有线路同时生效;高亮线路时,还能从stopIds快速找到每个站点并画出路径。
有人会问为什么不用STL的vector。在MFC项目中,CArray可以跟CListCtrl、DDX等MFC组件直接搭配,调试时能看见数组元素内容;vector也完全能用,只是与MFC的序列化、消息映射整合时多一步转换。我的选择是:工程整体用MFC的CArray,算法部分内部可以转vector。数据量不大,这一点选型不会成为瓶颈。
2.3 用文本文件模拟数据库:格式约定与加载逻辑
课程设计阶段很少专门引入SQLite,一个结构化的文本文件足够了。我常用的格式是分节:
STATIONS: 0 中央公园 121.47 31.23 1 东门 121.48 31.24 2 高新区 121.47 31.26 3 会展中心 121.45 31.20 4 大学城 121.43 31.22 ROUTES: K1 0 1 2 K2 3 0 4 K3 0 4 2站点的四个字段分别是ID、站名、x、y;线路行第一个是线路名,后面是依次经过的站点ID。这种格式可以用CStdioFile逐行读,不依赖外部库。加载函数我一般写成这样:
BOOL LoadBusData(LPCTSTR path, CArray<BusStop, BusStop&>& stops, CArray<BusRoute, BusRoute&>& routes) { CStdioFile f; if (!f.Open(path, CFile::modeRead | CFile::shareDenyNone)) { AfxMessageBox(_T("无法打开数据文件")); return FALSE; } int section = 0; // 0=未知,1=站点段,2=线路段 CString line; while (f.ReadString(line)) { line.Trim(); if (line == _T("STATIONS:")) { section = 1; continue; } if (line == _T("ROUTES:")) { section = 2; continue; } if (line.IsEmpty()) continue; if (section == 1) { BusStop s; int id = 0; if (swscanf_s(line, _T("%d"), &id) == 1) { s.id = id; // 读站名和坐标:按空格切分 line = line.Mid(line.Find(_T(' ')) + 1); int space = line.Find(_T(' ')); s.name = line.Left(space); line = line.Mid(space + 1); swscanf_s(line, _T("%lf %lf"), &s.x, &s.y); stops.Add(s); } } else if (section == 2) { BusRoute r; int pos = line.Find(_T(' ')); if (pos <= 0) continue; r.routeNo = line.Left(pos); line = line.Mid(pos + 1); int stopId; while (_stscanf_s(line, _T("%d"), &stopId) == 1) { r.stopIds.Add(stopId); while (!line.IsEmpty() && line[0] != _T(' ')) line = line.Mid(1); line.TrimLeft(); } routes.Add(r); } } f.Close(); return TRUE; }逻辑说明:用section变量区分当前读的是站点段还是线路段,比“读到哪个表名就切换解析方式”更直白。swscanf_s是安全格式化输入,要求缓冲区大小,因此站名解析时我没有直接用它读字符串,而是手动按空格切分,避免CString和格式化输入之间来回转换的麻烦。线路段每读一个站点ID,就把line从数字后面截掉,继续读下一个数字。
参数说明:path可以使用相对路径。程序启动后,先在InitInstance里调用LoadBusData,把stops和routes填充到文档类的成员变量中;如果文件缺失,直接弹窗提示。另外读取完成后我第一次做的是数据合法性检查:遍历每条线路的stopIds,凡是出现stops里不存在的ID,就说明数据文件有误,统计后弹出提示并跳过这条线路。这个检查必须做,因为后面画地图和换乘查询都依赖于“stopId一定有效”这一假设,一旦越界,程序会给你颜色看。
加载代码里还有个容易被忽略的边界:文件行尾是\r\n,ReadString会去掉换行符但可能保留\r,所以调用line.Trim()是必须的。如果忘记Trim,最后一段可能被解析成带\r的数字,导致站点ID匹配不上。习惯上我会在读取每条线路的第一个数字之前再TrimLeft一次。数据文件建议使用UTF-8带BOM或GBK编码,与工程字符集保持一致,这一点后面避坑专章还会提到。
3. 用GDI把线路画到屏幕上:坐标映射、绘制顺序与双缓冲
3.1 从WM_PAINT到OnDraw,一次完整的绘制闭环
MFC视图类的绘图入口是OnDraw,它由WM_PAINT消息触发。窗口首次显示、被其他窗口遮挡后恢复、用户拖大拖动时,系统都会安排一次WM_PAINT,视图的OnDraw被反复调用。关键是OnDraw不要依赖上一次绘制的状态,每次都要把整张图重新画一遍。为了后面打印和导出图片方便,我习惯把真正的绘图逻辑抽成一个DrawMap(CDC* pDC, CRect rc)函数,OnDraw只提供DC和客户区矩形:
void CMyView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(&rc); DrawMap(pDC, rc); }参数说明:GetClientRect取得的是窗口客户区矩形,坐标原点在客户区左上角。如果使用CScrollView,客户区只是可视窗口,逻辑坐标系还要叠加滚动偏移;初学时建议先做“整图缩放到客户区”,让所有站点和线路在窗口范围内,避免一上来就和滚动条纠缠。
3.2 城市坐标到屏幕坐标:别把经纬度直接当像素
公交数据里站点坐标通常是城市坐标或经纬度,数值范围可能很大。如果直接把31.23当作屏幕y值,站点全部挤在左上角。正确做法是先求所有站点的包围盒(minX/maxX/minY/maxY),再做线性映射。另外屏幕y轴向下,地图方向习惯是北在上,所以y轴要翻转一次。
double g_minX = 0, g_maxX = 0, g_minY = 0, g_maxY = 0; void InitGeoBounds(CArray<BusStop, BusStop&>& stops) { if (stops.GetCount() == 0) return; g_minX = g_maxX = stops[0].x; g_minY = g_maxY = stops[0].y; for (int i = 1; i < stops.GetCount(); i++) { if (stops[i].x < g_minX) g_minX = stops[i].x; if (stops[i].x > g_maxX) g_maxX = stops[i].x; if (stops[i].y < g_minY) g_minY = stops[i].y; if (stops[i].y > g_maxY) g_maxY = stops[i].y; } } CPoint GeoToScreen(const BusStop& stop, const CRect& rc) { const int margin = 50; // 四周留白,避免站点标签被窗口裁掉 double sx = (stop.x - g_minX) / (g_maxX - g_minX); double sy = (stop.y - g_minY) / (g_maxY - g_minY); int x = margin + (int)(sx * (rc.Width() - 2 * margin)); int y = rc.Height() - margin - (int)(sy * (rc.Height() - 2 * margin)); return CPoint(x, y); }逻辑说明:sx、sy都是0到1的比例,乘上“客户区宽度减去左右边距”就是像素位置。y轴翻转体现在rc.Height() - margin再减去sy那一段上。使用margin是为了给站名文字留出空间,否则边缘站点名字会被窗口边框切断。
这里有个容易被问到的点:为什么不直接用高德地图那种墨卡托投影?因为没有底图,只是画示意网络,线性映射足够清晰。站点分布不均时,偶尔会出现线路被拉得很奇怪,但只要站点顺序正确,乘客看拓扑关系不受影响。如果非要保持经纬度比例不变,可以把缩放系数统一为min(scaleX, scaleY),并按比例居中,代码并不复杂。
3.3 画线、画圆点、写站名:顺序与画笔管理
GDI绘制要讲究顺序。路线要先画,站点圆点在线上层,站名文字最后画,保证文字可读。画线时画笔是核心对象,创建一个CPen,选进DC,画完后一定要恢复原来的画笔,否则会影响后面所有绘制。
void DrawMap(CDC* pDC, CRect rc) { // 1. 画所有线路 CPen penNormal(PS_SOLID, 2, RGB(60, 120, 200)); CPen* pOldPen = pDC->SelectObject(&penNormal); for (int i = 0; i < routes.GetCount(); i++) { BusRoute& r = routes[i]; if (r.stopIds.GetCount() < 2) continue; CPoint ptPrev = GeoToScreen(stopsMap[r.stopIds[0]], rc); pDC->MoveToEx(ptPrev); for (int j = 1; j < r.stopIds.GetCount(); j++) { CPoint ptNext = GeoToScreen(stopsMap[r.stopIds[j]], rc); pDC->LineTo(ptNext); } } // 2. 画站点圆点 CBrush brDot(RGB(255, 80, 80)); CBrush* pOldBr = pDC->SelectObject(&brDot); for (int i = 0; i < stops.GetCount(); i++) { CPoint pt = GeoToScreen(stops[i], rc); pDC->Ellipse(pt.x - 4, pt.y - 4, pt.x + 4, pt.y + 4); } // 3. 画站名文字 pDC->SetTextColor(RGB(20, 20, 20)); pDC->SetBkMode(TRANSPARENT); for (int i = 0; i < stops.GetCount(); i++) { CPoint pt = GeoToScreen(stops[i], rc); pDC->TextOut(pt.x + 6, pt.y - 12, stops[i].name); } pDC->SelectObject(pOldPen); pDC->SelectObject(pOldBr); }参数说明:路线画笔用蓝色2像素,站点用红色4像素半径圆点,文字偏移(6, -12)是为了让站点名大致处于圆点右上侧。如果地图站点密集,12像素的字会互相压住,可以按当前缩放比例决定是否显示所有站名,或者把文字字号调小。SetBkMode(TRANSPARENT)很重要,否则TextOut会用一个矩形背景色块盖住下面的线路。
这里我用了stopsMap,它是一个由站点ID到BusStop的映射,本质上是为了在画线时快速取坐标。在MFC里可以用CMap<int,int,BusStop,BusStop&>,也可以std::map。每次OnDraw查询一次映射会有开销,但站点数少,完全可接受。
3.4 双缓冲:让地图拖动不再疯狂闪烁
直接在屏幕DC上画图时,每一帧都有“擦白-绘制”两步,屏幕刷新速度跟不上,人眼看到的就是闪烁。尤其公交线路图包含几十条线路和几百个文字,重绘时间超过16ms,闪烁更明显。解决方法是先在内存DC里把整张图画好,再一次性BitBlt到屏幕。这是GDI绘图最常用也是最基本的性能优化手段。
void CMyView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(&rc); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); memDC.FillSolidRect(&rc, RGB(255, 255, 255)); DrawMap(&memDC, rc); pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }参数说明:CreateCompatibleDC创建的DC和传入的pDC内存管理方式一致,CreateCompatibleBitmap分配一块与屏幕颜色格式兼容的位图。BitBlt是最后一个字,用于把内存位图整块拷贝到屏幕DC。注意CBitmap和CDC的生命周期:memDC和bmp是局部对象,函数结束后自动释放;SelectObject保存旧位图,函数结束前恢复,这样双击窗口再放大时不会因为位图选择残留而报错。
提示:要彻底消除闪烁,还要重载OnEraseBkgnd直接返回TRUE,让系统不再用背景色擦除客户区。否则双缓冲虽然缓解了内容闪烁,但擦除和BitBlt之间的白帧仍然可能出现。
4. 直达与一次换乘查询:从指令到高亮重绘
4.1 查询入口与消息映射
查询模块是整个系统的“内功”。我习惯在菜单里放两个命令,一个直达查询,一个换乘查询,都弹出同一个输入对话框:对话框上有两个带下拉选择的组合框,列出所有站名,避免用户输入了不存在的站名。菜单命令通过ON_COMMAND宏绑定到视图类的响应函数。
BEGIN_MESSAGE_MAP(CMyView, CView) ON_COMMAND(ID_QUERY_DIRECT, &CMyView::OnQueryDirect) ON_COMMAND(ID_QUERY_TRANSFER, &CMyView::OnQueryTransfer) ON_COMMAND(ID_VIEW_RESET, &CMyView::OnViewReset) END_MESSAGE_MAP()说明:ID_QUERY_DIRECT是在资源菜单里定义的命令ID。响应函数里先取出对话框选中的起终点ID,调用查询算法,然后把结果保存到成员变量,再Invalidate()触发重绘。查询算法本身不依赖UI,纯粹操作文档类里的stops和routes数组,这样测试时可以单独用控制台打印结果,不必非打开窗口。
4.2 直达查询:同时判断“在线路上”和“方向正确”
直达查询看起来很简单:某条线路的站点序列是否同时包含起点和终点。但公交线路有方向,一条上行线的站点顺序是A→B→C,如果从C到A,就不能说直达。所以必须检查起点在站序中的位置是否在终点之前。
BOOL IsDirect(const BusRoute& r, int startId, int endId, int& sIdx, int& eIdx) { sIdx = -1; eIdx = -1; for (int i = 0; i < r.stopIds.GetCount(); i++) { if (r.stopIds[i] == startId) sIdx = i; if (r.stopIds[i] == endId) eIdx = i; } return sIdx >= 0 && eIdx >= 0 && sIdx < eIdx; }参数说明:sIdx和eIdx通过引用带出来,是为了在换乘结果里告诉用户“在K2路的第3站上车、第7站下车”。如果这两条信息只是打印出来,前端展示也就有了基础。注意这里我用的是严格小于,如果起终点在同一站,返回值是FALSE,应当在查询前拦截。
还有一种情况是环线线路,站点序列收尾相连,比如A→B→C→A。此时从C到A虽然在物理上直达,但按严格顺序判断会漏掉。常见的处理是把环线的站点序列复制一份追加在后面,再去做位置判断。数据量小的时候,复制一次的成本可以忽略。
4.3 一次换乘查询:先缩小线路范围,再找换乘站
一次换乘的语义是:起点站坐L1,在某个中间站下车,换乘L2到达终点站。暴力做法是枚举所有线路两两组合,再枚举站点求交集。站点总数几百条时没问题,但为了说清楚优化思路,我更推荐先收集经过起点的线路和经过终点的线路,只在这两组线路之间求交。
BOOL FindOneTransfer(CArray<BusRoute>& routes, int startId, int endId, int& line1, int& transferStop, int& line2) { CArray<int, int> startLines, endLines; for (int i = 0; i < routes.GetCount(); i++) { BOOL hasStart = FALSE, hasEnd = FALSE; for (int j = 0; j < routes[i].stopIds.GetCount(); j++) { if (routes[i].stopIds[j] == startId) hasStart = TRUE; if (routes[i].stopIds[j] == endId) hasEnd = TRUE; } if (hasStart) startLines.Add(i); if (hasEnd) endLines.Add(i); } for (int i = 0; i < startLines.GetCount(); i++) { for (int j = 0; j < endLines.GetCount(); j++) { int li = startLines[i]; int lj = endLines[j]; if (li == lj) continue; // 求两条线路的站点交集,第一个公共站就是换乘站 for (int s1 = 0; s1 < routes[li].stopIds.GetCount(); s1++) { int stopId = routes[li].stopIds[s1]; for (int s2 = 0; s2 < routes[lj].stopIds.GetCount(); s2++) { if (routes[lj].stopIds[s2] == stopId) { line1 = li; line2 = lj; transferStop = stopId; return TRUE; } } } } } return FALSE; }逻辑说明:外层先构建起点线路集合和终点线路集合,确保换乘组合都从有效的两条线路里选。三层循环在最坏情况下是O(L1L2S1*S2),但公交演示数据通常只有几十条线路和几百个站点,毫秒级就能返回。如果数据量上千,建议把每条线路的站点序列先放进哈希集合,再遍历较短线路的站点来求交集,复杂度会从四次方降到二次方。
这里还有一个容易被忽略的细节:找到的第一个公共站只是“路线经过上的换乘站”,没有考虑它到底是起点方向的未知站还是终点方向的未知站。要提升体验,可以继续检查换乘站在line2上的位置是否位于终点之前,这也解释了为什么IsDirect里要返回索引。
4.4 结果高亮:把查询答案画到地图上
查到结果后,视图类维护一个高亮线路ID数组和换乘站点ID,OnDraw里根据线路ID选择不同画笔。为了视觉对比明显,我把所有非高亮线路画成浅灰色细线,高亮线路画成红色粗线,换乘站点用黄色圆点圈出来。
void CMyView::DrawMap(CDC* pDC, CRect rc) { // 先画非高亮线路 for (int i = 0; i < routes.GetCount(); i++) { if (m_highlightRoutes.Find(routes[i].id) >= 0) continue; CPen penGray(PS_SOLID, 1, RGB(190, 190, 190)); CPen* pOldPen = pDC->SelectObject(&penGray); DrawRouteLines(pDC, routes[i], rc); pDC->SelectObject(pOldPen); } // 再画高亮线路,确保压在上面 for (int i = 0; i < m_highlightRoutes.GetCount(); i++) { int routeId = m_highlightRoutes[i]; int routeIdx = FindRouteIndexById(routeId); if (routeIdx < 0) continue; CPen penRed(PS_SOLID, 5, RGB(255, 40, 40)); CPen* pOldPen = pDC->SelectObject(&penRed); DrawRouteLines(pDC, routes[routeIdx], rc); pDC->SelectObject(pOldPen); } }参数说明:高亮线路的画笔宽度用5像素,比普通线粗两倍多。两个循环的设计比循环内判断再改画笔更稳,避免画笔选择来回切换影响绘制效率。DrawRouteLines把画线路的一段逻辑单独提取,它里面只按站点顺序MoveToEx/LineTo,具体颜色由外部画笔决定。
查询结束后,把高亮ID数组清空再填入新结果,调用Invalidate(FALSE)。注意这里用Invalidate(FALSE)只让客户区无效,但不擦除背景,配合前面的双缓冲,重绘时画面不闪。如果直接把整个view的内容更新到对话框的输出框,也要记住对话框是另一个窗口,它的重绘不受View影响。
5. 避坑与排查:GDI绘图最常翻车的4个场景
5.1 窗口一拉伸线路图就闪成雪花
现象:用鼠标拖动窗口边缘,地图区域闪烁得厉害,甚至出现残影和错位线条。在新手机上,几十条线路一起重绘,闪屏直接让人怀疑代码有问题。
原因:OnDraw直接在屏幕DC上逐条画线路,每次WM_PAINT都有一次OnEraseBkgnd擦白和OnDraw重画。擦白和重画是两帧,速度一慢就形成闪烁。如果还用了Pens/SelectObject没有恢复,重绘过程中画笔状态错乱,会出现错位线。
解决:双缓冲加禁止擦除背景。在OnEraseBkgnd里return TRUE,OnDraw里先FillSolidRect再DrawMap,最后BitBlt。注意双缓冲的位图必须和客户区大小一致,窗口尺寸变化时,每次OnDraw都重新CreateCompatibleBitmap,不要试图复用旧的。如果这样还闪,可以在OnDraw开头用GetTickCount记录时间,看看单帧是否超过30ms,超过就考虑只重绘失效区域,而不是整张图。
5.2 中文站名输出成问号
现象:TextOut输出“中央公园”,显示为“????”;英文站名正常。同时控制台和弹窗里的中文也可能异常。
原因:工程字符集是MBCS(多字节字符集),数据文件用UTF-8无BOM保存,读取时CString按本地代码页解析,UTF-8字节流被错误拆成多字节字符。另一个常见原因是代码字符串字面量没有加_T(),在Unicode工程下转成窄字符串传给宽字符API,导致乱码。
解决:项目属性里把字符集统一成“使用Unicode字符集”;数据文件用记事本另存为“UTF-8带BOM”,或者统一用GBK。读取和输出都使用CString、TextOut、_T(),不要混用char和wchar_t。如果数据已经读乱,先输出一两个站名的BYTE看看是几个字节,再决定转码方案。另外如果站点名需要自定义字体,用CreateFontIndirect创建中文字体并选入DC,不要依赖系统默认字体。
5.3 GDI对象数持续上涨直到绘图异常
现象:程序运行十几分钟,任务管理器里进程的GDI对象数从几十涨到几千,最后整张图开始只画出一半,甚至直接崩溃。
原因:OnDraw里每次new一个CPen/CFont/画刷,用完没有DeleteObject。MFC的CPen局部对象在析构时会释放自己的GDI句柄,但用new创建的对象必须手动删除。另一个常见的泄漏点是反复SelectObject新对象但没有恢复旧对象,导致旧状态丢失,系统无法释放。
解决:坚持“谁创建谁释放”原则。凡是局部栈对象,函数结束自动清理;凡是new出来的,函数结束前必须DeleteObject。我在高亮绘制里使用栈对象和SelectObject旧对象,确保每个分支都恢复。还有一个习惯:写完一块绘制代码,立刻检查画笔、画刷、字体三个对象是不是都成对出现。这个坑用任务管理器监控GDI对象数是最实用的验证手段,也可以用GetGuiResources函数在程序里定时输出当前GDI对象数,一旦发现持续上涨就逐段注释代码定位。
5.4 地图坐标偏差很大,站点贴到窗口边缘或消失
现象:加载数据后,站点全部挤到左上角,或者窗口放大后线路越出客户区,滚动也找不到。
原因:GeoToScreen里直接用整数除法,比例变成0;或者没有做InitGeoBounds就调用GeoToScreen,g_minX等还是初始值0;还有可能是某些线路contains非法站点ID,取坐标时越界读到垃圾值。
解决:第一步在LoadBusData后做站点ID合法性校验,无效线路直接跳过;第二步确保GeoToScreen用的所有变量都是double,最后再转int;第三步是边界保护,当maxX == minX时,让该轴缩放系数为0,并手动居中。这三个处理做完,坐标异常基本绝迹。如果还想快速定位问题,写一个临时函数输出几个关键站点的屏幕坐标到OutputDebugString,一眼就能看出是映射问题还是数据问题。
6. 进阶:把绘制逻辑抽出来,一键导出线路图图片
6.1 绘制函数与设备无关化
前面DrawMap(CDC*, CRect)的全叫法其实已经暗示了进阶方向:画图不用管DC来自屏幕还是内存,只要CDC有效,绘制逻辑一样执行。打印机、导出图片、缩略图都可以复用。因此我在做这类系统时会把视图和绘图彻底分开,在文档类里做一个DrawMap(CDC* pDC, CRect rc)的公共接口,视图OnDraw只传屏幕DC。
6.2 用CImage把地图保存为PNG
导出图片的关键是构造一个内存DC,把图画好后转成CImage保存。CImage是MFC里比较方便的位图封装,既能装载位图,也能直接Save到文件。
BOOL ExportMapToFile(LPCTSTR path, int width, int height) { CDC memDC; memDC.CreateCompatibleDC(NULL); CBitmap bmp; bmp.CreateCompatibleBitmap(memDC, width, height); CBitmap* pOldBmp = memDC.SelectObject(&bmp); CRect rc(0, 0, width, height); memDC.FillSolidRect(&rc, RGB(255, 255, 255)); DrawMap(&memDC, rc); // 复用同一个绘制函数 CImage img; img.Attach((HBITMAP)bmp.GetSafeHandle()); BOOL ok = img.Save(path, Gdiplus::ImageFormatPNG); img.Detach(); memDC.SelectObject(pOldBmp); return ok == -1 ? FALSE : TRUE; }参数说明:CreateCompatibleDC(NULL)创建的是与屏幕兼容的内存DC,适合位图绘制;CImage::Attach把CBitmap占用的HBITMAP接管,保存PNG后Detach,避免双重释放。注意DrawMap里的站点坐标按客户区尺寸映射,导出大图时只要传入更大的width/height,图形会等比放大,不必额外调参。
如果嫌CImage的PNG支持需要GDI+初始化,可以改存BMP:img.Save(path, Gdiplus::ImageFormatBMP)。对课程设计来说,PNG体积更可控,BMP兼容性更好。打印也是同样思路,把DrawMap的DC换成打印机DC,先用GetDeviceCaps取打印页面的像素宽高,再调用同一个绘制函数,公交图就能直接输出成纸质版。
做完这些之后,我最大的教训是:千万不要把绘图代码越写越往View里面塞。第一次做时我把线路、站点、文字三层全部堆在OnDraw里,结果想导出图片只能复制粘贴一大段代码;后来抽成DrawMap,三十行改动解决。GDI本身不玄学,只要记住DC是画布、对象是画笔,剩余的问题大多出在对象生命周期和坐标数学上。希望这个方向能帮到正在被MFC和GDI绕晕的人。
本文还有配套的精品资源,点击获取