MFC自绘曲线图/柱状图/饼状图:坐标映射、双缓冲与交互实践
2026/9/11 17:22:37 网站建设 项目流程

简介:一套基于 MFC 的曲线图、饼状图与柱状图绘制完整源码,适合正在学习 Windows GUI 与 GDI 绘图的中级 C++ 开发者。工程采用自定义视图类重写 OnDraw 的方式,由 Graph、GraphSeries、GraphLegend 等类分别负责图表主逻辑、数据序列与图例绘制;通过 CDC 的 MoveTo/LineTo 绘制曲线、Pie 绘制扇区、Rectangle 绘制柱体,并配合颜色、边框和坐标处理,实现较美观的展示效果。资源包共 37 个文件,以 h/cpp 源文件为主体,另有 ico/bmp 图标和 rc/rc2 界面资源以及 dsw/sln/vcxproj 等多种工程配置,压缩包约 26.51MB;目录结构清晰,附带说明文档,适合按模块阅读与二次开发。已有 857 人学习下载。读者既能从中学习 MFC 图表绘制的整体框架,也能理解图形对象、设备上下文和交互控件的协作方式;若需要为数据可视化工具、教学演示或小型管理系统增加图表功能,此工程可直接作为基础扩展,也可进一步封装成可复用的图表控件。

1. 为什么MFC下要自绘曲线图、柱状图与饼状图

MFC程序里画图表,最常见的路径是先拖一个Picture Control,然后在WM_PAINT里拿CPaintDC直接画线。多数人做完第一步就踩坑:窗口一缩放图形变形、数据一刷新画面闪烁、想鼠标悬停看数值又无从下手。DrawGraph这类源码解决的是同一个核心问题——把“业务数据”翻译成“屏幕像素”。曲线图、柱状图、饼状图,三种图形无非绘制原语不同:折线与贝塞尔、填色矩形、扇形区域。只要把坐标映射、双缓冲、命中检测三件事拆开,不依赖任何第三方图表库,也可以用一个下午在MFC对话框里接出一个能用的自绘图表控件。本文按这个顺序展开,末尾给出两个可以直接落地的交互改造。

2. 绘制前的坐标映射与双缓冲架构

2.1 逻辑坐标与设备坐标的映射关系

在画任何图形之前,先把坐标系想清楚。CDC默认的设备坐标系原点在客户区左上角,X向右、Y向下,单位是像素。曲线图的数据坐标一般是“X越来越大从左到右,Y越来越大从下到上”,和屏幕正好相反。常见的做法有两条路:用SetMapMode切换映射模式,或自己写换算函数。我一般用后者,因为业务坐标的边界要动态跟着数据走,SetMapMode适合固定比例的场景,动态范围还是手算更灵活。

