简介:一套面向MFC开发者的ChartCtrl波形显示完整实例源码,演示如何利用MSChart控件在Windows应用中快速绘制和更新动态波形,适合需要做数据可视化或仪器仪表界面的VC++工程师参考。压缩包共80个文件,以36个h头文件、26个cpp源文件为主,另含inl内联定义、rc资源、VS2015工程配置文件以及可直接运行的exe,整体约41.86MB,工程结构完整、便于直接打开编译研究。已有3848人学习过该资源。实例整合了ChartCtrl在VS2015企业版中的集成方式,代码中展示了初始化图表属性、注册事件处理、组织波形数据、调用AddSeries与AddDataPoint绘制折线,并通过定时器实现实时刷新等关键环节;同时对坐标轴范围、视觉元素和图例交互做了处理,有助于快速理解MFC下ActiveX图表控件的使用套路,可直接迁移到示波器、信号监测、数据采集等项目中。
1. ChartCtrl 为什么适合 MFC 波形显示
做 MFC 界面开发的人,迟早会遇到一个需求:把采集到的数据画成实时波形图。很多人的第一反应是自己封装绘图,用 CPaintDC 画折线,但深入做下去会发现这是条血泪路——坐标换算自己写、缩放重绘自己管、多曲线叠加还要考虑遮挡关系,稍微复杂一点,光一个坐标轴自适应就能调一整天。而 ChartCtrl 是一个开源的 MFC 图表控件,封装好了坐标轴、曲线序列、网格和图例,你只需要把数据丢进去,控件自己完成从数据到屏幕坐标的全部转换。对这个标题来说,核心价值就在一个词:快。
它适合谁,我这里说得直白一点:做上位机软件、数据采集系统、仪器仪表交互界面,以及各种需要波形预览的 MFC 工程。不管你是三次握手里面的数据收发端、串口调试助手的曲线面板,还是一个工业设备的实时监控界面,ChartCtrl 都能把从零到显示波形的周期压缩到小时级别。它不是一个通用绘图库,做不了复杂的科学计算绘图,但在 MFC 对话框里显示 y=f(x) 波形这件事上,它是长期被验证过的高性价比方案。
本文按「接进来 → 配坐标轴 → 刷新数据 → 踩坑 → 进阶」这条链路往下写。中间章节都会给出可直接复制的代码思路和参数说明,最后一章收在几个能显著提升波形品质的细节上。你可以把这份笔记当成搭 ChartCtrl 的施工手册来用。
2. 把 ChartCtrl 接进对话框:环境配置与最小实现
2.1 获取控件与工程引入方式
ChartCtrl 在开源社区流传多年,文件结构不算复杂,核心就是几个类:CChartCtrl、CChartLineSerie、CChartAxis、CChartGrid 等。常见做法是直接把源码文件整体复制到工程目录,用 Visual Studio 的“现有项”功能加进工程。
接入 MFC 工程之前,先确认工程属性。我一般会检查这样几项:
- 字符集:建议使用“使用 Unicode 字符集”。如果用多字节字符集,后面中文字符串传入控件时会出现乱码风险。
- 公共语言运行时支持:保持“无公共语言运行时支持”,ChartCtrl 是原生 C++ 实现,不需要托管环境。
- MFC 使用:选择“在共享 DLL 中使用 MFC”,避免静态链接导致的库冲突。
把 ChartCtrl 的 .h 和 .cpp 文件全部加进工程之后,在对话框头文件里引入核心头文件:
// ChartCtrlDemoDlg.h #pragma once #include "ChartCtrl.h" // 控件主类 #include "ChartLineSerie.h" // 曲线序列类然后为对话框类添加一个 CChartCtrl 类型的成员变量。这里要注意:CChartCtrl 是从 CWnd 派生的,不是自定义控件类,不要用手动 DDX_Control 的方式在 OnInitDialog 里动态创建,而是直接作为成员变量,并在对话框模板上预留一块矩形区域。
2.2 用最少的代码画出一条波形
从零开始,最快的验证路径是直接在 OnInitDialog 里写一个测试函数,生成一组正弦波数据并显示出来。下面这段代码是我验证控件是否工程配置成功的最小用例,你可以直接照抄:
void CChartCtrlDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 1. 创建控件实例,指定父窗口为本对话框 m_chartCtrl.Create(WS_CHILD | WS_VISIBLE | WS_BORDER, CRect(10, 10, 500, 300), this, IDC_CHART_STATIC); // 2. 设置图表标题(Unicode 字符串) m_chartCtrl.SetTitle(_T("ChartCtrl 波形显示测试")); // 3. 创建一条曲线序列 CChartLineSerie* pSerie = m_chartCtrl.CreateLineSerie(); // 4. 生成 360 个点的正弦数据 const int nPointCount = 360; for (int i = 0; i < nPointCount; i++) { double dAngle = 6.28318530718 * i / nPointCount; double dY = sin(dAngle); pSerie->AddPoint(dAngle, dY); } // 5. 绘制曲线 m_chartCtrl.EnableRefresh(true); }这段代码的逻辑链条很清晰:先创建控件窗口,再创建曲线序列对象,然后向序列逐点追加数据,最后启用刷新。三步对应了 ChartCtrl 的核心对象模型——控制面板、序列、数据点。需要注意几个容易忽略的细节:
- Create 的矩形区域坐标是相对于父窗口客户区的。如果你希望控件跟随对话框大小变化,后面要在 OnSize 里用 MoveWindow 调整。
- CreateLineSerie 返回的是一个裸指针,由 CChartCtrl 内部管理生命周期。不要手动 delete,也不要保存指针超过控件生命周期。
- EnableRefresh 接受一个 BOOL 参数。传 true 时控件立刻重绘;如果你要追加大量数据,可以先传 false 关闭刷新,全部追加完再打开,避免每加一个点就重绘一次,导致界面闪烁。
2.3 代码成功运行后怎么确认结果
运行程序后,你应该看到:对话框上有一个带边框的绘图区,底部有 X 轴,左侧有 Y 轴,数据曲线默认使用控件预设的颜色绘制。如果窗口是空白的,大概率是创建失败。排查顺序如下:
- 检查工程有没有引入 ChartCtrl.cpp 的编译单元。有时文件加进工程了,但被 IDE 排除了。
- 检查 IDC_CHART_STATIC 这个资源 ID 是否和现有资源冲突。
- 检查对话框资源里是否有 Static Text 控件占用了相同 ID,导致 Create 返回失败但没报错。
一个更可靠的验证方式是:在 Create 之后调用 GetSafeHwnd(),确认控件窗口句柄不为 NULL。只要窗口成功创建,绘图不显示,剩下的问题基本都集中在数据范围和坐标轴设置上,这部分在下一章展开。
3. 坐标轴与曲线配置:让波形显示真正能读、不乱码
3.1 坐标轴范围策略:自动 vs 手动
ChartCtrl 默认会自动根据数据范围调整坐标轴上下限,这在数据量小、范围稳定的场景下很好用,但一旦涉及实时数据流,自动范围会让波形抖得没法看——数据每次刷新,上下限都可能跳动。常见做法是手动锁定 Y 轴范围,只让 X 轴滑动。
设置坐标轴的接口是 GetAxis 族函数,关键参数说明如下表:
| 函数 | 作用 | 典型参数 |
|---|---|---|
| GetYAxis()->SetMin / SetMax | 锁定 Y 轴上下限 | 针对电压、温度等物理量设置 |
| GetXAxis()->SetMin / SetMax | 设定 X 轴可见范围 | 时间窗口长度,如 10 秒 |
| GetYAxis()->SetAutomatic | 关闭/开启自动范围 | false 表示锁定 |
| SetTitle 的字符串参数 | 设置轴标题 | 需在 SetText 前调用 EnableRefresh(false) |
下面是我常用的固定坐标轴配置写法:
// 锁定 Y 轴范围为 [-1.5, 1.5] m_chartCtrl.GetYAxis()->SetAutomatic(false); m_chartCtrl.GetYAxis()->SetMin(-1.5); m_chartCtrl.GetYAxis()->SetMax(1.5); // X 轴截取最近 360 个采样点 m_chartCtrl.GetXAxis()->SetAutomatic(false); m_chartCtrl.GetXAxis()->SetMin(0.0); m_chartCtrl.GetXAxis()->SetMax(6.28318530718); // 设置坐标轴描述 m_chartCtrl.GetYAxis()->SetTitle(_T("幅值 (V)")); m_chartCtrl.GetXAxis()->SetTitle(_T("相位 (rad)"));参数设置里面最需要记住的是 SetAutomatic(false) 这一步。很多人只调了 SetMin 和 SetMax,忘了关自动范围,结果发现数值没生效,就误以为是函数调用无效。实际上 ChartCtrl 的坐标轴对象在自动模式下会持续覆盖你设定的范围值。
一个细节:调用 SetTitle 传中文时,如果工程是多字节字符集,你需要对字符串做一次转换,否则标题会乱码。Unicode 工程下直接用 _T() 宏包裹是没问题的。这块经常被忽略,本质上不是 ChartCtrl 的 bug,而是工程字符集匹配问题。
3.2 曲线序列的样式与颜色管理
每条可见的曲线,背后对应一个 CChartLineSerie 对象。ChartCtrl 的序列样式控制接口不多,但够用:颜色、线宽、点标记、隐藏显示。按照我的经验,波形显示的默认样式就让人舒服,因为控件预设的配色对比度合理,适合黄色和绿色这类深色背景下的示波风格;但如果你把控件嵌在白色背景的对话框里,需要主动调暗网格、加深曲线颜色。
常用配置如下:
// 获取序列对象 CChartLineSerie* pSerie = m_chartCtrl.CreateLineSerie(); // 设置曲线颜色为深蓝色,线宽 2 像素 pSerie->SetColor(RGB(0, 102, 204)); pSerie->SetWidth(2); // 让曲线平滑显示(本质是线性插值,不是贝塞尔平滑) pSerie->SetPointMarkVisible(false); // 隐藏某条曲线(比如对比模式下暂时不显示) pSerie->SetVisible(false);需要特别说明的是 SetWidth。ChartCtrl 内部使用 GDI 的 Pen 对象绘图,线宽参数以像素为单位。文档上写的是支持浮点宽度,但实际渲染时,部分版本会根据设备上下文的映射模式做取整,所以你传 1.5 和传 2 有时候效果一样。如果你发现线宽调了没反应,先检查是不是用了太旧的版本。
3.3 网格与背景的可读性调整
默认网格线和背景色是面向演示的对比色,在实际工程里经常要改。比如深色界面用深灰背景加浅色网格,浅色界面用白色背景加浅灰网格。ChartCtrl 暴露了网格和背景的接口,但版本间存在命名差异,常见的写法是:
// 设置背景颜色为白色 CChartCtrl::SetBkColor(RGB(255, 255, 255)); // 某些版本为 SetBackColor // 设置网格颜色为浅灰 m_chartCtrl.SetGridColor(RGB(220, 220, 220)); m_chartCtrl.EnableGrid(true);翻车频率最高的点就在 SetBkColor 和 SetBackColor 这个命名差异上。不同来源的源码包改动过函数名,编译时如果报“没有成员函数”的错误,去头文件里搜一下实际定义。还有一个参数值得提:EnableGrid 接受 BOOL 参数,只控制是否绘制网格,不影响坐标轴本身。如果你关掉网格后坐标轴文字还在,说明用法正确。
背景和网格的颜色调整,目的是让波形在特定光照环境下能看清,这个没有统一标准,建议在开发机上多换几张不同深浅的测试背景做对比。等实际部署环境确定了,再固化颜色配置。
4. 实时波形刷新与数据追加:从静态曲线到动态示波
4.1 追加数据的两种模式对比
静态显示只能算环境验证,波形显示的核心需求是动态刷新。ChartCtrl 支持两种数据追加模式,区别非常关键:
| 模式 | 实现方式 | 使用场景 | 风险点 |
|---|---|---|---|
| AddPoint 逐点追加 | 每来一个数据点,调用一次 AddPoint | 采样率低,如温度、压力 | 高频调用时刷新重绘耗 CPU |
| SetData 批量更新 | 先填数组,一次性传入 | 高频采集、示波器场景 | 数组拷贝内存开销 |
实际项目里,多数人的误区是拿着 AddPoint 死磕,以为只有逐点追加才能实时。但采样率一上到每秒钟几千个点,AddPoint 对应的重绘逻辑会让 UI 线程被拖住。我处理过最极端的情况是同时显示四条曲线、采样率 10kHz,界面卡到按按钮都没响应。后来改成批量更新,瞬间解决。
批量更新对应的方法在不同版本里叫法不一样,常见的是 SetData 或 ReplaceData。它的思路是:把新来的一批数据点先存在你自己管理的缓存数组里,到达一定数量后一次性推给序列,让整段曲线整体重绘。典型代码:
// 假设缓存了 1024 个新采样点 std::vector<double> vecX, vecY; vecX.reserve(1024); vecY.reserve(1024); // ... 后台线程采集数据,填满 vecX 和 vecY ... // 一次性更新曲线数据(伪代码,具体方法名以头文件为准) pSerie->SetData(1024, &vecX[0], &vecY[0]); // 强制重绘 m_chartCtrl.EnableRefresh(true);选择哪种模式,跟你的采集架构直接相关。如果你的数据已经按“帧”或“包”到达,天然就是批量模式;如果数据是逐样本到达的串口流,建议自己维护一个环形缓冲,攒够一批再刷新,既稳定又高效。
4.2 定时器刷新与线程安全边界
实时刷新最常见的工程实现是对话框定时器。MFC 的 SetTimer 是窗口消息驱动,不需要额外维护线程,写起来方便,但倒在使用它的第一原则:定时器回调在 UI 线程执行,不能做耗时操作。
我一般这样组织刷新逻辑:
// 启动一个 50ms 的定时器,即每秒约 20 次刷新 SetTimer(1, 50, NULL); // 在对话框消息处理函数中刷新 void CChartCtrlDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { // 尝试锁定共享数据区 CSingleLock lock(&m_csData); if (lock.Lock(0)) { // 从共享缓冲区取最新数据并推给曲线 m_pSerie->SetData(m_nCount, m_arrX, m_arrY); m_chartCtrl.EnableRefresh(true); } } CDialogEx::OnTimer(nIDEvent); }这里有一个踩过很多次坑的边界:如果你的采集线程和 UI 线程共用了同一个数据数组,必须加锁。不要以为 read 一下不写就安全,采集线程可能在你不注意的时候重新分配了数组内存,UI 线程拿着悬空指针去读,直接崩溃。用 CSingleLock 做轻量保护是 MFC 工程里最常见也最不容易出错的方案,加锁粒度控制在拷贝数据的一小段。
刷新率要按实际场景设。波形观感上,每秒 15~20 次刷新已经足够顺畅;设到 50ms 一刷,CPU 占用已经能被任务管理器看到。再往下调低刷新周期,只会增加 CPU 负担,视觉提升却非常有限。建议把最低刷新周期控制在 30~50ms 之间,超过这个频率就换批量更新模式。
4.3 X 轴滑动窗口与环形缓冲
波形要一直往前滚动,而不是画满了整体重来,这是做数据采集软件逃不掉的需求。控制这个行为的是 X 轴的范围加上数据点的采样时间戳。实测发现:现实中更容易处理的办法是,让 X 轴范围固定为最近 N 秒,数据点照样按时间戳追加,超过窗口范围的点不显示,但不需要删掉。
基础实现是,让数据的 X 坐标从程序启动开始递增计时:
// 假设采样周期为 10ms static double dXCurrent = 0.0; double dXStep = 0.01; // 每来一个采样点 dXCurrent += dXStep; // 使 X 轴窗口随最新时间滑动 m_chartCtrl.GetXAxis()->SetMin(dXCurrent - 5.0); // 显示最近 5 秒 m_chartCtrl.GetXAxis()->SetMax(dXCurrent);这套逻辑的关键是:不要真的把数据点从序列里删除。ChartCtrl 内部会按坐标范围裁剪出可见区,数据点保留在序列对象中不影响显示性能。但如果你长时间运行(比如连续跑几天),无限增长的数据点会侵蚀内存。规避手段是限制序列的最大点集数量,很多版本的 ChartCtrl 提供 SetMaxPointCount 之类的接口,超过上限自动丢弃最旧的点。
环形缓冲的工程实现并不复杂,但绝大多数场景用不上完整实现;序列内部能自动管理内存时,你只需要设置上限即可。如果确实遇到几十万的长期数据点,再考虑自己维护环形索引、把旧点覆盖成 NaN 或跳过 AddPoint。
5. 波形显示常见问题与避坑排查:闪烁、中文乱码与数据错乱
5.1 波形闪烁严重,重绘导致界面白屏闪烁
现象:每次刷新数据时,波形区域明显闪烁,拖动窗口时图表整个白掉再重新画出。
原因:ChartCtrl 默认重绘方式是先擦除背景再画曲线,这个过程产生经典的 GDI 闪烁问题。在高刷新率下表现尤其严重。
解决:开启双缓冲重绘。在 CChartCtrl 派生类里重写 OnPaint 也行,但更简单的是利用现有接口。很多版本内部实现了双缓冲,如果你的版本没有,按下面的方式处理:响应 WM_ERASEBKGND 消息,让它直接返回 TRUE,不执行背景擦除,然后让 OnPaint 里完成全量绘制。实测这套做法能把闪烁感降低七成以上。
5.2 中文标题和轴标签乱码
现象:SetTitle(_T("电压")) 编译通过,运行时显示乱码或问号。
原因:静态链接了多字节字符集版本,内部调用 ANSI 版本的 GDI 文本接口;或者系统区域设置与中文字符集不匹配。
解决:首选方案是把工程切换到 Unicode 字符集,再重新编译整个解决方案。如果因为历史原因不能切换,就把中文字符串先转成 UTF-8 再传给控件:
// 多字节工程下的应急方案 CStringA strTitle = CT2A(_T("电压"), CP_UTF8); m_chartCtrl.GetYAxis()->SetTitle(strTitle);这种方法在多数系统上有效,但不适合作为长期方案,因为控件内部可能假设了字符编码宽度。
5.3 曲线丢失或只画出部分数据
现象:数据点已经调用了 AddPoint,但曲线上只有一段,甚至看不到任何数据。
原因:坐标轴自动范围计算异常,数据点的 Y 值超出可视范围,或者 X 值非法(NaN、Inf、负值的乱序点)。ChartCtrl 遇到这类数据会直接跳过绘制。
解决:先打印数据中的最小值和最大值,人工对比坐标轴的 SetMin/SetMax。特别注意数据采集初始化阶段,很多缓冲区空闲位置是零值或未初始化内存,被当成有效点传进序列。采集开始前先 memset 整个数组,比在绘图侧排查快得多。
5.4 两条曲线数据错乱,A 曲线画出了 B 曲线的点
现象:开启双序列后,第一条曲线和第二条曲线数据交叉出现,图像看起来像被揉成一团。
原因:拷贝代码时定义了同一个序列指针,两个循环往同一个序列里面 AddPoint;或者是数组指针传给两条序列时指向了同一块内存。
解决:给每条曲线单独申请一个 CChartLineSerie 指针,管线、管点、管数据。传数组时确认数据源区段没有越界,最好每条曲线各自持有独立数组。
5.5 控件随窗口尺寸变化不缩放
现象:拖动对话框大小,图表区域原地不动,覆盖或露出难看的大片空白。
原因:ChartCtrl 是普通子窗口,不会自动跟随父窗口调整尺寸,需要你在父窗口的 OnSize 里手动同步位置。
解决:在对话框类中重写 OnSize,调用 MoveWindow 把控件矩形拉伸到目标区域:
void CChartCtrlDemoDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (m_chartCtrl.GetSafeHwnd() != NULL) { // 让图表控件总是填充对话框客户区,四周留 5 像素边距 m_chartCtrl.MoveWindow(5, 5, cx - 10, cy - 10); } }这里的 5 像素边距是经验值,如果你的对话框上有其他控件(比如按钮、列表),这个矩形要按实际布局计算。OnSize 在窗口创建早期也会触发,所以一定要用 GetSafeHwnd 判断控件是否已创建,否则 MoveWindow 会被调用在一个无效对象上。
6. 进阶技巧:多曲线对比与批量数据的显示性能优化
波形显示走到多曲线这一步,才真正进入实战状态。多曲线的核心不只是画多条线,而是区分数据语义和控制可见性。常见做法是把每条曲线用不同颜色区分,并且提供“显示/隐藏”逻辑。ChartCtrl 里每条序列的可见性由 SetVisible 决定,你可以通过对话框上的 CheckBox 来控制曲线开关。
对比模式下,Y 轴范围必须统一,否则两条量纲不同的曲线(比如电压和电流)会互相挤压到难以分辨。常见的处理手法是让两组数据归一化到同一量纲,或者使用两个坐标轴体系。老版本 ChartCtrl 对双 Y 轴的支持不够完善,更稳妥的做法是:把次级曲线数据线性缩放,映射到主 Y 轴范围内。这里的缩放系数要写清楚注释,避免后人看不懂。
批量数据显示的性能优化,核心原则是“减少 GDI 绘图次数,而不是减少数据量”。一次刷新周期内,所有数据点应当一次性提交给控件,绝不可以在刷新循环里逐点 SetYValue。往深了说,GDI 绘制大量折线点的瓶颈在于 MoveTo/LineTo 的调用次数,ChartCtrl 把整条曲线交给 GDI 的 Polyline 接口处理,这本身就已经是相对高效的方式。我们能做得更好的,是缩短刷新周期内的无效重绘——数据没变化时不刷新,只看前景窗口时才刷新。这一步在小内存的工控机上效果明显。
我自己的长期习惯是,在所有调用 m_chartCtrl.EnableRefresh(true) 的地方,加一个数据变化检测。只有在最新数据和上一帧数据不同的时候才触发重绘,让 99% 的无意义刷新直接跳过。代码层面不复杂,效果却非常可观:CPU 占用从常驻 15% 降到 3% 以下,整机的 UI 响应都会爽快起来。
另一个容易被忽略的优化点是坐标轴文字的重绘开销。如果每秒刷新 20 次,而坐标轴文字完全没有变化,理论上应该关闭坐标轴的重绘。虽然 ChartCtrl 没有直接提供独立开关,但如果你发现数据显示流畅度不够,而坐标轴刻度又特别密,试着把网格和刻度调的稀疏一些,GDI 负担会立刻下来。这是很多老手压箱底的优化手段。
把 ChartCtrl 用顺手之后,你会形成一套固定的接入习惯:先跑最小用例通环境,再锁坐标轴,再挂数据源,最后做刷新率和性能优化。这套顺序反过来做,往往会让问题排查变得困难。希望这份笔记能帮你少走一些弯路。
本文还有配套的精品资源,点击获取