MFC柱状图绘制实战:用CBrush和GDI实现无第三方依赖
简介面向MFC初学者、Windows图形编程学习者以及需要完成课程设计或日常绘制统计图表的开发者这份资源聚焦如何利用MFC在单文档界面中结合CBrush画刷绘制柱状图并从TXT文档读取数据动态更新图表完整展示了GDI绘图的核心流程。压缩包共36个文件大小2.28MB内含C源文件、头文件、可执行程序以及工程配置、资源与调试辅助文件既有可直接运行的exe也保留有完整工程源码、资源脚本和调试信息便于二次编译修改。已有599人学习下载。通过研读代码可掌握CPaintDC的设备上下文构建、画刷创建与选入、矩形填充绘制、CFile文本读取等关键技巧同时包内提供的TXT数据文件可供换数测试并支持自行替换数据验证动态更新效果工程目录结构清晰便于对照练习是理解MFC消息映射、视图刷新机制与设备上下文协作方式的入门实践范例。1. 用 MFC 画柱状图别再用第三方库一个画刷就能搞定很多人一接到「画个柱状图」的需求第一反应是去下载图表控件或者集成第三方绘图库结果光配置环境就折腾半天最后还因为库的版本冲突翻车。实际上在 MFC 里画简单的柱状图核心就是利用CClientDC结合CBrush画刷再加上CString做文字标注几十行代码就能完成一个能看的静态柱状图。这份资源适合刚接触 MFC 绘图的初学者也适合需要在对话框里快速展示几组数据的开发者——不需要额外依赖不碰 GDI纯 GDI 就能画完整个流程。2. CBrush 画刷与设备描述表绘图的基本功2.1 CBrush 的三种创建方式与选型理由柱状图的「柱体」本质上是矩形填充区域而填充就离不开CBrush画刷。用 MFC 画柱状图常见的坑在于画刷的创建方式选错了导致颜色不生效或者内存泄漏。CBrush有三种经典创建方式// 方式一创建纯色画刷 CBrush brushSolid(RGB(66, 133, 244)); // 蓝色画刷 // 方式二创建带阴影线的画刷 CBrush brushHatch(HS_DIAGCROSS, RGB(255, 0, 0)); // 红色交叉线 // 方式三从位图创建画刷 CBitmap bitmap; bitmap.LoadBitmap(IDB_BRUSH_PATTERN); CBrush brushBitmap(bitmap);纯色画刷最适合柱状图的柱体色彩直接、视觉效果明确阴影线画刷通常用于对比图中的特殊标记比如实际值和计划值从位图创建的画刷应用场景少一般只在需要纹理填充时才用。我的建议是柱状图柱体用纯色画刷辅助线或对比柱才考虑阴影画刷。选型理由很简单CBrush是 GDI 对象创建后必须用DeleteObject()释放否则在长时间运行的程序里会积累 GDI 句柄泄漏。CBrush析构函数会自动释放但如果你是通过SelectObject()选入设备描述表就得注意恢复旧画刷。2.2 设备描述表获取与画刷选入的正确姿势在 MFC 的对话框程序中获取绘图设备描述表一般有两种做法一是在OnPaint里通过CPaintDC获取二是在消息处理函数里通过CClientDC获取。画柱状图推荐在OnPaint中完成因为窗口刷新时会自动重绘。void CMyDialog::OnPaint() { CPaintDC dc(this); // 设备描述表 CBrush brush(RGB(66, 133, 244)); CBrush* pOldBrush dc.SelectObject(brush); // 画矩形柱体参数依次是左上角x、左上角y、右下角x、右下角y dc.Rectangle(50, 200, 100, 300); // 恢复到旧画刷 dc.SelectObject(pOldBrush); // brush 离开作用域时自动析构释放资源 }逻辑说明dc.SelectObject(brush)会把画刷对象选入设备描述表返回值是之前的画刷指针必须保存并在绘制完成后恢复。这里有个新手容易犯的错误——直接dc.SelectObject(brush)后不恢复旧画刷虽然CBrush析构时能释放对象但设备描述表如果没有正确还原后续绘制可能应用了错误的画刷。参数说明Rectangle的前两个参数是左上角坐标后两个是右下角坐标。坐标原点默认在窗口客户区的左上角X 轴向右为正Y 轴向下为正这一点在后续做数据映射时至关重要——真实数据的最大值要对应到 Y 轴方向的最小坐标值。如果你用CClientDC在OnLButtonDown里画图要注意每次触发都会新绘制一层不会自动擦除旧内容而CPaintDC在OnPaint中只会在窗口无效区域重绘时触发一次。3. 柱状图的完整实现流程从坐标轴到数据映射3.1 坐标轴设计先定画布区域再画轴画柱状图的第一步不是画柱子而是规划画布区域。如果柱子画到窗口客户区之外就会被截断这个问题很常见。我一般先把客户区划分出上下左右四个边距把绘图区约束在一个矩形内。// 定义绘图区域边距留出轴标签的空间 CRect rectClient; GetClientRect(rectClient); int marginLeft 60; // 左侧留白放Y轴刻度 int marginBottom 50; // 下方留白放X轴标签 int marginTop 20; // 上方留白防止柱子顶到窗口边缘 int marginRight 20; // 右侧留白 CRect rectPlot( rectClient.left marginLeft, rectClient.top marginTop, rectClient.right - marginRight, rectClient.bottom - marginBottom );然后画坐标轴。X 轴画在绘图区的底边Y 轴画在绘图区的左边CPen penAxis(PS_SOLID, 2, RGB(0, 0, 0)); CPen* pOldPen dc.SelectObject(penAxis); // X 轴 dc.MoveTo(rectPlot.left, rectPlot.bottom); dc.LineTo(rectPlot.right, rectPlot.bottom); // Y 轴 dc.MoveTo(rectPlot.left, rectPlot.top); dc.LineTo(rectPlot.left, rectPlot.bottom); // X 轴箭头 dc.MoveTo(rectPlot.right - 10, rectPlot.bottom - 5); dc.LineTo(rectPlot.right, rectPlot.bottom); dc.MoveTo(rectPlot.right - 10, rectPlot.bottom 5); dc.LineTo(rectPlot.right, rectPlot.bottom); // Y 轴箭头 dc.MoveTo(rectPlot.left - 5, rectPlot.top 10); dc.LineTo(rectPlot.left, rectPlot.top); dc.MoveTo(rectPlot.left 5, rectPlot.top 10); dc.LineTo(rectPlot.left, rectPlot.top); dc.SelectObject(pOldPen);逻辑说明固定画布区域可以让柱状图的绘制逻辑与窗口大小解耦后续调整边距就能适配不同分辨率。箭头是加分项线条用两段短斜线模拟视觉效果接近坐标轴箭头。参数说明CPen的构造参数依次是线型、线宽、颜色。PS_SOLID是实线线宽 2 像素如果你要画虚线做参考线可以用PS_DASH但线宽必须是 1。箭头的大小取决于短线的长度这里的10和5是经验值在小窗口里可以适当缩小。3.2 数据映射把业务数据变成屏幕坐标柱状图最核心的映射逻辑给一组数据比如{3, 7, 5, 9}要把值转换为绘图区内的像素坐标。这里的坑在于 Y 轴方向相反最大值要映射到绘图区顶部。double data[] { 3, 7, 5, 9, 6, 8 }; int nCount sizeof(data) / sizeof(double); // 找到最大值和最小值用于归一化 double maxVal data[0]; double minVal data[0]; for (int i 1; i nCount; i) { if (data[i] maxVal) maxVal data[i]; if (data[i] minVal) minVal data[i]; } // 绘图区宽度和高度 int plotWidth rectPlot.Width(); int plotHeight rectPlot.Height(); // 每组数据对应的柱宽柱间距设为柱宽的 0.3 倍 double barWidth plotWidth / (nCount * 1.3); double gap barWidth * 0.3; // 数据映射到像素坐标 int barLeft rectPlot.left (int)(i * (barWidth gap)); int barTop rectPlot.bottom - (int)((data[i] - minVal) / (maxVal - minVal) * plotHeight); int barRight barLeft (int)barWidth; int barBottom rectPlot.bottom;映射公式解释(data[i] - minVal) / (maxVal - minVal)把原始数据归一化到0~1区间再乘以plotHeight得到像素高度。因为 Y 轴方向是向下的所以用rectPlot.bottom减去高度保证数值越大柱子越高。如果不做归一化数据超出绘图区或者缩成一团都是常事。3.3 画柱体与数据标签柱子用Rectangle画矩形数据标签用DrawText或者TextOut输出。for (int i 0; i nCount; i) { // 不同柱子用不同颜色便于区分 CBrush brush(RGB(60 i * 30, 120 i * 20, 200)); CBrush* pOldBrush dc.SelectObject(brush); dc.Rectangle(barLeft, barTop, barRight, barBottom); // 画数据标签 -- 在柱子上方 CString strLabel; strLabel.Format(L%.1f, data[i]); dc.SetBkMode(TRANSPARENT); // 背景透明避免遮住柱子 dc.TextOut(barLeft 5, barTop - 20, strLabel); // 画 X 轴标签 CString strName; strName.Format(LP%d, i 1); dc.TextOut(barLeft (int)barWidth / 2 - 10, rectPlot.bottom 10, strName); dc.SelectObject(pOldBrush); }逻辑说明用取模间隔生成渐变色可以让相邻柱子在视觉上有区分度。SetBkMode(TRANSPARENT)很关键如果不设置文字背景会用默认的白色填充盖住柱子边缘。标签位置用barTop - 20是因为文字基线在左上角偏移量需要手动微调。参数说明TextOut的前两个参数是文本左上角坐标CString::Format的用法与printf一致L%.1f表示保留一位小数的宽字符格式化。如果你的工程是 ANSI 编码去掉L前缀即可。4. 避坑MFC 柱状图绘制中的常见问题与排查4.1 柱子画出去或者被截断现象柱子跑到了窗口外面或者底部被对话框的边框裁掉。 原因没有预先定义绘图区域直接在客户区坐标上画柱子或者边距设定不足。 解决先拿到客户区矩形用InflateRect或者手工边距约束出绘图区所有柱子坐标都基于绘图区计算。如果窗口可缩放在OnSize里保存新的绘图区并在Invalidate()后强制重绘。4.2 柱子颜色没变化全是黑色现象画刷设置了RGB(255, 0, 0)画出来还是黑色边框加白色填充。 原因用了DC.FrameRect或者FillRect绘制矩形这两种函数不受当前画刷影响而是直接用指定颜色绘制边框或填充。 解决画柱体用Rectangle函数它会同时使用当前画刷填充和当前画笔绘制边框。如果你想边框和填充不同色先SelectObject画刷、SelectObject画笔再调用Rectangle。4.3 窗口重绘时柱状图闪烁或者消失现象拖动窗口时图消失或者重绘时闪烁严重。 原因OnPaint里没有处理WM_ERASEBKGND消息系统先擦除背景再绘制前景导致闪烁图消失大多是因为绘图代码写在了其他消息函数里如OnCreate该函数只在窗口创建时执行一次。 解决把全部绘制逻辑放进OnPaint如果闪烁严重在消息映射里拦截WM_ERASEBKGND并直接返回TRUE或者使用双缓冲CBitmapMemoryDC先绘制到内存再一次性贴图。4.4 文字乱码或者标签显示问号现象TextOut输出的中文字符变成问号。 原因工程字符集设置不对项目使用了 Unicode 但代码里用了 ANSI 字符串或者字体不支持中文。 解决MFC 中统一使用CString搭配L前缀在OnPaint开始处设置字体比如CFont font; font.CreateFont(16, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, L微软雅黑)然后SelectObject进 DC。不设置字体时默认用系统字体不同操作系统渲染效果差异很大。4.5 GDI 句柄泄漏导致绘制越来越慢现象程序运行一段时间后柱状图绘制卡顿甚至界面假死。 原因画刷或画笔对象在循环中反复创建但没有释放GDI 句柄数量耗尽。 解决循环体内创建的CBrush和CPen在循环结束前会因析构自动释放但如果用new创建了 GDI 对象必须手动delete。建议循环内直接声明局部对象借助栈对象生命周期管理资源。5. 进阶技巧用双缓冲消除闪烁并让柱状图动态响应数据双缓冲的实现思路是先在内存中创建一张与绘图区等大的位图把所有图形画到位图上再用BitBlt一次性贴到屏幕上。这样避免了每次画矩形时窗口背景被反复擦除的闪烁。void CMyDialog::DrawChart(CDC* pDC, CRect rectPlot, double* data, int nCount) { // 创建内存兼容 DC 和位图 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap memBitmap; memBitmap.CreateCompatibleBitmap(pDC, rectPlot.Width(), rectPlot.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); // 把绘图区背景刷成白色 CBrush bgBrush(RGB(255, 255, 255)); memDC.FillRect(rectPlot, bgBrush); // 在 memDC 中执行坐标轴和柱子绘制逻辑 DrawAxes(memDC, rectPlot); DrawBars(memDC, rectPlot, data, nCount); // 一次性贴到屏幕 pDC-BitBlt( rectPlot.left, rectPlot.top, rectPlot.Width(), rectPlot.Height(), memDC, 0, 0, SRCCOPY ); // 恢复并释放 GDI 对象 memDC.SelectObject(pOldBitmap); }在这份资源里我一般会多做一个步骤把数据源抽成一个CArraydouble成员变量然后提供一个SetChartData(CArraydouble newData)方法内部调用Invalidate()触发重绘。这样外部只要更新数据柱状图就自动刷新而不需要在每个按钮点击事件里重复粘贴绘制代码。参数说明CreateCompatibleBitmap需要原始 DC 作为参考这样可以确保和屏幕显示格式一致BitBlt的SRCCOPY是直接覆盖模式不涉及透明混合。如果你的绘图区特别大位图会占用较多内存但一般对话框级别的柱状图几百像素宽没有压力。实际效果验证时我会把数据改成随机数再加大循环次数模拟动态刷新。如果这个时候画面依然丝滑说明双缓冲生效了如果还有肉眼可见的闪烁多半是OnEraseBkgnd没有处理干净——直接在这个消息处理函数里return TRUE让系统跳过背景擦除。从那以后我每次做柱状图都强制走一遍「绘图区约束 → 双缓冲贴图 → 自定义数据刷新方法」这套流程新手阶段浪费在调闪烁上的时间几乎归零。希望帮到你。本文还有配套的精品资源点击获取