ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MFC画图程序核心实现:橡皮筋技术、双缓冲与GDI资源管理

MFC画图程序核心实现:橡皮筋技术、双缓冲与GDI资源管理 简介面向初学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 大于 rightRectangle 和 Ellipse 绘出的结果是空矩形。NormalizeRect 会交换左右、上下边界保证矩形总是从左上到右下。直线不受影响但矩形和椭圆必须处理。3.3 在 OnMouseMove 里完成两步绘制有了 DrawTrackOnMouseMove 的代码就非常简洁了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析构时调用 EndPaintCClientDC 在任意时刻都可以构造直接针对窗口客户区绘图。鼠标移动事件不是 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::vectorShape 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 是光栅操作码表示直接覆盖。宽高取自客户区如果窗口缩放变大位图会扩大并重新创建绘制内容仍然完整。双缓冲的配套动作是重写 OnEraseBkgndBOOL 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 工程里展开为 LCString 的构造函数恰好匹配。项目右键属性里“使用 MFC 的静态库”也是部署时经常忽略的一项静态链接后目标机器不需要单独安装 VC 运行库避免对方环境缺少 DLL 导致程序起不来。5.3 定位 GDI 对象泄漏计数器与断点画图程序长时间运行后界面越来越卡多半是 GDI 对象泄漏。打开任务管理器在“详细信息”里添加“GDI 对象”列然后反复画线观察计数。每次绘制后计数不回落到基线说明画笔、画刷或位图对象有一个没被释放。最常见的原因是每次重绘 new 一个 CPen但没有 SelectObject 换回旧对象导致 GDI 句柄随每次绘制递增。提示排查时在 OnPaint 结尾加一个断点单步执行后打开调试器的监视窗口查看 m_shapes.size() 的数值与 GDI 计数是否同步增长。如果图形数量没变而 GDI 计数在涨问题几乎必然出现在循环里的 SelectObject 配对少了先把画笔创建移到循环外循环中只更换颜色和宽度能显著降低泄漏概率。本文还有配套的精品资源点击获取
返回列表