news 2026/11/6 2:54:14

MFC柱状图绘制实践:GDI画刷、坐标映射与双缓冲防闪烁

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MFC柱状图绘制实践:GDI画刷、坐标映射与双缓冲防闪烁

简介:一份面向MFC初学者的VC6单文档绘图示例,围绕用画刷绘制简单柱状图展开,适合正在学习Windows GDI绘图、CView消息响应与TXT数据读取的开发者参考。压缩包共36个文件,约2.28MB,包含cpp与h源码、rc资源、ico图标、exe可执行文件及pdb、obj等调试中间文件,工程结构完整,可直接编译运行并对照调试。已有601人学习下载。示例重点演示CBrush创建与SelectObject选入CPaintDC、Rectangle填充柱体,以及用CFile读取TXT数值并转换为绘图数据;读者可据此理解OnPaint绘制流程、客户区坐标计算和柱状图刷新思路,并在此基础上扩展坐标轴、标题、图例与交互功能。对于想从画刷填充和文件读入两个环节打通MFC绘图链路的读者,这份小型工程具有较好的拆解与复用价值。

1. 从一条折线都画不稳说起:MFC柱状图到底解决什么问题

上个月帮某实验室改一个工控采集界面,十个通道的采样值挤在对话框底部,原本用折线画,数据一抖动就糊成一片。后来换成用MFC画简单的柱状图,每根柱体用不同颜色的画刷填充,现场调试的人一眼就能看出哪一路偏高。这类需求不需要引入第三方图表库,一个对话框工程加几百行GDI代码就能跑起来,资源包里是一份可编译的MFC示例,含坐标轴、柱体和基础刷新。

它解决的是“界面里要有一张能快速看趋势的柱状图,但不想为图表控件增加依赖”的问题。适合已经会用VC++建MFC对话框、能看懂OnPaint和CDC的桌面开发者;刚接触GDI的新手也能按后面的步骤把第一根柱体画出来。需要说明的是,这份示例只覆盖单组数据的纵向柱状图,不做三维、堆叠和动画,边界就在这。

2. 先把坐标算对:数据域到客户区的映射与画刷颜色表

坐标没算对,画刷选得再漂亮也白搭。MFC的客户区坐标原点在左上角,Y轴向下增长,而柱状图的数据域通常是从0往上增长,两者方向相反。很多第一次写柱状图的人会直接把数据值当像素用,结果数据是100,柱体跑到窗口外面;或者把m_yMax写死成100,换一组量程到5000的数据,柱体全部顶到天花板。资源包里的示例把映射单独抽成函数,画刷颜色也只从数据项里取,不在绘制阶段临时拍脑袋。

2.1 为什么先做映射再碰画刷

映射要解决三件事:基线在哪、比例尺多大、越界值怎么截。基线一般是Y轴的最小值,如果只画正值,基线就是客户区绘图区的底边;比例尺是“一个数据单位对应多少像素”,等于绘图区高度除以数据跨度;越界值要截断,不然柱体会画到坐标轴外面,和标题、刻度文字叠在一起。画刷是在映射之后才参与填充的,颜色再对,矩形坐标错了也看不到。

我一般把绘图区单独存成m_rcPlot,它不等于整个客户区。左边留Y轴刻度文字,底边留X轴标签,顶边和右边留一点呼吸空间。这样做的好处是柱体位置、刻度线、鼠标命中测试都引用同一块矩形,后面改边距不会牵一发动全身。画刷颜色表也在这里定下来:每个数据项带一个COLORREF,缺省用一组低饱和度的颜色,避免十根柱子全是大红大绿。

2.2 一个能挂到对话框上的CBarChartCtrl骨架

资源包里的控件派生自CStatic,这样可以直接在对话框资源里放一个Static占位,再用SubclassDlgItem把它换成自己的类。数据结构只保留标签、数值、颜色三个字段,够简单柱状图用。

// BarChartCtrl.h #pragma once #include <vector> #include <afxwin.h> struct BarItem { CString label; // X轴标签 double value; // 数据值 COLORREF color; // 柱体画刷颜色 }; class CBarChartCtrl : public CStatic { public: void SetData(const std::vector<BarItem>& data); void SetYRange(double minValue, double maxValue); protected: afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); DECLARE_MESSAGE_MAP() private: std::vector<BarItem> m_data; double m_yMin = 0.0; double m_yMax = 100.0; CRect m_rcPlot; // 不含轴标签的绘图区 void RecalcLayout(const CRect& rcClient); int ValueToY(double value) const; void DrawAxes(CDC& dc); void DrawBars(CDC& dc); };

