☰
MFC GDI绘图实战:从画刷到双缓冲的柱状图实现
2026/10/20 8:49:46 网站建设 项目流程

简介:面向MFC初学者的柱状图绘制完整工程,演示如何在单文档界面(SDI)中利用CBrush画刷从TXT文件读取数据并绘制图表。工程内含可运行exe、全套源码(6个h、5个cpp)以及“描绘文件.txt”数据样例,直接对照即可理解GDI绘图流程、CFile文件读取、CPaintDC设备上下文及OnPaint消息处理等关键环节;代码中还能看到SelectObject选入画刷、Rectangle绘制矩形、获取客户区等典型用法。打包文件共36个、大小2.28MB,除源码外还包含obj、pdb、res、rc等编译中间文件与工程配置,另外还带图标、工具栏位图、ReadMe说明及DSW/DSP工程文件,目录结构清晰,方便逐项展开学习或改造。当前已有599人学习下载;对于想快速掌握MFC画刷填充矩形、坐标换算和简单统计图形渲染的开发者,是一份便于上手的实践参考,也可作为课程设计或毕业设计的入门模板。

1. 用MFC画柱状图:一次GDI绘图从画刷到像素的完整落地

在Win32桌面端,想在老项目里加一组可视化图表,MFC配合GDI依然是最轻量、最可控的路线——不需要引入任何重型图表库,一个OnPaint加上几把画刷就能出图。本文拆解的这个MFC柱状图Demo,核心就三件事:创建设备上下文、用画刷填充矩形、把数据标签画到柱子上。拆它不是为了炫技,而是因为这里面藏着GDI对象生命周期、坐标原点、双缓冲这些Win32绘图的基础功。适合两类人:一类是手里维护着老MFC项目、需要快速出可视化效果的开发者;另一类是刚接触Windows GDI、想通过一个小项目把绘图链路彻底跑通的初学者。顺着这个项目跑一遍,你对设备上下文和画刷的理解会清晰很多。

2. 架构与绘图链路:消息循环、设备上下文和画刷是怎么协作的

2.1 MFC绘图的消息驱动链路

MFC框架下的绘图跟控制台程序完全不同,它本质上是事件驱动的。窗口需要重绘时,系统会给窗口过程发送WM_PAINT消息,MFC把这条消息封装成OnPaint虚函数。你不需要关心消息循环的底层细节,但必须理解一件事:OnPaint随时可能被触发,窗口大小变化、被其他窗口遮挡后露出、程序主动调用Invalidate或UpdateWindow,都会让系统认为"这块区域脏了",进而走一遍绘图流程。

在这个项目里,所有绘制代码都集中在视图类的OnPaint方法中。这样做的好处是绘制逻辑统一收口,不会出现"这里画一笔、那里画一笔"的混乱状态。窗口每收到一次WM_PAINT,就会执行一次完整的重绘,包括计算布局、创建画刷、绘制柱体、绘制坐标轴和文字标签。

设备上下文(DC)是整个绘图的核心对象。MFC把Windows的HDC封装成了CDC类,通过CPaintDC或CClientDC这类派生类管理DC的生命周期。这个项目用的是CPaintDC,它在构造函数里自动调用BeginPaint,在析构函数里自动调用EndPaint,这样就能确保绘图操作被正确地框在无效区域内。

GDI对象的选择是另一个关键环节。DC本身不持有具体的画刷或画笔,它只是维护一个"当前选中对象"的指针。你要用某种颜色的画刷,就得先创建画刷,再通过SelectObject把它选入DC,之后所有涉及填充的绘图操作都会使用这把画刷。用完后得把原来的GDI对象选回去,再清理自己创建的对象。

2.2 GDI对象生命周期与画刷选入机制

GDI对象的生命周期管理是这类小项目最容易翻车的地方。画刷CBrush在创建后会占用一个GDI句柄,系统对每个进程的GDI句柄数量有限制。如果每次重绘都创建画刷却不销毁,句柄就会持续泄漏,最终导致绘图失败——这是项目上线后最常见的隐患之一。

