资讯详情

资讯详情

MFC ChartCtrl 快速实现实时波形显示:从集成到性能优化

简介这份源码资源面向具备一定C基础的Windows开发者尤其是需要在MFC工程中快速实现数据可视化的初学者与进阶者。它围绕ChartCtrl控件展开演示了从控件初始化、波形数据组织、折线绘制到定时刷新与鼠标交互事件处理的完整流程可帮助读者理解在VC对话框程序中嵌入图表控件并驱动动态波形显示的核心思路。资源包共80个文件以36个h头文件与26个cpp源文件为主体辅以inl内联实现、ico图标、vcxproj工程文件、rc资源脚本及sln解决方案等压缩包约41.86MB工程结构完整可直接用VS2015企业版打开编译运行。目前已有3852人学习下载适合作为MFC图表开发的入门范例通过阅读与调试源码读者能掌握AddSeries、坐标轴设定、数据点追加及OnTimer刷新等关键用法并借鉴颜色、网格线、图例等美化技巧为后续开发实时监控或数据分析类界面打下基础。1. 从一块“画不出波形”的工控屏说起ChartCtrl 到底能省多少事前阵子帮一个做数据采集的朋友看代码他那个 MFC 上位机要实时显示 8 通道的电压波形一开始用自绘 CDC 硬画结果刷新一快就闪屏鼠标拖动窗口直接卡成幻灯片。后来换成 ChartCtrl 控件同样的硬件、同样的采样率波形稳了代码量还砍掉一大半。这件事让我意识到很多做 MFC 上位机的同行其实卡在同一个地方不是不会采数据而是不知道怎么把数据“又快又稳”地画出来。这份《MFC 利用 ChartCtrl 快速实现波形显示实例源码》要解决的正是这个痛点。ChartCtrl 是一个基于 MFC 的免费图表控件库封装了坐标轴、曲线、图例、网格这些常用元素你不需要从 GDI 的 MoveTo/LineTo 开始啃只要把数据点喂进去它负责渲染。适合谁做工业上位机、仪器仪表界面、传感器数据监控的 C 开发者尤其是那些用 MFC 做界面、又不想引入 Qt 或第三方商业图表库的团队。源码包里通常包含控件源码、示例工程和必要的头文件拿到手编译就能跑起来看效果。2. ChartCtrl 的绘图机制与工程集成为什么它比自绘 CDC 稳2.1 双缓冲与分层渲染闪屏问题的根源在哪自绘 CDC 闪屏的根本原因是每次重绘都直接往屏幕 DC 上画窗口一刷新背景擦除和前景绘制之间有个时间差人眼就看到了闪烁。ChartCtrl 内部做了两件事一是用内存 DC 做双缓冲所有绘制先在内存位图上完成再一次性 BitBlt 到屏幕二是把坐标轴、网格、曲线分成不同的图层数据更新时只重绘曲线层坐标轴和网格不动。这个机制决定了它的性能边界如果你的数据点每秒更新几千个曲线层重绘的开销主要在路径构建上而不是屏幕刷新。我一般会建议把采样数据先攒在一个环形缓冲区里每 50ms 触发一次重绘而不是每来一个点就刷新一次。这样既不会丢数据也不会把 CPU 吃满。2.2 把控件集成进对话框工程四步走集成 ChartCtrl 不需要改工程架构按下面四步走就行。假设你已经有一个基于对话框的 MFC 工程工程名用WaveDemo代称。第一步把控件源码目录下的ChartCtrl.h、ChartCtrl.cpp、ChartCtrlLib相关文件复制到工程目录在 VS 的“解决方案资源管理器”里右键“头文件”和“源文件”分别添加。第二步在对话框头文件里包含控件头文件并声明一个成员变量// WaveDemoDlg.h #include ChartCtrl.h // 控件主头文件 class CWaveDemoDlg : public CDialogEx { // ... 其他成员 private: CChartCtrl m_ChartCtrl; // 图表控件对象 };第三步在对话框的DoDataExchange里做 DDX 绑定或者在OnInitDialog里手动创建。手动创建更灵活适合控件是动态生成的场景// WaveDemoDlg.cpp BOOL CWaveDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 在对话框客户区创建一个图表控件ID 用占位符 CRect rect; GetClientRect(rect); rect.DeflateRect(20, 20, 20, 20); // 留出边距 if (!m_ChartCtrl.Create(this, rect, IDC_CHART_PLACEHOLDER)) { AfxMessageBox(_T(ChartCtrl 创建失败)); return FALSE; } // 初始化坐标轴和曲线见下一节 InitChart(); return TRUE; }第四步在对话框的OnSize里让控件跟随窗口缩放否则窗口拉大后图表还是原来大小void CWaveDemoDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (m_ChartCtrl.GetSafeHwnd()) { CRect rect(20, 20, cx - 20, cy - 20); m_ChartCtrl.MoveWindow(rect); } }这四步做完编译应该能过。如果报链接错误检查一下控件的.cpp文件是否都加进了工程以及是否在stdafx.h或pch.h里包含了必要的 GDI 头文件。ChartCtrl 内部用到了 GDI需要在工程里初始化 GDI常见做法是在CWinApp::InitInstance里调用GdiplusStartup退出时GdiplusShutdown。2.3 坐标轴与曲线的初始化参数怎么设控件创建好之后默认是空的需要手动添加坐标轴和曲线。下面这段代码初始化一个 X 轴为时间、Y 轴为电压的双轴图表void CWaveDemoDlg::InitChart() { // 获取 X 轴和 Y 轴对象0 表示底部轴1 表示左侧轴 CChartAxis* pBottomAxis m_ChartCtrl.GetBottomAxis(); CChartAxis* pLeftAxis m_ChartCtrl.GetLeftAxis(); // 设置轴标题 pBottomAxis-SetText(_T(时间 (s))); pLeftAxis-SetText(_T(电压 (V))); // 设置轴范围X 轴 0~10 秒Y 轴 -5~5 伏 pBottomAxis-SetMinMax(0.0, 10.0); pLeftAxis-SetMinMax(-5.0, 5.0); // 设置网格线主刻度 1.0次刻度 0.2 pBottomAxis-SetTickLength(5); pLeftAxis-SetTickLength(5); m_ChartCtrl.SetGridVisible(true); // 添加一条曲线返回曲线对象指针 m_pSeries m_ChartCtrl.AddSeries(CChartCtrl::Line); if (m_pSeries ! nullptr) { m_pSeries-SetColor(RGB(0, 180, 0)); // 绿色曲线 m_pSeries-SetLineWidth(2); // 线宽 2 像素 m_pSeries-SetVisible(true); } }参数说明SetMinMax设的是轴的显示范围不是数据范围数据超出范围会被裁剪AddSeries的参数决定曲线类型Line是折线还有Bar、Points等SetLineWidth的单位是像素太细在工控屏上看不清太粗会盖住相邻曲线。我一般把线宽设在 2 到 3 之间颜色避开红绿同时出现防止色弱用户分不清。3. 实时波形数据推送从定时器到环形缓冲区的完整链路3.1 数据更新频率与重绘策略的匹配波形显示的核心矛盾是数据来得快重绘慢。假设采样率是 1kHz每秒 1000 个点如果每个点都触发一次Invalidate界面线程基本没时间处理消息鼠标都会卡住。正确的做法是解耦数据采集线程只管往缓冲区写界面线程按固定节奏读缓冲区并重绘。常见做法是用一个环形缓冲区ring buffer加一个定时器。定时器周期设 50ms也就是每秒重绘 20 次人眼看起来已经足够流畅。每次定时器触发时把缓冲区里新增的点追加到曲线数据里然后调用m_ChartCtrl.RefreshCtrl()触发重绘。// 环形缓冲区容量 4096 个点 #define BUF_SIZE 4096 struct SamplePoint { double time; double value; }; SamplePoint g_RingBuf[BUF_SIZE]; volatile LONG g_WriteIdx 0; // 写入位置 volatile LONG g_ReadIdx 0; // 读取位置 // 采集线程调用写入一个点 void PushSample(double t, double v) { LONG idx InterlockedIncrement(g_WriteIdx) % BUF_SIZE; g_RingBuf[idx].time t; g_RingBuf[idx].value v; } // 界面定时器调用读取新增点并追加到曲线 void CWaveDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent TIMER_REFRESH) { LONG write g_WriteIdx; while (g_ReadIdx ! write) { LONG idx InterlockedIncrement(g_ReadIdx) % BUF_SIZE; m_pSeries-AddPoint(g_RingBuf[idx].time, g_RingBuf[idx].value); } // 控制曲线点数超过 2000 点就移除最旧的 if (m_pSeries-GetPointsCount() 2000) m_pSeries-RemovePoints(0, m_pSeries-GetPointsCount() - 2000); m_ChartCtrl.RefreshCtrl(); } CDialogEx::OnTimer(nIDEvent); }逻辑说明InterlockedIncrement保证多线程下索引递增的原子性AddPoint是追加单点如果一次要追加一批可以用AddPoints批量接口减少内部锁的开销RemovePoints用来控制曲线总点数不控制的话内存会一直涨最后卡死。参数上缓冲区容量和曲线保留点数要匹配缓冲区太小会丢数据曲线保留点数太多会拖慢重绘。我一般让曲线保留点数等于缓冲区容量的一半左右。3.2 多通道波形的颜色与图例管理8 通道波形如果颜色相近根本分不清哪条是哪条。ChartCtrl 支持给每条曲线单独设颜色和图例名称struct ChannelConfig { COLORREF color; LPCTSTR name; }; ChannelConfig g_Channels[8] { { RGB(220, 20, 60), _T(CH1) }, { RGB(30, 144, 255), _T(CH2) }, { RGB(50, 205, 50), _T(CH3) }, { RGB(255, 165, 0), _T(CH4) }, { RGB(148, 0, 211), _T(CH5) }, { RGB(0, 206, 209), _T(CH6) }, { RGB(255, 105, 180), _T(CH7) }, { RGB(139, 69, 19), _T(CH8) }, }; // 初始化时循环添加曲线 for (int i 0; i 8; i) { CChartSeries* pSeries m_ChartCtrl.AddSeries(CChartCtrl::Line); pSeries-SetColor(g_Channels[i].color); pSeries-SetLineWidth(2); pSeries-SetName(g_Channels[i].name); // 图例名称 m_SeriesArray.Add(pSeries); }图例默认显示在右上角如果挡住波形可以用m_ChartCtrl.SetLegendPosition挪到下方或隐藏。颜色选择上避开纯红和纯绿同时出现因为红绿色盲用户占比不低工控现场又经常有老师傅配色要考虑可读性。3.3 暂停、缩放与游标交互功能的实现边界实时波形跑起来之后用户一定会要暂停、缩放、看某个点的值。ChartCtrl 本身提供了缩放接口但和实时刷新会冲突缩放后如果继续自动滚动视图会被拉回去。常见做法是加一个“暂停”按钮暂停时停止定时器允许用户自由缩放和拖动恢复时重新对齐时间轴。void CWaveDemoDlg::OnBnClickedPause() { if (m_bPaused) { SetTimer(TIMER_REFRESH, 50, nullptr); // 恢复刷新 m_bPaused false; } else { KillTimer(TIMER_REFRESH); // 暂停刷新 m_bPaused true; } }游标取值需要自己处理鼠标消息把屏幕坐标转成数据坐标再在曲线上找最近点。ChartCtrl 没有内置游标这部分要自己写工作量不大但要注意坐标转换的精度。如果项目对交互要求高建议把游标做成独立图层不要混在曲线层里。4. 避坑与排查ChartCtrl 集成中最容易翻车的五个地方4.1 编译报错“无法解析的外部符号”现象工程编译时提示CChartCtrl::Create或CChartCtrl::AddSeries无法解析。原因通常是控件的.cpp文件没有全部加入工程或者 GDI 库没有链接。解决检查ChartCtrl.cpp、ChartCtrlLib.cpp等源文件是否都在“源文件”过滤器下在工程属性 → 链接器 → 输入 → 附加依赖项里加上gdiplus.lib。4.2 波形刷新时界面闪烁现象定时器触发重绘时整个对话框闪一下。原因可能是对话框本身没有开启WS_CLIPCHILDREN样式导致父窗口重绘时把控件区域也擦了。解决在对话框属性里勾选“Clip Children”或者在OnInitDialog里手动加样式ModifyStyle(0, WS_CLIPCHILDREN);4.3 数据点追加后曲线不显示现象AddPoint调用了但界面上看不到曲线。原因通常是轴范围没设对数据点落在显示范围之外。解决检查SetMinMax的范围是否覆盖了数据值如果是动态范围需要在每次追加数据后根据最大值最小值调整轴范围但频繁调整会导致坐标轴跳动建议用固定范围加越界裁剪。4.4 长时间运行后内存持续增长现象程序跑几个小时后内存占用从几十兆涨到几百兆。原因是没有限制曲线点数AddPoint一直在追加内部数组无限扩张。解决每次追加后检查点数超过阈值就调用RemovePoints移除最旧的数据。阈值根据屏幕像素和采样率来定一般 2000 到 5000 点足够。4.5 多线程直接操作控件导致崩溃现象采集线程里直接调用m_pSeries-AddPoint偶尔程序崩在 GDI 内部。原因是 ChartCtrl 不是线程安全的所有控件操作必须在界面线程完成。解决采集线程只写环形缓冲区界面定时器读缓冲区并更新控件。如果一定要跨线程用PostMessage把数据指针传回界面线程处理。5. 进阶技巧让波形显示从“能用”到“好用”5.1 用批量接口替代单点追加单点AddPoint每次都会触发内部数组的扩容和锁操作数据量大时开销明显。ChartCtrl 提供了AddPoints批量接口一次追加一组点内部只做一次扩容。我一般会在定时器里攒够 50 个点再批量追加而不是来一个加一个。// 批量追加示例 std::vectordouble times, values; // ... 从环形缓冲区读取一批数据填充 times 和 values m_pSeries-AddPoints(times.data(), values.data(), (int)times.size());参数说明AddPoints接受两个double数组指针和点数数组长度必须一致。批量接口的代价是内存拷贝所以批量大小要权衡太小没效果太大增加延迟。50 到 200 之间比较合适。5.2 动态轴范围与自动滚动实时波形通常需要 X 轴随时间滚动只显示最近 10 秒的数据。做法是每次追加数据后把 X 轴范围设为[latestTime - 10, latestTime]同时把超出范围的点移除。这样曲线看起来是向左滚动的符合工控习惯。double latestTime m_pSeries-GetXValue(m_pSeries-GetPointsCount() - 1); m_ChartCtrl.GetBottomAxis()-SetMinMax(latestTime - 10.0, latestTime);注意SetMinMax会触发重绘不要在循环里频繁调用每次定时器刷新调一次就够了。5.3 导出波形数据与截图工控软件经常需要把波形保存下来做报告。ChartCtrl 没有内置导出功能但可以自己实现数据导出就是把曲线上的点写进 CSV截图则是用CChartCtrl的DrawToDC画到内存 DC再保存为 BMP。// 导出 CSV void ExportToCsv(CChartSeries* pSeries, LPCTSTR path) { CStdioFile file; if (!file.Open(path, CFile::modeCreate | CFile::modeWrite | CFile::typeText)) return; CString line; for (int i 0; i pSeries-GetPointsCount(); i) { line.Format(_T(%.6f,%.6f\n), pSeries-GetXValue(i), pSeries-GetYValue(i)); file.WriteString(line); } file.Close(); }截图部分需要创建一个兼容 DC 和位图调用控件的绘制函数然后保存。这部分代码稍长但逻辑不复杂关键是记得释放 GDI 对象否则会泄漏。5.4 性能边界与替代方案ChartCtrl 适合中等数据量的实时显示大概每秒几千个点、曲线条数在 10 条以内。如果数据量再大一个数量级或者需要 3D 显示、复杂交互建议考虑 Direct2D 自绘或换用更专业的图表库。我自己的经验是先用 ChartCtrl 把功能跑通如果性能不够再针对性优化不要一上来就追求极致性能那样开发周期会失控。从那以后我每次集成图表控件都会先写一个最小可运行的波形 Demo把数据推送、刷新、暂停、导出这四条链路走通再往正式工程里搬。这样即使遇到玄学问题也能快速定位是控件本身还是工程配置的锅。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →