MFC画图程序核心实现:橡皮筋技术、双缓冲与GDI资源管理
2026/9/16 14:19:51 网站建设 项目流程

简介:面向初学Windows编程、希望快速上手图形界面开发的读者,一份基于Visual C++实现的简易画图程序源码项目,可用于理解Windows窗口程序从创建到绘图的完整流程。压缩包采用rar格式,共包含7个文件,其中.dsp和.dsw为VC工程文件,.cpp为程序主体源码,.ncb/.opt/.plg记录编译辅助信息,.txt为相关说明,整体大小仅7KB,便于快速下载与阅读。目前已有211人学习下载,适合对照学习GDI+图形绘制、鼠标输入响应、颜色与线宽选择、橡皮擦以及位图保存加载等具体功能。通过这份源码,读者可以直观看到如何通过消息循环接收用户操作并调用绘图API完成界面刷新,对于后续学习MFC、Qt等其他GUI框架也能打下良好基础,同时资源中保留了完整的工程配置,可在VC6.0等环境直接打开运行,是Windows编程入门和课程设计的实用参考。

1. VC画图程序:比源码包更难拿到的是一条完整的 MFC 链路

网上搜 VC 画图程序,能找到的源码包不在少数,但多数人解压后第一步就卡在编译,不是缺头文件就是字符集不匹配。这个标题看着只是一个画图小工具,实际是 MFC 入门最完整的一条链路:鼠标消息路由、GDI 绘图对象、橡皮筋技术、图形重绘,全部串在一起。把这条链路拆开弄懂,不仅课设能交掉,以后接手遗留的 MFC 代码也不虚。适合 C++ 基础已经具备、准备拿它当综合训练的人,也适合多年没碰 MFC、想快速捡回状态的老手。

2. 搭建画图程序的 MFC 骨架:从文档视图到鼠标消息

画图程序看起来简单,但底层框架的选择决定了后面每一步的复杂度。先用对框架,再谈画图。

2.1 为什么画图程序要选 CView 而不是对话框或普通窗口

MFC 里可以接收鼠标消息的窗口类型很多,对话框、CFrameWnd、CView 都能响应 WM_LBUTTONDOWN。但画图程序有一个特殊需求:窗口里的内容随时可能被系统擦掉,必须在每次 WM_PAINT 时重新画出来。CView 的 OnDraw 机制就是为这个设计的,视图失效时框架自动触发重绘;对话框没有这样的钩子,图形一被遮挡就永久消失,需要自己额外处理 WM_PAINT,等于重新实现了一半框架。

创建工程时用 VS 的 MFC 应用向导,应用程序类型选“单文档”,项目样式选“MFC 标准”,把视图基类从默认的 CView 或 CScrollView 改成 CView。这里有一个细节值得注意:不要选 CScrollView,它会把客户区坐标和滚动偏移混在一起,画图程序还没开始就背上坐标换算的负担。向导生成的视图类就是后续绘图的主画布,内存 DC、画笔、位图都围绕它展开。

2.2 画图程序最小骨架:三个鼠标消息映射函数

画一条直线最少需要三个鼠标事件:按下时记住起点,移动时更新终点,松开时确定图形。MFC 用消息映射宏把这几个 Windows 消息绑定到视图类的成员函数,这一步错了后面全部白搭。

// DraftView.h——关键成员与消息函数声明 class CDraftView : public CView { protected: CPoint m_ptStart; // 鼠标按下时的起点 CPoint m_ptEnd; // 鼠标移动中的当前点 BOOL m_bDrawing; // 是否处于拖拽绘制状态 public: afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() };
// DraftView.cpp——消息映射与鼠标响应入口 BEGIN_MESSAGE_MAP(CDraftView, CView) ON_WM_LBUTTONDOWN() ON_WM_MOUSEMOVE() ON_WM_LBUTTONUP() END_MESSAGE_MAP() void CDraftView::OnLButtonDown(UINT nFlags, CPoint point) { m_ptStart = point; m_ptEnd = point; m_bDrawing = TRUE; SetCapture(); // 捕获鼠标,拖出窗口外仍能收到MouseMove CView::OnLButtonDown(nFlags, point); } void CDraftView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { m_ptEnd = point; // 重绘逻辑在第3章实现 } CView::OnMouseMove(nFlags, point); } void CDraftView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_ptEnd = point; m_bDrawing = FALSE; ReleaseCapture(); } CView::OnLButtonUp(nFlags, point); }