void CChartCtrl::GetPlotRect(const CRect& rcClient, CRect& rcPlot) { // 左右上下各留出边距,给坐标轴刻度和图例留位置 rcPlot = rcClient; rcPlot.DeflateRect(55, 20, 25, 35); }

这里的55像素放在左边,是为了容纳Y轴刻度的文字;底部35像素给X轴刻度和图例。边距留得太小,刻度文字会被裁掉;留得太大,绘图区域变小,这需要在OnSize或OnPaint里统一计算,而不是写死成全局常量。

如果你偏爱切换映射模式,下面这段是MFC里固定逻辑范围的常见写法:

void CChartCtrl::SetLogicMapping(CDC* pDC, const CRect& rcPlot, int nLogicW, int nLogicH) { pDC->SetMapMode(MM_ISOTROPIC); pDC->SetWindowExt(nLogicW, nLogicH); // 逻辑窗口的范围 pDC->SetViewportExt(rcPlot.Width(), rcPlot.Height()); // 设备视口范围 pDC->SetWindowOrg(0, 0); pDC->SetViewportOrg(rcPlot.left, rcPlot.top); }

MM_ISOTROPIC会保持X和Y的缩放比例一致,窗口宽高比不等于绘图区比例时自动取较小倍率,图形不会变形。缺点也很直接——X和Y方向不能分别拉伸,数据范围发生变化时你得重新SetWindowExt。

映射模式逻辑单位适用场景特点
MM_TEXT像素屏幕绘制默认,Y轴向下
MM_ISOTROPIC自定义保持比例的图表等比例缩放,窗口比例锁死
MM_ANISOTROPIC自定义需要X/Y独立缩放的图表X和Y分别拉伸,可能变形

实际使用中,把SetMapMode、SetWindowExt这些放在OnPaint里每次重设即可。真正要注意的是,如果同时用了ScrollWindow或需要局部刷新,映射模式的窗口原点会影响设备坐标换算,建议统一在绘图入口处做一次,不要在多个函数里各设一遍。

2.2 用内存DC实现双缓冲绘图的骨架

自绘图表最容易被吐槽的就是闪烁。闪烁的根源是OnEraseBkgnd擦背景和OnPaint重绘之间隔了一次屏幕刷新,人眼会看到一帧空画面。双缓冲的套路是把所有绘制先画到一张兼容位图上,最后用一次BitBlt拷贝上屏。下面是一个可以直接塞进CChartCtrl的OnPaint:

void CChartCtrl::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(&rcClient); CDC memDC; CBitmap memBmp; memDC.CreateCompatibleDC(&dc); memBmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOld = memDC.SelectObject(&memBmp); // 在这里统一用内存DC绘制全部内容 memDC.FillSolidRect(&rcClient, RGB(255, 255, 255)); DrawAll(&memDC, rcClient); // 一次性上屏 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }

说明:CreateCompatibleDC得到的DC本身没有画布,不SelectObject位图的话,所有绘图函数都不会生效。CreateCompatibleBitmap创建的位图尺寸要和客户区一致,否则BitBlt只拷贝部分区域。最后一个SelectObject(pOld)把原位置换回来,是为了析构顺序正确,否则位图句柄可能残留。

与双缓冲配套的是要把背景擦除关掉:

BOOL CChartCtrl::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景已在OnPaint中绘制,禁止系统再擦 }

ON_WM_ERASEBKGND需要加在消息映射里,否则OnEraseBkgnd不会进入你的处理逻辑。双缓冲的代价是每帧多一次全屏位图的创建和销毁,数据点不多时压力不大;如果曲线数据超过十万个点或者窗口频繁OnPaint,可以考虑把memBmp做成成员变量,只在WM_SIZE里重建。

注意:窗口大小变化后,旧的兼容位图尺寸失效,继续用会漏画或拉伸错位;要在OnSize里释放并重建位图。

3. 曲线图绘制:数据换算、网格刻度与平滑插值

3.1 把业务数据换算成像素坐标

曲线图的核心是把一组业务坐标(比如时间与温度)映射到绘图区的像素坐标。映射公式本身不复杂,麻烦的是边界条件:X轴范围、Y轴范围、留白。下面这段是DrawGraph类源码里最常见的换算封装:

bool CChartCtrl::MapPoint(CPoint& ptOut, double valX, double valY) { if (m_dMaxX <= m_dMinX || m_dMaxY <= m_dMinY) return false; CRect rcPlot; GetPlotRect(m_rcClient, rcPlot); ptOut.x = rcPlot.left + (int)( (valX - m_dMinX) / (m_dMaxX - m_dMinX) * (rcPlot.Width() - 1)); ptOut.y = rcPlot.bottom - (int)( (valY - m_dMinY) / (m_dMaxY - m_dMinY) * (rcPlot.Height() - 1)); return true; }

说明:X方向用差值比例乘以绘图区宽度,再加上左边界;Y方向用rcPlot.bottom减去比例值,因为屏幕Y轴向下而业务Y轴向上。rcPlot.Width() - 1中的减1是为了避免坐标映射到右边界或下边界之外,配合Polyline时线条不会溢出。

拿到映射点之后,画曲线本体就很简单:

void CChartCtrl::DrawLineSeries(CDC* pDC, const std::vector<double>& vx, const std::vector<double>& vy) { std::vector<CPoint> pts; CPoint pt; for (size_t i = 0; i < vx.size() && i < vy.size(); i++) { if (MapPoint(pt, vx[i], vy[i])) pts.push_back(pt); } if (pts.size() < 2) return; CPen penCurve(PS_SOLID, 2, RGB(30, 120, 220)); CPen* pOld = pDC->SelectObject(&penCurve); pDC->Polyline(pts.data(), (int)pts.size()); pDC->SelectObject(pOld); }

Polyline只按照点顺序画折线,不做插值和断线。业务数据里有NaN或者无效值时,要提前过滤,否则一组无效点会把曲线拽到构图之外。另一个容易踩的坑是:如果数据点超过几万个,Polyline在低分辨率屏幕上的绘制开销会增加,常见的处理是先做降采样,只保留每个像素列上的极值点。

3.2 绘制网格、刻度与曲线本体

网格的作用是让看图的人能估算数值,它和曲线主体要分层画。先画网格再画数据,这样数据会盖在网格上面,视觉上更干净。网格间隔建议按“窗口像素分份数”计算,而不是按业务值的理论区间,保证缩放之后依然均匀:

void CChartCtrl::DrawGrid(CDC* pDC, const CRect& rcPlot, int nDivX, int nDivY) { CPen penDot(PS_DOT, 1, RGB(210, 210, 210)); CPen* pOld = pDC->SelectObject(&penDot); int nStepX = rcPlot.Width() / nDivX; int nStepY = rcPlot.Height() / nDivY; if (nStepX < 1 || nStepY < 1) { pDC->SelectObject(pOld); return; } for (int x = rcPlot.left + nStepX; x < rcPlot.right; x += nStepX) { pDC->MoveTo(x, rcPlot.top); pDC->LineTo(x, rcPlot.bottom); } for (int y = rcPlot.bottom - nStepY; y > rcPlot.top; y -= nStepY) { pDC->MoveTo(rcPlot.left, y); pDC->LineTo(rcPlot.right, y); } pDC->SelectObject(pOld); }

nDivX和nDivY通常根据绘图区宽度来定,比如(nRight-nLeft)/40,也就是每隔40像素一条网格线。网格线数量太多时,整屏都是点线,视觉噪音大,还拖慢绘制;数量太少则无法读值。40像素的间距是屏幕上比较舒服的密度。

刻度文字也别忽略。X轴刻度放在网格交点下方,Y轴刻度放在绘图区左侧。为了不让文字与网格冲突,Y轴刻度文字要用SetBkMode(TRANSPARENT):

pDC->SetBkMode(TRANSPARENT); CString strY; strY.Format(_T("%.0f"), m_dMaxY); pDC->TextOut(rcPlot.left - 50, rcPlot.top, strY); strY.Format(_T("%.0f"), m_dMinY); pDC->TextOut(rcPlot.left - 50, rcPlot.bottom - 16, strY);

这样刻度文字可以叠在底色上,不需要清除背景矩形,也不会产生白色块遮挡网格。

画笔样式视觉特征典型用途
PS_SOLID实线曲线、坐标轴
PS_DASH虚线参考线、阈值线
PS_DOT点线网格背景线
PS_DASHDOT点划线预警线、均值线

3.3 用GDI+让曲线更平滑

折线在数据点稀疏时,会显得生硬。常见做法是引入GDI+,借助DrawCurve做样条插值。DrawCurve内部用的是一种张力控制的三次B样条,张力越大,曲线越贴近折线。下面是一个可运行的片段:

#include <gdiplus.h> #pragma comment(lib, "gdiplus.lib") void CChartCtrl::DrawSmoothCurve(CDC* pDC, const std::vector<CPoint>& pts) { if (pts.size() < 2) return; Gdiplus::Graphics graphics(pDC->m_hDC); graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); Gdiplus::Pen pen(Gdiplus::Color(255, 30, 120, 220), 2.0f); std::vector<Gdiplus::PointF> ptsF; for (size_t i = 0; i < pts.size(); i++) ptsF.push_back(Gdiplus::PointF((float)pts[i].x, (float)pts[i].y)); graphics.DrawCurve(&pen, ptsF.data(), (INT)ptsF.size(), 0.3f); }

张力参数0.3f是折中点:0.0f时曲线退化成折线,0.5f以上曲线会在极值点附近出现过冲,数据本身有尖峰时尤其明显。如果业务上要求曲线不能超过原始数据的最大值,建议放弃平滑,直接用Polyline,否则插值后可能出现比原始数据更高的值。

要记住GDI+的初始化:

Gdiplus::GdiplusStartupInput gdiplusStartupInput; ULONG_PTR gdiplusToken; Gdiplus::GdiplusStartup(&gdiplusToken, &gdiplusStartupInput, NULL); // 程序退出时: // Gdiplus::GdiplusShutdown(gdiplusToken);

这两个调用放在CWinApp::InitInstance和ExitInstance里。漏掉初始化,DrawCurve会静默失败,图表区域空白,排查时不容易想到这层。

4. 柱状图与饼状图绘制:间距、配色与百分比标签

4.1 柱状图的矩形填充与数值标注

柱状图是最容易出效果、也最容易出现间距问题的图形。柱子本身的绘制很简单,就是FillRect填矩形;真正影响观感的因素是柱宽和柱间距的比例。常见的做法是把绘图区宽度除以“2倍柱数+1”,得到每个柱子的宽度,这样柱子两侧各留一个柱宽的间隙。

void CChartCtrl::DrawBarChart(CDC* pDC, const std::vector<double>& values) { CRect rcPlot; GetPlotRect(m_rcClient, rcPlot); int nCount = (int)values.size(); if (nCount <= 0) return; int nBarW = rcPlot.Width() / (nCount * 2 + 1); if (nBarW < 2) nBarW = 2; double dMax = *std::max_element(values.begin(), values.end()); double dMin = 0.0; static const COLORREF clrBars[] = { RGB(70, 130, 220), RGB(240, 150, 60), RGB(90, 180, 120), RGB(210, 90, 90) }; for (int i = 0; i < nCount; i++) { int nLeft = rcPlot.left + nBarW * (2 * i + 1); int nRight = nLeft + nBarW; int nTop = rcPlot.bottom - (int)((values[i] - dMin) / (dMax - dMin) * rcPlot.Height()); CBrush br(clrBars[i % 4]); pDC->FillRect(CRect(nLeft, nTop, nRight, rcPlot.bottom), &br); CString str; str.Format(_T("%.1f"), values[i]); CRect rcText(nLeft - 10, nTop - 22, nRight + 10, nTop - 4); pDC->DrawText(str, rcText, DT_CENTER | DT_SINGLELINE | DT_VCENTER); } }

nTop可能因为values[i]超出范围而小于rcPlot.top,尤其当数据动态刷新时。稳妥的做法是在换算前做一次clamp:nTop = max(nTop, rcPlot.top)。数值文字区域比柱体宽20像素,目的就是容忍较窄的柱子,让文字始终在柱体上方居中。如果负值也要显示,零线的y坐标要用(0 - dMin)/(dMax - dMin)计算,柱子高度从零线开始画,不能直接从rcPlot.bottom开始。

多个序列对比的柱状图,常见做法是把同一类别的柱子分组,在绘图区宽度内按“组数×每组柱数”来分配,组内再按比例偏移。这属于布局计算,可以先画一版单序列的,确认间距观感无误后,再把偏移量加进去。

4.2 饼状图的扇形划分与百分比标签

饼状图的核心是GDI的Pie函数。Pie(lprc, ptStart, ptEnd)画的是矩形内切椭圆上的一段弧,再加上弧两端到圆心的两条线,形成一个封闭扇区。角度换算成椭圆上的点是这里最值得注意的:

// 角度换算成椭圆上的点,是所有扇区计算的基础 POINT AngleToPoint(CPoint c, double rx, double ry, double angleDeg) { double rad = angleDeg * 3.141592653589793 / 180.0; POINT pt; pt.x = c.x + (int)(rx * cos(rad)); pt.y = c.y - (int)(ry * sin(rad)); return pt; }

注意Y方向用了减号:数学里的角度按逆时针增加,屏幕坐标Y向下,所以直接减去正弦值得到的是视觉上的逆时针排列。GDI的Pie沿椭圆从ptStart到ptEnd取逆时针方向绘制,因此当dStart递增时,扇区在视觉上按逆时针排列。要改成顺时针,可以交换ptStart和ptEnd,或者把sin前面的负号去掉,具体以预期排列方向为准。

void CChartCtrl::DrawPieChart(CDC* pDC, const std::vector<double>& values) { double dTotal = 0.0; for (double v : values) dTotal += v; if (dTotal <= 0.0) return; CRect rcPlot; GetPlotRect(m_rcClient, rcPlot); CRect rcArc = rcPlot; rcArc.DeflateRect(15, 15); // 给标签留空间 CPoint c = rcArc.CenterPoint(); double dStart = 0.0; int nCount = (int)values.size(); static const COLORREF clrPies[] = { RGB(70, 130, 220), RGB(240, 150, 60), RGB(90, 180, 120), RGB(210, 90, 90) }; for (int i = 0; i < nCount; i++) { double dSweep = values[i] / dTotal * 360.0; CPoint ptStart = AngleToPoint(c, rcArc.Width() / 2.0, rcArc.Height() / 2.0, dStart); CPoint ptEnd = AngleToPoint(c, rcArc.Width() / 2.0, rcArc.Height() / 2.0, dStart + dSweep); CBrush br(clrPies[i % 4]); CPen pen(RGB(255, 255, 255), 2); CBrush* pOldBr = pDC->SelectObject(&br); CPen* pOldPen = pDC->SelectObject(&pen); pDC->Pie(rcArc, ptStart, ptEnd); // 百分比放在扇区中线上 double dMid = (dStart + dSweep / 2.0) * 3.141592653589793 / 180.0; int lx = c.x + (int)(rcArc.Width() / 2.0 * 0.62 * cos(dMid)); int ly = c.y - (int)(rcArc.Height() / 2.0 * 0.62 * sin(dMid)); CString str; str.Format(_T("%.1f%%"), values[i] / dTotal * 100.0); pDC->SetBkMode(TRANSPARENT); pDC->DrawText(str, CRect(lx - 40, ly - 15, lx + 40, ly + 15), DT_CENTER | DT_VCENTER | DT_SINGLELINE); pDC->SelectObject(pOldBr); pDC->SelectObject(pOldPen); dStart += dSweep; } }

边缘处用白色2像素画笔隔离相邻扇区,这是饼状图显干净清爽的关键。如果不加边线,相邻两个相同色系的扇区会糊成一片。百分比标签放在0.62倍半径处,可以避免文字贴到扇区边缘;扇区小于5%时,标签容易被相邻扇区挤压,这时可以考虑在扇区外拉一条引出线,再把百分比写在线的末端。

函数作用使用场景
FillRect填充矩形区域柱状图柱子、背景色块
Rectangle画矩形外框加填充需要边框的柱子
Ellipse画椭圆(含圆)数据点标记、环形图背景
Pie画扇区饼状图主体
Polygon画多边形雷达图、命中检测辅助

再补充一点:不要让数据为零的项占一个扇区。单独把比重小于0.5%的项合并成“其他”,在DrawGraph里可以用一个阈值过滤后再调用Pie,避免出现肉眼几乎看不见的细长三角区。

5. 交互改造:命中检测、缩放与性能验证

5.1 鼠标悬停的命中检测与扇区高亮

柱状图的命中检测最容易,保存每根柱子绘制后的矩形,在OnMouseMove里依次PtInRect。饼状图麻烦一些,不能用简单的矩形判断,常见的做法是把扇区离散成多边形区域,再用CRgn::PtInRegion:

CRgn rgn; std::vector<POINT> vecPts; int nSeg = std::max(12, (int)(dSweep / 8)); // 保证窄扇区也有足够分段 for (int k = 0; k <= nSeg; k++) { double rad = (dStart + dSweep * k / nSeg) * 3.141592653589793 / 180.0; POINT pt; pt.x = c.x + (int)(rx * cos(rad)); pt.y = c.y - (int)(ry * sin(rad)); vecPts.push_back(pt); } vecPts.push_back(CPoint(c.x, c.y)); // 闭合到圆心 rgn.CreatePolygonRgn(vecPts.data(), (int)vecPts.size(), WINDING); if (rgn.PtInRegion(point)) { /* 命中当前扇区 */ }

分段数按扇区角度自适应:窄扇区少分几段,避免生成过大的区域;宽扇区多分几段,边缘更接近真实弧线。每次OnMouseMove都重新创建CRgn不划算,可以只在鼠标坐标变化或数据变化时重建,命中结果用一个int成员缓存。

5.2 缩放滚轮、范围检查与性能自检

曲线图缩放最常用的实现是OnMouseWheel里调整Y轴范围:

BOOL CChartCtrl::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { double dFactor = (zDelta > 0) ? 0.85 : 1.2; m_dMinY *= dFactor; m_dMaxY *= dFactor; // 防止范围被缩放成零或反向 if (m_dMaxY - m_dMinY < 0.0001) m_dMaxY = m_dMinY + 0.0001; Invalidate(FALSE); return TRUE; }

0.85和1.2是常见的缩放倍率,对应鼠标滚轮每格缩小或放大15%~20%。如果滚轮节奏太快或太慢,可以调这两个系数,不要用0.5和2.0,视觉跳跃感太强。性能自检层面,我的做法是在工具栏加一个开关,控制是否显示帧耗时。用QueryPerformanceCounter量OnPaint的耗时,超过16毫秒就说明当前绘制跟不上60Hz刷新。

LARGE_INTEGER freq, t1, t2; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&t1); RedrawWindow(); QueryPerformanceCounter(&t2); double dMs = (t2.QuadPart - t1.QuadPart) * 1000.0 / freq.QuadPart; TRACE(_T("OnPaint耗时: %.2f ms\n"), dMs);

曲线数据量大时,瓶颈通常不在线本身,而在网格线和刻度文字的GDI调用次数。网格间隔按40像素分份,一屏也就二十几条线;如果网格线数量超过了绘图区像素宽度,说明分子分母计算有误,画出来的线全叠在一起。此外,高DPI下CreateCompatibleBitmap按物理像素创建,而逻辑像素和物理像素的换算会放大内存占用,可以在WM_DPICHANGED里保留位图重建逻辑。

提示:双缓冲改到位之后,如果还有轻微闪烁,检查是否漏了WS_CLIPCHILDREN或WS_EX_COMPOSITED;MFC控件重绘时,子窗口的擦除事件也可能触发二次无效区合并。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询