
MFC里画东西第一反应多数人都是“在OnDraw里写两句FillRect不就完事了”可真落到实际项目里坐标怎么算、颜色怎么选、窗口一缩放就重绘、GDI对象一多就泄漏样样都是坑。这篇文章就围绕“基于MFC绘制一个彩色正方形”这个入门题把背后那套GDI绘图机制讲透再给出可以直接抄走的代码和几个进阶玩法适合刚接触MFC的人也适合想要系统搞懂设备上下文、画刷画笔、双缓冲这些概念的老同学。你要是有VC 6.0或者VS2010以上版本直接照着敲一遍效果比看十遍文档都实在。1. 先别急着写代码MFC绘图的底层逻辑1.1 设备上下文与“画什么、在哪画”MFC绘图绕不开一个概念设备上下文英文简称DC。你可以把它理解成一块“虚拟画布”所有画线、画圆、填充颜色的动作最终都是在一个DC上完成的。MFC里的CDC类就是对Windows GDI图形设备接口的封装它把画刷、画笔、字体、位图这些GDI对象和一系列绘图函数统一收在一起。初学者最容易绕晕的是CPaintDC、CClientDC、CWindowDC这三兄弟。CPaintDC只能在OnPaint或者OnDraw这种响应WM_PAINT消息的地方用它是从窗口客户区拿DC并且在构造时自动BeginPaint析构时自动EndPaint。CClientDC则更自由一点它在你需要的时候直接GetDC客户区常用于鼠标消息里画临时图形。CWindowDC连非客户区都能画边框、标题栏都能动但这属于“大力出奇迹”日常项目里用得少而且容易把人家的窗口画花。对咱们这个彩色正方形项目来说最简单的路子就是在视图类的OnDraw里用CPaintDC。为什么选OnDraw而不是自己写OnPaint因为MFC的CView框架里OnDraw会被框架在WM_PAINT时自动调用而且和文档视图架构绑定得特别好视图尺寸变化、窗口被遮挡后恢复框架都会帮你触发必要的重绘。自己接管OnPaint也不是不行但你需要处理很多事情比如背景擦除、无效矩形区域优化对新手来说增加理解成本和踩坑面。1.2 OnDraw、窗口重绘与消息驱动很多同学写了OnDraw却发现“窗口被别的窗口挡住再拉回来正方形不见了”这就是没搞懂重绘机制。Windows窗口系统是一个消息驱动模型当窗口需要重新绘制时系统会给它发WM_PAINT消息。MFC把这件事封装得比较友好Invalidate、InvalidateRect这些函数会向窗口发送“请重绘”的通知框架收到WM_PAINT后会调用你的OnDraw并传入一个已经准备好的DC指针。所以关键点是什么是你的绘图代码必须写在OnDraw里并且不能依赖上一次绘制的状态。窗口每次重绘都是一次“推倒重来”你要做的不是记住“之前画过多少”而是在OnDraw里把整个正方形完整画出来。这一点和游戏开发里的“渲染循环”思路很像每一帧都是全量绘制而不是增量绘制。还有一个小细节容易被忽略如果你在鼠标消息比如OnLButtonDown里用CClientDC画了图形然后窗口一刷新画面就没了。因为消息处理函数里画的内容并不会被“记住”重绘时只有OnDraw里的内容会被重新执行。这也是为什么MFC官方文档和多数教程都建议把相对长期、需要保留的绘制内容统统放进OnDraw临时性的交互反馈比如拖拽过程中的橡皮筋框才放在消息处理里画。2. 从零搭建单文档项目把彩色正方形画出来2.1 创建MFC应用工程我用的是Visual Studio 2019VS2010、VS2015、VS2022操作基本一样打开“新建项目”选择“MFC应用”项目名随意例如“ColorSquareDemo”。到了向导界面应用类型选“单文档”项目样式选“MFC标准”其余保持默认即可。这里要特别注意如果你选的不是基于文档视图的架构而是“对话框”那后面OnDraw就没地方写了得改成在对话框的OnPaint里画逻辑类似但路径不同。创建完成后解决方案资源管理器里会生成一堆类。我们需要动的核心是视图类名字一般是CColorSquareDemoView里面已经有个OnDraw函数的空壳。这就是我们的主战场。如果你是老派的VC 6.0用户操作也差不多文件 - 新建 - 工程 - MFC AppWizard(exe)选单文档完成。MFC的绘图机制二十多年没怎么变过这套知识放到今天依然通用。2.2 核心代码在OnDraw中绘制彩色正方形现在打开视图类的OnDraw函数把下面的代码贴进去。完整代码我在这里一次性给全每一段都有注释。void CColorSquareDemoView::OnDraw(CDC* pDC) { CColorSquareDemoDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // 1. 获取客户区矩形 CRect rcClient; GetClientRect(rcClient); // 2. 定义正方形边长并计算居中位置 int nSideLength 200; int nLeft (rcClient.Width() - nSideLength) / 2; int nTop (rcClient.Height() - nSideLength) / 2; CRect rcSquare(nLeft, nTop, nLeft nSideLength, nTop nSideLength); // 3. 用画刷填充正方形内部颜色 CBrush brFill(RGB(255, 100, 50)); // 橙红色 pDC-FillRect(rcSquare, brFill); // 4. 用画笔绘制边界 CPen penFrame(PS_SOLID, 3, RGB(0, 0, 255)); // 蓝色边框宽3像素 CPen* pOldPen pDC-SelectObject(penFrame); pDC-Rectangle(rcSquare); pDC-SelectObject(pOldPen); // 5. 在正方形中心绘制文字验证DC的文本能力 pDC-SetBkMode(TRANSPARENT); // 背景透明否则文字会有白底 pDC-SetTextColor(RGB(255, 255, 255)); CString strText _T(MFC Color Square); pDC-DrawText(strText, rcSquare, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }这里解释几个关键点。FillRect接收一个CRect和一个CBrush指针作用是填充矩形区域Rectangle则是画一个“空心矩形边框”使用的是当前选中的画笔。所以整体效果就是内部填充橙色边框是蓝色粗线看上去就是一个标准的彩色正方形。CRect的右手系逻辑也要留意。在Windows屏幕坐标系里y轴是向下增加的所以CRect(left, top, right, bottom)中bottom比top数值大才是正常朝向。很多人刚开始会写成“左上角坐标宽高”的形式结果发现正方形位置跑偏多半是把bottom参数当高度用了。这个坑后面章节还会细说。2.3 运行与效果验证顺便聊聊CRect的坐标坑按F5运行你会看到一个居中显示的正方形底色橙红边框蓝色中间有一行白色文字。这时候可以随便拉伸窗口正方形会一直保持居中因为每次重绘都会重新计算客户区大小。这一步跑通之后你就可以开始做实验了。比如把窗口拖到屏幕边缘再拖回来观察正方形是否会丢失。理论上MFC会自动重绘正方形始终在。这里可以顺便验证一下之前讲的“重绘机制”如果你把绘图代码移到一个普通成员函数里比如菜单命令处理函数然后调用Invalidate触发重绘画面就会恢复成空白的因为OnDraw里什么都没画。反过来如果你只在OnDraw里画不再调用任何重绘相关函数画面则永远保持正常。这个简单的验证能让你彻底理解WM_PAINT和OnDraw的关系。关于坐标还有个容易踩的点客户区GetClientRect返回的矩形right和bottom并不是坐标的最大值而是“宽度”和“高度”。比如一个800x600的窗口GetClientRect返回CRect(0, 0, 800, 600)这里的800和600实际是宽度和高度。当你在Rectangle里使用这个矩形时画出来的实际宽高是正确的因为GDI对right和bottom是“不包含”的。但如果你自己计算坐标就容易绕晕。我的习惯是始终把CRect当“左上角宽高”来理解用right - left得到宽度bottom - top得到高度而不是直接拿right当x坐标去计算。3. 让正方形“活”起来渐变、交互与双缓冲3.1 从纯色到渐变让“彩色”名副其实标题里写的是“彩色正方形”如果只是填充单一颜色多少有点辜负这个“彩色”。我建议做一个渐变效果让正方形从左到右颜色连续变化这样既锻炼颜色理解又让成果更炫。GDI没有直接提供渐变填充矩形的高层函数但我们可以用逐行扫描的方式模拟。思路很简单正方形内部区域从上到下或者从左到右按像素行绘制一条条水平线每条线的颜色根据位置实时计算。为了让代码简洁我选择纵向渐变也就是每条水平方向直线的颜色相同行与行之间颜色变化。void CColorSquareDemoView::DrawGradientSquare(CDC* pDC, CRect rc) { int nHeight rc.Height(); int nWidth rc.Width(); for (int y 0; y nHeight; y) { // 颜色按当前行位置做线性变化 BYTE byRed static_castBYTE(255.0 * y / nHeight); BYTE byGreen static_castBYTE(255.0 * (nHeight - y) / nHeight); BYTE byBlue static_castBYTE(128 127 * sin(y * 3.14159 / nHeight)); CPen penLine(PS_SOLID, 1, RGB(byRed, byGreen, byBlue)); CPen* pOldPen pDC-SelectObject(penLine); pDC-MoveTo(rc.left, rc.top y); pDC-LineTo(rc.right, rc.top y); pDC-SelectObject(pOldPen); } }这段代码里红色的分量从0递增到255绿色从255递减到0蓝色用一个正弦函数做波动这样得到的渐变不但有方向感还会出现颜色的自然过渡。你把OnDraw里原来的FillRect换成调用DrawGradientSquare再运行看效果会明显比单色填充生动得多。这里有一个性能问题值得说逐行画线的效率并不高正方形边长如果是200就是循环200次每次创建一支画笔再删除开销不小。如果正方形边长变成2000你再试试刷新一下就能感受到明显的卡顿。这也是为什么大型绘图项目通常会用FillSolidRect或者直接操作DIB位图的像素数据而不是一条线一条线地画。这属于后话但你需要知道这个边界在哪里。3.2 鼠标拖拽绘制正方形消息处理与橡皮筋效果静态的正方形演示到位后可以加一点交互用鼠标在窗口上拖拽动态生成一个正方形。这就要用到视图类的鼠标消息了。头文件里需要添加成员变量protected: BOOL m_bDrawing; // 是否正在拖拽 CPoint m_ptOrigin; // 按下鼠标时的起点构造函数里初始化m_bDrawing FALSE; m_ptOrigin CPoint(0, 0);然后通过类向导或者手动添加三个消息处理函数void CColorSquareDemoView::OnLButtonDown(UINT nFlags, CPoint point) { // 按下左键记录起点开始拖拽 m_bDrawing TRUE; m_ptOrigin point; CView::OnLButtonDown(nFlags, point); } void CColorSquareDemoView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { // 这里使用异或绘制模式达到“橡皮筋”效果 CClientDC dc(this); CRect rcClient; GetClientRect(rcClient); // 计算正方形矩形以起点和当前点为对角顶点 CRect rcSquare(m_ptOrigin, point); int nSide min(rcSquare.Width(), rcSquare.Height()); if (point.x m_ptOrigin.x point.y m_ptOrigin.y) rcSquare.SetRect(m_ptOrigin.x, m_ptOrigin.y, m_ptOrigin.x nSide, m_ptOrigin.y nSide); else if (point.x m_ptOrigin.x point.y m_ptOrigin.y) rcSquare.SetRect(m_ptOrigin.x - nSide, m_ptOrigin.y, m_ptOrigin.x, m_ptOrigin.y nSide); else if (point.x m_ptOrigin.x point.y m_ptOrigin.y) rcSquare.SetRect(m_ptOrigin.x, m_ptOrigin.y - nSide, m_ptOrigin.x nSide, m_ptOrigin.y); else rcSquare.SetRect(m_ptOrigin.x - nSide, m_ptOrigin.y - nSide, m_ptOrigin.x, m_ptOrigin.y); // 设置异或模式 int nOldRop dc.SetROP2(R2_XORPEN); // 先画一个旧正方形来擦除因为异或同一个矩形画两次会消失 dc.Rectangle(m_rcLastSquare); m_rcLastSquare rcSquare; dc.Rectangle(m_rcLastSquare); // 恢复绘制模式 dc.SetROP2(nOldRop); } CView::OnMouseMove(nFlags, point); } void CColorSquareDemoView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_bDrawing FALSE; // 正式绘制把最终的正方形固化到OnDraw的内容中 m_rcFinalSquare m_rcLastSquare; Invalidate(FALSE); // 请求重绘 } CView::OnLButtonUp(nFlags, point); }这个方案里用了R2_XORPEN异或模式。它的原理是新像素颜色由画笔颜色和背景颜色按位异或得到。同一个矩形区域画两次颜色会恢复到第一次画之前的状态。这样拖拽过程中旧矩形就被“画第二次”的方式擦掉了从而产生橡皮筋效果。这也是Windows程序员处理橡皮筋线的经典做法。构造函数的初始化里需要给m_rcLastSquare和m_rcFinalSquare一个初始值比如SetRect(0, 0, 0, 0)避免第一次OnMouseMove时画一个未初始化的矩形导致乱画。3.3 双缓冲绘图彻底解决闪烁问题拖拽交互加完你会发现一个严重问题经常拖拽时画面闪烁尤其窗口尺寸大或者机器配置一般的时候那个闪断真的让人怀疑人生。闪烁的根源是“背景擦除 重绘”被分开执行了Windows先把旧背景抹成白色再在当前DC上画新图形这个过程中间有一个短暂的空档人眼就会感知到闪烁。解决办法是双缓冲。说白了就是在内存里建一块“后台画布”把图形全部画到内存画布上最后一次性把整块内存画布复制到屏幕。因为复制操作是原子性的不会出现“先擦除再画”的中间态闪烁自然就消失了。MFC里实现双缓冲的标准套路是这样的void CColorSquareDemoView::OnDraw(CDC* pDC) { // 获取客户区 CRect rcClient; GetClientRect(rcClient); // 创建内存DC和兼容位图 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 用背景色填充内存DC否则边缘会有杂色 CBrush brBack(RGB(255, 255, 255)); memDC.FillRect(rcClient, brBack); // 在内存DC上完成所有绘图 // 1. 画渐变正方形 CRect rcSquare CalcCenteredSquare(rcClient); // 自己封装个计算函数 DrawGradientSquare(memDC, rcSquare); // 2. 画边框 CPen penFrame(PS_SOLID, 3, RGB(0, 0, 255)); CPen* pOldPen memDC.SelectObject(penFrame); memDC.Rectangle(rcSquare); memDC.SelectObject(pOldPen); // 3. 画文字 memDC.SetBkMode(TRANSPARENT); memDC.SetTextColor(RGB(255, 255, 255)); memDC.DrawText(_T(Double Buffered), rcSquare, DT_CENTER | DT_VCENTER | DT_SINGLELINE); // 一次性把内存DC内容复制到屏幕DC pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); // 恢复内存DC的旧位图然后局部变量会自动释放资源 memDC.SelectObject(pOldBmp); }这里CreateCompatibleBitmap是关键字眼它创建了一块兼容屏幕显示的位图。所有在memDC上的绘制操作看似在视图上其实发生在内存里速度飞快。BitBlt是“块复制”的意思把内存里已经画好的完整画面一次性搬到屏幕DC。由于整个过程没有“擦除-画”的中间态闪烁问题彻底解决。在实际工程里双缓冲还会配合OnEraseBkgnd重载。因为MFC默认会在WM_ERASEBKGND里用白色或背景画刷擦除窗口这本身就是闪烁的来源之一。一个常见做法是在视图类里重载OnEraseBkgnd直接返回TRUE表示“背景擦除我包了”把擦除工作合并到双缓冲里统一处理。BOOL CColorSquareDemoView::OnEraseBkgnd(CDC* pDC) { // 屏蔽默认背景擦除闪烁问题从源头消除 return TRUE; }这样做以后OnDraw里的双缓冲逻辑已经涵盖了背景填充窗口客户区不会再出现“白色闪一下再画图”的问题。4. MFC绘图最容易踩的坑与排查技巧4.1 画不出来、颜色不对、坐标偏移三大高频问题先说最常见的代码明明写了FillRect但运行起来一片空白。这种情况99%是因为绘图代码没写在OnDraw里而是写在了别的函数比如OnCreate、OnInitialUpdate中。OnCreate执行的时候窗口还没有创建完成客户区DC无效画了也白画。OnInitialUpdate在视图创建后调用一次但那里绘制的图形在窗口重绘时不会保留。正确的做法是永久性内容进OnDraw临时性内容放消息处理。第二个问题是颜色看起来不对劲。比如RGB(255, 0, 0)画出来却是暗红色或者FillRect填充后看不到文字。这通常是因为你忘了设置背景模式。文字绘制的默认背景是Opaque不透明文字背后会有一个矩形底色区域如果你用FillRect填充了橙红色再DrawText文字背后就会出现一个白色的矩形块观感很差。设置SetBkMode(TRANSPARENT)就能解决。第三个问题是坐标偏移尤其在做鼠标拖拽时。鼠标消息里的point坐标是相对于窗口客户区的这一点没有问题。但如果你在OnDraw里画的时候用了整个窗口坐标系或者用了屏幕坐标图像就会偏离鼠标位置。建议统一使用客户区坐标需要转换时用ScreenToClient / ClientToScreen。还有一个细节不要忽略画刷和画笔的选入与恢复。SelectObject会把一个新的GDI对象选进DC同时返回旧的GDI对象。画完以后必须把旧的选回去否则这个DC销毁时可能因为GDI对象没有正确恢复而出现资源泄漏。MFC的CBrush、CPen对象有析构函数会自动删除底层GDI对象但如果你频繁创建新的画刷在同一时刻有大量GDI对象存在还是会撑爆系统资源。所以规范写法永远是“用完就选回老对象”。4.2 高频问题速查表现象根本原因解决方案运行后一片空白没有任何图形绘图代码没写在OnDraw或窗口未重绘把永久性绘图逻辑移到OnDraw临时绘制后用Invalidate触发重绘图形被其他窗口遮挡后消失对WM_PAINT机制理解不到位确保全套绘图逻辑都在OnDraw里执行窗口缩放时图形变形或位置偏移使用了固定坐标没有计算客户区大小动态GetClientRect并按比例计算位置拖拽时画面剧烈闪烁背景擦除与绘图分离重载OnEraseBkgnd返回TRUE并用双缓冲文字周围有白底背景模式是Opaque调用SetBkMode(TRANSPARENT)颜色偏暗或发灰RGB参数顺序混淆或显示器色深问题RGB是(BYTE红, BYTE绿, BYTE蓝)检查显示器是否32位色程序退出时报告GDI对象泄漏SelectObject后没有恢复旧对象绘图结束一定SelectObject回旧对象CBrush/CPen实例不要长期持有字体显示为方块乱码没有使用宽字符集或字体不支持中文用_Unicode宏创建CString时用_T()宏项目属性里字符集选“使用Unicode字符集”4.3 这套思路还能用在哪儿MFC绘图的影响范围把彩色正方形画明白你的能力并不是停留在“会画一个方块”而是理解了整个GDI绘图体系。往后你做MFC项目很多场景都能复用这套思路。比如自定义控件你可以在CStatic或CButton的子类里重写OnPaint用双缓冲绘制进度条、仪表盘、图标按钮。核心步骤和画正方形一模一样拿DC创建内存DC逐行或逐区域绘图BitBlt一次性输出。再比如数据可视化你要画折线图、柱状图、波形图本质上就是在一张CRect范围内计算坐标、画线和填充。很多项目的图表模块都是工程师自己用GDI手写的用到的核心函数无非就是MoveTo、LineTo、Polygon、Rectangle这几样。还有图像处理方向MFC工程里读入一张图片转成DIB位图再在OnDraw里按像素处理最后StretchBlt显示出来。这也就是热搜词里“mfc zxing-cpp 读取图片中的二维码”这类需求的底层逻辑——显示哪部分、怎么显示全靠坐标和DC控制。甚至以后你用QT、用C#写图形界面绘图模型的本质还是“拿DC、选GDI对象、画、释放”只是API名字换了一茬。所以这篇看似简单的小项目其实是一把钥匙。你把单色、渐变、双缓冲、鼠标交互这些玩熟后面遇到再复杂的界面需求至少不会茫然无措。根据我个人经验从一个小正方形入手比直接啃大型MFC工程要有效得多。我当年就是在会议室摸鱼的时候拿这个题目练手把OnDraw、DC、画刷、坐标计算这些概念一次性理清的。后来真到做实时曲线监控界面的时候几乎没费什么劲就写完了靠的就是这点底子。这篇文章你可以当个脚本来敲也可以当成“MFC绘图词典”随手翻。核心结论就这几句话画布选对坐标算对GDI对象选进选回长驻画面进OnDraw闪烁就上双缓冲。把这五件事刻进脑子你已经超过一半只会在对话框里拖按钮的人。下次再遇到需要“自己画点什么”的MFC需求回来翻这篇就好。