
简介面向MFC初学者的柱状图绘制示例工程演示如何在单文档界面SDI中通过GDI画刷填充矩形并从TXT文档读取数据生成简单图表适合学习C图形编程与Windows应用开发的读者。压缩包共36个文件大小约2.28MB以C源代码h/cpp、VC6.0工程文件dsp/dsw/rc和调试生成物obj/pdb/exe为主并附TXT示例数据、图标及工具栏位图可直接编译运行。已有597人浏览学习工程按视图类、文档类、主框架等模块化组织便于对照源码理解MFC程序结构。借助这份工程可重点掌握CBrush画刷、CPaintDC设备上下文和CFile文件读取等关键类在实际绘图中的配合方式理清从加载数据、计算绘图区到调用Rectangle完成柱体填充的完整流程并能在此基础上扩展坐标轴、标题等动态图表功能。 接手过MFC老项目的人十有八九碰到过这种需求需求方看着Excel里的柱状图随口一句“这页界面也给我加个柱状图”。翻了翻手头的工具要么是收费图表库的授权审批流程太长要么是开源控件在x64下编译直接报错折腾一下午可能连个Demo都没跑起来。我也踩过这个坑最后老老实实用MFC的GDI把柱状图画了出来几百行代码解决问题数据展示、鼠标交互、动态刷新全都有。这篇文章就聊聊怎么用MFC自己画一个能用的柱状图以及画图过程中那些文档里不会写的坑。1. 为什么放着现成控件不用偏要自己画柱状图1.1 第三方图表库为什么在MFC项目里水土不服先别急着写代码得先想清楚一个问题为什么不自带MFC本身没有提供任何现成的图表控件这是事实。CListCtrl能显示列表、CStatic能显示文本和图片但你要一个正经的柱状图对不起真没有。于是很多人第一反应是找第三方库但实际接进来之后你会发现事情没那么简单。我自己试过几个方案各有各的麻烦方案存在的问题TeeChart / FlexChart 等商业控件授权费高老项目走采购流程要等好几周开源的ChartCtrl等部分库停留在32位时代x64编译链接各种报错用Web前端图表库ECharts等还得嵌入CEF浏览器控件包体膨胀内存占用惨不忍睹用ActiveX旧控件兼容性是个大坑很多在Win10/11上直接白屏更关键的是多数第三方图表库都自带一套皮肤和交互风格想改成和老项目UI一致反而要花大把时间去调样式不如自己画来得直接。1.2 自绘方案的定位够用且学得到东西自己画柱状图并不是追求功能多强大而是解决“内部工具/老项目界面需要一个简单清晰的数据可视化区域”这个需求。用GDI画柱状图的优势很明显无任何外部依赖工程拷贝到哪里都能编译。代码量可控一个简单柱状图的绘图核心逻辑几十行就够。样式、颜色、交互完全自己掌控想怎么改就怎么改。不涉及跨技术栈一个类里全搞定。适合的场景包括MFC课程设计、公司内部报表小工具、老系统界面的数据看板补充。如果项目需要的是实时大屏、海量数据、3D效果那就别自己造轮子了老实评估商业图表库更划算。不过话说回来不管选哪条路GDI绘图的底层逻辑都是通用的哪怕是转到C#的GDI或者Python的matplotlib坐标系映射和图形绘制思路都一脉相承学一次受用很久。2. 绘图前必须先搞清楚的坐标系问题从客户区坐标到“正常”图表坐标2.1 设备坐标系和直觉坐标系的差异很多人第一次画柱状图代码明明写对了逻辑画出来的柱子却是倒着长的原因就出在坐标系上。客户区设备坐标的原点在窗口客户区的左上角横轴X向右为正纵轴Y向下为正。而我们习惯的数学直角坐标系是Y轴向上为正。所以在MFC里画柱状图本质上要做一件事把数据值的大小映射成“从底部往上走”的像素高度。还有一点容易忽略客户区坐标不等于窗口坐标也不等于屏幕坐标。画图前要通过GetClientRect获取客户区矩形范围而不是拿整个窗口的位置来算。如果你在OnPaint里直接用的是CPaintDC系统已经帮你把DC的坐标系对齐到客户区了。2.2 把数值映射到屏幕像素的三个关键公式自绘柱状图的核心就三个映射公式。搞清楚了这三个公式你就拿下了GDI绘图的大半。第一步在客户区里留出边距确定“绘图区”。比如客户区是rcClient上下左右各留白后得到一个rcPlotCRect rcPlot(60, 20, rcClient.Width() - 20, rcClient.Height() - 40);左边留60像素放Y轴刻度文字上方留20像素放柱顶数值标签下方留40像素放X轴分类名称。这个边距不是拍脑袋定的是实测几个不同数据量后确定的最小值多了看着空少了文字挤在一起。第二步计算柱宽。假设有nCount根柱子每根柱子之间的间距固定为nGapint nBarWidth (rcPlot.Width() - nGap * (nCount 1)) / nCount;为什么间距要乘(nCount1)而不是nCount因为第一根柱子前面和最后一根柱子后面也都需要留一点空隙这样整组柱子才能在绘图区里居中而不是紧贴着Y轴或右边框。第三步也是最关键的——柱高和柱顶Y坐标double dMax /* 数据最大值向上取整到10的倍数 */; int nBarHeight (int)(value / dMax * rcPlot.Height()); int y rcPlot.bottom - nBarHeight;value除以dMax得到一个0到1之间的比例乘以绘图区高度得到像素高度再用rcPlot.bottom往上减柱子就长在了“地面”上。如果你直接拿value当像素高度数据稍微大一点柱子就冲出窗口了。这三个公式建议抽成一个布局计算函数因为后面鼠标命中检测、绘制坐标轴、绘制标签都要用到同一套参数。我见过不少人在OnPaint里手写一遍公式在OnLButtonDown里再复制一遍结果改了一个边距忘了改另一个点击和绘制错位排查半小时才发现是两边参数不一致。3. 从画第一根柱子开始数据、画笔和OnPaint核心流程3.1 数据和UI怎么解耦画柱状图之前先想想数据怎么存。别把数据源和绘制逻辑写死在同一个函数里否则后期无论是加数据还是换展示方式都会很痛苦。我一般这样定义一条柱状图数据struct CBarData { CString strName; // 分类名称 double dValue; // 数值 }; // 控件类里维护一个数据数组 std::vectorCBarData m_vecData;然后提供一个对外接口void SetData(const std::vectorCBarData vecData);SetData里拷贝数据、计算一次布局参数柱宽、最大值刻度等然后调用Invalidate触发重绘。这样外部调用方只需要关心数据怎么来完全不关心图怎么画UI和业务逻辑各管各的。3.2 OnPaint完整实现双缓冲与图形绘制接下来是重头戏——OnPaint的实现。这里面有个不能省的步骤双缓冲。为什么要双缓冲因为GDI绘制是直接往屏幕上写的如果每根柱子、每个文字分别往屏幕写窗口在刷新那一瞬间就会出现肉眼可见的闪烁。双缓冲的原理是先在一张内存位图上把所有内容画好再一次BitBlt拷贝到屏幕画面就是完整刷新不会闪。核心代码是这样的void CBarChartCtrl::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); // 内存DC和兼容位图这就是双缓冲 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBitmap memDC.SelectObject(bmp); // 画白色背景 memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); // 绘图区 CRect rcPlot(60, 20, rcClient.Width() - 20, rcClient.Height() - 40); DrawChart(memDC, rcPlot); // 一次性拷贝到窗口 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); }然后DrawChart是真正画图的核心void CBarChartCtrl::DrawChart(CDC* pDC, CRect rcPlot) { if (m_vecData.empty()) return; // 计算最大值向上取整到10的倍数保证刻度整齐 double dMax 0; for (const auto item : m_vecData) dMax max(dMax, item.dValue); if (dMax 0) dMax 1; dMax ceil(dMax / 10.0) * 10.0; int nCount (int)m_vecData.size(); int nGap 20; int nBarWidth (rcPlot.Width() - nGap * (nCount 1)) / nCount; if (nBarWidth 0) nBarWidth 1; // 画每根柱子 for (int i 0; i nCount; i) { int nBarHeight (int)(m_vecData[i].dValue / dMax * rcPlot.Height()); int x rcPlot.left nGap i * (nBarWidth nGap); int y rcPlot.bottom - nBarHeight; CBrush brush(RGB(100, 149, 237)); CBrush* pOldBrush pDC-SelectObject(brush); // Rectangle画的是带边框的矩形边框用当前画笔内部用当前画刷 pDC-Rectangle(x, y, x nBarWidth, rcPlot.bottom); pDC-SelectObject(pOldBrush); } }这里有个小细节Rectangle画出来的是“带边框的实心矩形”。边框用的是DC当前画笔如果没设置过默认是黑色细线。画柱状图时这个黑边框有时候显得突兀一个简单的处理是创建一根NULL_PEN选进去这样柱子就只有填充色没有边框CPen penNull(PS_NULL, 0, RGB(0, 0, 0)); CPen* pOldPen pDC-SelectObject(penNull);另外绘制柱子时如果不希望相邻柱子间的颜色形成强烈对比干扰阅读可以用同一色系的不同深浅来区分具体项目看UI风格没有标准答案。4. 光有柱子不算图表坐标轴、刻度与数据标签4.1 坐标轴和刻度怎么画才像样只有几根彩色矩形那叫色块不叫图表。配上坐标轴和刻度才能让别人一眼看出“这个数大概有多大”。坐标轴绘制最简单两个MoveTo加两个LineToCPen axisPen(PS_SOLID, 1, RGB(80, 80, 80)); CPen* pOldPen pDC-SelectObject(axisPen); // X轴在绘图区底部 pDC-MoveTo(rcPlot.left, rcPlot.bottom); pDC-LineTo(rcPlot.right, rcPlot.bottom); // Y轴在绘图区左侧 pDC-MoveTo(rcPlot.left, rcPlot.bottom); pDC-LineTo(rcPlot.left, rcPlot.top); pDC-SelectObject(pOldPen);刻度线的思路是把Y轴等分成若干段我这里固定分成5段每段高度就是rcPlot.Height()/5。然后根据数据最大值dMax算出每个刻度对应的数值const int nTicks 5; for (int i 0; i nTicks; i) { double dTickValue dMax * i / nTicks; int y rcPlot.bottom - (int)(rcPlot.Height() * i / nTicks); // 刻度短线 pDC-MoveTo(rcPlot.left - 4, y); pDC-LineTo(rcPlot.left, y); // 刻度文字右对齐显示在Y轴左侧 CString strTick; strTick.Format(_T(%.0f), dTickValue); CRect rcText(rcPlot.left - 60, y - 10, rcPlot.left - 8, y 10); pDC-DrawText(strTick, rcText, DT_RIGHT | DT_VCENTER | DT_SINGLELINE); }为什么要分成5段而不是根据数据大小动态分因为5段是人眼最容易快速读出数值的粒度再细会显得刻度线密密麻麻再粗则信息量不足。数据量特别大或者精度要求特别高的场景可以把nTicks改成成员变量由外部控制。还有Y轴最大值一定要向上取整而且是取到整数倍数。如果数据最大是73.5dMax不取整的话最大刻度就是73.5刻度线标出来非常突兀取整到80刻度线就是0、16、32、48、64、80整整齐齐图表的“专业感”一下就出来了。4.2 数据标签与字体设置的细节光有刻度柱顶不标数值看的人还得一格一格数高度体验很差。所以每根柱子的顶部应该直接显示数值。柱顶数值的绘制可以用DrawText把文字画在柱子顶部上方的一小块矩形区域内CString strValue; strValue.Format(_T(%.1f), m_vecData[i].dValue); CRect rcValue(x, y - 20, x nBarWidth, y - 2); pDC-DrawText(strValue, rcValue, DT_CENTER | DT_BOTTOM | DT_SINGLELINE);柱子的分类名称画在底部CRect rcName(x, rcPlot.bottom 5, x nBarWidth, rcPlot.bottom 25); pDC-DrawText(m_vecData[i].strName, rcName, DT_CENTER | DT_TOP | DT_SINGLELINE);这里有个容易翻车的地方中文乱码或中文变成一排问号。原因是DC当前字体没有指定中文字符集。创建字体时如果直接省略字符集参数系统可能用英文默认字符集去解释中文字符。解决办法是CreateFont时显式指定DEFAULT_CHARSETCFont font; font.CreateFont( 16, 0, 0, 0, FW_NORMAL, FALSE, FALSE, 0, DEFAULT_CHARSET, // 关键默认字符集才能正常显示中文 OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, _T(Microsoft YaHei)); CFont* pOldFont pDC-SelectObject(font);还有一个特别隐蔽的细节绘制文字之前最好调用一次pDC-SetBkMode(TRANSPARENT);。如果不设置文字背景会使用当前背景色默认白色填充画在坐标轴线附近时会盖住轴线留下一块块白斑看起来像是绘图区被啃了几口。5. 让柱状图“认得”鼠标点击命中检测与动态刷新5.1 通过列表框联动切换数据源“鼠标点哪儿就在哪儿显示柱状图”这个需求在实际项目中通常表现为界面左边放一个列表框列出所有指标维度用户点一个右边的柱状图就切换成对应的数据。控件规划很简单——对话框上放一个CListBox和一个CBarChartCtrlCBarChartCtrl是上面那个自绘控件类从CStatic派生。在对话框的OnInitDialog里给列表框填数据m_listData.AddString(_T(一季度销售)); m_listData.AddString(_T(二季度销售)); m_listData.AddString(_T(三季度销售)); m_listData.SetCurSel(0); // 填充第一组柱状图数据 std::vectorCBarData vecData; vecData.push_back({_T(华东), 45.5}); vecData.push_back({_T(华南), 62.0}); // ... 更多数据 m_chartCtrl.SetData(vecData);然后在消息映射里处理列表框的选中通知。在CListBox的“当前选中项已改变”事件LBN_SELCHANGE中更新柱状图数据void CMyDialog::OnLbnSelchangeListData() { int nSel m_listData.GetCurSel(); if (nSel LB_ERR) return; std::vectorCBarData vecData; // 根据nSel从业务层获取对应的柱状图数据 // ... m_chartCtrl.SetData(vecData); }SetData内部CleanData、重新拷贝数组然后调Invalidate触发重绘整个流程就串起来了。这里关键的一点是SetData不要直接调用RedrawWindow强制重绘先用Invalidate标记窗口无效再等消息泵处理连续快速切换数据时不会卡顿。5.2 鼠标命中检测与柱体高亮比“点击切换数据”更进一步的是鼠标直接点在柱子图上命中哪根柱子哪根柱子就高亮并显示出具体数值。实现命中检测的思路是“反向换算”。绘制的时候我们已经知道每根柱子的矩形区域是怎么算出来的现在只要把点击坐标带到同一套公式里比对即可。所以之前那个布局计算函数就派上大用场了在OnLButtonDown里也调用它就能保证参数不漂移。void CBarChartCtrl::OnLButtonDown(UINT nFlags, CPoint point) { if (!m_vecData.empty()) { CRect rcClient; GetClientRect(rcClient); CRect rcPlot(60, 20, rcClient.Width() - 20, rcClient.Height() - 40); int nCount (int)m_vecData.size(); int nGap 20; int nBarWidth (rcPlot.Width() - nGap * (nCount 1)) / nCount; for (int i 0; i nCount; i) { CRect rcBar( rcPlot.left nGap i * (nBarWidth nGap), rcPlot.top, rcPlot.left nGap i * (nBarWidth nGap) nBarWidth, rcPlot.bottom); if (rcBar.PtInRect(point)) { m_nSelectedIndex i; // 只刷新柱状图区域避免整个窗口闪烁 InvalidateRect(rcPlot, FALSE); break; } } } CStatic::OnLButtonDown(nFlags, point); }命中后在DrawChart里对选中的柱子用不同颜色绘制比如高亮色RGB(0, 112, 192)其余柱子用浅色RGB(100, 149, 237)同时在高亮柱子上方加一个浮动面板显示数值。做到这一步一个可交互的柱状图就基本成型了。用户点击柱子柱子立刻高亮并显示精确数值操作反馈和网页端图表的手感已经非常接近。6. 画图过程中最容易踩的四个坑闪烁、乱码、残留与缩放6.1 画面闪烁与刷新时机闪烁是自绘控件最容易遇到的问题。原因在于窗口背景擦除和前景绘制不同步系统用背景色擦掉整个窗口然后OnPaint再一格格画柱子这个“先擦后画”的过程肉眼可见就形成了闪烁。解决手段除了前面说的双缓冲还要重写WM_ERASEBKGND消息让它直接返回TRUE告诉系统“背景不用你擦我画图时会自己处理”BOOL CBarChartCtrl::OnEraseBkgnd(CDC* pDC) { return TRUE; }说实话跳过OnEraseBkgnd但不做双缓冲画面会更闪做了双缓冲但没跳过OnEraseBkgnd会闪一下再显示新画面。两个配合起来才是完整的防闪烁方案缺一个都不彻底。再有一个和刷新相关的坑InvalidateRect指定了FALSE意思是“擦除背景的事别管直接重绘”在某些情况下容易出现边角残留。针对这个问题的正确姿势是把重绘区域计算准确比如只重绘柱子区域的union然后用InvalidateRect(区域, TRUE)让系统擦除对应区域再配合双缓冲。关键原则就是重绘区域越小闪烁越小区域算得越准残留越少。6.2 中文乱码、文字残留与DPI黑边中文乱码前面已经讲了字符集指定DEFAULT_CHARSET。这里再补一个场景如果用的是CFont::CreateFontIndirect也要检查lfCharSet字段同样要设为DEFAULT_CHARSET。文字残留的问题多在绘制浮动的提示框或柱顶标签时出现。原因是上次绘制的文字区域比这次大新的绘制没有完全覆盖旧内容。解决思路是让提示区域的高度固定或者每次重绘前对整个绘制区做一次背景色填充。很多人的习惯是“画哪补哪”结果旧内容补不干净白底一块块叠着非常难看。我的建议是最简单粗暴的办法每次OnPaint都先用白色FillSolidRect整个客户区再开始画图一了百了。最后说DPI。高DPI显示器下很多MFC程序会出现文字模糊、控件边缘有黑边的情况。如果项目清单里开的是Per-Monitor DPI Aware绘图的坐标都要乘以缩放比例如果用的还是旧的System DPI模式至少得保证字体大小在缩放后不会超出为它预留的矩形区域。最直接的排查方式是在客户区画一个颜色对比明显的矩形边框看它在100%和150%缩放下是否被裁剪。我自己的经验是MFC项目如果不是必须支持多显示器DPI差异干脆在exe清单里保持System DPI Aware界面整体由系统缩放自绘部分反而问题最少。反过来如果已经切了Per-Monitor DPI Aware那就老老实实给绘图控件的字体、边距、柱宽都乘上缩放系数一步都省不了。现在这个柱状图已经能应付大多数内部工具场景了数据通过列表框切换、鼠标点击高亮、坐标轴刻度齐全、不闪烁不残留。后续再扩展比如把柱子连成折线图就是在同一个坐标系里改画Polyline做堆叠柱状图就是多叠几个矩形以及调整起始高度给鼠标加数据提示浮层就是复用5.2节那套命中检测逻辑。底子打好了扩展起来都是顺水推舟的事。本文还有配套的精品资源点击获取