关于画刷的创建方式,MFC的CBrush类提供了多种途径。最直接的是用构造函数传颜色值,例如CBrush brush(RGB(40, 120, 200)),它内部会封装CreateSolidBrush的逻辑。还有一种是先构造一个空对象,再调用CreateSolidBrush或CreateHatchBrush。两种方式的关键点在于:画刷对象必须在使用它的DC释放之前存在,否则析构时会因为试图删除一个已被设备上下文引用的GDI对象而触发断言错误。

画刷选入DC的典型操作是这样的:

CDC* pDC = GetDC(); // 获取客户区DC CBrush brush(RGB(40, 120, 200)); // 创建实体画刷,颜色为蓝色系 CBrush* pOldBrush = pDC->SelectObject(&brush); // 选入DC,记录旧画刷 // 此处执行需要这个画刷的填充操作 pDC->SelectObject(pOldBrush); // 选回旧画刷 ReleaseDC(pDC); // 释放DC

这段代码的逻辑核心在于SelectObject的返回值。SelectObject在选入新对象的同时会返回之前选中的对象指针,这个指针必须保存下来并在绘图结束后选回DC。很多新手会忽略选回这一步,直接在函数结束时让画刷析构。这样做虽然表面上程序能运行,但DC里还残留着一个已析构对象的指针,后续如果再执行SelectObject或绘图操作,行为是不确定的。

参数说明:CBrush brush(RGB(40, 120, 200))中的RGB宏接收红、绿、蓝三个0到255的整数,决定画刷颜色;SelectObject(&brush)接收一个CBrush指针,返回的是先前选中的CBrush指针。顺带一提,选回旧对象比不选回更安全。释放DC时如果当前选中的GDI对象不是该DC默认的初始对象,某些情况下会导致资源回收异常。

2.3 视图类与布局计算的边界

这个项目没有把布局代码单独抽成类,因为柱状图的规模不大。数据量小、结构固定时,直接在OnPaint里计算布局是最高效的做法。但要在脑子里把"数据"和"绘制"分开:数据是逻辑层的数组,柱体矩形、坐标轴位置、文字锚点则是由数据映射到像素的结果。

整个项目跑起来后,你会看到一次完整的链路:用户调整窗口大小或点击刷新按钮,系统发送WM_PAINT,框架调用OnPaint,OnPaint获取客户区尺寸,根据尺寸和数据集计算柱体布局,创建画刷填充矩形,绘制坐标轴和刻度,再绘制文字标签。这条链路里最值得研究的就是"数据到像素的映射"这一步,它决定了图表在不同窗口尺寸下是否还能保持协调的布局。

3. 核心实现拆解:OnPaint里的坐标映射、画刷填充与文本标注

3.1 OnPaint主流程:从客户区尺寸到柱体矩形

先看这个项目最主要的绘图方法完整代码。这段代码会出现在视图类或对话框类的OnPaint里,是整个柱状图的核心:

