简介:一份基于MFC类库编写的图表绘制源码工程,面向熟悉C++基础语法、希望进阶Windows GUI开发的学习者,也可作为高校《Visual C++程序设计》课程设计或软件工程师快速实现数据可视化的参考。它围绕CDC设备上下文与GDI绘图机制,示范了曲线图、饼状图、柱状图的完整绘制过程,包括数据点坐标换算、扇形角度计算、矩形柱体布局以及OnDraw视图刷新等关键环节,能帮助使用者跳过繁琐的环境搭建和接口摸索。压缩包共37个文件,整体大小约26.51MB,主要包含9个h头文件、8个cpp源文件,其余为rc资源脚本、图标、工具栏位图及Visual Studio工程配置文件,结构完整,可用Visual Studio直接打开并编译运行。资源中Graph、GraphSeries、GraphLegend等模块分别承担图形绘制、数据序列管理和图例绘制职责,适合对照源码理解MFC图形编程框架。目前已有857人学习下载,对于需要系统掌握MFC绘图思路的开发者来说,是一份值得阅读的实战示例。
1. 折腾过自绘图表的人,才懂 DrawGraph 这套源码的价值
MFC 工程里要画曲线图、饼状图、柱状图,第一反应是找 ChartCtrl 这类第三方控件。但做过工控上位机的人都知道,第三方图表库要么版本老,在各版本 VS 之间编译报错;要么定制需求一来,改源码比重画还痛苦。DrawGraph 这类自绘源码的价值正在这:图表本质是 GDI 绘图函数的组合,搞清坐标系和绘制流程,自维护代码的长期成本远低于第三方依赖。
这套源码适合手上有 MFC 维护任务、要把数据快速可视化的工程师。温度曲线、产量对比、占比饼图,核心就是三件事:坐标换算、设备上下文管理、图形学基础。拆开学透,后续改样式只是加函数。
下文按坐标映射、曲线图、统计图、工程集成四段展开,每段给出可直接抄进工程的代码和参数说明。
2. MFC 自绘图表的第一步:逻辑坐标与设备上下文的映射
所有图表库的底层都是同一个问题:把数据域映射到屏幕上一块矩形区域。比如温度 0~100℃ 的数据,要显示在客户区扣除边距后的矩形内。坐标映射做错了,曲线会被裁剪,柱状图会底部错位,饼图会偏移出绘图区,而且不同图表现还不一样,排查起来很费时间。
2.1 数据坐标到设备坐标的换算公式
2.1.1 先留出绘图区边距,再写转换函数
GDI 的 SetMapMode 可以把映射模式设为 MM_ISOTROPIC、MM_ANISOTROPIC,让系统自动缩放。实际工程里我一般不用,原因有两个:坐标轴刻度、网格线、文本标签都要在设备坐标下精确定位,开了映射模式后所有文字位置都得跟着反向换算;MM_ANISOTROPIC 的非等比缩放会改变线条宽度,画出来的边框粗细不一。常见做法是自己维护一个 RECT 表示绘图区,写两个可逆的转换函数。
// 数据坐标转设备坐标:xData/yData 是原始数据值 // rcPlot 是绘图区矩形,dMinX/dMaxX/dMinY/dMaxY 是当前数据范围 CPoint DataToDevice(double xData, double yData, const CRect& rcPlot, double dMinX, double dMaxX, double dMinY, double dMaxY) { CPoint pt; // x 方向:数据范围线性平铺到绘图区宽度 pt.x = (int)(rcPlot.left + (xData - dMinX) / (dMaxX - dMinX) * rcPlot.Width()); // y 方向:屏幕坐标向下为正,最大值要映射到绘图区顶部 pt.y = (int)(rcPlot.bottom - (yData - dMinY) / (dMaxY - dMinY) * rcPlot.Height()); return pt; }关键在第 8 行的 y 方向处理:数学坐标 y 向上为正,屏幕坐标 y 向下为正,所以用rcPlot.bottom减去偏移量,否则曲线上下颠倒。x 方向直接把数据范围平铺到绘图区宽度上,调用时要注意(dMaxX - dMinX)不能为 0,设置数据范围的地方要做保护。rcPlot 一般在 OnSize 里根据客户区减去左边距 50、下边距 30、上边距 10 得到,工具栏和状态栏占掉的高度才不会压到图形。
2.1.2 用 1/2/5 序列算出"好看"的刻度步长
直接拿 min/max 当刻度边界,画出来的刻度线常常是 37.5、84.2 这种难读的值。常见的做法是固定用 1、2、5 乘以 10 的幂次作为候选步长,选一个最接近目标值的:
// 根据数据范围计算规整的刻度步长,使刻度落在 1/2/5 的整数倍上 double CalcNiceStep(double dRange, int nTargetTicks) { double dRaw = dRange / nTargetTicks; // 原始步长 double dMag = pow(10.0, floor(log10(dRaw))); // 取数量级 double dNorm = dRaw / dMag; // 归一化到 [1,10) double dStep; if (dNorm < 1.5) dStep = 1.0; else if (dNorm < 3.0) dStep = 2.0; else if (dNorm < 7.0) dStep = 5.0; else dStep = 10.0; return dStep * dMag; }得到步长后,刻度下限取floor(dMin/dStep)*dStep,上限取ceil(dMax/dStep)*dStep,网格线就能对齐到整数。几个典型输入输出:
| 数据范围 dRange | 目标刻度数 | 返回步长 | 实际刻度 |
|---|---|---|---|
| 100 | 5 | 20 | 0,20,40,60,80,100 |
| 37 | 4 | 10 | 0,10,20,30,40 |
| 0.85 | 4 | 0.2 | 0,0.2,0.4,0.6,0.8 |
这套取整逻辑同时适用于横轴和纵轴。曲线图两个方向都用,柱状图只需要纵轴,饼图不涉及。
2.2 双缓冲绘制:WM_PAINT 里不闪烁的标准写法
OnPaint 里直接画曲线,窗口一刷新就闪,原因是背景擦除和绘制不同步。标准做法是先在内存 DC 上画完,再一次 BitBlt 到屏幕:
void CChartView::OnPaint() { CPaintDC dc(this); // 客户区 DC CRect rcClient; GetClientRect(&rcClient); // 内存 DC 与兼容位图,位图大小跟随客户区 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 先填背景,避免内存 DC 初始内容不确定 memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); DrawGrid(&memDC, rcClient); // 画网格和刻度 DrawData(&memDC, rcClient); // 画曲线/柱状/饼状 // 一次性拷贝到屏幕,消除闪烁 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); // 恢复旧对象,位图析构才安全 }CreateCompatibleBitmap 创建的位图和屏幕 DC 色深一致,直接画不丢色。SelectObject 后旧的 GDI 对象必须保存并在结束前恢复,否则 CBitmap 析构时因为对象还被选中会失败。DrawGrid 和 DrawData 接收 CDC 指针,绘制逻辑可以复用在同一套代码上,做报表打印时把 DC 换成打印机 DC 即可。
注意:WM_ERASEBKGND 默认会被系统响应并擦掉客户区,这是闪烁的另一半来源,工程集成阶段要在消息映射里处理掉,见第 5 章。
3. 曲线图自绘实现:数据容器、网格与折线绘制
曲线图是三个图里最常用的,坐标映射问题也暴露得最充分。实时趋势、历史回放、频率分析,本质都是把一组 (x, y) 序列按顺序连成折线。
3.1 数据容器存原始数据,不存屏幕坐标
一个容易踩的坑:把屏幕坐标 CPoint 存进数组,绘制时用 MoveTo/LineTo 连起来。窗口一 Resize,整条曲线错位,因为屏幕坐标跟着客户区大小变了。正确做法是存原始 double 数据,绘制时才换算:
class CCurveData { public: CArray<double, double> m_arrX; // X 采样点,存原始数据 CArray<double, double> m_arrY; // Y 采样值 COLORREF m_clrLine; // 曲线颜色 int m_nPenWidth; // 线宽,默认 2 BOOL m_bVisible; // 显示/隐藏开关 void AddPoint(double x, double y) { m_arrX.Add(x); m_arrY.Add(y); } int GetCount() const { return (int)m_arrX.GetCount(); } };用 CArray 而不是 std::vector,一是保持 MFC 工程风格统一,二是数据可以配合 CArchive 直接序列化,保存历史数据到文件时不用手写循环。m_bVisible 用来做多曲线显隐,同时画温度和湿度两条曲线时,点图例切换对应项的可见性,重新 Invalidate 即可。追加数据用 AddPoint,绘制函数在数据变化后调用 Invalidate(FALSE) 触发重绘。
3.2 网格、刻度与折线的具体绘制
绘制顺序固定为三步:网格 → 刻度文字 → 折线。网格用浅灰色细线,折线用 2 像素实线,层次才清楚:
void CChartView::DrawCurve(CDC* pDC, const CRect& rcPlot) { // 第一步:画 5x5 网格,浅灰色 1px 线 CPen penGrid(PS_SOLID, 1, RGB(220, 220, 220)); CPen* pOldPen = pDC->SelectObject(&penGrid); for (int i = 0; i <= 5; i++) { int x = rcPlot.left + i * rcPlot.Width() / 5; int y = rcPlot.top + i * rcPlot.Height() / 5; pDC->MoveTo(x, rcPlot.top); pDC->LineTo(x, rcPlot.bottom); // 纵向网格 pDC->MoveTo(rcPlot.left, y); pDC->LineTo(rcPlot.right, y); // 横向网格 } // 第二步:把数据点全部换算成屏幕坐标,再一次性画折线 CPen penCurve(PS_SOLID, 2, RGB(0, 102, 204)); pDC->SelectObject(&penCurve); CArray<CPoint, CPoint&> arrPts; CCurveData& data = m_curve[0]; for (int i = 0; i < data.GetCount(); i++) { arrPts.Add(DataToDevice(data.m_arrX[i], data.m_arrY[i], rcPlot, m_dMinX, m_dMaxX, m_dMinY, m_dMaxY)); } if (arrPts.GetCount() > 1) pDC->Polyline(arrPts.GetData(), (int)arrPts.GetCount()); pDC->SelectObject(pOldPen); }Polyline 一次调用画完整条折线,比逐段 MoveTo/LineTo 性能高,数据点上万时差异明显。m_dMinX/m_dMaxX 这些范围值在 SetData 时算好缓存成成员,不要每次绘制都重新扫描数组。多曲线时除了颜色区分,还可以换线型:
| 线型常量 | 值 | 说明与限制 |
|---|---|---|
| PS_SOLID | 0 | 实线,线宽任意,默认推荐 |
| PS_DASH | 1 | 虚线,仅线宽 1 时生效 |
| PS_DOT | 2 | 点线,仅线宽 1 时生效 |
| PS_DASHDOT | 3 | 点划线,仅线宽 1 时生效 |
注意:PS_DASH、PS_DOT 在线宽大于 1 时会被 Windows 忽略,画出来仍是实线。要加粗又要区分线型,就叠加颜色或把每个数据点画成小圆点标记。
3.3 实时追加时的刷新节奏控制
实时曲线最常见的问题:数据点超过绘图区宽度后,x 轴映射把后续点画到区域外。两种处理方案:滑动窗口只显示最近 N 个点;或 x 轴范围随数据自动右移。滑动窗口用取模下标覆盖写入,i % N定位写入位置,绘制时从最新点向前取 N 个,不涉及内存搬移。
刷新频率是另一个隐藏坑。OnTimer 里每 10ms 追加一个点,但 Invalidate 不要跟着每 10ms 调一次,GDI 绘制吞吐跟不上,CPU 直接拉满。常见做法是数据按 10ms 累计,界面每 100~200ms 批量刷新一次,肉眼看曲线依然连续,CPU 占用能低一个量级。这个"采样快、绘制慢"的节奏控制,是实时曲线平滑度和资源占用之间的关键平衡。
4. 柱状图与饼状图:从矩形填充到扇区角度计算
柱状图和饼状图的坐标处理比曲线图简单,难点在布局:柱状图的柱宽、柱间距、分组柱偏移,饼状图的累计角度和百分比标注。这两类图的数据量通常不大,绘制开销主要在 GDI 对象切换上。
4.1 柱状图的柱宽、间距与分组柱布局
柱状图的经典错误是柱子贴满绘图区宽度,之间没有间隔。正确做法是把可用宽度按"组数 × 组宽"分配,组内再按柱数细分:
void CChartView::DrawBar(CDC* pDC, const CRect& rcPlot) { int nCount = (int)m_arrBarValue.GetSize(); // 柱子总数 int nGroupCount = (int)m_arrGroups.GetSize(); // 每组柱数,单组时 =1 int nGroups = nCount / nGroupCount; // 分组数 int nGap = 8; // 组间间距,像素 // 每组可用宽度 = (绘图区宽 - 总组间距) / 组数 int nWidth = (rcPlot.Width() - nGap * (nGroups - 1)) / nGroups; int nBarW = nWidth / nGroupCount - 4; // 单根柱宽,组内留 4px CBrush br(RGB(52, 120, 246)); for (int i = 0; i < nCount; i++) { int nGroup = i / nGroupCount; // 属于第几组 int nInGroup = i % nGroupCount; // 组内第几根 // 组起点 = 绘图区左 + 组号 * 组宽;组内再加偏移 int xLeft = rcPlot.left + nGroup * nWidth + nInGroup * (nBarW + 4) + 2; // 柱高按数据值占纵轴范围的比例计算,从底部向上 int nH = (int)(rcPlot.Height() * m_arrBarValue[i] / m_dMaxBarValue); int yTop = rcPlot.bottom - nH; pDC->FillRect(CRect(xLeft, yTop, xLeft + nBarW, rcPlot.bottom), &br); // 柱顶数值标签,y 方向预留 18px 给文字 CString str; str.Format(_T("%.1f"), m_arrBarValue[i]); pDC->TextOut(xLeft + nBarW / 2 - 12, yTop - 18, str); } }m_dMaxBarValue 取数据最大值的 1.1 倍,给柱顶标签留空间,否则文字被裁剪。分组柱适合同比、环比场景,比如 2023 和 2024 的产量并排,nGroupCount = 2,两组柱子用不同颜色区分。TextOut 居中用了固定偏移 12px,严格做法是先 GetTextExtent 量文本宽度再居中,数据位数变化时才不会偏。y 轴默认从 0 开始,但数据全在 5000~5100 时柱子高度几乎没差异,这时把 dMinY 设成 4900 能放大差异,但刻度上必须明确标注纵轴非零起点,避免误导。
柱状图常见问题集中在这几类:
| 常见问题 | 现象 | 处理方式 |
|---|---|---|
| 柱宽写死 | 窗口拉宽后柱子挤在左侧 | 柱宽按绘图区宽度动态计算 |
| 数据含负值 | 柱子向下溢出绘图区 | 绘制前按正负分段或调整纵轴范围 |
| 总间距算错 | 最后一根柱被裁剪 | 先用总宽减间距再分组均分 |
4.2 饼状图的累计角度、扇区填充与百分比标签
饼状图核心是 GDI 的 Pie 函数,它接收外接矩形和起始、结束两个点,而不是角度。角度按数学约定 0 度在三点方向、逆时针为正,但屏幕 y 向下,计算点坐标时 sin 要取负:
void CChartView::DrawPie(CDC* pDC, const CRect& rcPlot) { // 饼图区域取绘图区中心的最大内接正方形 int nSide = min(rcPlot.Width(), rcPlot.Height()); CRect rcPie(rcPlot.CenterPoint().x - nSide / 2, rcPlot.CenterPoint().y - nSide / 2, rcPlot.CenterPoint().x + nSide / 2, rcPlot.CenterPoint().y + nSide / 2); CPoint ptCenter = rcPie.CenterPoint(); int nRadius = nSide / 2 - 20; // 半径留 20px 给外部图例 // 先求总和,后续按比例算扇区角度 double dSum = 0.0; for (int i = 0; i < (int)m_arrPieValue.GetSize(); i++) dSum += m_arrPieValue[i]; if (dSum <= 0.0) return; // 全 0 直接返回 double dAngle = 0.0; // 当前扇区起始角,弧度 for (int i = 0; i < (int)m_arrPieValue.GetSize(); i++) { double dSweep = m_arrPieValue[i] / dSum * 2 * 3.1415926; // 屏幕 y 向下,sin 取负才是常规顺时针排布 CPoint ptStart((int)(ptCenter.x + nRadius * cos(dAngle)), (int)(ptCenter.y - nRadius * sin(dAngle))); CPoint ptEnd((int)(ptCenter.x + nRadius * cos(dAngle + dSweep)), (int)(ptCenter.y - nRadius * sin(dAngle + dSweep))); CBrush br(m_arrPieColor[i]); // 每块扇形独立颜色 CBrush* pOld = (CBrush*)pDC->SelectObject(&br); pDC->Pie(rcPie, ptStart, ptEnd); pDC->SelectObject(pOld); // 百分比文字画在扇区角平分线方向,半径 60% 处 double dMid = dAngle + dSweep / 2.0; int nx = (int)(ptCenter.x + nRadius * 0.6 * cos(dMid)); int ny = (int)(ptCenter.y - nRadius * 0.6 * sin(dMid)); CString str; str.Format(_T("%.1f%%"), m_arrPieValue[i] / dSum * 100.0); pDC->TextOut(nx, ny, str); dAngle += dSweep; } }最容易错的是 y 方向取负:ptCenter.y - nRadius * sin(...)。屏幕 y 向下,不取负的话饼图走向会反。nRadius 应该由 rcPie 动态算出而不是写死,窗口缩放时才不会把扇区画出界。标签放在 0.6 倍半径处,既避开圆心重叠,又不会压到扇区边缘。Pie 函数的契约是起始角累加不超过 2π,dSum 归一化之后天然满足;某个值占比极小如 0.1% 时,扇区画出来是一条细线,分类超过 10 个时建议把占比小于 5% 的合并成"其他"一项,图例里再展开明细。
5. 把 DrawGraph 接进工程:自适应重绘与命中检测
5.1 WM_SIZE 里重算绘图区,WM_ERASEBKGND 返回 TRUE
OnSize 在窗口尺寸变化时触发,绘图区 rcPlot 应该在这里重算并缓存成成员变量。不要每次 OnPaint 里临时算,否则工具栏、状态栏占掉的高度没扣除,曲线底部被截断。绘图区由客户区减去左 50、下 30、上 10 的边距得到,这块逻辑集中在一个成员函数 UpdatePlotRect 里,OnSize 调用它再 Invalidate。
WM_ERASEBKGND 的处理函数直接return TRUE,告诉系统背景已经处理过,配合 OnPaint 里的 FillSolidRect 统一擦除。注意两点:OnPaint 必须无条件先 FillSolidRect,否则残留上一帧内容;窗口缩放会频繁触发擦除和重绘,双缓冲已保证不闪,但位图不要每次 OnPaint 都创建销毁,把 CBitmap 做成成员变量,只在客户区尺寸变化时重建,能省掉大量内存分配和 GDI 句柄切换。
5.2 柱状图命中检测:从鼠标位置反查柱子
交互需求常见的场景是"鼠标点哪根柱就在哪显示明细"。实现思路是绘制时把每根柱子的实际 CRect 存进 CArray,鼠标移动时做矩形命中判断:
void CChartView::OnMouseMove(UINT nFlags, CPoint point) { // m_arrBarRects 在 DrawBar 里同步填充,索引与柱子一一对应 for (int i = 0; i < (int)m_arrBarRects.GetSize(); i++) { if (m_arrBarRects[i].PtInRect(point)) { CString strTip; strTip.Format(_T("分类 %s,数值 %.1f"), m_arrBarNames[i], m_arrBarValue[i]); SetWindowText(strTip); // 标题栏显示命中信息 SetCursor(LoadCursor(NULL, IDC_HAND)); return; } } CView::OnMouseMove(nFlags, point); }命中检测用的矩形必须和绘制时严格一致,柱子加了 3D 效果或圆角时,用整个外接矩形判断会有几像素误差,属于可接受范围。曲线图的命中检测则反过来:把鼠标 x 坐标反算成数据 x 值,二分查找最近采样点,再判断 y 偏差是否在阈值内,这也是"从设备坐标回到数据坐标"的典型应用。
5.3 用三组数据快速验证源码可用性
验证一套 DrawGraph 自绘代码是否合格,我一般跑三组数据:一组 10000 点的正弦波叠加噪声,验证曲线性能和 Polyline 的吞吐;一组 12 分类的占比数据,验证饼图布局和标签是否重叠;一组带负值且数值跨度极大的数据,验证柱状图刻度是否规整。三组跑完不闪、不越界、标签清晰,就可以接正式工程。性能检查用任务管理器看 CPU,绘制逻辑跑在 UI 线程,持续超过 15% 就要查 Invalidate 频率或位图是否在反复创建,通常问题都出在这两处,而不是绘图函数本身。
本文还有配套的精品资源,点击获取