简介:一份面向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挂上去,再按表里的边界数据改一遍。希望帮到你。
本文还有配套的精品资源,点击获取