void CBarChartView::OnPaint() { CPaintDC dc(this); // 自动BeginPaint/EndPaint的DC CRect rcClient; GetClientRect(&rcClient); // 客户区矩形 struct BarItem { CString label; // 柱体下标签文字 double value; // 柱体数值 }; BarItem data[] = { { _T("一月"), 65.0 }, { _T("二月"), 82.0 }, { _T("三月"), 45.0 }, { _T("四月"), 98.0 }, { _T("五月"), 73.0 }, { _T("六月"), 56.0 } }; int nCount = sizeof(data) / sizeof(data[0]); // 边距:左侧给Y轴刻度留空间,底部给标签留空间 const int nLeftMargin = 70; const int nBottomMargin = 50; const int nTopMargin = 30; const int nRightMargin = 30; // 绘图区域矩形 int nChartLeft = rcClient.left + nLeftMargin; int nChartRight = rcClient.right - nRightMargin; int nChartTop = rcClient.top + nTopMargin; int nChartBottom = rcClient.bottom - nBottomMargin; int nPlotWidth = nChartRight - nChartLeft; int nPlotHeight = nChartBottom - nChartTop; // 柱体宽度:绘图区宽度均分给nCount根柱,间隙取20像素 int nSlotWidth = nPlotWidth / nCount; int nBarWidth = nSlotWidth - 20; double dMaxValue = 100.0; // Y轴最大值,可改成数据中的实际最大值 // Y轴线和X轴线 CPen pen(PS_SOLID, 2, RGB(80, 80, 80)); CPen* pOldPen = dc.SelectObject(&pen); dc.MoveTo(nChartLeft, nChartTop - 10); dc.LineTo(nChartLeft, nChartBottom); dc.LineTo(nChartRight + 10, nChartBottom); dc.SelectObject(pOldPen); // 柱体填充 CBrush brush(RGB(41, 128, 185)); CBrush* pOldBrush = dc.SelectObject(&brush); for (int i = 0; i < nCount; i++) { int nBarHeight = (int)(data[i].value / dMaxValue * nPlotHeight); int nLeft = nChartLeft + i * nSlotWidth + 10; int nTop = nChartBottom - nBarHeight; // 关键:底部对齐,向上生长 CRect rcBar(nLeft, nTop, nLeft + nBarWidth, nChartBottom); dc.Rectangle(rcBar); } dc.SelectObject(pOldBrush); // 文字标签:画在每个柱体下方 dc.SetBkMode(TRANSPARENT); dc.SetTextColor(RGB(50, 50, 50)); for (int i = 0; i < nCount; i++) { int nLeft = nChartLeft + i * nSlotWidth + 10; CRect rcLabel(nLeft, nChartBottom + 8, nLeft + nBarWidth, nChartBottom + 30); dc.DrawText(data[i].label, rcLabel, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } }

逻辑说明:这段代码把整个绘制过程分成了三个阶段。先用GetClientRect取得客户区的当前尺寸,这是布局计算的基础数据来源。然后定义数据数组,数据在这里是写死的,但在二次开发阶段可以替换为成员变量。接着计算绘图区矩形,把边距预留出来,目的是避免柱体顶到窗口边缘或者标签无处安放。

柱体绘制阶段的精髓在于nTop = nChartBottom - nBarHeight这一行。Windows GDI的坐标系是Y轴向下,如果不做处理,矩形越大越往下走,画出来的是"倒长"的柱。这里先用数值除以最大值、再乘以绘图区高度得到柱高,然后用绘图区底部减去柱高得到柱体的上边缘Y坐标,整个柱体就被正确地顶起来了。

参数说明:dc.Rectangle(rcBar)画的是带边框的填充矩形,边框画笔用当前选中的画笔,填充用当前选中的画刷。GetClientRect(&rcClient)接收一个CRect指针,返回的是客户区矩形的像素尺寸。DT_CENTER | DT_VCENTER | DT_SINGLELINE是DrawText的对齐标志,第一个水平居中,第二个垂直居中,第三个强制单行显示,避免标签被折行。

3.2 画刷参数深度:实体、斜线与渐变风格

这个项目里的柱体填充用的是最简单的实体画刷,但CBrush的能力远不止单色填充。如果你希望图表看起来更有层次感,可以替换成其他类型的画刷。项目里可以这样改出三种视觉效果:

// 斜线画刷:交叉线风格,适合黑白打印 CBrush hatchBrush(HS_BDIAGONAL, RGB(41, 128, 185)); // 渐变色:先创建纯色画刷,再在矩形区域内混合处理 CBrush gradientBrush; // 渐变色需要配合GDI+,此处仅示意创建方式

逻辑说明:HS_BDIAGONAL是画刷的阴影风格枚举值,表示从左下到右上的斜线条纹。这些斜线画刷在老式的黑白打印机上效果很好。渐变画刷在纯GDI里没有直接的等价物,通常的做法是用GDI+的LinearGradientBrush,或者用循环按像素高度算出一系列逐步逼近目标色的色值,再用FillSolidRect逐条填充。

画刷的颜色和风格参数决定了柱体的视觉辨识度。实体画刷RGB(41, 128, 185)是接近于深蓝的色调,在一张白底窗口上既清晰又不过分刺眼。如果换成RGB(231, 76, 60)这种暖红色,柱体视觉冲击力会更强,更适合强调数据的场景。

3.3 坐标轴、刻度线与数据标签的交互关系

这个项目里坐标轴和标签的绘制比重不大,但它们是图表可读性的基础。坐标轴用画笔CPen(PS_SOLID, 2, RGB(80, 80, 80))画出粗细为2像素的实线线,Y轴从绘图区上缘画到下缘,X轴从绘图区左缘画到右缘。你没看错,MFC默认坐标系就是上下颠倒的,所以X轴其实也在窗口底部——这正是3.1节里翻转柱体方向的原因。

文本标签通过DrawText绘制,第一组DrawText调用以柱体下方的矩形区域为边界,实现居中对齐。在调用DrawText之前,有一句dc.SetBkMode(TRANSPARENT)很关键,它把文字背景设为透明,否则文字会带一个白色矩形底块,把柱体顶部的颜色盖住一部分,视觉效果会很糟。

标记点或者数据值本身也可以画上去。你可以在每个柱体顶部画一个数值文本,只需根据柱体高度算出文本的绘制位置即可。常见做法是CRect rcVal(nLeft, nTop - 20, nLeft + nBarWidth, nTop - 4),把数值写在这个紧贴柱顶的小矩形区域里。

4. 让图表不闪不糊:双缓冲绘制与坐标变换

4.1 逻辑坐标到客户区坐标的映射规则

用MFC画图,第一道坎就是坐标系。GDI默认的映射方式是MM_TEXT,原点在客户区左上角,X轴向右,Y轴向下。这对柱状图来说有一个很反直觉的地方:数值越大,柱体矩形的bottom值越大,而矩形是往下长的。

所以这个项目里"向上生长"的实现,其实是在做一次坐标变换。柱体矩形的顶部Y坐标等于绘图区底部减去柱高,这就是把"数值高度"映射为"像素高度"的公式。反向的线段、文字标注也遵循同样的规律,所有定位都从nChartBottom往上减。

如果你的数据范围不是从0到100,比如销售额从100到500,那坐标映射就得改。常见做法是找出数据里的最大值,令dMaxValue = data[0].value,然后遍历取最大值。这样Y轴比例会随数据动态调整,但也会出现一个问题:当窗口尺寸不变而数据整体偏小时,柱体会整体变矮。解决好这个问题的关键在于明确Y轴范围到底是固定的,还是跟随数据波动的,这个决策应该在数据层完成,而不是在绘制层临时算。

4.2 双缓冲绘制的实现:从闪烁到丝滑

直接往CPaintDC上画柱状图,在窗口拉伸、最小化还原时会频繁触发重绘,每次重绘都先清空背景再逐根画柱子,肉眼看到的就是闪烁和残影。要消除闪烁,最有效的方案是双缓冲:先在内存里创建一张与客户区等大的位图,把所有绘制操作都落到内存DC上,最后一口气用BitBlt把整张位图像素拷贝到屏幕DC。一次拷贝完成整个刷新,视觉上就是无缝的。

这个项目在实战中切换成双缓冲后的OnPaint核心流程是这样的:

void CBarChartView::OnPaint() { CPaintDC dc(this); // 最终的输出DC CRect rcClient; GetClientRect(&rcClient); // 1. 创建兼容内存DC CDC memDC; memDC.CreateCompatibleDC(&dc); // 2. 创建与客户区尺寸一致的位图并选入内存DC CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 3. 用白色背景清空内存画布 memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); // 4. 把柱状图绘制代码整体迁移到memDC上 // 这部分就是第3章里的完整绘图逻辑,只把dc换成memDC DrawBarChart(&memDC, rcClient); // 5. 一次BitBlt把内存图拷贝到窗口 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); // 6. 还原并释放GDI对象 memDC.SelectObject(pOldBmp); }

逻辑说明:CreateCompatibleDC(&dc)会创建一块与屏幕DC兼容的内存画布,这块画布初始是1x1像素的,必须给它选入一张足够大的位图才能在上面作画。CreateCompatibleBitmap(&dc, w, h)生成的位图格式与屏幕DC一致,直接通过SelectObject把它选入内存DC后,内存DC就变成了一块w x h像素的画布。接下来所有绘制逻辑不变,只是目标从屏幕DC换成了内存DC。

BitBlt是一次完整覆盖的位块传输操作,六个关键参数分别是目标X、目标Y、目标宽度、目标高度、源DC、源X、源Y、光栅操作码。最后的SRCCOPY表示直接把源区域像素原样覆盖到目标区域。这行代码是整个双缓冲机制的核心,它的执行效率远高于逐根柱体绘制的多次传输。

参数说明:FillSolidRect(rcClient, RGB(255, 255, 255))把内存画布初始化为白色,这一步很必要,否则画布上是随机数据。SelectObject(pOldBmp)在结束时把原位置图选回内存DC,避免位图对象在析构时被DC引用。

4.3 双缓冲下的WM_ERASEBKGND处理

双缓冲能消除闪烁的另一个关键点在于,要阻止系统在发送WM_PAINT之前先擦一遍窗口背景。默认情况下,窗口收到WM_PAINT前会先擦背景,擦背景的动作本身就会造成白屏闪烁。双缓冲画完后,背景擦没擦其实已经没有意义了,因为你的位图会完全覆盖客户区。

常见做法是在消息映射里重写OnEraseBkgnd,让它直接返回TRUE,意思是"背景我已经处理过了,别擦":

BOOL CBarChartView::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 阻止默认擦除动作,由双缓冲位图统一覆盖 }

逻辑说明:返回TRUE就是告诉系统不需要擦背景。这个函数返回后,系统不会再执行默认的背景填充。设置双缓冲并在OnEraseBkgnd里返回TRUE,窗口重绘时就不会出现那个先白后图的过程。

踩过坑的人都知道,只做双缓冲不处理OnEraseBkgnd,窗口拉伸时仍然会有微弱的闪烁感,因为背景擦除和位图画布刷新之间存在时间差。把两件事都做了,才算彻底根治闪烁问题。

5. 避坑指南:GDI泄漏、闪烁与绘制失真的排查手记

5.1 柱状图越画越慢,任务管理器GDI对象数不断上涨

现象:程序运行一段时间后,窗口重绘一次比一次慢,最终出现绘图缺失甚至界面卡死。打开任务管理器看GDI对象数,数值随每次重绘持续上涨。

原因:在OnPaint里通过new CBrush(...)创建画刷,却从不调用DeleteObject或者把CBrush交给局部作用域自然析构。每次WM_PAINT泄漏一个画刷句柄,时间一长句柄达到进程上限,绘图就罢工了。

解决:核心原则是GDI对象生命周期与使用它的作用域绑定。把画刷定义为OnPaint的局部栈对象,函数结束时会自动析构并释放句柄;如果确实需要在堆上创建,记得在重绘完成后调用DeleteObject。这个项目里用CBrush brush(...)这种栈对象写法,就是为了从源头上避开泄漏问题。

5.2 柱子从中间往上下两头"倒着长"

现象:数据里明明数值越大,画出来的柱子反而越长地挂在上方,或者柱子从某个中间位置向上下两个方向同时延伸,跟预想的"底部对齐、向上生长"完全不符。

原因:GDI坐标系Y轴向下,很多人第一次画柱状图直接拿矩形高度对应数值,导致越大越长,而且底部没有对齐到X轴。绘图区底部线是窗口下缘,矩形bottom直接设置成y坐标时,柱子底部会跟着数据变化。

解决:无论数据怎么变,柱体的bottom永远固定在nChartBottom即X轴位置,顶部Y坐标通过nChartBottom - nBarHeight计算得出。把这个换算逻辑单独写成一个小函数,比如GetBarTop(int nIndex),只要数据、布局变了,调用它就能算出正确的顶部Y坐标。

5.3 窗口拉伸后柱子挤成一团大量空白

现象:窗口默认大小下柱状图整齐好看,拉宽窗口后柱体还挤在左侧,右侧大片空白;缩小窗口后柱体互相重叠。

原因:柱体宽度和间距是按固定像素算的,没有跟随客户区宽度动态变化。代码里用了nPlotWidth / nCount,说明宽度是动态的,但如果你把nPlotWidth写死成某个常量,窗口变了布局不变,就会出现这种现象。

解决:每次都从GetClientRect重新取客户区尺寸,再基于这个尺寸计算nSlotWidth和nBarWidth。确保布局计算全部发生在OnPaint内部,不要缓存上一次的柱体坐标。这也是这个项目把布局代码放在OnPaint里的原因之一——OnPaint每次被调用时都会拿到最新的窗口尺寸。

5.4 文字标签带白色底块,盖住柱体颜色

现象:DrawText输出的一月、二月等标签后面跟着一块白色矩形背景,在深色柱体上方格外明显。

原因:默认情况下,GDI绘制文本会使用当前背景色填充文字像素所在的矩形区域。项目里没有显式设置背景模式,默认的OPAQUE模式会用白色填充。

解决:在首次调用DrawText之前执行dc.SetBkMode(TRANSPARENT),把背景模式设为透明,只绘制文字本身像素,不填充文字背景区域。这个调用一次即可,影响整个DC后续的所有文字输出。

5.5 窗口重绘时闪白,拖拽窗口时残影明显

现象:窗口每次刷新都先闪一片白色,再慢慢把柱体画出来;拖动窗口边界时,柱状图区域出现明显的拖影。

原因:默认情况下WM_ERASEBKGND会先擦掉背景,这个擦除动作在屏幕上真实生效过,然后WM_PAINT才开始绘制新图。擦背景和绘制是两个独立步骤,之间有时间差,视觉上就是先白后图。

解决:走完整的双缓冲流程,也就是第4章里那套代码,并且在消息映射里重写OnEraseBkgnd返回TRUE。阻止擦背景动作本身,再由内存位图一次性覆盖,闪烁与残影会同步消失,这也是这个项目里双缓冲方案存在的意义。

6. 从静态图到交互组件:二次开发思路与验证方法

项目跑通后,接下来最有价值的改造方向有两个:数据动态刷新和柱体选中反馈。动态刷新需要把数据从局部数组提升为类的成员变量,然后暴露一个更新数据的接口,更新后调用InvalidateRect(NULL, TRUE)触发重绘。定时器方式可以这样搭:

void CBarChartView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_REFRESH_DATA) { RefreshData(); // 更新成员数据 InvalidateRect(NULL, TRUE); // 请求重绘,让OnPaint重新执行 } CView::OnTimer(nIDEvent); }

选中高亮则需要做一次命中测试:在WM_LBUTTONDOWN里拿到鼠标坐标,遍历所有柱体矩形,用PtInRect判断点击是否落在某个柱体内部。命中的柱体索引存为成员变量,下次重绘时给这根柱体换一把不同的画刷,例如从蓝色换成橙色,视觉上就完成了选中反馈。

如果想把这份逻辑封装成可复用控件,更彻底的改造是把绘制代码从视图类搬到自定义的CStatic派生类里,重写该控件类的OnPaint,再向外暴露SetData(double* pValues, int nCount)和SetBarColor(COLORREF clr)这类接口,这样就能在多个对话框上复用同一套柱状图绘制能力。

回想当初踩过的坑,有一次我在对话框上先画了标尺再画柱体,窗口DPI变了之后标尺位置全乱,柱体却还认原来的坐标,图表整体错位。从那以后,我每次动绘图代码都强制走一遍同样的验证流程:先以默认窗口尺寸打开,再拉大窗口看布局是否跟随,换一组范围差异很大的数据看比例是否合理,最后连续最小化再还原十次,确认没有闪烁和GDI泄漏。这套流程花不了几分钟,却能筛掉八成以上的绘制问题。希望帮到你。

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

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

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

立即咨询