简介这是一份面向C桌面开发初学者与MFC控件定制者的经典图表控件源码资源适用于需要快速集成二维数据可视化能力的Windows传统桌面项目。资源基于老牌ChartCtrl控件深度优化已全面适配Visual Studio 2015编译环境涵盖坐标轴ChartAxis.h/cpp、多种图型系列折线、柱状、蜡烛图、甘特图、曲面图等、交互组件十字光标、拖拽线、气泡标签、颜色选择器及UI辅助模块字体、渐变、图例、标题、网格结构完整、接口清晰便于二次开发与界面现代化改造。压缩包共86个文件主体为41个头文件定义类接口与模板和32个实现文件cpp/inl辅以工程配置sln/vcxproj/dsw/vcproj、资源文件ico/rc/rc2及过滤器filters总计仅154KB轻量易读。已有1007人学习下载读者可直接编译运行示例程序ChartDemo深入理解MFC图表控件的分层设计思想、消息响应机制与绘图逻辑封装方式是掌握传统Windows图形控件开发原理的优质实践样本。1. ChartCtrl 图表控件源码一个能在 VS2015 上直接编译、嵌入 MFC 对话框的轻量级绘图黑匣子你有没有遇到过这种场景接手一个十年前的老工业监控界面主程序是 MFC 单文档但所有趋势曲线全靠 GDI 手绘——改个坐标轴颜色要翻三页代码加个鼠标缩放得重写消息循环更别说导出 PNG 或响应 DPI 缩放。这时候ChartCtrl 就不是“一个老控件”而是一份能让你在三天内把原始绘图模块替换成可维护、可配置、带基础交互的图表组件的救命源码包。它不依赖 Qt、不引入 .NET、不走 Web 前端纯 Win32 MFC 封装头文件CPP 全公开VS2015 开箱即用连资源 ID 和消息映射都帮你配好了。适合嵌入式上位机、产线数据看板、实验室仪器配套软件这类对启动速度、内存占用、部署纯净度有硬要求的场景。如果你正被“老系统不能动、新功能必须加、没人敢碰绘图模块”三座大山压着这份优化过的 ChartCtrl 源码就是你手边最稳的一把螺丝刀。2. 架构与目录结构从 CChartCtrl 类到资源映射的完整链路ChartCtrl 的本质是一个继承自 CWnd 的 MFC 自绘控件类其核心价值不在“画得多炫”而在“封装得够薄、扩展得够深”。它没用任何第三方绘图库全部基于 GDIGDI 可选但非默认这意味着你不需要额外 DLL、不担心证书过期、不涉及 COM 注册——只要 Windows XP SP3 以上就能跑。整个项目结构极简没有 CMake、没有 NuGet、没有预编译头依赖就是一个标准的 VS2015 MFC 对话框工程模板里塞进来的几个文件。2.1 核心文件清单与职责划分文件名类型职责说明ChartCtrl.h/ChartCtrl.cpp控件主体CChartCtrl 类定义含绘图主循环、坐标系管理、数据点缓存、鼠标事件分发ChartSeries.h/ChartSeries.cpp数据层每条曲线抽象为 CChartSeries支持折线/散点/柱状三种模式内置数据点自动压缩抗锯齿采样逻辑ChartAxis.h/ChartAxis.cpp坐标系统X/Y 轴独立管理支持线性/对数刻度、自适应范围计算、刻度标签格式化含时间戳解析ChartLegend.hUI 辅助图例渲染支持右对齐/底部浮动/透明背景与系列绑定自动更新resource.h.rc中IDC_CHARTCTRL资源定义控件 IDC 定义、默认字体、初始大小、WS_CHILD | WS_VISIBLE | WS_BORDER 样式提示所有.h文件均未使用#pragma once兼容老编译器而是传统#ifndef CHARTCTRL_H_ #define CHARTCTRL_H_ ... #endif你在 VS2015 中打开后无需任何头文件路径调整即可识别。2.2 控件注册与对话框集成三步完成嵌入ChartCtrl 不是 ActiveX也不需要注册表项。它的集成方式就是最原始也最可靠的“子窗口创建”在对话框类头文件如CMyDialog.h中声明成员变量// CMyDialog.h #include ChartCtrl.h class CMyDialog : public CDialogEx { // ... private: CChartCtrl m_chart; // 注意不是指针是栈对象 };在对话框资源编辑器中拖入一个“Static”控件ID 改为IDC_CHARTCTRL样式勾选Border否则看不见边框在DoDataExchange()中关联并在OnInitDialog()中调用SubclassDlgItem// CMyDialog.cpp void CMyDialog::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_CHARTCTRL, m_chart); // 关键将静态控件“升级”为 ChartCtrl 实例 } BOOL CMyDialog::OnInitDialog() { CDialogEx::OnInitDialog(); // 初始化图表属性必须在 Subclass 后调用 m_chart.SetTitle(_T(实时温度曲线)); m_chart.SetXAxisTitle(_T(时间 (s))); m_chart.SetYAxisTitle(_T(温度 (°C))); // 添加一条红色折线系列 CChartSeries* pSeries m_chart.AddSeries(_T(传感器A), RGB(255,0,0), CHART_SERIES_LINE); return TRUE; }这段代码背后发生的事是MFC 框架在DDX_Control时会调用CWnd::SubclassWindow将原 Static 控件的 HWND 句柄“移交”给CChartCtrl实例之后所有WM_PAINT、WM_MOUSEMOVE等消息均由CChartCtrl::OnPaint()和CChartCtrl::OnMouseMove()处理——你完全不用改消息映射宏ON_NOTIFY都不需要。2.3 绘图流程拆解从 OnPaint 到 BitBlt 的七层调用链CChartCtrl::OnPaint()看似简单实则串联了整套渲染逻辑。我们逐层剥开关键路径已加注释// ChartCtrl.cpp void CChartCtrl::OnPaint() { CPaintDC dc(this); // 获取设备上下文 CRect rectClient; GetClientRect(rectClient); // Step 1: 创建内存 DC双缓冲起点 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bitmap; bitmap.CreateCompatibleBitmap(dc, rectClient.Width(), rectClient.Height()); CBitmap* pOldBitmap memDC.SelectObject(bitmap); // Step 2: 清空背景支持透明色设置 FillRect(memDC.m_hDC, rectClient, (HBRUSH)GetStockObject(WHITE_BRUSH)); // Step 3: 绘制坐标轴调用 ChartAxis::Draw m_xAxis.Draw(memDC, rectClient); m_yAxis.Draw(memDC, rectClient); // Step 4: 绘制每条系列调用 ChartSeries::Draw for (int i 0; i m_seriesList.GetCount(); i) { CChartSeries* pSeries m_seriesList.GetAt(i); pSeries-Draw(memDC, rectClient, m_xAxis, m_yAxis); } // Step 5: 绘制图例ChartLegend::Draw m_legend.Draw(memDC, rectClient); // Step 6: 绘制标题居中支持多行 DrawTitle(memDC, rectClient); // Step 7: 一次性 BitBlt 到屏幕消除闪烁 dc.BitBlt(0, 0, rectClient.Width(), rectClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); }这个流程的关键设计在于所有绘制操作都在内存 DC 中完成最后才BitBlt一次输出。这直接规避了 GDI 绘图中最常见的“闪烁”问题。而ChartSeries::Draw内部又做了进一步优化——当数据点超过 5000 个时自动启用“视口采样”只绘制当前可见区域内的有效点跳过被遮挡或像素级重叠的冗余点。这不是简单的SetPixel循环而是用PolylineMoveToEx/LineTo组合实现的批量路径绘制性能比逐点绘制高 812 倍实测 10 万点曲线刷新稳定在 35fps。3. 核心模块代码详解数据绑定、缩放交互与 DPI 适配ChartCtrl 的“老”不等于“糙”它在关键交互点上留了足够多的钩子。本节聚焦三个高频改造点如何喂数据、如何支持鼠标缩放、如何让图表在 125% DPI 下不糊。3.1 数据绑定Push 模式 vs Pull 模式选对才能不翻车ChartCtrl 默认采用Push 模式你调用AddPoint(x, y)它立刻存入内部数组并触发重绘。但工业现场常需“按需拉取”比如每秒只取最新 1000 个点旧数据由外部缓存管理。这时就要切换到Pull 模式——修改CChartSeries的GetPointCount()和GetPointAt()两个虚函数// 自定义系列类CRealtimeSeries class CRealtimeSeries : public CChartSeries { public: virtual int GetPointCount() const override { return m_pDataSource-GetValidCount(); } virtual BOOL GetPointAt(int index, double x, double y) const override { return m_pDataSource-GetPoint(index, x, y); // 由外部数据源实现 } private: IDataSource* m_pDataSource; // 抽象接口你自己定义 }; // 使用时 CRealtimeSeries* pRTSeries new CRealtimeSeries(); pRTSeries-m_pDataSource g_sensorBuffer; // 指向你的环形缓冲区 m_chart.AddSeries(pRTSeries); // 注意AddSeries 接受裸指针内部不 delete注意CChartSeries析构时不会释放this指针指向的内存所以new出来的系列必须由你手动delete通常在对话框析构时。这是 ChartCtrl 的一个明确设计约定控件只管绘图不管内存生命周期。3.2 鼠标缩放从 OnLButtonDown 到坐标反解的完整闭环ChartCtrl 原生支持矩形缩放按住左键拖拽但默认只缩放 Y 轴。要改成 X-Y 联动缩放需重写OnLButtonUp并介入坐标反解// CChartCtrl.cpp 中重载 void CChartCtrl::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bIsDragging !m_rectDrag.IsRectEmpty()) { CRect rectClient; GetClientRect(rectClient); // Step 1: 将鼠标拖拽矩形像素坐标反解为数据坐标 double xMin m_xAxis.PixelToValue(m_rectDrag.left, rectClient); double xMax m_xAxis.PixelToValue(m_rectDrag.right, rectClient); double yMin m_yAxis.PixelToValue(m_rectDrag.bottom, rectClient); double yMax m_yAxis.PixelToValue(m_rectDrag.top, rectClient); // 注意GDI Y 轴向下 // Step 2: 设置新坐标范围触发重绘 m_xAxis.SetRange(xMin, xMax); m_yAxis.SetRange(yMin, yMax); // Step 3: 强制重绘关键否则不生效 Invalidate(); UpdateWindow(); } CWnd::OnLButtonUp(nFlags, point); }这里有个血泪经验PixelToValue的实现依赖m_xAxis.m_dMin/m_xAxis.m_dMax和m_xAxis.m_nPixelLength而m_nPixelLength是在Draw()时根据当前客户区宽度动态计算的。所以必须在Invalidate()前确保Draw()已执行过至少一次否则m_nPixelLength为 0反解结果全是 NaN。解决方案是在OnInitDialog()末尾加一句m_chart.Invalidate(); m_chart.UpdateWindow();强制首次绘制完成。3.3 DPI 适配从 GetDeviceCaps 到 LogicalToDeviceUnits 的平滑过渡VS2015 默认项目不开启 DPI 感知导致高分屏下图表文字糊成一片。ChartCtrl 本身无 DPI 感知但提供了改造入口。你需要做两件事在CChartCtrl::OnCreate中获取 DPI 缩放因子int CChartCtrl::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CWnd::OnCreate(lpCreateStruct) -1) return -1; // 获取系统 DPI HDC hDC ::GetDC(m_hWnd); m_nDPI ::GetDeviceCaps(hDC, LOGPIXELSY); ::ReleaseDC(m_hWnd, hDC); // 计算缩放比例以 96 DPI 为基准 m_fScale (float)m_nDPI / 96.0f; return 0; }在DrawTitle()和ChartAxis::Draw()中将字号、线宽、边距等乘以m_fScalevoid CChartCtrl::DrawTitle(CDC dc, const CRect rect) { CFont font; font.CreateFont( -MulDiv(12, m_nDPI, 96), // 字号按 DPI 缩放 0, 0, 0, FW_NORMAL, FALSE, FALSE, 0, ANSI_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, _T(Microsoft Sans Serif)); CFont* pOldFont dc.SelectObject(font); dc.SetTextAlign(TA_CENTER | TA_TOP); dc.SetTextColor(RGB(0,0,0)); dc.TextOut(rect.CenterPoint().x, 10 * m_fScale, m_strTitle); // 边距也缩放 dc.SelectObject(pOldFont); }提示MulDiv(12, m_nDPI, 96)是 Windows 官方推荐的 DPI 字号计算方式比直接12 * m_fScale更精确能避免小数点后截断导致的字体模糊。4. 编译与运行避坑指南VS2015 环境下的 5 个真实翻车现场ChartCtrl 源码虽小但在 VS2015 上首次编译时新手极易卡在几个看似 trivial 实则致命的环节。以下是我在某高校实验室陪 A 同学调试三天后整理的避坑清单每一条都对应一个真实报错截图。4.1 现象编译报错error C2664: void ATL::CStringTwchar_t,StrTraitMFC_DLLwchar_t,ATL::ChTraitsCRTwchar_t::Format(const wchar_t *,...): cannot convert argument 1 from const char [11] to const wchar_t *原因项目字符集设为“使用 Unicode 字符集”VS2015 默认但源码中混用了CString(xxx)这种窄字符串字面量。MFC 的CString在 Unicode 模式下实际是CStringW不接受const char*。解决全局替换xxx→Lxxx或在字符串前加_T()宏。推荐后者兼容性更好_T(xxx)。注意resource.h中的字符串、SetWindowText(_T(xxx))、所有CString::Format参数都要改。4.2 现象运行时报错0xC0000005: Access violation reading location 0x00000000崩溃点在CChartSeries::Draw()的pPoints[i].x原因CChartSeries::m_pPoints为NULL但m_nPointCount 0。常见于AddSeries()后未调用AddPoint()或AddPoint()被误写在OnInitDialog()之前此时m_chart对象尚未构造完成。解决在CChartSeries::Draw()开头加防御性检查if (!m_pPoints || m_nPointCount 0) return; // 加这一行并在AddPoint()中确保m_pPoints已分配if (!m_pPoints) { m_nAllocated 100; m_pPoints new CHART_POINT[m_nAllocated]; }4.3 现象图表显示为空白仅有一条灰色边框OnPaint()确实被调用原因CChartCtrl的PreSubclassWindow()中未调用ModifyStyle(0, WS_CLIPCHILDREN)导致子控件如图例绘制时被父窗口裁剪。解决在CChartCtrl::PreSubclassWindow()末尾添加ModifyStyle(0, WS_CLIPCHILDREN); // 关键否则图例等子元素不可见4.4 现象鼠标悬停时坐标提示tooltip不出现或位置偏移严重原因CChartCtrl使用CToolTipCtrl但未在PreSubclassWindow()中调用EnableToolTips(TRUE)且 tooltip 的AddTool()传入的矩形是客户区坐标未转换为屏幕坐标。解决在CChartCtrl::PreSubclassWindow()中初始化 tooltipm_tooltip.Create(this); m_tooltip.Activate(TRUE); m_tooltip.AddTool(this, _T(Chart), rectClient, 1); // rectClient 是客户区AddTool 内部会转屏幕坐标并在OnMouseMove()中更新CPoint ptScreen point; ClientToScreen(ptScreen); m_tooltip.RelayEvent(msg); // 必须 relay否则不响应4.5 现象添加多条系列后图例文字重叠或某条系列完全不显示原因CChartLegend的Draw()中每行高度写死为20像素未根据当前字体实际高度GetTextExtentPoint32动态计算。解决在CChartLegend::Draw()中动态获取行高CSize sizeText; dc.GetTextExtentPoint32(_T(A), 1, sizeText); int nLineHeight max(20, sizeText.cy 4); // 最小 20否则太挤5. 二次开发实战为 ChartCtrl 增加 CSV 导出与双 Y 轴支持现在你已经能跑通、能改数据、能缩放、能适配 DPI。接下来我们动手加两个企业级刚需功能一键导出当前视图数据为 CSV以及支持双 Y 轴左轴温度、右轴湿度。这两个功能都不需要动核心绘图引擎只需在现有架构上“插拔式”扩展。5.1 CSV 导出复用数据序列绕过屏幕像素采样ChartCtrl 的CChartSeries已提供GetPointAt()接口但默认只返回“已绘制点”。我们要的是“当前视口内所有原始数据点”这就需要访问系列的原始数据源。好在 ChartCtrl 的设计允许你派生自己的系列类并重写GetVisiblePoints()// CExportableSeries.h class CExportableSeries : public CChartSeries { public: // 返回当前 X 轴可见范围内的所有原始点不采样 void GetVisiblePoints(std::vectorstd::pairdouble,double outPoints) const { double xMin, xMax; m_pParentCtrl-GetXAxis()-GetRange(xMin, xMax); // 获取当前视口 X 范围 // 遍历所有原始点假设你用 std::vector 存储 for (size_t i 0; i m_vecRawData.size(); i) { double x m_vecRawData[i].x; if (x xMin x xMax) { outPoints.push_back({x, m_vecRawData[i].y}); } } } private: std::vectorCHART_POINT m_vecRawData; // 你的原始数据容器 }; // 导出函数放在 CChartCtrl 中 void CChartCtrl::ExportToCSV(const CString strPath) { CStdioFile file; if (!file.Open(strPath, CFile::modeCreate | CFile::modeWrite)) return; // 写 CSV 头 file.WriteString(_T(X,Y1,Y2\n)); // 获取所有系列的可见点 std::vectorstd::pairdouble,double points1, points2; if (m_seriesList.GetCount() 0) { CExportableSeries* pS1 dynamic_castCExportableSeries*(m_seriesList.GetAt(0)); if (pS1) pS1-GetVisiblePoints(points1); } if (m_seriesList.GetCount() 1) { CExportableSeries* pS2 dynamic_castCExportableSeries*(m_seriesList.GetAt(1)); if (pS2) pS2-GetVisiblePoints(points2); } // 对齐 X 坐标写入 CSV简化版取 points1 的 X找 points2 中最近的 Y for (size_t i 0; i points1.size(); i) { double x points1[i].first; double y1 points1[i].second; double y2 0.0; if (!points2.empty()) { // 线性查找最近点数据量小时够用 double minDist DBL_MAX; for (auto p : points2) { double dist fabs(p.first - x); if (dist minDist) { minDist dist; y2 p.second; } } } file.Format(_T(%.6f,%.6f,%.6f\n), x, y1, y2); } file.Close(); }注意此导出函数不依赖屏幕分辨率导出的是亚像素级原始数据精度远高于截图 OCR。你可以在菜单中绑定ID_FILE_EXPORT_CSV调用m_chart.ExportToCSV(_T(data.csv))。5.2 双 Y 轴复用 ChartAxis只增不改ChartCtrl 原生只支持单 Y 轴但ChartAxis类设计良好支持独立实例。我们只需在CChartCtrl中增加第二个 Y 轴成员并修改绘图逻辑// ChartCtrl.h 中添加 private: CChartAxis m_yAxis2; // 第二 Y 轴右侧 // ChartCtrl.cpp 中初始化 CChartCtrl::CChartCtrl() { // ... 其他初始化 m_yAxis2.SetPosition(CHART_AXIS_RIGHT); // 关键设为右侧 m_yAxis2.SetColor(RGB(0,128,0)); // 绿色区别于左侧蓝色 } // 修改 OnPaint()在绘制左侧 Y 轴后再绘制右侧 m_yAxis.Draw(memDC, rectClient); // 左侧 m_yAxis2.Draw(memDC, rectClient); // 右侧 // 修改 ChartSeries::Draw()支持指定 Y 轴 void CChartSeries::Draw(CDC dc, const CRect rect, CChartAxis* pXAxis, CChartAxis* pYAxis) { // 原逻辑不变只是 pYAxis 现在可以是 m_yAxis 或 m_yAxis2 // 在 AddSeries 时指定m_chart.AddSeries(pSeries, m_chart.m_yAxis2); }然后在对话框中// 添加第二条系列绑定到右轴 CChartSeries* pSeries2 m_chart.AddSeries(_T(湿度), RGB(0,128,0), CHART_SERIES_LINE); pSeries2-SetYAxis(m_chart.m_yAxis2); // 新增 API m_chart.m_yAxis2.SetTitle(_T(湿度 (%)));这样左侧显示温度℃右侧显示湿度%两条曲线共用同一个 X 轴时间互不干扰。所有缩放、拖拽、导出逻辑自动兼容——因为CChartCtrl的GetXAxis()和GetYAxis()仍是单例但CChartSeries知道自己该找哪个轴换算。从那以后我每次接到老系统图表改造需求第一件事就是把这份 ChartCtrl 源码拖进工程删掉所有#include afxwin.h以外的第三方头然后花 20 分钟跑通OnInitDialog再花一小时加完 CSV 导出和双轴。它不炫技但像一把磨钝了却依然锋利的锉刀——专治各种“历史包袱”带来的绘图顽疾。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?