资讯详情 基于GDI的VC++公交线路查询系统:MFC地图绘制与换乘算法实践
📅 2026/10/10 14:58:20
简介基于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” CArrayint,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, CArrayBusStop, BusStop stops, CArrayBusRoute, 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\nReadString会去掉换行符但可能保留\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(CArrayBusStop, 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里可以用CMapint,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(CArrayBusRoute routes, int startId, int endId, int line1, int transferStop, int line2) { CArrayint, 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数组和换乘站点IDOnDraw里根据线路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 TRUEOnDraw里先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就调用GeoToScreeng_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初始化可以改存BMPimg.Save(path, Gdiplus::ImageFormatBMP)。对课程设计来说PNG体积更可控BMP兼容性更好。打印也是同样思路把DrawMap的DC换成打印机DC先用GetDeviceCaps取打印页面的像素宽高再调用同一个绘制函数公交图就能直接输出成纸质版。做完这些之后我最大的教训是千万不要把绘图代码越写越往View里面塞。第一次做时我把线路、站点、文字三层全部堆在OnDraw里结果想导出图片只能复制粘贴一大段代码后来抽成DrawMap三十行改动解决。GDI本身不玄学只要记住DC是画布、对象是画笔剩余的问题大多出在对象生命周期和坐标数学上。希望这个方向能帮到正在被MFC和GDI绕晕的人。本文还有配套的精品资源点击获取