2026/10/20 8:49:44

MFC柱状图绘制教程:基于GDI画刷与坐标映射的轻量实现

MFC柱状图绘制教程:基于GDI画刷与坐标映射的轻量实现 简介用MFC绘制简单柱状图的完整示例工程面向正在学习MFC框架或GDI绘图的C开发者重点演示在单文档界面中利用CBrush画刷填充矩形来生成柱状图同时搭配TXT数据文件实现动态读取适合作为课程设计或图形编程入门参考。压缩包共36个文件约2.28MB包含6个头文件与5个C源文件并有MFC资源脚本、图标、位图工具栏和可直接运行的exe工程文件采用VC6格式附调试信息能完整还原当时开发环境。已有599人浏览学习作为小型实践项目能直观展示MFC应用从创建到绘图的关键环节。工程在CMyView::OnPaint中创建画刷并通过SelectObject选入设备上下文结合CFile读取TXT数据与atoi/atof转换再调用Rectangle完成柱子绘制文档类、视图类和资源文件的结构清晰完整便于在此基础上扩展坐标轴、标题、图例等元素也能帮助理解MFC消息映射和GDI基础绘制。1. 用MFC画柱状图画刷一笔一笔画才是老系统里最稳的方案接手一个老系统的改造需求时业务方要求在窗口上展示产线的日产量对比MFC界面里没有现成的柱状图控件引入第三方图表库又担心和旧代码的GDI环境打架。最后我用CBrush画刷在OnPaint里手工完成了整套柱状图蓝色柱体、灰色网格、黑色刻度文字十几个数据点实时渲染窗口缩放也不闪烁。这个方案看着原始实际是控制力最强的——柱宽、间距、颜色、Label全部自己说了算不依赖任何外部库编译出来就是一个几十KB的单文件程序。这篇文章把整个项目摊开讲MFC窗口如何组织绘图代码、画刷和画笔怎么配合、数据值怎么映射成屏幕坐标最后附上运行步骤和二次开发思路。2. 项目骨架与GDI绘图基础从OnPaint到坐标映射2.1 程序结构一个对话框窗口撑起全部绘图这个项目是一个标准的MFC对话框程序没有用文档/视图结构因为单纯展示数据用不着那么重的框架。工程里真正需要看的就三个文件对话框类的头文件、实现文件和资源文件。头文件声明类和成员变量实现文件写绘图逻辑和消息映射资源文件管窗口模板和占位控件。消息映射表里只保留WM_PAINT、WM_TIMER、WM_MOUSEMOVE这几个关键项向导生成的无关映射直接删掉减少干扰。文件职责BarChartDemoDlg.h类声明、成员变量、函数原型BarChartDemoDlg.cppOnPaint、DrawChart、数据初始化BarChartDemo.rc对话框模板、静态文本占位控件MFC的绘图入口是OnPaint它取代了SDK时代的WM_PAINT消息处理。当窗口首次显示、被其他窗口遮挡后露出、或者调用Invalidate时系统都会发送WM_PAINTOnPaint随之触发。初学MFC的人最常犯的错是直接在按钮事件里画图画完一刷新就没了——按钮事件里的绘图没有持久性下一次WM_PAINT一来画面就被清掉。正确做法是把所有绘制逻辑放进OnPaint业务代码只负责改数据然后调用Invalidate。void CBarChartDlg::OnPaint() { CPaintDC dc(this); // 构造时BeginPaint析构时EndPaint DrawChart(dc); // 所有绘制逻辑收拢到这一个函数 }CPaintDC是CDC的包装类它接管了BeginPaint和EndPaint的配对避免漏调EndPaint导致窗口一直处于“正在绘制”状态。DrawChart是自定义函数参数传CDC*而不是某个具体DC这样既能被OnPaint调用也能被后面双缓冲的memDC调用绘图逻辑不用写两遍。资源文件里只放了一个静态文本控件做占位实际运行时整个客户区都是绘图区控件反而碍事。2.2 画刷和画笔填充与描边要分清GDI里CBrush负责填充区域内部CPen负责画边框和线条二者分工明确。绘制柱体时柱子的填充色用画刷边框颜色用画笔。最常见的翻车点是创建了CBrush却没有SelectObject选入DC结果画出来要么是默认白色填充要么是黑色边框颜色完全不对。// 创建实心画刷参数是RGB颜色 CBrush brush(RGB(70, 130, 180)); // 关键一步把画刷选入设备上下文保存旧画刷指针 CBrush* pOldBrush pDC-SelectObject(brush); // 绘制带边框的矩形柱体边框用当前画笔内部用当前画刷 pDC-Rectangle(x1, y1, x2, y2); // 恢复旧画刷避免GDI对象泄漏 pDC-SelectObject(pOldBrush);新手必踩的坑在这里SelectObject返回的是之前选入的CBrush*用完必须选回去。如果每次都new一个画刷而不恢复程序跑久了GDI对象耗尽绘制会越来越慢直到完全失效。我一般把画刷和画笔声明成局部变量函数结束自动析构配合SelectObject恢复旧对象GDI句柄才安全。CBrush还有两个常用变体。CBrush(RGB(200, 200, 200), HS_CROSS)创建交叉阴影画刷适合画背景网格CBrush((HBRUSH)GetStockObject(NULL_BRUSH))创建空画刷画出来的矩形只描边不填充适合做命中检测的透明区域。用空画刷配合PS_DOT画笔画网格比再开一个专门的网格函数更省代码后面DrawGrid里就用这个思路。2.3 坐标系映射MFC的Y轴是反的MFC的窗口坐标系原点在客户区左上角X轴向右Y轴向下。柱状图的数据语义是“值越大柱子越高”也就是Y值要向上。如果不做转换直接把数据值当屏幕坐标用画出来的柱子会倒挂数据越大位置越低。这块转换是柱状图的第一个拦路虎。转换公式很简单把数据值归一化到[0, 1]区间再映射到绘图区域的Y坐标范围。// 数据值映射到屏幕Y坐标 int DataToScreenY(int value, int maxValue, int chartTop, int chartBottom) { if (maxValue 0) return chartBottom; // 防除零数据全为0时柱子落在底部 double ratio (double)value / maxValue; return chartBottom - (int)(ratio * (chartBottom - chartTop)); }chartTop是绘图区顶部Y坐标chartBottom是底部Y坐标value为0时返回chartBottomvalue等于maxValue时返回chartTop。(int)强转是为了消除浮点余数让柱体边缘对齐像素。举个具体例子chartBottom300chartTop50数据值100maxValue200ratio0.5算出来y300-0.5×250175正好是绘图区垂直中点。这块逻辑是全项目的基石柱体高度、Y轴刻度全部复用它改一次全联动。如果想调整映射方式比如让柱子高度按平方根压缩只需要改ratio这一行。3. 核心代码拆解一组数据变成柱状图的全过程3.1 数据准备与绘图区域计算项目里用CArray存数据真实场景可以换成std::vector或直接来自数据库的记录集。绘制前先算清楚每根柱子的左上角和右下角坐标涉及柱宽、间距、留白多个参数任何一个算错画面都会出现柱子重叠或贴边。void CBarChartDlg::DrawChart(CDC* pDC) { CRect rcClient; GetClientRect(rcClient); // 取客户区尺寸不含标题栏和边框 int nLeft 60; // 左侧留白放Y轴刻度文字 int nRight 20; // 右侧留白 int nTop 20; // 顶部留白 int nBottom 40; // 底部留白放X轴分类标签 int chartLeft rcClient.left nLeft; int chartRight rcClient.right - nRight; int chartTop rcClient.top nTop; int chartBottom rcClient.bottom - nBottom; int nCount m_data.GetCount(); // 柱子数量 int gap 12; // 柱间间距 int colWidth 0; if (nCount 0) colWidth (chartRight - chartLeft - (nCount - 1) * gap) / nCount; DrawGrid(pDC, chartLeft, chartRight, chartTop, chartBottom); // 背景网格 for (int i 0; i nCount; i) { int x1 chartLeft i * (colWidth gap); int x2 x1 colWidth; int y1 DataToScreenY(m_data[i], m_maxValue, chartTop, chartBottom); int y2 chartBottom; DrawBar(pDC, x1, y1, x2, y2, m_colors[i % 5]); DrawCategoryLabel(pDC, i, x1, x2, chartBottom); // 分类名 } DrawAxis(pDC, chartLeft, chartRight, chartTop, chartBottom); }colWidth的计算把总宽度减去所有间距后再均分这样最后一根柱子不会超出右边界。x1到x2是柱子的水平范围y1是柱顶根据数据值算出来的屏幕Y坐标值越大越靠上y2固定为chartBottom柱子从底部向上长。m_colors定义成静态COLORREF数组i % 5让颜色按五组循环数据多时也能区分相邻柱子。绘制顺序经过刻意安排先画网格再画柱子网格线会被柱子盖住视觉上更干净坐标轴最后画压在柱子和网格上面线条始终清晰。背景网格DrawGrid的实现用的是空画刷加虚线画笔只描边不填充void CBarChartDlg::DrawGrid(CDC* pDC, int left, int right, int top, int bottom) { CBrush* pOldBrush (CBrush*)pDC-SelectObject( CBrush::FromHandle((HBRUSH)GetStockObject(NULL_BRUSH))); CPen pen(PS_DOT, 1, RGB(220, 220, 220)); // 浅灰点线 CPen* pOldPen pDC-SelectObject(pen); for (int i 1; i 4; i) { int y top (bottom - top) * i / 4; pDC-MoveTo(left, y); pDC-LineTo(right, y); } pDC-SelectObject(pOldPen); pDC-SelectObject(pOldBrush); }四等分网格线和后面Y轴刻度的四等分是同一套逻辑都是把绘图区垂直方向分成四段。网格线用点线而不是实线是为了和坐标轴区分主次。这里没有重新创建画刷而是用GetStockObject拿系统空画刷省掉一次CBrush构造。3.2 单根柱子的绘制画刷填充加边框DrawBar负责一根柱子的具体绘制。这里用Rectangle一次完成填充和描边如果只想要纯色块不带边框可以用FillRect和FrameRect两个函数分开做。项目里柱子带细边框所以Rectangle更省事。void CBarChartDlg::DrawBar(CDC* pDC, int x1, int y1, int x2, int y2, COLORREF color) { CBrush brush(color); // 新建画刷颜色由调用方决定 CBrush* pOldBrush pDC-SelectObject(brush); CPen pen(PS_SOLID, 1, RGB(40, 40, 40)); // 1像素实线边框 CPen* pOldPen pDC-SelectObject(pen); pDC-Rectangle(x1, y1, x2, y2); pDC-SelectObject(pOldPen); // 恢复顺序与创建顺序相反 pDC-SelectObject(pOldBrush); }Rectangle绘制时同时用到画刷和画笔画刷填内部画笔描边界。边框颜色用了深灰色而不是黑色视觉上更柔和打印出来也不会一片死黑。恢复顺序和创建顺序相反是GDI对象的规范做法避免画刷被覆盖时还残留旧画笔导致后续图形边框错乱。如果想让某根柱子高亮比如鼠标悬停时换色只需要在外面换一个color参数重新调DrawBar内部逻辑完全不用动。3.3 坐标轴、刻度与分类标签坐标轴用MoveTo和LineTo画两条线X轴画在chartBottom上Y轴画在chartLeft - 5处。刻度文字要配合CFont指定字体否则中文会显示成方块。Y轴刻度均匀分成四段数值从0到m_maxValue递增。void CBarChartDlg::DrawAxis(CDC* pDC, int left, int right, int top, int bottom) { pDC-MoveTo(left - 5, bottom); pDC-LineTo(right 5, bottom); // X轴 pDC-MoveTo(left - 5, bottom); pDC-LineTo(left - 5, top - 5); // Y轴 CFont font; font.CreateFontW(16, 0, 0, 0, FW_NORMAL, FALSE, FALSE, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH, LSimSun); CFont* pOldFont pDC-SelectObject(font); for (int i 0; i 4; i) { int val m_maxValue * i / 4; int y DataToScreenY(val, m_maxValue, top, bottom); CString strText; strText.Format(L%d, val); pDC-TextOutW(left - 45, y - 8, strText); // 左对齐垂直居中 } pDC-SelectObject(pOldFont); }CreateFontW的参数里16是字高FW_NORMAL是常规字重DEFAULT_CHARSET让系统根据字体名选择字符集最后一个参数传字体名称。TextOutW的坐标是文本左上角Y方向减去半个字高y - 8刻度文字才会和刻度线垂直居中。分类标签写在X轴下方横坐标取柱子的水平中点用GetTextExtent量出文字宽度再居中偏移void CBarChartDlg::DrawCategoryLabel(CDC* pDC, int index, int x1, int x2, int baseline) { int centerX (x1 x2) / 2; CString strLabel; strLabel.Format(LQ%d, index 1); CSize size pDC-GetTextExtent(strLabel); // 当前字体下的文字尺寸 pDC-TextOutW(centerX - size.cx / 2, baseline 8, strLabel); }GetTextExtent依赖当前选入DC的字体所以在DrawAxis里选入字体后DrawCategoryLabel拿到的尺寸是准确的。baseline 8里的8是文字和X轴的间距看着留白合适就行。分类标签如果改用ASC编码的窄字符串在Unicode工程下会乱码这里用Format(L%s, ...)的宽字符版本和前面统一。4. 运行与验证新建工程、编译、跑出第一张图4.1 创建MFC对话框工程并集成绘制代码在IDE里用MFC应用向导新建一个“基于对话框”的项目——注意别选“文档/视图”纯图表展示用不着那么重的框架。工程生成后自带一个CBarChartDemoDlg类和一个对话框资源按顺序改四处。第一步在类声明里添加成员变量和函数原型。成员包括CArrayint, int m_data、int m_maxValue、COLORREF m_colors[5]函数包括OnPaint重写、DrawChart、DrawBar、DrawAxis、DrawGrid、DataToScreenY。第二步在OnInitDialog里初始化数据这一步负责填充m_data和m_maxValue。第三步重写OnPaint把向导生成的默认代码替换成调用DrawChart的版本。第四步编译运行按F5看效果发现问题用调试器在DrawChart入口下断点。BOOL CBarChartDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 初始化柱状图数据 m_data.Add(120); m_data.Add(88); m_data.Add(156); m_data.Add(102); m_data.Add(135); m_maxValue 200; // Y轴上限建议比最大数据高20%~30% return TRUE; }Add是CArray的追加方法。m_maxValue设置成比最大数据略大的“整数值”数据最大156就设200这样Y轴刻度是整齐的50、100、150、200而不是奇怪的138、139。如果后续数据会动态增长m_maxValue放在RefreshData里统一更新别散落在多个函数否则改数据后上限对不上柱子直接戳出边界。m_colors的定义放在实现文件顶部COLORREF CBarChartDemoDlg::m_colors[5] { RGB(70, 130, 180), // 钢蓝 RGB(220, 120, 80), // 橙红 RGB(100, 160, 100), // 草绿 RGB(200, 180, 90), // 土黄 RGB(150, 110, 180) // 紫色 };4.2 运行效果与参数调整编译运行后对话框弹出就能看到五根柱子从左到右排列左侧Y轴刻度从0到200底部X轴下方是分类标签。如果画面空白优先检查OnPaint是不是被向导生成的默认版本覆盖了再看DrawChart第一行的GetClientRect有没有拿到合法尺寸这两个问题占了八成“画不出来”的场景。参数位置作用调整建议nLeftDrawChartY轴刻度文字区宽度刻度位数多时加大到70gapDrawChart柱间间距柱子密集时减小到6m_maxValueOnInitDialogY轴显示上限比最大数据高20%~30%m_colors文件顶部柱子填充色RGB三原色随意调CreateFontW字高DrawAxis刻度文字大小窗口大时调到20调整nLeft时注意DrawAxis里left - 45的刻度文字横坐标要跟着变否则文字会压到柱子上。我一般把留白和文字偏移量定义成同一组const常量改一处就能对齐。调试时可以在DrawChart入口加OutputDebugString输出窗口尺寸和数据个数运行起来用调试器看输出窗口比肉眼猜快得多。窗口尺寸变化后图形不跟随缩放需要在对话框类的OnSize里调用Invalidate(FALSE)让尺寸变化时触发重绘。5. 避坑指南画刷失效、中文乱码与窗口闪烁5.1 画刷创建了却不生效柱子全是黑色现象代码里明明用RGB(70, 130, 180)创建了蓝色画刷跑起来柱子却是黑边框加默认背景色。原因CBrush对象虽然创建了但从来没SelectObject选进DCRectangle用的是DC里默认的白色画刷或者画刷创建在if分支里出作用域就被析构绘制时拿到的已经是悬空句柄。解决绘制前必须pDC-SelectObject(brush)选入绘制后恢复旧画刷。把画刷声明放在DrawBar函数体最前面确保它的生命周期覆盖整个绘制过程。调试时可以临时在SelectObject后加一行ASSERT(pOldBrush ! NULL)验证选入成功。这个坑我踩了不止一次后来养成习惯凡是新建GDI对象必定在同一作用域内配对选入和恢复。5.2 中文刻度变成乱码方块现象柱子画出来了但刻度文字显示成??或者一排小方块英文数字正常。原因工程编译成Unicode代码里却用窄字符常量或者CreateFontW传了ANSI_CHARSET中文字体无法正确映射字符集。CString在Unicode工程下默认是宽字符和char*混用时发生隐式转换字符串内容直接损坏。解决统一使用宽字符——字符串字面量加L前缀调用TextOutW版本CreateFontW的字符集参数改成DEFAULT_CHARSET字体名用LSimSun。我踩过最隐蔽的一次是工程属性里字符集是“未设置”导致CString和LPCTSTR混用后来强制在stdafx.h里定义_UNICODE和UNICODE才算根治。5.3 窗口缩放时画面闪烁、出现残影现象拖动窗口边缘柱子区域频繁闪烁或者有上一帧的残影残留。原因OnPaint默认先擦背景再画前景每次重绘都清空一遍擦和画之间的时间差就表现为闪烁残影则是绘图区域没有完全覆盖失效区域旧像素残留。解决两件事一起做。第一Invalidate(FALSE)告诉系统重绘前不要擦除背景第二用双缓冲——先把所有内容画到内存DC再一次BitBlt整块拷贝到屏幕。void CBarChartDlg::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); DrawChart(memDC); // 所有绘制逻辑在内存DC上执行 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }CreateCompatibleDC创建的内存DC和屏幕DC兼容CreateCompatibleBitmap分配一块和客户区一样大的画布。DrawChart里所有GetClientRect要用窗口客户区尺寸我把rcClient提到OnPaint层DrawChart只负责画不负责拿尺寸逻辑更清晰。这套双缓冲在数据量大、刷新频繁时效果尤其明显画面从“闪成花屏”变成“无缝切换”。5.4 柱子被窗口边缘截断或柱顶出界现象最后一根柱子在窗口右边界被切掉一半或者柱顶顶着窗口上沿出界顶部刻度也看不到。原因colWidth整除取整后余数导致最后一根柱子的x2超过了chartRight柱顶出界则是m_maxValue设小了数据值超出上限后被截断。解决colWidth计算前先判断nCount数据量大时在DrawChart入口断言chartRight大于chartLeft。柱顶出界检查y1是否小于chartTop是就把m_maxValue调大。我遇到过一次是窗口设置了粗边框样式GetClientRect拿到的尺寸和实际客户区不一致改用AdjustWindowRectEx修正后正常。排查这类问题的通用套路在DrawChart里输出每个柱子的x2和y1值对比chartRight和chartTop一眼就能看出哪个坐标越界。5.5 Invalidate参数写错导致绘制卡顿现象数据刷新后调用Invalidate窗口重绘正常但CPU占用飙高界面拖拽卡顿。原因Invalidate(TRUE)强制重绘前擦除背景每次刷新都全屏擦一遍再画一遍擦除动作还会触发WM_ERASEBKGND多重叠加双缓冲时性能急剧下降。解决统一使用Invalidate(FALSE)配合双缓冲重绘只发生在内存DC上屏幕只接收一次BitBlt拷贝。定时刷新场景把SetTimer间隔从50毫秒改成200毫秒以上肉眼依然流畅CPU占用能降一个数量级。这条经验是从一个数据采集项目里总结出来的当时每秒刷新20次直接跑满一个核改成200毫秒加双缓冲后占用降到3%以下。6. 二次开发思路让柱状图动起来6.1 定时刷新SetTimerInvalidate组合静态柱状图只是基础真实场景里数据是不断变的。用SetTimer配合Invalidate实现秒级刷新不需要额外开线程MFC消息循环天然串行不会出现数据竞争。void CBarChartDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { RefreshData(); // 从数据库、串口或全局共享区取值 Invalidate(FALSE); // 不擦背景配合双缓冲基本无闪烁 } CDialogEx::OnTimer(nIDEvent); }SetTimer(1, 1000, NULL)在OnInitDialog里启动1000毫秒触发一次。RefreshData负责把新数据写进m_data根据新数据更新m_maxValue。注意当数据最大值变化超过20%时才扩展上限避免柱子高度频繁跳动导致视觉疲劳。6.2 鼠标悬停高亮一根柱子有了绘制链路加交互就是加一个命中检测。在OnMouseMove里判断鼠标点落在哪根柱子的CRect范围内把悬停索引存成员变量Invalidate触发重绘DrawBar里对悬停柱子换一个画刷颜色。int CBarChartDlg::HitTest(CPoint pt) { for (int i 0; i m_data.GetCount(); i) { CRect rcHit GetBarRect(i); if (rcHit.PtInRect(pt)) return i; } return -1; }GetBarRect把DrawBar里算x1、y1、x2、y2的逻辑抽出来复用命中后高亮颜色用金色RGB(255, 200, 0)和默认蓝色区分明显。再加一组SetCapture和ReleaseCapture的配对就能支持鼠标按下拖拽选中连续区域这个交互做出来柱状图就从“展示工具”变成了“分析工具”。6.3 扩展染色方案与多系列这套代码改成堆叠柱状图只需要在DrawBar里把数据拆成多段柱体底部固定每段依次向上绘制画刷换成同色系不同明度。折线图更简单把DrawBar换成Polyline点坐标复用DataToScreenY。从那以后我接到任何“在MFC里画图表”的需求都先走一遍坐标映射和重绘链路这两步再动手写画刷——顺序对了后面全是体力活。希望帮到你。本文还有配套的精品资源点击获取