
简介一份面向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 语言标准至少是 C14因为有些头文件里用了 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.hC2664 无法转换 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 中的坐标能快速定位是数据端错误还是轴映射函数的比例计算问题。这个技巧比反复打断点高效得多。本文还有配套的精品资源点击获取