这段代码的重点不是类名,而是把“数据”和“布局”分开。m_data只负责存业务数据,m_rcPlot只负责存当前客户区里真正画柱体的矩形,m_yMin/m_yMax负责量程。后面所有坐标计算都从这三个成员出发,画刷颜色也挂在BarItem上,换一组数据只改SetData,不用动绘制代码。参数上要注意m_yMax必须大于m_yMin,否则映射函数里要做保护。

消息映射里至少挂上ON_WM_PAINT和ON_WM_ERASEBKGND。OnEraseBkgnd返回TRUE,表示背景由OnPaint里的双缓冲统一刷,不让系统先擦一遍,这是后面消闪烁的前提。

// BarChartCtrl.cpp BEGIN_MESSAGE_MAP(CBarChartCtrl, CStatic) ON_WM_PAINT() ON_WM_ERASEBKGND() END_MESSAGE_MAP() BOOL CBarChartCtrl::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景交给OnPaint的双缓冲处理 }

OnEraseBkgnd里不画任何东西,直接返回TRUE。有些示例在这里刷白底,结果OnPaint再刷一次,窗口拖动时就会闪。参数上没有额外输入,返回值TRUE表示“我已经处理了背景”,MFC不会再调默认擦除。

2.3 刻度步长和边距参数的取法

边距不要拍脑袋写死。左边距要能放下Y轴最大刻度的文字,底边距要能放下X轴标签,顶边距要放标题或单位。资源包里用四个常量控制,改控件大小时按比例微调。刻度步长用“ nice number”算法,把原始步长归到1、2、5、10这几个档位,避免出现“每格3.7”这种读起来费劲的刻度。

