MFC绘图程序实战:从消息循环到GDI图形对象与双缓冲重绘
简介一款基于MFC的C绘图程序压缩包内同时提供可运行的程序文件与完整Visual Studio工程源码适合正在学习Windows界面编程、MFC框架或图形应用开发的读者。程序可绘制直线、空心矩形、空心圆形、空心圆角矩形、填充矩形、填充圆形、填充圆角矩形以及文本并配有菜单栏、工具栏和鼠标右键菜单三类操作入口。绘图过程中能自由修改画笔颜色、线宽、线型以及填充颜色和文字字体绘制完成的图形支持保存为文件也可以重新打开进行二次编辑能作为课程设计、毕业设计或日常工具开发的基础。资源包共51个文件主要包含C头文件与源文件、Visual Studio解决方案与工程文件、编译后的可执行程序、PDF说明文档以及图标、位图等辅助资源整体大小约14.18MB目录结构清晰便于按需查阅。目前已有59人学习下载借助配套源码与说明可较快理解MFC的消息映射机制、菜单与工具栏交互方式、图形绘制和文件序列化流程便于在此基础上继续添加新功能。1. 用 MFC 写绘图程序核心不是画图而是消息循环很多人拿到「基于 MFC 的绘图程序」这类题目第一反应是去查Rectangle、Ellipse这些 API 怎么调用。实际上图形绘制 API 只是最外层的一小部分。MFC 程序里画图这件事本质是消息循环驱动下的「状态记录 差异化重绘」——鼠标按下记录起点鼠标移动显示橡皮筋预览鼠标弹起把图形对象存入容器然后统一调用Invalidate触发重绘。整个过程中CDC、CBrush、CPen都只是工具。真正的骨架是消息映射、图形对象模型和绘制循环这三者。这篇文章直接落地到一个可运行的 MFC 单文档程序上画直线、空心矩形、空心圆形、空心圆角矩形以及对应的三种填充图形和文本。界面包含菜单栏、工具栏、鼠标右键快捷菜单。源码和 exe 程序这种交付形态本质上就是在 Debug/Release 下分别处理「运行库依赖」和「资源打包」的问题文章最后一章一并说清楚。适合的人群有这两类一类是刚接触 MFC 的初学者跟着章节顺序去理解消息流和数据流另一类是工作几年但很少碰 GDI 的老开发直接看第 5 章的坑位和第 6 章的精简骨架能快速回忆起 MFC 的工程结构和 CDC 的使用细节。2. 图形对象建模用继承把七种图元统一成 Shape 基类2.1 先想清楚 MFC 里画图应该在哪里画MFC 程序里画图有几种选择CClientDC、CPaintDC、CWindowDC。最常见也最稳妥的方案是在OnDraw里用CPaintDC因为OnDraw会在窗口需要重绘时被框架自动调用——包括窗口大小变化、被遮挡后重新显示、手动调用Invalidate时。这个机制是所有后续操作的基础你永远不需要「主动画一次」只需要修改数据后通知窗口「该重画了」。参数说明简单地说CPaintDC dc(this)这个对象构造时会自动调用BeginPaint析构时调用EndPaint而CClientDC没有这个配对机制适合在鼠标事件里临时取客户区坐标做测算。因此鼠标移动时的橡皮筋预览用CClientDC真正落笔绘制统一走OnDraw。2.2 定义 Shape 基类把绘制、序列化和命中检测统一起来七种图元直线、空心矩形、空心圆形、空心圆角矩形、填充矩形、填充圆形、填充圆角矩形加上文本虽然形态不同但抽象出来有共同特征类型、起点、终点、画笔颜色、画笔画宽、填充色若有。因此基类设计可以这样写// Shape.h enum ShapeType { SHAPE_LINE 0, SHAPE_RECT, SHAPE_ELLIPSE, SHAPE_ROUND_RECT, SHAPE_FILL_RECT, SHAPE_FILL_ELLIPSE, SHAPE_FILL_ROUND_RECT, SHAPE_TEXT }; class Shape { public: Shape() {} virtual ~Shape() {} CPoint startPoint; CPoint endPoint; COLORREF penColor RGB(0, 0, 0); int penWidth 2; COLORREF brushColor RGB(255, 255, 255); ShapeType type; CString textContent; // 文本内容仅 SHAPE_TEXT 使用 virtual void Draw(CDC* pDC) 0; virtual bool HitTest(CPoint point) 0; };逻辑说明基类的成员变量覆盖了所有图元公有的属性。startPoint和endPoint构成图元的包围盒直线是两端点矩形/圆形/圆角矩形是包围矩形。Draw是纯虚函数派生类各自实现绘制。HitTest用于鼠标右键点击时判断「点中了哪个图形」这是编辑功能的前提。没有必要把每个图形单独建一个类再到处switch那样代码会散落在消息处理函数里后期加一个「图形选中高亮」功能要改七八个地方。派生类的实现模式高度一致以空心矩形和填充矩形为例// ShapeRect.cpp class ShapeRect : public Shape { public: virtual void Draw(CDC* pDC) override { CPen pen(PS_SOLID, penWidth, penColor); CPen* pOldPen pDC-SelectObject(pen); CBrush* pOldBrush (CBrush*)pDC-SelectStockObject(NULL_BRUSH); pDC-Rectangle(CRect(startPoint, endPoint)); pDC-SelectObject(pOldPen); pDC-SelectObject(pOldBrush); } }; class ShapeFillRect : public Shape { public: virtual void Draw(CDC* pDC) override { CPen pen(PS_SOLID, penWidth, penColor); CBrush brush(brushColor); CPen* pOldPen pDC-SelectObject(pen); CBrush* pOldBrush pDC-SelectObject(brush); pDC-Rectangle(CRect(startPoint, endPoint)); pDC-SelectObject(pOldPen); pDC-SelectObject(pOldBrush); } };参数说明SelectStockObject(NULL_BRUSH)是空心图形的关键。如果不指定空画刷Rectangle和Ellipse会用当前画刷填充内部那「空心」就变成实心方块了。SelectObject返回值必须保存绘制结束后立即还原。GDI 对象是系统资源虽然在CPen和CBrush的析构里会自动DeleteObject但 DC 里残留选中对象会导致后续绘制异常。这属于 MFC 高频隐患一会儿第 5 章专门说。2.3 用容器管理图形对象实现「存储 → 重绘」闭环程序里需要一个全局容器保存所有已完成的图形// MainFrm.h 或者 View.h 内部 std::vectorstd::unique_ptrShape shapeList;在OnDraw里统一遍历void CMyDrawView::OnDraw(CDC* pDC) { for (auto shape : shapeList) { shape-Draw(pDC); } }所有菜单、工具栏、鼠标操作都只做一件事向shapeList添加图形对象或修改对象属性然后调用Invalidate()。这样做的收益是复杂交互擦除、选中、拖动不需要处理像素级操作只需要更新数据模型。这也为后续实现「重绘整个窗口」的双缓冲方案提供了天然入口。3. 绘制实现直线、空心图形、填充图形和文本的最小代码3.1 用橡皮筋预览实现交互式绘图绘制过程的交互逻辑统一在 View 的鼠标消息里。按下记录起点移动时用异或模式画预览弹起时生成图形对象。以一个常见做法为例void CMyDrawView::OnLButtonDown(UINT nFlags, CPoint point) { m_drawing true; m_startPoint point; m_endPoint point; CView::OnLButtonDown(nFlags, point); } void CMyDrawView::OnMouseMove(UINT nFlags, CPoint point) { if (m_drawing) { CClientDC dc(this); // 用异或模式擦掉上一次预览 dc.SetROP2(R2_NOTXORPEN); DrawShapeTemplate(dc, m_startPoint, m_endPoint, m_currentShapeType); m_endPoint point; // 重新绘制预览 DrawShapeTemplate(dc, m_startPoint, m_endPoint, m_currentShapeType); } CView::OnMouseMove(nFlags, point); } void CMyDrawView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_drawing) { m_drawing false; m_endPoint point; ShapeFactory::CreateAndAddShape(m_currentShapeType, m_startPoint, m_endPoint); Invalidate(); } CView::OnLButtonUp(nFlags, point); }逻辑说明m_currentShapeType是当前工具栏选中的图形类型由一个int变量表示菜单和工具栏都想改它时直接赋值即可。SetROP2(R2_NOTXORPEN)是橡皮筋预览的核心。第一次画时像素变成反色第二次在相同位置再画一遍像素还原。这样不需要保存客户区背景图像实现成本最低。DrawShapeTemplate是纯绘制函数和 Shape 派生类的唯一区别是不写入shapeList。3.2 填充图形的画刷选择和恢复顺序填充图形的实现集中在CBrush的创建与选择。需要注意的是MFC 中CBrush的构造方式影响资源释放时机CBrush brush(brushColor)是合理做法构造时创建逻辑画刷不要使用CBrush* brush new CBrush(...)容易泄漏。绘制填充圆角矩形时注意圆角半径参数RoundRect的第三对坐标不是半径绝对值而是椭圆圆角的宽度和高度。要把圆角做得常规适中一般取短边的一半稍小void ShapeFillRoundRect::Draw(CDC* pDC) { CPen pen(PS_SOLID, penWidth, penColor); CBrush brush(brushColor); CPen* pOldPen pDC-SelectObject(pen); CBrush* pOldBrush pDC-SelectObject(brush); CRect rect(startPoint, endPoint); int width abs(rect.Width()); int height abs(rect.Height()); int cornerX min(width, height) / 2; int cornerY cornerX; pDC-RoundRect(rect, CPoint(cornerX, cornerY)); pDC-SelectObject(pOldPen); pDC-SelectObject(pOldBrush); }参数说明RoundRect的最后一个参数是CPoint(cornerX, cornerY)表示圆角椭圆的宽和高。设成同一个值时圆角是 1/4 正圆。cornerX min(width, height) / 2保证图形较扁时圆角不会占满整个矩形。若直接写死 30 或 50在图形很扁时会很突兀。3.3 文本绘制的两种模式文本图形和其他图形的区别在于它需要一个额外的输入流程。常见实现方式为选择「文本」工具后在客户区点一下弹出一个CEdit对话框或者直接在鼠标位置调用CInputDialog。文本内容存入textContent后调用Drawvoid ShapeText::Draw(CDC* pDC) { CFont font; font.CreatePointFont(120, _T(宋体)); CFont* pOldFont pDC-SelectObject(font); pDC-SetBkMode(TRANSPARENT); pDC-SetTextColor(penColor); CPoint pt min(startPoint, endPoint); pDC-TextOut(pt.x, pt.y, textContent); pDC-SelectObject(pOldFont); }参数说明SetBkMode(TRANSPARENT)是文本不遮挡背景的关键。默认的OPAQUE模式会用当前背景色填充文本矩形文字背后的图形会被盖掉。CreatePointFont(120, _T(宋体))的 120 表示 12 磅1 磅 0.35 毫米换算关系是点阵高度 / 10。文本图形的位置只用了startPoint作为左上角锚点endPoint可以用于记录文本区域宽高便于未来的自动换行和对齐计算。中间的ShapeType枚举值传进ShapeFactory工厂函数里switch创建对应对象这部分逻辑集中在ShapeFactory.cpp中比较合适避免在消息处理函数里堆砌大量if。4. 菜单栏、工具栏和鼠标右键编辑界面怎么搭4.1 菜单和工具栏的命令映射MFC 的资源编辑器负责摆放菜单和工具栏按钮这一步永远是 ID 对接。你需要为每个图形类型分配一个命令 ID例如图元菜单 ID工具栏图标建议直线ID_DRAW_LINE斜线空心矩形ID_DRAW_RECT空心方块空心圆形ID_DRAW_ELLIPSE空心圆空心圆角矩形ID_DRAW_ROUNDRECT圆角方块填充矩形ID_DRAW_FILLRECT实心方块填充圆形ID_DRAW_FILLELLIPSE实心圆填充圆角矩形ID_DRAW_FILLROUNDRECT实心圆角文本ID_DRAW_TEXT字母 A在 View 类的消息映射里统一处理BEGIN_MESSAGE_MAP(CMyDrawView, CView) ON_COMMAND(ID_DRAW_LINE, CMyDrawView::OnDrawLine) ON_COMMAND(ID_DRAW_RECT, CMyDrawView::OnDrawRect) ON_COMMAND(ID_DRAW_ELLIPSE, CMyDrawView::OnDrawEllipse) // 其余类似 END_MESSAGE_MAP() void CMyDrawView::OnDrawLine() { m_currentShapeType SHAPE_LINE; }这里要强调ON_COMMAND和ON_UPDATE_COMMAND_UI的关系。若要让菜单项和工具栏按钮在选中时显示「按下」状态必须额外添加ON_UPDATE_COMMAND_UI映射实现勾选状态void CMyDrawView::OnUpdateDrawLine(CCmdUI* pCmdUI) { pCmdUI-SetCheck(m_currentShapeType SHAPE_LINE); }参数说明pCmdUI-SetCheck(TRUE)会把菜单项左边显示一个对勾工具栏按钮会变为凹陷状态。没有这个处理菜单和工具栏的「当前选中工具」完全不可见这类绘图程序的操作体验会大大降低。4.2 右键快捷菜单用 TrackPopupMenu 一步到位在OnRButtonDown里加载菜单资源并弹出右边框菜单项直接复用主菜单的 IDvoid CMyDrawView::OnRButtonDown(UINT nFlags, CPoint point) { CMenu menu; menu.LoadMenu(IDR_MENU_EDIT); // 在资源编辑器里创建好右键菜单 CMenu* pPopup menu.GetSubMenu(0); ClientToScreen(point); pPopup-TrackPopupMenu(TPM_LEFTALIGN | TPM_RIGHTBUTTON, point.x, point.y, this); CView::OnRButtonDown(nFlags, point); }逻辑说明LoadMenu加载整个菜单栏资源GetSubMenu(0)取出第一个下拉菜单。这里是IDR_MENU_EDIT你也可以把右键菜单做成独立资源不依赖主菜单。ClientToScreen(point)不能省。TrackPopupMenu的坐标参数是屏幕坐标系而鼠标消息给到的是客户区坐标不换算的话菜单会出现在偏离鼠标的地方。右键菜单里放什么常见组合是显示图形类型切换子菜单、删除选中图形、清空画布。ID 统一映射到之前那个ON_COMMAND相同的处理函数上。4.3 选中和删除把编辑操作接到图形对象上右键菜单的「编辑」概念常见落地路径是「选中图形 → 修改属性或删除」。选中能力依赖HitTest的实现。不同的图形命中逻辑不同bool ShapeRect::HitTest(CPoint point) { CRect rect(startPoint, endPoint); rect.NormalizeRect(); // 给一定的容错范围方便用户点选 rect.InflateRect(5, 5); // 判断点是否在矩形边框附近点在矩形内但距离边缘有一定范围 if (rect.PtInRect(point)) { // 精确判断靠近边缘 CRect innerRect rect; innerRect.DeflateRect(10, 10); return rect.PtInRect(point) !innerRect.PtInRect(point); } return false; }参数说明NormalizeRect确保startPoint在左上、endPoint在右下。用户拖拽时可能从右下往左上拉不做归一化PtInRect会返回 FALSE。InflateRect(5, 5)是为了容错。用户很难在 1 像素宽的边框上精确点击外扩 5 像素后框边附近都可以选中。直线图形的HitTest用点到直线的距离判断圆形则用「点到圆心距离和半径的差值」判断。这些都可以作为派生类各自的实现。删除操作的代码非常简单shapeList.erase(iterator)然后Invalidate()。但遍历删除时要反向迭代避免索引失效void CMyDrawView::OnDeleteSelected() { int i m_selectedIndex; if (i 0 || i (int)shapeList.size()) return; shapeList.erase(shapeList.begin() i); m_selectedIndex -1; Invalidate(); }选中高亮可以让图形Draw时检测一个bSelected标志选中时额外绘制一个虚线外框。不需要在消息处理里做像素级处理。5. 高频踩坑为什么你的 MFC 绘图程序总是闪、崩、丢5.1 缺少ON_UPDATE_COMMAND_UI导致菜单状态不同步这是 MFC 程序最典型的状态管理问题。很多人给菜单关联了ON_COMMAND但从不处理ON_UPDATE_COMMAND_UI结果就是菜单项永远处于「未选中」状态工具栏按钮点击后无法呈现出按下效果用户不知道当前正在画什么图形一一对应地补上ON_UPDATE_COMMAND_UI映射后程序状态立刻清晰。这是检索用户最容易忽略的细节。5.2 坐标映射在缩放和滚动模式下错乱MFC 的 view 默认是MM_TEXT映射模式即一个逻辑单位等于一个像素。这是写绘图程序最舒服的模式。如果你改成了MM_ISOTROPIC或MM_ANISOTROPIC鼠标坐标和绘制坐标就不一定一致了处理起来会比较麻烦。常见错误是在CClientDC中直接使用GetDeviceCaps做坐标换算但忽略了 view 的滚动偏移。如果你的 view 是从CScrollView派生的OnPrepareDC会被自动调用你需要在鼠标事件里主动调用它void CMyDrawView::OnLButtonDown(UINT nFlags, CPoint point) { CClientDC dc(this); OnPrepareDC(dc); dc.DPtoLP(point); // 之后的 point 才是正确的逻辑坐标 }参数说明DPtoLP将设备坐标像素转换为逻辑坐标。对于滚动文档这一步会把滚动偏移量和缩放比例都算进去避免偏移错位。CClientDC构造时默认的映射模式是MM_TEXT如果OnPrepareDC里调用了SetMapMode这里要重新设置映射模式。5.3 GDI 对象泄漏SelectObject 之后必须还原MFC 的CPen/CBrush/CFont是 GDI 对象的包装类。对象析构时会调用DeleteObject释放对应的 GDI 资源。但如果这个对象仍被选入 DCDeleteObject调用会失败最终在任务管理器里看到 GDI 对象数量持续上涨。这就是典型的 GDI 泄漏。一个可靠的写作模式是每个Draw函数结束时必须把SelectObject的返回值全部还原。例如CPen* pOldPen pDC-SelectObject(pen); CBrush* pOldBrush pDC-SelectObject(brush); // 绘制... pDC-SelectObject(pOldPen); pDC-SelectObject(pOldBrush); // pOldPen / pOldBrush 的析构顺序安全在绘制代码里画完立即恢复既能防止 GDI 泄漏也能避免pDC析构时试图删除仍在选中的对象导致的断言失败。Debug 模式下这种断言会直接弹窗Release 模式则可能随机表现得不可预期。5.4 双缓冲重绘解决拉伸窗口时图形的严重闪烁GDI 绘图直接写在屏幕上时每次Invalidate都会先擦除背景再重画。图形很多时这个「擦除-重画」过程肉眼可见地闪烁。常见做法是修改OnEraseBkgnd返回 TRUE再在OnDraw里用内存 DC 一次性画完BOOL CMyDrawView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 不擦除背景交给 OnDraw 处理 } void CMyDrawView::OnDraw(CDC* pDC) { CRect rect; GetClientRect(rect); CDC memDC; CBitmap bmp; memDC.CreateCompatibleDC(pDC); bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 先用背景色填充整个内存画布 memDC.FillSolidRect(rect, RGB(255, 255, 255)); // 再绘制所有图形 for (auto shape : shapeList) { shape-Draw(memDC); } // 一次性输出到屏幕 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }参数说明OnEraseBkgnd返回 TRUE 之后窗口背景不再由系统擦除所有重绘工作集中在OnDraw完成闪烁明显减轻。内存 DC 创建后必须SelectObject一个兼容位图否则内存 DC 的默认位图是 1x1 单色位图画上去的内容会不显示或显示异常。BitBlt的SRCCOPY完全覆盖目标区域把内存画布一次贴到屏幕。垃圾清理交给CBrush/CPen等临时对象的析构函数处理不必手动调用DeleteObject但一定要确保还原 DC 里选中的旧对象。6. 收尾技巧把整个程序收敛成一个可拷贝的绘图骨架有个做法能让你在写 MFC 绘图程序时思路非常清晰把「图形绘制逻辑」「交互逻辑」「界面状态逻辑」严格分开。图形的绘制逻辑全部在 Shape 派生类里交互逻辑全部在 View 的鼠标消息里界面状态当前图形类型、当前画笔颜色、画刷颜色全部集中在 View 的成员变量里。这样做的直接好处是当你想把图形扩展到更多类型时只改ShapeType枚举和工厂函数不用动任何鼠标消息代码。实际交付时还有两个值得关注的点。如果你要发布 Release 版 exe需要在项目属性里设置「在静态库中使用 MFC」避免目标机器缺少 MFC 运行库。菜单工具栏资源和图标资源使用相对路径引用保持在 exe 同目录下不同机器解压后直接运行不容易出问题。验证一个绘图程序是否健壮的标准很简单打开 exe画满整屏图形然后快速拖动窗口大小改变窗体尺寸再看是否闪烁接着按住鼠标左键快速拖动看橡皮筋预览是否残留残影最后右键删除图形再移动位置确认画面重绘后内容一致。能达到这三点这个程序的基本质量就算过关了。本文还有配套的精品资源点击获取