简介:一份面向MFC开发者的ChartCtrl图表控件演示工程,演示如何在Windows桌面程序中集成第三方图表插件并绘制高质量曲线。资源以源码形式提供,共58个文件,其中31个头文件、23个实现文件与4个内联文件分别对应控件接口声明、核心功能实现和模板逻辑,整体仅104KB,结构紧凑,便于直接查阅和移植。通过这个Demo可以学习ChartCtrl的完整接入流程,包括坐标轴、序列、图例、滚动条等模块的组织方式,掌握曲线数据绑定、动态刷新、鼠标交互和样式定制等关键操作,同时体会MFC框架下控件生命周期与资源管理的基本方法。目前已有700人学习下载,适合正在做实时数据监控、曲线分析或科学绘图界面的中级MFC开发者参考。
1. 用 ChartCtrl 在 MFC 里画曲线图,为什么值得自己拆一遍 Demo
老旧的 MFC 项目里突然要加实时曲线、历史回放、多轴对比,第一反应是找现成控件。ChartCtrl 这个免费插件在工程师圈子里流传了很多年,它不是一个庞大的商业图表库,而是一整套散落的 .h 和 .cpp 文件,你把它拖进工程就能用。但正因为是源码直接参与编译,很多人第一次集成时会被类名绕晕:ChartCtrl、ChartLineSerie、ChartAxis、ChartGrid,到底谁管数据、谁管坐标轴、谁管重绘?这个 DEMO 的价值恰好在於,它把这层关系摊开了。如果你正准备在 MFC 对话框或视图里绘制曲线,又不想为了一个曲线图引入几百兆的第三方依赖,花一小时读完这个项目源码,比盲目搜索"mfc 曲线控件"并直接复制粘贴要靠谱得多。
2. ChartCtrl 的类体系与核心绘图架构
2.1 从 ChartCtrl 头文件看主从关系
打开 ChartCtrl.h 可以看到,这个控件的主类继承自 CWnd,也就是一个标准的 MFC 窗口控件。它的职责非常清晰:持有所有序列(Serie)、轴(Axis)、网格(Grid)和标题对象,然后在 WM_PAINT 消息里统一调度绘制。把 ChartCtrl 想象成一个舞台,Serie 是演员,Axis 是布景里的刻度尺,ChartGrid 是背景网格,而 OnPaint 就是导演。理解这个主从关系后,你看到 ChartCtrl.cpp 里大量对子对象的遍历操作就不会晕。
一个常见的误区是以为 ChartSerie 自己会画到屏幕上。实际上,ChartSerieBase 只负责把数据点转换成屏幕坐标,真正的画线、画柱子、画 K 线是在 ChartCtrl::OnPaint 里调用各 Serie 的 Draw 方法完成的。这个设计让同一个 Serie 可以同时注册到不同的 ChartCtrl 上,也方便你在内存中做离屏渲染。
// 典型调用链,从 WM_PAINT 到具体序列 void CChartCtrl::OnPaint() { CPaintDC dc(this); // 1. 让坐标轴计算当前可见范围对应的像素坐标 // 2. 绘制网格 // 3. 遍历所有序列,调用其 Draw(dc) 把原始数据映射到坐标 // 4. 绘制标题、图例、十字光标 }这段代码说明了一个关键点:数据到屏幕坐标的转换不是由坐标轴完成的,而是由每条序列内部完成。每个 Serie 都在构造时绑定了一个纵向轴和一个横向轴,比如 ChartLineSerie 可以通过 SetHorizontalAxis 和 SetVerticalAxis 分别指定横轴和纵轴。这样做的直接好处是,你可以让一条序列使用左侧纵轴,另一条使用右侧纵轴,轻松绘制双 Y 轴曲线。
2.2 序列(Serie)的抽象层次
ChartSerieBase 是所有序列的基类,它管理数据索引、颜色、线宽、是否可见等通用属性。在此基础上派生出了 ChartPointsSerie,它额外维护一个 ChartPointsArray 容器,用于存放实际的数据点。ChartLineSerie 则进一步实现了折线图的具体绘制逻辑,包括线段连接、标记点、填充区域等。
这个继承层次决定了你的使用方式:如果需要自定义一种全新的图表类型,不要从 CWnd 开始写,而是继承 ChartSurfaceSerie 或 ChartPointsSerie,实现 DrawData、GetSerieName 等几个纯虚函数。DEMO 里的 ChartCandlestickSerie 和 ChartBarSerie 就是最好的参考范本。Candlestick(蜡烛图)看似复杂,本质上也只是在 DrawData 里画矩形和上下影线,数据点坐标同样来自 ChartPointsArray。
2.2.1 ChartPointsArray 的内存布局
ChartPointsArray.inl 看起来像是一个简单的动态数组,但它在内部维护了真正的绘图坐标和原始数据坐标两套值。SetPoint 方法接受的是数据坐标,而 GetPoint 返回的是已经经过坐标轴映射后的屏幕坐标。这一点非常容易踩坑:如果你在外部直接修改 Serie 的数据后没有调用 Refresh,折线还是旧的。常见做法是修改完数据后调用 ChartCtrl::Refresh 触发重绘。
// 数据点结构 - 典型实现 struct ChartPoint { double X; // 数据坐标 double Y; int nX; // 屏幕坐标 int nY; };这个结构说明了一个重要的性能优化:数据坐标到屏幕坐标的换算只发生在坐标轴范围变化或数据更新时,而不是每次绘制时重复计算。对于成千上万点的曲线,这个设计能明显降低 CPU 占用。当你发现曲线拖动卡顿时,优先检查是不是每次都调用 Refresh,而不是检查绘制函数本身。
2.3 坐标轴的自适应与对数轴
ChartStandardAxis 是最常用的线性轴,它负责计算刻度步长、格式化标签文字。ChartLogarithmicAxis 继承自它,只重写了刻度计算和坐标映射部分。DEMO 里虽然没有专门演示对数轴曲线,但源码结构非常清晰,值得研究。
轴的自适应体现在 SetAutoHide 和 GetMaxValue、GetMinValue 的组合上。默认情况下,ChartCtrl 会根据所有序列的数据范围自动调整坐标轴范围,这样曲线永远在可视区域内。但这种自动有一个代价:当数据点慢慢变多时,纵轴范围会被历史的极端值撑大,导致新曲线的波动看起来越来越平。解决办法是手动设置轴范围,或者定期重置数据。
// 手动固定纵轴范围,避免自动范围被历史极值干扰 m_ChartCtrl.GetVerticalAxis()->SetMinMax(-1.5, 1.5); m_ChartCtrl.GetVerticalAxis()->SetAutomatic(true); // 重新开启自动时需谨慎参数说明:SetMinMax 接收两个 double 参数,分别是轴的最小值和最大值。SetAutomatic(true) 会把控制权交还自动计算,但若之前手动设置过,有些版本会自动调整一次后不再更新。实际项目中,我一般固定纵轴,只让横轴自动滚动,这样实时曲线更接近示波器的效果。
3. 把 ChartCtrl 集成进 MFC 工程并画出第一条曲线
3.1 源码对比里的隐藏依赖
拿到 ChartCtrl.rar 后,先不要急着把 40 多个文件全部拷入工程。检查一下项目属性,确认 C++ 语言标准至少是 C++14,因为有些头文件里用了 nullptr 和 override。另一点是字符集:DEMO 里大量的字符串处理假设项目使用多字节字符集,如果新建的 MFC 工程默认是 Unicode,你会在 ChartAxisLabel.cpp 这类文件里看到大量 CString 转 LPCTSTR 的编译错误。最快的方法是把整个工程的字符集改回"使用多字节字符集",因为 Plot 控件对中文标签的渲染在 Unicode 下还需要单独处理字体映射。
还有一个隐藏依赖:ChartCtrl 依赖 GDI 对象,没有任何外部 DLL。这意味着你要保证每个调用的 HDC 在绘制结束后正确恢复,否则长时间运行会出现画笔、画刷泄露。常见做法是在 Paint 里用 CPaintDC 而不是 CClientDC,后者在某些派生类里不会自动处理裁剪区域。
3.2 对话框工程中的最小接入步骤
下面是一段可以直接运行的接入流程,基于 VS2022 MFC 对话框工程。
// 在对话框头文件中声明控件指针 class CMyChartDemoDlg : public CDialogEx { CChartCtrl m_ChartCtrl; // ChartCtrl 实例 CChartLineSerie* m_pSerie; // 曲线序列指针 };// 在 OnInitDialog 中创建控件并配置 BOOL CMyChartDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 在指定矩形区域内创建控件 CRect rc(10, 10, 480, 300); m_ChartCtrl.Create(WS_CHILD | WS_VISIBLE, rc, this, 1001); // 创建一条线序列 m_pSerie = m_ChartCtrl.CreateLineSerie(); m_pSerie->SetSerieName(_T("正弦曲线")); m_pSerie->SetColor(RGB(0, 0, 255)); m_pSerie->SetWidth(2); // 配置横轴和纵轴显示范围及网格 m_ChartCtrl.GetHorizontalAxis()->SetMinMax(0.0, 10.0); m_ChartCtrl.GetVerticalAxis()->SetMinMax(-1.0, 1.0); m_ChartCtrl.EnableGrid(true, true); // 添加数据点(这里模拟 y = sin(x)) for (int i = 0; i <= 100; i++) { double x = i * 0.1; double y = sin(x); m_pSerie->AddPoint(x, y); } // 立即刷新显示 m_ChartCtrl.Refresh(); return TRUE; }这段代码里的 Create 方法继承自 CWnd,四个参数分别是窗口样式、矩形、父窗口和控件 ID。随后 CreateLineSerie 返回一个内部构造好的序列指针,注意这个指针由 ChartCtrl 管理,不要手动 delete。SetSerieName 用于图例显示,SetColor 和 SetWidth 控制曲线外观。AddPoint 接受两个 double 型参数,这里故意从 0 到 10 步进 0.1,生成 101 个点,模拟正弦曲线。
逻辑说明:整个过程中两个轴的范围都是手动指定的,这样做的原因是,如果依靠自动范围,横轴会刚好从 0 到 10,纵轴从 -1 到 1,和固定设置结果相同。但手动设置让你有机会留出边距,比如把纵轴设置到 -1.2 到 1.2,曲线不会顶到边界。
3.3 编译报错清单与常规修复
| 典型报错 | 原因 | 解决 |
|---|---|---|
| C3861 “_T” 找不到标识符 | 工程未包含 afxwin.h 或字符集设置混乱 | 确认通过预编译头#include "pch.h"后引入 afxwin.h |
| C2664 无法转换 CString 到 const char* | Unicode 与多字节混用 | 项目属性 -> 常规 -> 字符集修改为“使用多字节字符集”或使用 CW2A 转换 |
| LNK2005 已在 ChartCtrl.obj 中定义 | 某些 .cpp 被重复加入工程 | 在解决方案资源管理器中搜索是否把同一文件添加了两次 |
| 控件不显示 | 未调用 Create,或父窗口指针错误 | 确保 Create 传入了当前对话框的 this |
| 曲线刷新无变化 | 修改数据后未调用 Refresh | 强制调用m_ChartCtrl.Refresh() |
这些编译问题是搜索引擎里除了 DEMO 之外最常被问到的点。很多"为什么我的 mfc 曲线图黑屏"的帖子,最后都落在 Create 时忘了加 WS_VISIBLE 样式这一个原因上。
4. 曲线数据刷新、坐标轴联动与交互缩放
4.1 用定时器模拟实时数据流
真实项目中不太可能一次性把数据全部绘制完,更多是每隔 100ms 追加一个新数据点,让曲线向左滚动。ChartCtrl 没有内置自动滚动,你需要自己控制横轴范围。
void CMyChartDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1 && m_pSerie != nullptr) { // 模拟新数据 static double t = 0.0; t += 0.1; m_pSerie->AddPoint(t, sin(t)); // 控制横轴只显示最近 10 个单位的窗口 double currentMax = t; m_ChartCtrl.GetHorizontalAxis()->SetMinMax(currentMax - 10.0, currentMax); m_ChartCtrl.Refresh(); } CDialogEx::OnTimer(nIDEvent); }这里有一个细节:SetMinMax 会强制覆盖轴自动范围。如果 SetAutomatic 一直开着,你手动设置的范围会被下一个 Refresh 后的自动调整覆盖。所以实时滚动模式下,必须先把自动范围关掉(也就是调用一次 SetAutomatic(false)),或者像这样每次都设置固定窗口。上述代码采用固定宽度窗口,表现上就是曲线从右往左平滑推进。
参数说明:SetMinMax 的第一个参数是横轴最小值,第二个是最大值。这个顺序和很多人的直觉相反,写成前后颠倒后曲线会变成镜像显示。如果发现横轴方向反了,多半是这个原因。
4.2 动态添加多条曲线并绑定双纵轴
当数据源不只有一个信号时,比如需要同时显示温度和压力,两条曲线的量级差好几倍,就必须使用两个纵轴。
// 左侧纵轴用于温度 CChartVerticalAxis* pAxisLeft = m_ChartCtrl.GetVerticalAxis(); CChartLineSerie* pTempSerie = m_ChartCtrl.CreateLineSerie(); pTempSerie->SetVerticalAxis(pAxisLeft); // 右侧纵轴用于压力 CChartVerticalAxis* pAxisRight = new CChartVerticalAxis(); m_ChartCtrl.AddVerticalAxis(pAxisRight); CChartLineSerie* pPressureSerie = m_ChartCtrl.CreateLineSerie(); pPressureSerie->SetVerticalAxis(pAxisRight);这里要注意 AddVerticalAxis 的顺序。在 ChartCtrl 内部,坐标轴保存在一个列表中,绘制顺序会影响网格和标签的层次。如果先添加了右轴但没有设置任何序列绑定到它,该轴可能不会显示。很多人在 DEMO 里看到双轴却找不到设置位置,原因就在这里。右轴的标题可以通过 pAxisRight->SetTitleString 设置。
4.3 鼠标缩放与十字光标实现思路
ChartCrossHairCursor 这个类专门负责十字光标跟随鼠标移动。它本质上是一个覆盖在绘图区上的透明鼠标追踪器,在 WM_MOUSEMOVE 里更新光标的横竖线位置,并实时显示数据坐标。
缩放功能则需要自己处理 WM_MOUSEWHEEL 消息。一个常见实现是:在 OnMouseWheel 中获取当前鼠标位置,然后根据滚轮方向把横轴取值范围等比缩小或放大,缩放中心是鼠标位置对应的数据坐标。实现时注意先调用 ScreenToClient,再通过轴转换把像素坐标映射为数据坐标。
BOOL CMyChartDemoDlg::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { CPoint ptClient = pt; ScreenToClient(&ptClient); // 获取当前横轴范围 double minV = m_ChartCtrl.GetHorizontalAxis()->GetMinValue(); double maxV = m_ChartCtrl.GetHorizontalAxis()->GetMaxValue(); double span = maxV - minV; double center = minV + span * (ptClient.x / (double)m_width); // 简易映射 double factor = (zDelta > 0) ? 0.8 : 1.25; double newSpan = span * factor; m_ChartCtrl.GetHorizontalAxis()->SetMinMax(center - newSpan / 2, center + newSpan / 2); m_ChartCtrl.Refresh(); return TRUE; }这段代码只是一个粗糙示例。真正的生产级做法需要用 ChartCtrl 内部提供的坐标转换方法,而不是用控件宽度按比例估算。ChartCtrl.h 里有 GetHorizontalAxis()->CalculateCoordinate 之类的方法,可以把像素坐标精确转换为数据坐标。我之前在模具监控软件中做实时曲线缩放,就是通过重写鼠标事件配合这些方法实现的,但需要注意:频繁 Refresh 会重算所有序列的屏幕坐标,数据点超过五万后会有明显卡顿,这时候最好在鼠标滚轮消息里节流,或者只重绘裁剪区域。
5. 样式复用与从 DEMO 中提炼自己的图表工具函数
看这个 DEMO 时最有价值的收获不是画出一条线,而是学会把配置代码从业务数据中剥离出来。ChartCtrl 提供的序列和轴属性很多,如果你每次都在业务代码里直接设置颜色、字体、网格样式,代码很快就会变成一团难以维护的意大利面。我一般会把样式配置封装成一个工具函数,输入是 ChartCtrl* 指针和一些简单的结构体参数,输出是配置好的图表。
struct ChartStyleConfig { COLORREF lineColor; COLORREF gridColor; COLORREF axisColor; int lineWidth; BOOL showLegend; }; void ApplyChartStyle(CChartCtrl* ctrl, const ChartStyleConfig& cfg) { // 设置坐标轴文字与刻度颜色 ctrl->GetHorizontalAxis()->SetLabelColor(cfg.axisColor); ctrl->GetVerticalAxis()->SetLabelColor(cfg.axisColor); // 设置网格 ctrl->GetGrid()->SetMajorGridColor(cfg.gridColor); ctrl->EnableGrid(true, true); // 如果有序列,统一应用到最新一条 CChartCollection* pSeries = ctrl->GetSeries(); if (pSeries != nullptr && pSeries->GetCount() > 0) { CChartSerieBase* pSerie = pSeries->GetAt(pSeries->GetCount() - 1); pSerie->SetColor(cfg.lineColor); pSerie->SetWidth(cfg.lineWidth); } ctrl->Refresh(); }这个函数里的 GetSeries 和 GetAt 可以在 ChartCtrl.h 中查到,它们返回一个对象数组。在实际应用中,你可以把这种风格配置存成 ini 或注册表,程序启动时读取一次,然后所有页面共用同一套视觉规范。我曾在生产设备上位机软件中,用这个方式驱动了四台不同分辨率的显示器,曲线在不同 DPI 下依然能保持一致的表观尺寸。
另外,如果你要把这个控件扩展到柱状图或 K 线图,复用方式也类似:创建 ChartBarSerie 或 ChartCandlestickSerie,同样通过 SetVerticalAxis 绑定轴,再配置各自的属性如 BarWidth、BullishColor。ChartGanttSerie 的接口稍有不同,它要求的数据点是开始时间、结束时间和层级索引,但底层仍然是 ChartPointsArray。当你真正动手改这些派生类源码时,才会意识到这个 Demo 给了一个很好的起点:每根柱子、每根 K 线,本质上都只是数据坐标映射后的 GDI 绘图调用,理解了这一点,自定义一个面积图或散点图也只是时间问题。
最后留一个调试技巧:当你发现曲线位置和预期不符时,在序列的 DrawData 函数里加一段导出当前数据点到 CSV 文件的代码,把 Data 数组中的原始 X、Y 值和转换后的 nX、nY 值都打出来。比对 CSV 中的坐标,能快速定位是数据端错误还是轴映射函数的比例计算问题。这个技巧比反复打断点高效得多。
本文还有配套的精品资源,点击获取