void CBarChartCtrl::RecalcLayout(const CRect& rcClient) { const int leftMargin = 56; // 给Y轴刻度文字 const int rightMargin = 20; const int topMargin = 24; const int bottomMargin = 36; // 给X轴标签 m_rcPlot = rcClient; m_rcPlot.DeflateRect(leftMargin, topMargin, rightMargin, bottomMargin); if (m_rcPlot.Width() < 10) m_rcPlot.right = m_rcPlot.left + 10; if (m_rcPlot.Height() < 10) m_rcPlot.bottom = m_rcPlot.top + 10; } double NiceStep(double rawStep) { if (rawStep <= 0.0) return 1.0; double exp10 = pow(10.0, floor(log10(rawStep))); double f = rawStep / exp10; double nice = (f <= 1.0) ? 1.0 : (f <= 2.0 ? 2.0 : (f <= 5.0 ? 5.0 : 10.0)); return nice * exp10; }

RecalcLayout的逻辑是把客户区往里收一圈,收出来的矩形就是柱体和坐标轴线的活动范围。leftMargin=56是经验值,字体大一点要往上加;bottomMargin=36能放下一行横排标签,标签旋转或两行就要加。NiceStep把原始步长归到1、2、5、10,参数rawStep通常取(m_yMax - m_yMin) / 5.0,想要更密的刻度就把除数改成8,但别超过10,否则文字会挤。

映射函数本身很短,但边界保护必须写。

int CBarChartCtrl::ValueToY(double value) const { double span = m_yMax - m_yMin; if (span <= 1e-9) span = 1.0; // 全相等数据防止除零 double ratio = (value - m_yMin) / span; ratio = (std::max)(0.0, (std::min)(1.0, ratio)); // 截断越界值 return m_rcPlot.bottom - (int)(ratio * m_rcPlot.Height() + 0.5); }

这里用m_rcPlot.bottom做基线,ratio=0落在底部,ratio=1落在顶部。加0.5是为了四舍五入,避免柱体高度差一个像素时看起来像没画。span的保护不能省,现场数据经常出现十个通道全是同一个值的情况,不保护就会除零,映射结果变成未定义。截断放在映射里做,画刷填充阶段就不用再判断一遍。

画刷颜色表我一般在SetData里补默认值:如果调用方没给颜色,就按索引从一组预置色里取。预置色不要用纯红纯绿,柱体边框再用深一号的颜色,打印成灰度时还能区分。参数上COLORREF用RGB(r,g,b),每个分量0到255,别把GetSysColor的返回值直接塞进去,系统色在深色主题下可能和背景混在一起。

3. OnPaint里把柱体画出来:CPen、CBrush画刷与双缓冲

到了OnPaint,事情分成两层:一层是画坐标轴和文字,一层是用画刷填柱体。新手最容易翻车的地方是直接在CPaintDC上画,窗口一拖动就闪成一片;另一个是画刷选进DC后忘了恢复,后面画坐标轴时笔和刷子全乱了。资源包里的写法是先把所有内容画到内存DC,再一次性贴回屏幕,画刷只在内存DC里选进选出,屏幕DC不参与具体绘制。

3.1 画刷选型:SOLID、HATCH、NULL_BRUSH各自用在哪

CBrush常用三种创建方式:CreateSolidBrush实心填充,CreateHatchBrush斜线/网格填充,CreatePatternBrush位图填充。柱状图默认用实心画刷,颜色区分通道最直接。HATCH画刷适合黑白打印或者需要区分“告警/正常”两种状态的场合,但斜线密度在低分辨率下会糊,柱体太窄时不要用。NULL_BRUSH不是画刷,是stock object,选进DC表示“只画边框不填充”,用来画坐标轴网格线或者空心柱体。

void CBarChartCtrl::DrawBars(CDC& dc) { if (m_data.empty() || m_rcPlot.Height() <= 0) return; const int n = (int)m_data.size(); int gap = (std::max)(4, m_rcPlot.Width() / (n * 5)); // 柱间距 int barW = (m_rcPlot.Width() - gap * (n + 1)) / n; barW = (std::max)(2, barW); // 柱体最小宽度 CPen framePen(PS_SOLID, 1, RGB(60, 60, 60)); CPen* pOldPen = dc.SelectObject(&framePen); int baseY = ValueToY(m_yMin); for (int i = 0; i < n; ++i) { int x = m_rcPlot.left + gap + i * (barW + gap); int topY = ValueToY(m_data[i].value); CRect rcBar(x, topY, x + barW, baseY); if (rcBar.top > rcBar.bottom) rcBar.top = rcBar.bottom; CBrush brush(m_data[i].color); // 每根柱体独立画刷 CBrush* pOldBrush = dc.SelectObject(&brush); dc.Rectangle(rcBar); dc.SelectObject(pOldBrush); // 立刻恢复,防止画刷泄漏到后续绘制 } dc.SelectObject(pOldPen); }

这段的逻辑是:先算柱宽和间距,再逐根算矩形,最后用CBrush填充。gap取绘图区宽度的二十分之一左右,barW最小2像素,柱体太多时宁可窄一点也不要重叠。baseY取m_yMin对应的Y坐标,只画正值时它就是底边;如果数据里允许负值,基线要改成ValueToY(0.0),矩形方向也要跟着翻。SelectObject保存旧画刷再恢复,是GDI里必须养成的习惯,少写这一句,后面画坐标轴时可能继承到上一根柱体的颜色。

画刷对象用栈上的CBrush,每次循环构造一次,析构时自动DeleteObject。如果柱体数量上百,频繁创建画刷会有开销,可以提前建一个std::vector<CBrush>缓存,但简单柱状图没必要。参数上RGB(60,60,60)是边框色,比填充色深一档,柱体边界清楚但不抢眼。

3.2 双缓冲OnPaint的完整写法

双缓冲不是玄学,就是把“画”和“显示”拆开。内存DC先画完整张图,再用BitBlt一次性贴到CPaintDC。窗口刷新时屏幕上只看到一次完整结果,不会看到先画白底再画柱体的中间过程。

void CBarChartCtrl::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(&rcClient); if (rcClient.Width() <= 0 || rcClient.Height() <= 0) return; RecalcLayout(rcClient); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); memDC.FillSolidRect(rcClient, RGB(250, 250, 250)); DrawAxes(memDC); DrawBars(memDC); dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }

CreateCompatibleDC拿屏幕DC的格式建内存DC,CreateCompatibleBitmap建同样大小的位图,SelectObject把位图选进去之后,所有绘制都落在内存里。FillSolidRect刷背景,DrawAxes和DrawBars按顺序画,最后BitBlt贴回屏幕。参数上SRCCOPY表示直接拷贝,不要用SRCAND之类,除非做透明叠加。rcClient宽高为0时提前返回,防止CreateCompatibleBitmap失败。

DrawAxes里画坐标轴和刻度文字,建议用NULL_BRUSH选进DC,避免Rectangle把绘图区填掉。

void CBarChartCtrl::DrawAxes(CDC& dc) { CPen axisPen(PS_SOLID, 1, RGB(90, 90, 90)); CPen* pOldPen = dc.SelectObject(&axisPen); CBrush* pOldBrush = (CBrush*)dc.SelectStockObject(NULL_BRUSH); dc.Rectangle(m_rcPlot); // 只画边框,不填充 dc.MoveTo(m_rcPlot.left, m_rcPlot.bottom); dc.LineTo(m_rcPlot.right, m_rcPlot.bottom); dc.MoveTo(m_rcPlot.left, m_rcPlot.top); dc.LineTo(m_rcPlot.left, m_rcPlot.bottom); dc.SelectObject(pOldBrush); dc.SelectObject(pOldPen); }

这里用SelectStockObject(NULL_BRUSH)把画刷换成空刷,Rectangle就只描边。axisPen颜色比柱体边框浅一点,坐标轴不跟数据抢视觉。刻度线、文字、单位可以在这个函数里继续补,但要注意文字绘制用SetBkMode(TRANSPARENT),不然文字底下会出现一块背景色。

3.3 柱宽、间距和负值柱体的边界处理

柱宽公式看起来简单,坑在取整。(m_rcPlot.Width() - gap * (n + 1)) / n是整数除法,n很大时barW可能变成0,后面max(2, barW)兜底,但兜底之后总宽度会超过绘图区,最后一根柱子被裁掉。稳妥做法是先算barW,如果小于2,就把gap压到1,再算一次;仍然放不下,就只画前N根并在界面提示“数据过多”。简单柱状图不需要滚动条,控制数据条数比硬塞更实际。

负值柱体是另一个边界。如果m_yMin设成负数,baseY不能取m_rcPlot.bottom,要取ValueToY(0.0);矩形构造时topY和baseY的上下关系会翻转,CRect允许top>bottom,但Rectangle画出来的方向会怪。我一般统一成rcBar.top = min(topY, baseY),rcBar.bottom = max(topY, baseY),再用画刷填充。这样正负值都能画,代价是柱体从基线向两侧长,X轴标签位置要重新调。

画刷颜色在负值场景下不要只用一种色。正值用蓝绿系,负值用橙红系,填充后再用深色边框勾一圈,现场一眼能看出哪根是负向。参数上m_yMin/m_yMax要覆盖数据的最小最大值,别只按正数设量程,否则负值全被截断成0,看起来像没数据。

4. 让柱状图跟着数据动:定时刷新、命中测试与打印DC

静态柱状图只是第一步。采集界面里数据每隔几百毫秒变一次,柱体要跟着长;鼠标移到柱体上要知道是哪一路;偶尔还要把当前图打印出来存档。这三件事分别对应定时器、命中测试和打印DC,代码都不长,但每个都有容易忽略的参数。

4.1 定时器刷新与局部重绘

定时器不要直接在OnTimer里画,OnTimer只负责改数据和请求重绘。重绘用InvalidateRect(&m_rcPlot, FALSE),第二个参数FALSE表示不擦背景,配合前面的OnEraseBkgnd返回TRUE,闪烁会小很多。如果整张图都要变,传NULL重绘整个客户区;只变柱体高度时传绘图区矩形,坐标轴文字不重画。

void CBarChartCtrl::StartRefresh(UINT intervalMs) { SetTimer(1, intervalMs, nullptr); // ID=1,避免和对话框其他定时器冲突 } void CBarChartCtrl::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { // 这里换成真实采集值,示例里做简单模拟 for (auto& item : m_data) { item.value += (rand() % 21 - 10) * 0.5; } InvalidateRect(&m_rcPlot, FALSE); return; } CStatic::OnTimer(nIDEvent); }

SetTimer的ID用1,是因为对话框上可能还有别的定时器,ID冲突会导致消息串台。intervalMs按数据刷新频率给,500毫秒对现场看趋势够用,100毫秒以下要考虑绘制开销。InvalidateRect只是标记区域无效,真正绘制发生在消息循环空闲时,连续调用多次不会画多次,系统会合并。OnTimer里改数据要注意线程安全,如果采集线程在另一个线程写m_data,要加临界区,简单示例里放在UI线程模拟。

停止刷新用KillTimer(1),控件销毁时也要调,不然定时器消息可能发到已经析构的对象上。参数上FALSE不是“不刷新”,是“不擦背景”,别和UpdateWindow搞混,后者会强制立刻重绘,频繁调用反而卡。

4.2 鼠标悬停的命中测试

命中测试不要重新算一套柱宽,要和DrawBars共用同一组参数。我一般抽一个GetBarRect(int index, CRect& rcBar),绘制和命中都调它,改间距时只改一处,不会出现“画的位置和点的位置差几个像素”的尴尬。

bool CBarChartCtrl::GetBarRect(int index, CRect& rcBar) const { int n = (int)m_data.size(); if (index < 0 || index >= n || m_rcPlot.Width() <= 0) return false; int gap = (std::max)(4, m_rcPlot.Width() / (n * 5)); int barW = (std::max)(2, (m_rcPlot.Width() - gap * (n + 1)) / n); int x = m_rcPlot.left + gap + index * (barW + gap); int topY = ValueToY(m_data[index].value); int baseY = ValueToY(m_yMin); rcBar = CRect(x, (std::min)(topY, baseY), x + barW, (std::max)(topY, baseY)); return true; } int CBarChartCtrl::HitTest(CPoint pt) const { for (int i = 0; i < (int)m_data.size(); ++i) { CRect rcBar; if (GetBarRect(i, rcBar) && rcBar.PtInRect(pt)) return i; } return -1; }

GetBarRect里用min/max处理正负柱体,返回的矩形始终是左上到右下。HitTest遍历所有柱体,返回索引,没命中返回-1。参数上CPoint是客户区坐标,OnMouseMove里拿到的point就是客户区坐标,不用转换;如果从屏幕坐标转,记得调ScreenToClient。

OnMouseMove里可以弹一个简单的CToolTipCtrl,或者自己在柱体上方画一个文字框。资源包用的是状态栏文字,改动最小:命中变化时GetParent()->SetDlgItemText(...),把通道名和值写过去。注意鼠标移动很频繁,不要在OnMouseMove里创建GDI对象,画刷和字体提前建好。

4.3 打印和导出时画刷颜色的映射

打印DC和屏幕DC的物理尺寸不一样,直接拿屏幕坐标画到打印机上,柱体会缩成一小团。常见做法是用MM_ANISOTROPIC映射模式,把逻辑坐标定成1000×700,再按打印纸的可打印区域设视口范围,这样同一套绘制代码既能画屏幕也能画纸。

void CBarChartCtrl::DrawTo(CDC& dc, const CRect& rcTarget) { CRect oldPlot = m_rcPlot; int oldMap = dc.SetMapMode(MM_ANISOTROPIC); dc.SetWindowExt(1000, 700); dc.SetViewportExt(rcTarget.Width(), rcTarget.Height()); dc.SetViewportOrg(rcTarget.left, rcTarget.top); RecalcLayout(CRect(0, 0, 1000, 700)); DrawAxes(dc); DrawBars(dc); dc.SetMapMode(oldMap); m_rcPlot = oldPlot; // 恢复屏幕布局,避免打印后界面错位 }

SetWindowExt(1000,700)定义逻辑画布大小,SetViewportExt把它映射到目标矩形,SetViewportOrg把原点挪到目标左上角。RecalcLayout用逻辑坐标重算绘图区,画完恢复m_rcPlot和映射模式。参数上rcTarget取打印机的可打印区域,通常从GetDeviceCaps(HORZRES)和VERTRES拿,但更省事的是用CPrintDialog返回的m_rectDraw。

打印机画刷颜色可能被驱动转成灰度,HATCH画刷在黑白打印下比实心色块更容易区分。打印前可以准备两套颜色:屏幕用实心,打印用CreateHatchBrush(HS_BDIAGONAL, RGB(0,0,0)),或者把填充色改成浅灰、边框加深。别指望彩色打印机一定能还原RGB,先打一张黑白预览,柱体之间能分清再交付。

5. 避坑与排查:画刷不显示、闪烁、越界的五个现场

这部分是血泪经验堆出来的。柱状图代码不长,但GDI对象的生命周期、坐标方向、设备差异叠在一起,出问题时现象往往和原因隔得很远。下面五个现场是我在改这份示例和帮人排查时遇到最多的,按“现象→原因→解决”写,照着顺序看能省不少时间。

5.1 画刷和重绘相关的三个翻车点

现象一:柱体只有边框,里面是透明或背景色。原因通常是画刷选进了DC,但Rectangle之前又选了NULL_BRUSH,或者画刷对象在SelectObject之后立刻析构了。CBrush brush(...)是栈对象,如果把它传给一个异步绘制或者存到成员里,函数返回后对象析构,DC里留下一个无效句柄,填充就变成未定义。解决是把画刷的生命周期控制在一次SelectObject到SelectObject恢复之间,画完立刻恢复旧画刷;需要缓存画刷就存CBrush成员,不要存临时对象的指针。还有一种情况是Rectangle的矩形高度为0,画刷根本填不出东西,先检查topY和baseY是否相等。

现象二:窗口一拖动就闪白,柱体像在跳。原因不是画刷颜色,是背景擦除和绘制不同步。默认OnEraseBkgnd会先用背景色刷一遍,OnPaint再画一遍,两次之间屏幕上有空档。解决是OnEraseBkgnd直接返回TRUE,把背景刷白放进双缓冲的内存DC里,屏幕只接收BitBlt的最终结果。另一个常见原因是OnPaint里直接用了CPaintDC逐根画柱体,没有内存DC,每画一根就刷一次屏幕。改成双缓冲之后,再把InvalidateRect的第二个参数设为FALSE,减少背景擦除。

现象三:换一组数据后柱体颜色全变成同一种。原因多半是画刷颜色表在SetData里没更新,或者CBrush复用了同一个对象但没重新CreateSolidBrush。MFC的CBrush对象如果已经绑定了GDI句柄,再次CreateSolidBrush前要先DeleteObject,否则旧句柄泄漏,新句柄可能创建失败。解决是每个柱体用局部CBrush,或者缓存时先DeleteObject再创建。颜色值也要检查,RGB(0,0,0)在深色背景上看不见,不是没画,是画了黑色。

5.2 坐标和显示设备相关的两个翻车点

现象四:高DPI屏幕上柱体和文字错位,鼠标点不中。原因是布局用了固定像素边距,系统缩放150%之后,字体变大了但leftMargin还是56,Y轴文字压到绘图区里;鼠标坐标是物理像素,绘制坐标是逻辑像素,两边没对齐。解决是用GetDpiForWindow(或GetDeviceCaps(LOGPIXELSX))算缩放比,把边距和柱宽按比例放大;更稳的做法是切换MM_ANISOTROPIC映射模式,让绘制和命中都用同一套逻辑坐标。如果暂时不想改映射,至少把leftMargin改成MulDiv(56, dpi, 96),命中测试也走同一个布局函数。

现象五:打印出来柱体挤在左上角,或者只有坐标轴没有柱体。原因是打印DC沿用了屏幕的像素坐标,打印机分辨率远高于屏幕,1000像素在纸上只有几厘米。解决是用SetMapMode(MM_ANISOTROPIC)加SetWindowExt/SetViewportExt做逻辑到物理的映射,打印前用打印纸的可打印区域重算绘图区,打印后恢复屏幕布局。另一个原因是打印时m_rcPlot被改坏,DrawBars里柱宽算成负数,直接返回。打印函数里先保存m_rcPlot,画完恢复,别让一次打印把界面布局带偏。画刷在打印机上如果变成黑色,检查是不是用了COLORREF但驱动做了灰度转换,换成HATCH画刷或加深边框。

6. 把柱状图做稳的进阶手法:映射模式与一页自检表

柱状图写到后面,真正省事的不是多写几百行绘制代码,而是把坐标体系固定下来。我后来习惯在控件初始化时就切到MM_ANISOTROPIC,逻辑画布固定成1000×700,屏幕、打印、导出图片都用同一套DrawAxes和DrawBars,只改视口原点和范围。这样高DPI、窗口缩放、打印纸尺寸变化,都变成改几个视口参数的事,不用在每个绘制函数里乘缩放比。代价是文字大小要跟着映射模式调,字体高度用逻辑单位创建,别用固定像素。

void CBarChartCtrl::PrepareLogicalDC(CDC& dc, const CRect& rcTarget) { dc.SetMapMode(MM_ANISOTROPIC); dc.SetWindowExt(1000, 700); // 逻辑画布 dc.SetViewportExt(rcTarget.Width(), rcTarget.Height()); dc.SetViewportOrg(rcTarget.left, rcTarget.top); }

这段的逻辑是把逻辑坐标1000×700映射到目标矩形,目标可以是客户区,也可以是打印纸的可打印区域。参数上SetWindowExt不要设得太小,否则柱宽取整误差变大;SetViewportExt用目标矩形的实际宽高,不要用屏幕分辨率。

验证柱状图是否稳,我一般拿一组边界数据跑一遍,不靠肉眼盯正常数据。下面这张表是每次改完绘制代码都会过的自检项,资源包里也带了一份对应的示例数据。

自检项输入期望
全零数据10根柱值都是0柱体高度为0,坐标轴正常,不除零
全相等数据10根柱值都是42柱体等高,量程自动留余量,不出现满高
单根柱只有1个数据项柱体居中或靠左,不撑满整个绘图区
负值数据含-20到80基线在0位置,负值向下,标签不重叠
超量程值大于m_yMax柱体截断在顶部,不画出绘图区
100根柱100个数据项柱宽不小于2像素,不重叠,末尾不越界

最后说一个习惯:每次改完DrawBars或RecalcLayout,我都会先把窗口拖到最小再最大化,然后切一次高DPI缩放,最后打印预览一遍。这三个动作能暴露大部分画刷选错、坐标越界和打印映射的问题。从那以后我每次给MFC界面加图表,都强制走一遍这张自检表,不再等现场反馈“柱子不见了”才回头查。完整工程和示例数据在资源包里,拿到后先编译那个最小对话框工程,把CBarChartCtrl挂上去,再按表里的边界数据改一遍。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/6 2:54:06

Win32+GDI双缓冲捕鱼小游戏:消息泵、帧循环与碰撞计分实战

简介&#xff1a;面向高校C课程设计与Windows游戏开发入门者的“捕鱼达人”实践项目&#xff0c;围绕窗口创建、GDI绘图、消息循环、鼠标事件、定时器与多线程等知识点&#xff0c;给出可运行的游戏逻辑示例&#xff0c;适合用来理解面向对象封装、继承与多态在小型游戏中的落地…

作者头像 李华
网站建设 2026/11/6 2:53:26

Eye Candy 7.2.3.37 Mac汉化版安装与效果调参实战指南

眼睛糖果这套插件&#xff0c;做设计的老炮儿基本都听过。Alien Skin Eye Candy 系列从早期的 Photoshop 外挂滤镜时代一路走到现在&#xff0c;核心卖点始终没变——把那些原本需要手工调半天渐变、描边、纹理、光影的效果&#xff0c;压缩成几个滑杆就能出结果。这次我拿到的…

作者头像 李华
网站建设 2026/11/6 2:53:25

PCL renderViewTesselatedSphere:均匀点云采样与配准实战

1. 从三角网格到点云&#xff1a;这个函数到底在解决什么问题手里有一份 STL 或者 OBJ 模型&#xff0c;想把它变成点云做配准、做分割、做深度学习训练&#xff0c;这是点云处理里绕不开的一步。很多人第一反应是“把顶点抽出来不就行了”&#xff0c;但真这么干过的人都知道&…

作者头像 李华
网站建设 2026/11/6 2:53:19

膝关节核磁数据集实战:从数据预处理到多任务模型构建

1. 从一张膝盖片子说起&#xff1a;这个数据集到底解决了什么问题如果你做过膝关节的影像检查&#xff0c;大概会有印象&#xff1a;拍完核磁之后&#xff0c;等报告的时间往往比拍片本身长得多。放射科医生要在一叠层厚几毫米的切片里&#xff0c;逐层翻看半月板、前交叉韧带、…

作者头像 李华
网站建设 2026/11/6 2:52:18

Android MQTT通信框架封装实践与优化

1. Android MQTT通信框架封装实践在物联网应用开发中&#xff0c;MQTT协议凭借其轻量级、低功耗和发布/订阅模式等特性&#xff0c;成为设备与云端通信的首选方案。但在Android平台上直接使用Paho等原生库时&#xff0c;开发者常面临连接管理复杂、回调分散、线程安全等痛点。本…

作者头像 李华
网站建设 2026/11/6 2:51:49

开发者高效项目管理与展示全攻略

1. 项目概述"my projects"这个标题看似简单&#xff0c;实际上涵盖了一个开发者或创作者工作流中最核心的部分——个人项目的管理与展示。作为一个拥有多年实战经验的开发者&#xff0c;我深知一个高效的项目管理系统对个人成长的重要性。它不仅是我们技术能力的展示…

作者头像 李华