逻辑说明:m_bDrawing 相当于一个简单的状态锁,只有左键按下后才处理 MouseMove,避免鼠标悬浮时产生无意义的坐标更新。SetCapture 的作用是把鼠标消息持续送到当前视图,即使鼠标拖出客户区边缘,画图程序依然能收到 MouseMove 事件;如果不调用它,鼠标一移出窗口,拖拽就断了,画到一半的线直接消失。ReleaseCapture 必须和 SetCapture 成对出现,否则后续鼠标消息全部被这个窗口独占。

参数说明:nFlags 携带的是键盘和鼠标的组合状态,MK_SHIFT 表示 Shift 键按下,MK_CONTROL 表示 Ctrl 键按下,画正圆或正方形时就是靠它来判断;point 是以客户区左上角为原点的像素坐标,可以直接传给 GDI 绘图函数,不需要再做任何换算。

2.3 坐标陷阱:客户区坐标、屏幕坐标和 GetMessagePos

很常见的错误写法是去调用 GetMessagePos 拿鼠标位置,然后 ScreenToClient 转成客户区坐标。这么做不是不行,但属于重复劳动。MFC 传给鼠标处理函数的 point 参数,已经是经过框架换算的客户区坐标,直接用即可。如果画图程序后续要支持坐标显示在状态栏,也是基于这个 point 展开,不需要再碰屏幕坐标。

消息宏处理函数触发时机point 坐标含义
ON_WM_LBUTTONDOWN()OnLButtonDown鼠标左键按下客户区像素坐标
ON_WM_MOUSEMOVE()OnMouseMove鼠标在客户区移动,捕获后移出也触发当前鼠标像素位置
ON_WM_LBUTTONUP()OnLButtonUp左键释放释放瞬间的鼠标位置

真正容易出问题的是窗口边缘的情况。鼠标拖拽到客户区边界外,point 可能出现负值或超过窗口宽度,如果用这个坐标直接创建 CRect,矩形方向会反过来。这个坑在后面画矩形时会集中爆发。

注意:画图程序的骨架阶段不要急于写绘图代码,先把三个消息函数跑通,用 TRACE 或 OutputDebugString 输出坐标,确认消息路由和坐标体系正确再往下。

3. 画图程序橡皮筋技术:用 SetROP2 让拖拽中的图形跟着鼠标走

骨架搭好后,第一个真正有技术含量的问题是:拖拽过程中,怎么让直线或矩形“粘”在鼠标上实时变化,同时不留下残影。直接用 GDI 画一条永久直线,鼠标一动,旧线还在,新线又画上去,屏幕上会叠出一把扇子。

3.1 为什么橡皮筋必须用异或画法:R2_NOT 的擦除原理

GDI 画笔和屏幕已有像素的关系由光栅操作码控制,默认是 R2_COPYPEN,即画笔颜色直接覆盖目标像素。橡皮筋要求的是“画两次等于擦一次”,这意味着需要用 R2_NOT 模式。在这个模式下,每个目标像素被取反,与画笔颜色无关。同一个像素连续画两次,第一次取反,第二次再次取反,数学上等于 N(N(x)) = x,画面完全恢复原状。

这才是橡皮筋技术的根基。它不依赖背景颜色,不用记录被覆盖的像素内容,只要保证在同一位置画两次,就能无痕擦除。很多 MFC 教程直接给代码,但不说清这个数学原理,导致读者把 R2_NOT 和 R2_XORPEN 混用,出现颜色残留。

ROP2 模式运算规则橡皮筋场景
R2_COPYPEN像素 = 画笔颜色默认值,覆盖绘制
R2_NOT像素 = 当前像素取反画两次即可擦除,最适合橡皮筋
R2_XORPEN像素 = 画笔 XOR 当前像素依赖画笔颜色,换色后擦不干净
R2_BLACK像素 = 黑色强制抹黑,不适合临时图形

R2_NOT 的优点是与当前画笔颜色完全无关,黑色、红色、蓝色画笔在同一点上两次取反的结果都一样,擦除逻辑最简单。

