简介:这份面向 MFC 初学者的柱状图绘制示例,围绕单文档界面下的 GDI 绘图展开,重点演示 CBrush 画刷设置、CPaintDC 设备上下文选入、矩形柱体填充,以及从 TXT 文档读取数据并动态更新的基本流程,适合正在学习 Windows 界面编程与图形绘制、需要一个小型可运行范例的读者。压缩包共 36 个文件,约 2.28MB,包含 h/cpp 头文件与实现源码、rc/ico/bmp 等界面资源、dsp/dsw 工程配置及 Debug 编译产物,可据此梳理项目结构并对照消息处理与绘图代码。目前已有 601 人学习或下载。读者可从中了解视图类 OnPaint 的绘制入口、坐标区域计算、柱宽与间距安排、画刷创建与释放等关键细节,也能参考文件读取与数据转换的写法,把静态示例扩展为带坐标轴、标题和图例的简单图表。整个工程规模不大,适合作为调试绘图逻辑、理解 MFC 文档/视图框架的练手材料。
1. 为什么还在用 MFC 画柱状图:一张十根柱子的老界面需求
上周有个做设备的同行发来一段聊天记录,需求方只写了半行字:用 MFC 画简单的柱状图,十根柱子,数据从采集卡来,别引入第三方库。这种活儿在 2024 年听起来像考古,但它真实存在——工控上位机、老旧检测仪界面、实验室自研软件,MFC 骨架还在跑,加一个图表控件就得重装整个渲染栈,成本不划算。这时候最省事的路径就是拿 CDC 直接画:Rectangle铺柱体、CBrush定填充、CPen描边框,一百多行代码能出一张能看的图。适合谁?手上已经有 MFC 工程、只想加一块可视化区域、不追求动画和交互的人。不适合谁?需要缩放拖拽、图例联动、几十万点刷新的场景,那该上专门的绘图库。这篇就把这条最朴素的路线拆开,从画刷选型到双缓冲刷新,把能直接抄的代码和会翻车的地方都摆出来。
2. GDI 绘图链路与画刷选型:CDC、CPen、CBrush 谁管什么
2.1 从窗口到像素:MFC 绘图的三层结构
MFC 的绘图说到底是 Windows GDI 的一层薄封装,理解这三层,后面所有参数才有地方落。最底层是窗口句柄 HWND 和绘图句柄 HDC,系统通过WM_PAINT消息通知窗口重绘,在消息处理里调用BeginPaint拿到 HDC,画完再EndPaint释放。MFC 把这一对调用封成了CPaintDC,它的构造函数里做BeginPaint,析构函数里做EndPaint,所以你只要在OnPaint里声明一个CPaintDC dc(this);,后面就可以直接用dc画。
中间一层是CDC类,它是 HDC 的 C++ 包装,几乎所有 GDI 绘图函数都有对应的成员函数:MoveTo、LineTo、Rectangle、Ellipse、TextOut、SetBkMode、SelectObject。这一层不持有任何资源,它只是一个“画笔的把手”,真正决定线条颜色、粗细、填充样式的是挂在它上面的 GDI 对象。
最上层是CGdiObject的派生类:CPen管线条,CBrush管填充,CFont管文字,CBitmap管位图,CPalette管调色板,CRgn管裁剪区域。这些对象在创建时向系统申请内核资源,用完必须释放,否则就是典型的 GDI 句柄泄漏——进程句柄数一路涨到上限(默认每个进程 10000 个),界面会先卡死再崩掉。
还有一个容易混的点:CView体系的绘图入口是OnDraw(CDC* pDC),不是OnPaint。框架的OnPaint会构造CPaintDC然后回调OnDraw,所以视图类里把绘图代码写在OnDraw里,打印和打印预览也能复用同一套逻辑。对话框程序没有视图,就直接在OnPaint里写。这两种写法在本文后面都会出现,别看到OnDraw就以为是另一套东西。
设备上下文里天生带了一批“库存对象”(stock objects),比如WHITE_BRUSH、BLACK_PEN、NULL_BRUSH、HOLLOW_BRUSH。这些对象归系统所有,不需要你创建,也不能删除。刚拿到一个 DC 时,它上面挂的就是这些默认对象,这也是为什么你不选任何画刷直接画矩形,出来的是一个白底黑框的方块。
注意:
CDC对象本身不拥有 GDI 对象的所有权,谁Create的谁负责DeleteObject,这条规则贯穿全篇。
2.2 画刷的四种来源与适用场景
柱状图里画刷决定的是柱体内部那一块颜色或纹理,选哪种创建方式是第一个要定的事。MFC 里常用的画刷来源有四类,差别不只是“好不好看”,还牵扯背景模式、性能和资源开销。
| 创建方式 | 典型 API | 适用场景 | 需要注意 |
|---|---|---|---|
| 纯色画刷 | CreateSolidBrush(COLORREF) | 柱体填充、背景块、图例色块 | 最常用,RGB(r,g,b)三个分量都是 0–255 |
| 阴影画刷 | CreateHatchBrush(int, COLORREF) | 单色打印、区分同色系柱体 | 受SetBkMode影响,不设透明会出现底色方块 |
| 图案画刷 | CreatePatternBrush(CBitmap*) | 位图纹理填充 | 传入的是单色位图时会用当前文字色和背景色重绘 |
| 库存画刷 | GetStockObject(WHITE_BRUSH) | 擦背景、临时填白 | 系统所有,不能DeleteObject |
纯色画刷是柱状图的主力,一根柱子一个颜色或者一组柱子一个色系,都是它。阴影画刷在黑白打印场景很有用——比如同一个指标今年的柱子和去年的柱子,用斜线密度区分,打印出来仍然能分辨。图案画刷一般不推荐用在柱体上,因为小尺寸柱体里纹理看不清,反而显脏;它更适合大面积背景。
创建画刷的代码很短,但有几个细节值得说:
// 方式一:纯色画刷,柱体填充最常用 CBrush brushFill; brushFill.CreateSolidBrush(RGB(46, 117, 182)); // 方式二:阴影画刷,用于黑白打印区分 CBrush brushHatch; brushHatch.CreateHatchBrush(HS_DIAGCROSS, RGB(200, 60, 60)); // 阴影画刷画在彩色背景上时,需要把背景模式设为透明,否则会有底色块 pDC->SetBkMode(TRANSPARENT); // 方式三:库存画刷,画背景或临时覆盖 CBrush* pWhite = (CBrush*)pDC->SelectStockObject(WHITE_BRUSH);RGB(46, 117, 182)这个宏返回的是一个 32 位COLORREF,内存布局是0x00BBGGRR,注意是反的——红在最低字节。所以如果你从别处拿到一个0x2E75B6这样的十六进制值,不能直接赋给COLORREF,得用RGB(GetRValue(c), GetGValue(c), GetBValue(c))或者手动拆字节。这个坑我见过不止一次,表现是颜色“红蓝颠倒”。
画刷还分“逻辑画刷”和“物理画刷”。CreateSolidBrush创建的是逻辑画刷,只有被SelectObject选进 DC 之后,GDI 才会把它转成设备相关的物理画刷去实际填充。同一个CBrush对象可以被反复选入不同的 DC,但同一时刻只能挂在一个 DC 上。
2.3 选入选出的配对规则与资源归属
这是 MFC 绘图里最容易被忽略、也最容易出事的一环。SelectObject的语义是“把新对象换上去,返回原来那个对象”,返回的旧对象必须保存下来,用完再换回去。DC 在绘制结束时如果还挂着你创建的对象,而这个对象随后被析构,DC 里就留下一个野指针,下一次重绘时行为不可预测——表现可能是整块区域不刷新、颜色错乱,或者干脆崩溃。
标准写法是三步:保存旧对象、绘制、还原旧对象。柱体绘制里通常同时涉及画笔和画刷,两个都要还原。
void CMyView::DrawOneBar(CDC* pDC, const CRect& rcBar, COLORREF crFill) { // 1. 创建填充画刷与边框画笔 CBrush brushFill; brushFill.CreateSolidBrush(crFill); CPen penEdge; penEdge.CreatePen(PS_SOLID, 1, RGB(24, 64, 100)); // 2. 选入时必须接住返回的旧对象指针,这是还原的唯一凭据 CBrush* pOldBrush = pDC->SelectObject(&brushFill); CPen* pOldPen = pDC->SelectObject(&penEdge); // 3. 绘制:Rectangle 用当前画刷填内部、用当前画笔画边框 pDC->Rectangle(&rcBar); // 4. 还原顺序不强制,但必须成对,且要在画刷/画笔析构之前完成 pDC->SelectObject(pOldPen); pDC->SelectObject(pOldBrush); }逻辑说明:brushFill和penEdge是栈上的局部对象,函数返回时析构,析构里会调用DeleteObject。如果此时它们还挂在 DC 上,删除的是正在使用的对象,后续绘制就会引用无效句柄。所以“还原”这一步不是礼貌,是安全底线。参数说明:PS_SOLID是实线样式,1 是线宽(逻辑单位,默认映射模式下等于像素),颜色用深色描边能让柱体边界更清晰,尤其是在浅色背景上。
还有一个常见误区是用pOldBrush的类型。SelectObject有多个重载,传入CBrush*时返回CBrush*,传入CPen*时返回CPen*,不要图省事都接成CGdiObject*,虽然能编译,但还原时类型不匹配在某些 MFC 版本里会触发断言。
库存画刷是个例外,SelectStockObject返回的指针不需要你删除,但同样要保存并还原,因为 DC 的初始状态在重绘之间不保证一致。
3. 在 OnPaint 里画出第一张柱状图:坐标映射、绘制顺序与可编译骨架
3.1 建立绘图区与数据到像素的映射
柱状图的核心是一件事:把数据值域映射到像素区间。这一步做不对,后面画得再漂亮也是歪的。常见的错误是直接把数据值当成像素高度,小数据看不出来,数据一大柱子就冲出窗口。
正确做法是先划出绘图区(plot area),再在绘图区内做归一化。绘图区不是整个客户区,要留出左边给 Y 轴刻度标签、下边给 X 轴分类标签、上边留一点顶部余量。我用一组固定边距起步,实际项目里再按字体大小调:
// 在 OnPaint 或 OnDraw 中先算绘图区 CRect rcClient; GetClientRect(&rcClient); const int nMarginLeft = 60; // 左侧留给 Y 轴刻度文字 const int nMarginRight = 20; const int nMarginTop = 30; // 顶部留白,避免最高柱顶到边 const int nMarginBottom = 50; // 底部留给 X 轴分类标签 CRect rcPlot(nMarginLeft, nMarginTop, rcClient.right - nMarginRight, rcClient.bottom - nMarginBottom); // 窗口被拖得极小时直接放弃绘制,避免出现负宽度矩形 if (rcPlot.Width() < 20 || rcPlot.Height() < 20) return;有了rcPlot,纵向映射就是一条比例式:像素高度 = 数据值 / 最大值 * rcPlot.Height()。最大值要留余量,我一般乘 1.1,让最高的柱子顶部离边框还有一点空隙,视觉上不压抑。横向映射按柱子数量等分:每格宽度 = rcPlot.Width() / 柱数,柱体本身只占每格的 60%,剩下 40% 作为间距。
为什么是 60% 而不是 80%?实测下来 60%–65% 在十根柱子左右时视觉最舒服,太宽会显得拥挤,太窄会显得稀疏。柱数超过 30 根时,间距比例要降到 30% 以下,否则柱体宽度会小于 1 像素,Rectangle直接画不出来或者只画一条线。
还有一个边界情况必须处理:数据全为 0 或者全为负。全 0 时最大值为 0,除法会得到inf或者整数除零异常;全负时柱体方向要反过来,从基线向下画。合格的做法是先求dMax,如果小于等于 0 就强制设成 1,保证分母安全;负值场景本文不展开,但映射式要改成从基线往下计算。
3.2 柱体、边框、数值标签的绘制顺序
绘制顺序错了会出现“标签被柱子盖住”“坐标轴被填色覆盖”这类低级但很耗时的 bug。我的固定顺序是四步:背景与网格线 → 坐标轴 → 柱体 → 文字标签。后画的覆盖先画的,文字永远最后。
网格线用浅灰细线,从绘图区顶部到底部横向拉几条,帮助读数。坐标轴就是绘图区的左边和下边两条线,用MoveTo/LineTo画,颜色比网格线深一档。柱体逐个画,每根独立设置画刷和画笔,颜色可以从一个调色板数组里循环取。
文字部分有两个细节容易被忽略。第一是背景模式:TextOut默认会用当前背景色填充文字矩形,压在柱体或网格线上时会出现一块难看的底色块,所以画文字前要pDC->SetBkMode(TRANSPARENT)。第二是文字对齐:Y 轴刻度标签应该右对齐到轴线左侧,X 轴分类标签应该居中对齐到每根柱子下方,这需要先用GetTextExtent量出文字宽度再算起点,不能直接TextOut到固定坐标。
数值标签(柱顶显示具体数字)在十根柱子以内的场景很有用,超过二十根就会互相重叠,这时候要么隔几根显示一个,要么干脆去掉,把数值放到鼠标悬停提示里——但那属于交互范畴,超出“简单柱状图”的范围了。
3.3 完整可编译的代码骨架
下面这段是一个自包含的绘制函数,可以直接放进视图类的OnDraw或者对话框的OnPaint里调用。数据用CArray<double, double>传进来,柱体颜色按索引循环取色。
void CMyView::DrawBarChart(CDC* pDC, const CRect& rcPlot, const CArray<double, double>& arrData) { int nCount = (int)arrData.GetSize(); if (nCount <= 0 || rcPlot.Width() < 20 || rcPlot.Height() < 20) return; // ---- 1. 求最大值并留 10% 余量,避免除零 ---- double dMax = 0.0; for (int i = 0; i < nCount; ++i) if (arrData[i] > dMax) dMax = arrData[i]; if (dMax <= 0.0) dMax = 1.0; dMax *= 1.1; // ---- 2. 网格线:5 条横向参考线,浅灰、点线 ---- CPen penGrid(PS_DOT, 1, RGB(210, 210, 210)); CPen* pOldPen = pDC->SelectObject(&penGrid); for (int g = 1; g <= 5; ++g) { int y = rcPlot.bottom - rcPlot.Height() * g / 5; pDC->MoveTo(rcPlot.left, y); pDC->LineTo(rcPlot.right, y); } pDC->SelectObject(pOldPen); // ---- 3. 坐标轴:左侧竖线 + 底部横线 ---- CPen penAxis(PS_SOLID, 1, RGB(90, 90, 90)); pOldPen = pDC->SelectObject(&penAxis); pDC->MoveTo(rcPlot.left, rcPlot.top); pDC->LineTo(rcPlot.left, rcPlot.bottom); pDC->LineTo(rcPlot.right, rcPlot.bottom); pDC->SelectObject(pOldPen); // ---- 4. 柱体:每格 60% 宽度,居中摆放 ---- static const COLORREF s_crPalette[] = { RGB(46, 117, 182), RGB(237, 125, 49), RGB(112, 173, 71), RGB(255, 192, 0), RGB(91, 155, 213), RGB(165, 105, 189) }; const int nPaletteSize = sizeof(s_crPalette) / sizeof(s_crPalette[0]); double dSlotW = (double)rcPlot.Width() / nCount; int nBarW = (int)(dSlotW * 0.6); for (int i = 0; i < nCount; ++i) { int nBarH = (int)(arrData[i] / dMax * rcPlot.Height()); if (nBarH <= 0) nBarH = 1; // 值为 0 时仍画 1 像素,保证可见 int nLeft = rcPlot.left + (int)(i * dSlotW) + (int)((dSlotW - nBarW) / 2); int nTop = rcPlot.bottom - nBarH; CRect rcBar(nLeft, nTop, nLeft + nBarW, rcPlot.bottom); CBrush brushFill; brushFill.CreateSolidBrush(s_crPalette[i % nPaletteSize]); CPen penEdge(PS_SOLID, 1, RGB(60, 60, 60)); CBrush* pOldBrush = pDC->SelectObject(&brushFill); pOldPen = pDC->SelectObject(&penEdge); pDC->Rectangle(&rcBar); pDC->SelectObject(pOldPen); pDC->SelectObject(pOldBrush); } }逻辑说明:函数按“网格 → 坐标轴 → 柱体”的顺序绘制,保证后面的图形压在前面的图形上。最大值加 10% 余量是为了让柱顶不贴边,同时兜住全 0 数据的除零风险。柱体高度用int截断,会有一两个像素的舍入误差,在十根柱子的场景下肉眼不可见,如果要做像素级对齐,应该改成MulDiv做四舍五入。nBarH <= 0时强制设成 1 像素,是因为Rectangle在高度为 0 时什么都不画,数据里有 0 值的那根柱子就会“消失”,看起来像少了数据。
参数说明:rcPlot由前一节的边距计算得出;arrData建议由外部维护,绘图函数只读不写;调色板数组用static const放在函数内,避免每次调用都重新构造。柱体颜色按i % nPaletteSize循环,柱数超过 6 根时会重复用色,实际项目里可以在重复段加不同亮度的变体来区分。
如果柱子数量固定且不多,把CBrush和CPen提到循环外面创建一次更好,能减少 GDI 对象的创建销毁开销。但要注意提到循环外之后,画笔和画刷的还原仍然只能做一次,且必须在所有绘制完成之后。
4. 数据刷新与双缓冲:把闪烁和残影按下去
4.1 无效区域与 Invalidate 的正确用法
数据变了,界面不会自己更新,必须调用Invalidate或者InvalidateRect通知系统这块区域需要重绘。这里有个参数决定了闪不闪:Invalidate(TRUE)会先擦除背景再触发WM_PAINT,Invalidate(FALSE)不擦背景。柱状图这种自己会铺满整个绘图区的场景,应该用Invalidate(FALSE),自己负责把每个像素画到,避免“先擦成白底再画”的中间态被看见,那一下白闪就是最典型的闪烁来源。
配套的还有OnEraseBkgnd。默认实现会用背景色刷整个客户区,如果你在OnPaint里已经全量绘制,这一步就是多余的,而且它和绘制之间有时间差,闪烁就来自这里。重写它直接返回TRUE表示“背景我已经处理了,系统别刷”:
BOOL CMyView::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 抑制系统擦背景,交给 OnPaint 全量绘制 }注意:返回
TRUE的前提是OnPaint里确实覆盖了整个客户区。如果只画了绘图区,四周留白就会残留上一次的像素,表现为“残影”。
无效区域还有个粒度问题。数据每秒刷新一次时,没必要整窗重绘,只失效绘图区那一块就行:InvalidateRect(&rcPlot, FALSE)。柱数很多、刷新很快的场景,这个优化能把 CPU 占用降下来一大截,因为坐标轴和标题这些静态元素不用每帧重画。但rcPlot是OnPaint里算出来的局部变量,要跨函数使用就得做成成员变量或者在刷新时重新算一遍,我一般抽一个CalcPlotRect()出来,两处都调它。
4.2 内存 DC 双缓冲的标准写法
抑制了背景擦除,闪烁能少一半,但柱子多、刷新快的时候还是会有撕裂感,因为绘制过程是逐根柱子往屏幕上画的,屏幕刷新和绘制不同步。彻底解决要上双缓冲:先在内存 DC 里把整张图画完,再一次性BitBlt到屏幕 DC。
void CMyView::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(&rcClient); if (rcClient.IsRectEmpty()) return; // ---- 1. 创建与屏幕兼容的内存 DC 和位图 ---- CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmpBuffer; // 关键:位图必须与屏幕 DC 兼容,不能与 memDC 兼容 bmpBuffer.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmpBuffer); // ---- 2. 在内存 DC 上完成全部绘制 ---- memDC.FillSolidRect(&rcClient, RGB(255, 255, 255)); CRect rcPlot = CalcPlotRect(rcClient); DrawBarChart(&memDC, rcPlot, m_arrData); // ---- 3. 一次性拷贝到屏幕 ---- dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); // ---- 4. 还原并让局部对象析构 ---- memDC.SelectObject(pOldBmp); }逻辑说明:第 1 步创建内存 DC 后,它默认挂着一张 1×1 的单色位图,必须选入一张与窗口等大的兼容位图才有地方画。第 2 步所有绘制都发生在内存里,屏幕上看不到中间过程。第 3 步BitBlt一次搬运,视觉上就是“瞬间出现”。第 4 步把旧位图选回去,bmpBuffer和memDC析构时释放资源。
参数说明:CreateCompatibleBitmap(&dc, ...)的第一个参数必须是屏幕 DC(这里是dc),不能传&memDC。这是双缓冲最经典的坑——传memDC会创建一张单色位图,画出来的图全是黑白两色,或者干脆一片黑。原因是memDC默认位图是 1 位色的,兼容出来的还是 1 位色。BitBlt最后一个参数SRCCOPY表示直接拷贝源像素,不做混合。
这个写法每次重绘都创建和销毁一张全窗位图,窗口很大时(比如 1920×1080)会有可感知的开销。刷新频率高的场景应该把memDC和bmpBuffer做成成员变量,只在窗口尺寸变化(OnSize)时重建,OnPaint里只做选入和绘制。这是从“能跑”到“跑得顺”的分界线,十根柱子静态展示用不上,但数据每秒刷十次以上就必须做。
4.3 定时刷新与数据接入
数据从哪来决定了刷新怎么触发。采集卡、串口、网络这三种来源,我一般都不在数据回调里直接调Invalidate——回调可能在高频线程里,频率远高于屏幕刷新率,每来一个点就重绘一次会把消息队列塞满,界面直接卡住。常见做法是回调里只更新数据缓冲区,加个临界区保护,然后用一个 200–500 毫秒的定时器统一触发重绘。
// 定时器里做两件事:取最新数据、请求重绘 void CMyView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == ID_TIMER_REFRESH) { EnterCriticalSection(&m_csData); m_arrDisplay.Copy(m_arrData); // 拷贝一份快照,避免绘制时数据被改 LeaveCriticalSection(&m_csData); InvalidateRect(&m_rcPlot, FALSE); // 只失效绘图区 } CView::OnTimer(nIDEvent); }这里用数据快照而不是直接读共享缓冲区,是因为绘制过程可能被系统打断,中途数据被采集线程改写会导致柱子高度前后不一致,看起来像在抖动。拷贝一份的开销在十到几十个元素的量级可以忽略。
定时器周期怎么定?屏幕 60Hz 刷新,理论上 16 毫秒一次就够,但柱状图不需要那么高的帧率。200 毫秒(5 帧每秒)对数值监控已经足够流畅,还能把 CPU 压到很低。如果数据变化本身很慢(比如每分钟一次),那就别用定时器,直接在数据更新回调里PostMessage一个自定义消息,收到消息再重绘,更省资源。
5. 避坑与排查:画刷、坐标、刷新上的五条血泪记录
5.1 画刷与 GDI 对象相关
现象:界面跑十几分钟后越来越卡,最后整个窗口变黑或者进程退出。原因几乎可以肯定是 GDI 对象泄漏。最常见的写法是在OnPaint里new CBrush然后忘了delete,或者创建了画刷但没在函数结束前从 DC 里选出来。每次重绘泄漏一两个句柄,几千次重绘就到上限了。排查方法是在任务管理器里打开“GDI 对象”列盯着看,数字只涨不跌就是泄漏。解决办法是把画刷、画笔都改成栈上对象,靠析构自动释放,并且严格保证SelectObject成对还原——这也是前面 2.3 节反复强调配对的原因。
现象:柱体颜色变成黑白,或者纹理画刷画出来是一块纯色。原因多半是位图或画刷的位深不对。用CreatePatternBrush传入一张单色位图时,GDI 会用当前的文字色和背景色去重绘这张位图,原本的颜色信息全部丢失。另一个同类问题是双缓冲里CreateCompatibleBitmap传了memDC,得到 1 位色位图,整张图只有黑白。解决办法是画刷尽量用CreateSolidBrush,图案画刷要用彩色位图,双缓冲的兼容位图必须基于屏幕 DC 创建。
5.2 坐标与绘制相关
现象:柱子高度看着不对,最大的那根顶到窗口外面去了,或者全部柱子都很矮。先检查最大值有没有乘余量系数,再检查绘图区高度是不是取的rcPlot.Height()而不是rcClient.Height()。用客户区高度做分母,柱子会整体偏高;反过来用绘图区高度但减去了错误的边距,柱子会整体偏矮。还有一种是整数除法截断:arrData[i] / dMax * rcPlot.Height()如果arrData是整型,前一步除法会直接得 0 或 1,后面乘多大都没用。这类问题把中间值打到调试输出里一看就清楚,别靠眼睛猜。
现象:最后一根柱子被切掉一半,或者柱子和右侧边框重叠。这是横向等分的累计误差。(int)(i * dSlotW)每次截断都会丢掉不到一个像素,柱数多了之后误差累积,最后一根的位置就偏了。更稳妥的写法是先用浮点算好每根柱子的左右边界,最后一次取整:int nLeft = rcPlot.left + (int)(i * dSlotW + (dSlotW - nBarW) / 2.0);。另外柱体右边界要用nLeft + nBarW重新算,不能再用浮点乘一遍,否则左右边界的取整方向可能不一致,导致柱宽少一像素。
5.3 刷新与显示相关
现象:拖动窗口改变大小时,界面疯狂闪烁,柱子像在抽搐。这是“擦背景”和“重绘”两个动作交替出现造成的。解决分两步:重写OnEraseBkgnd直接返回TRUE,并且把Invalidate(TRUE)改成Invalidate(FALSE)。如果还有轻微闪烁,就上 4.2 节的双缓冲。还有一种特殊情况是窗口尺寸变化时没有重新计算绘图区,导致柱体画在旧坐标上,看起来像在跳动——在OnSize里调一次Invalidate就能解决。
现象:在高分屏笔记本上,柱子和文字都很模糊,边缘发虚。这是 DPI 感知问题。MFC 工程默认可能是“系统 DPI 感知”,系统会把整个窗口位图拉伸到 150% 或 200%,所有线条都变模糊。解决办法是在程序入口调用SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2),或者用清单文件声明 per-monitor 感知,然后所有坐标和字体大小都按GetDpiForWindow返回的 DPI 缩放,不要写死像素值。这条改动量不小,但工控现场的高分屏越来越多,早晚要处理。
6. 封成可复用控件:从一堆绘图代码到一行调用
画完第一张图之后,真正省时间的做法是把绘图逻辑从视图类里剥出来,封成一个独立的CBarChartCtrl,继承CWnd或者直接做成一个不依赖窗口的绘制类。我倾向后者——不创建窗口句柄,只接收一个CDC*和一个矩形,这样它既能画在视图上,也能画在对话框的静态控件区域,还能画到打印机 DC 上,复用面最广。
封装的关键是把散落在函数里的状态收拢成成员:数据数组、调色板、边距、最大值策略、是否显示数值标签。对外只暴露几个设置函数,绘制入口保持一个。接口大致是这样:
| 接口 | 作用 | 常用取值 |
|---|---|---|
SetData(const CArray<double,double>&) | 更新数据并触发重算 | 元素个数建议 1–50 |
SetPalette(const COLORREF*, int) | 替换柱体配色 | 至少 1 个颜色,超出循环 |
SetBarWidthRatio(double) | 柱体占格宽比例 | 0.3–0.8,默认 0.6 |
SetShowValueLabel(BOOL) | 柱顶是否显示数值 | 柱数 > 20 时建议关 |
Draw(CDC*, const CRect&) | 唯一绘制入口 | 矩形小于 20×20 直接返回 |
调用方就变成三行:构造控件、设置数据、在OnPaint里调Draw。视图类里不再出现任何CreateSolidBrush或坐标计算,换一个界面只需要复制一个文件。
// 使用侧:视图的 OnPaint 里只剩三行 m_barChart.SetData(m_arrData); m_barChart.SetPalette(s_crPalette, 6); m_barChart.Draw(&dc, rcPlot);有一个细节值得单独拿出来说:封装之后,Draw内部不要调用Invalidate,也不要知道自己画在什么窗口上。刷新由调用方负责,绘制类只做纯计算和纯输出。这条边界一旦模糊,控件就会和某个特定窗口绑死,复用性直接归零。
血泪经验是别在第一次就把接口设计得太全。我最早那版加了动画过渡、鼠标悬停高亮、点击选中,结果每个功能都要在Draw里插一段状态判断,代码膨胀到六百多行,改一个颜色要翻三个函数。后来全部砍掉,只留数据、配色、绘制三件事,一百八十行,反而在四个项目里都用上了。需要交互的场景,交互逻辑放在外层窗口的消息处理里,用HitTest(x, y)反查是哪根柱子,绘制类只提供一个“高亮第 i 根”的开关就够了。从那以后我每次新写绘图类,都先把OnEraseBkgnd返回TRUE和双缓冲这两处写上,再去调柱子的颜色和间距——顺序反过来的话,后面调样式的时间全耗在跟闪烁较劲上。希望帮到你。
本文还有配套的精品资源,点击获取