3.2 橡皮筋三步重绘:擦旧图、更新坐标、画新图

拖拽过程中的每次 MouseMove,要做的事是固定的三步:用 R2_NOT 把当前图形重新画一遍,屏幕上立即消失;更新 m_ptEnd 为新的鼠标位置;再用 R2_NOT 把新图形画一遍。由于同一位置的第二次取反已经把像素还原,这个过程等效于旧图被擦除、新图出现。

// 画图程序中的橡皮筋绘制函数 #define DRAW_LINE 0 #define DRAW_RECT 1 #define DRAW_ELLIPSE 2 void CDraftView::DrawTrack(CDC* pDC, CPoint start, CPoint end, int nType) { CPen pen(PS_DOT, 1, RGB(0, 0, 0)); // 点线画笔,临时图形视觉上更轻 CPen* pOldPen = pDC->SelectObject(&pen); int nOldMode = pDC->SetROP2(R2_NOT); // 声明“画两次等于擦一次” switch (nType) { case DRAW_LINE: pDC->MoveTo(start); pDC->LineTo(end); break; case DRAW_RECT: { CRect rc(start, end); rc.NormalizeRect(); // 处理反向拖拽 pDC->SelectStockObject(NULL_BRUSH); // 矩形只画边框,不填充 pDC->Rectangle(rc); break; } case DRAW_ELLIPSE: { CRect rc(start, end); rc.NormalizeRect(); pDC->SelectStockObject(NULL_BRUSH); pDC->Ellipse(rc); break; } } pDC->SetROP2(nOldMode); // 恢复原光栅模式 pDC->SelectObject(pOldPen); // 画笔对象恢复,避免GDI泄漏 }

逻辑说明:DrawTrack 函数本身不关心“画哪条线”,只负责按照当前起点和终点把图形画出来。擦除和重画是同一个函数,靠 R2_NOT 的两次调用实现。这个设计是橡皮筋技术最优雅的地方,如果擦除和绘制用两套代码,颜色、线型稍有不同就擦不干净。

参数说明:PS_DOT 是点线样式,但需要线宽为 1 时才能生效,Windows GDI 对宽于 1 像素的点线会自动转成实线。SetROP2 返回旧模式,必须保存并在函数结束前恢复,否则下一次正常绘制会全部变成取反模式。

NormalizeRect 的细节值得单独说。鼠标从右下往左上拖,CRect 的 left 大于 right,Rectangle 和 Ellipse 绘出的结果是空矩形。NormalizeRect 会交换左右、上下边界,保证矩形总是从左上到右下。直线不受影响,但矩形和椭圆必须处理。

3.3 在 OnMouseMove 里完成两步绘制

有了 DrawTrack,OnMouseMove 的代码就非常简洁了:

void CDraftView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { CClientDC dc(this); // 视图客户区DC DrawTrack(&dc, m_ptStart, m_ptEnd, m_nDrawType); // 第一次画:擦除 m_ptEnd = point; // 更新终点 DrawTrack(&dc, m_ptStart, m_ptEnd, m_nDrawType); // 第二次画:显示新图形 } CView::OnMouseMove(nFlags, point); }

这里用的是 CClientDC 而不是 CPaintDC。两者的区别在于:CPaintDC 只在处理 WM_PAINT 消息时使用,构造时调用 BeginPaint,析构时调用 EndPaint;CClientDC 在任意时刻都可以构造,直接针对窗口客户区绘图。鼠标移动事件不是 WM_PAINT 上下文,用 CPaintDC 会破坏 WM_PAINT 的绘制周期。

提示:橡皮筋绘制过程中,如果鼠标移动速度极快,MouseMove 消息可能间隔较大,线段看起来不连续。这是 Windows 消息机制的固有行为,不是代码 bug。要解决得用 SetTimer 高频采样或 WM_POINTER 系列消息,对一个小画图程序来说没必要。

4. 画图程序的存储与重绘:对象链表、OnPaint 与双缓冲

橡皮筋解决了“画的时候”的问题,但松手之后还有一个更隐蔽的问题:画好的图形在窗口被遮挡、最小化、改变大小时会全部消失。Windows 不会替你保存窗口上的像素,所有内容必须在每次 WM_PAINT 时重新画一遍。

4.1 用 std::vector 保存图形对象的参数

把图形存起来,存的是什么?答案是参数,不是绘制结果。GDI 设备和位图对象都依赖具体窗口的上下文,窗口尺寸一变就失效;而起点、终点、颜色、线宽这些参数是稳定的,每次重绘时重新创建 GDI 对象即可。

// Shape.h——图形对象定义 #include <vector> struct Shape { int nType; // DRAW_LINE / DRAW_RECT / DRAW_ELLIPSE CPoint ptStart; CPoint ptEnd; COLORREF clrLine; // 画笔颜色 int nLineWidth; // 线宽 }; class CDraftView : public CView { ... std::vector<Shape> m_shapes; // 已完成的图形列表 };

选择 vector 而不是 MFC 的 CObList,主要原因是简单。Shape 是 POD 结构体,拷贝开销小,支持随机访问,OnPaint 里用 for 循环按顺序绘制即可。如果图形数量上万,vector 的插入会涉及内存重分配,可以提前 reserve 一个预估容量,比如 m_shapes.reserve(1024)。

4.2 OnLButtonUp 落库:把一次拖拽固化为图形

松手时,橡皮筋图形还在屏幕上,此时把它存入 m_shapes,然后触发一次重绘。

void CDraftView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_bDrawing = FALSE; ReleaseCapture(); m_ptEnd = point; Shape s; s.nType = m_nDrawType; s.ptStart = m_ptStart; s.ptEnd = m_ptEnd; s.clrLine = m_clrLine; s.nLineWidth = m_nLineWidth; m_shapes.push_back(s); // 保存参数,供后续重绘使用 Invalidate(FALSE); // 请求重绘,但不擦除背景 } CView::OnLButtonUp(nFlags, point); }

逻辑说明:m_shapes 里保存的是鼠标松手瞬间的最终参数,橡皮筋绘制过程产生的所有中间坐标都不需要保存。Invalidate(FALSE) 的 FALSE 参数是关键,它告诉系统只重绘客户区内容,不用先擦除背景。如果这里用了 Invalidate(TRUE),窗口会先被背景色覆盖再重绘,画面的闪烁感会明显加重。双缓冲之后,擦不擦背景都由 OnPaint 内部统一控制,这个 FALSE 就更有必要。

4.3 双缓冲重绘 OnPaint:让复杂图形不再闪烁

默认的 OnPaint 实现会委托给 OnDraw,但图形数量一多,逐条绘制到屏幕上的闪烁会非常显著。原因是每次 GDI 操作直接作用在可见窗口 DC 上,系统频繁执行“擦除背景—绘制前景”的循环,肉眼看到的就是闪。双缓冲的思路是:先在内存里画好整幅图像,一次性拷贝到屏幕。

void CDraftView::OnPaint() { CPaintDC dc(this); // 窗口DC,最终上屏目标 CDC memDC; // 内存DC memDC.CreateCompatibleDC(&dc); CRect rcClient; GetClientRect(&rcClient); // 取客户区宽高 CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 内存DC选中位图 memDC.FillSolidRect(&rcClient, RGB(255, 255, 255)); // 白色背景 for (size_t i = 0; i < m_shapes.size(); i++) { const Shape& s = m_shapes[i]; CPen pen(PS_SOLID, s.nLineWidth, s.clrLine); CPen* pOldPen = memDC.SelectObject(&pen); memDC.SelectStockObject(NULL_BRUSH); CRect rc(s.ptStart, s.ptEnd); rc.NormalizeRect(); switch (s.nType) { case DRAW_LINE: memDC.MoveTo(s.ptStart); memDC.LineTo(s.ptEnd); break; case DRAW_RECT: memDC.Rectangle(rc); break; case DRAW_ELLIPSE: memDC.Ellipse(rc); break; } memDC.SelectObject(pOldPen); } dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }

逻辑说明:CreateCompatibleDC 创建的内存 DC 初始只有 1x1 像素的白色位图,必须用 CreateCompatibleBitmap 创建和客户区等大的位图并 SelectObject 进去,否则画上去的内容无处存放。所有图形画进 memDC 后,BitBlt 把整块内存位图以 SRCCOPY 方式一次复制到屏幕 DC,屏幕只发生一次绘制操作,闪烁被彻底消除。

参数说明:BitBlt 的六个关键参数分别是目标横坐标、目标纵坐标、宽度、高度、源 DC、源横坐标、源纵坐标,最后一个 SRCCOPY 是光栅操作码,表示直接覆盖。宽高取自客户区,如果窗口缩放变大,位图会扩大并重新创建,绘制内容仍然完整。

双缓冲的配套动作是重写 OnEraseBkgnd:

BOOL CDraftView::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景由OnPaint里的FillSolidRect统一处理 }

OnEraseBkgnd 默认会用白色刷子擦除客户区,再交给 OnPaint 重绘,两次填充造成闪烁。返回 TRUE 后,系统不再做背景擦除,全部由双缓冲代码控制。

重绘方式画面效果适用场景
直接 OnDraw 逐条绘制图形一多明显闪烁图形数量少,几十个以内
Invalidate(TRUE) 触发重绘每次刷新闪一次白简单测试,不推荐
OnPaint 双缓冲 + OnEraseBkgnd 返回 TRUE无闪烁多边形、大量线条时推荐

注意:内存 DC 和 CBitmap 都是局部变量,函数退出时析构,会自动删除 GDI 对象。但 SelectObject 必须先换回旧位图再析构,否则内存 DC 析构时可能尝试删除当前选中的外部位图对象,引发对象句柄混乱。代码里最后一行 memDC.SelectObject(pOldBmp) 就是为此。

5. 画图程序收官技巧:颜色联动、字符集兼容与 GDI 泄漏排查

到这里画图程序已经能用,下面三个细节决定它是否“好使”。

5.1 用 CColorDialog 把画笔颜色和界面联动起来

选择颜色最常见的是 CColorDialog,它是 Windows 标准颜色选择器,无需引入额外依赖:

void CDraftView::OnColorPick() { CColorDialog dlg(m_clrLine, CC_FULLOPEN); if (dlg.DoModal() == IDOK) { m_clrLine = dlg.GetColor(); // 保存颜色选择结果 Invalidate(FALSE); // 颜色只影响后续图形,这里刷新无关 } }

值得注意的联动细节:颜色改变只会影响之后绘制的图形,已经存进 m_shapes 的图形颜色不受影响。如果希望历史图形颜色也更新,要么遍历 m_shapes 修改 clrLine,要么把新颜色作为下次绘制的默认值。多数画图程序选择后者,前者会破坏用户的操作预期。

5.2 ANSI 与 UNICODE 字符集下的字符串处理

MFC 新项目的默认字符集是 Unicode,但网上流传的画图程序源码多半还停留在 ANSI 时代。直接编译常见报错是 C2664,提示无法将 const char* 转换为 LPCTSTR。项目属性里可以把字符集切回多字节,但更稳妥的办法是统一用.T()宏包字符串字面量,CString 会根据工程字符集自动选择宽窄版本:

CString strTitle; strTitle.Format(_T("画图程序 - %d 个图形"), m_shapes.size()); SetWindowText(strTitle);

.T()在 ANSI 工程里展开为普通 char 字符串,在 Unicode 工程里展开为 L"",CString 的构造函数恰好匹配。项目右键属性里“使用 MFC 的静态库”也是部署时经常忽略的一项,静态链接后目标机器不需要单独安装 VC 运行库,避免对方环境缺少 DLL 导致程序起不来。

5.3 定位 GDI 对象泄漏:计数器与断点

画图程序长时间运行后界面越来越卡,多半是 GDI 对象泄漏。打开任务管理器,在“详细信息”里添加“GDI 对象”列,然后反复画线观察计数。每次绘制后计数不回落到基线,说明画笔、画刷或位图对象有一个没被释放。最常见的原因是每次重绘 new 一个 CPen,但没有 SelectObject 换回旧对象,导致 GDI 句柄随每次绘制递增。

提示:排查时在 OnPaint 结尾加一个断点,单步执行后打开调试器的监视窗口,查看 m_shapes.size() 的数值与 GDI 计数是否同步增长。如果图形数量没变而 GDI 计数在涨,问题几乎必然出现在循环里的 SelectObject 配对少了;先把画笔创建移到循环外,循环中只更换颜色和宽度,能显著降低泄漏概率。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询