
简介这份源码资源面向具备一定C基础的Windows开发者尤其是需要在MFC工程中快速实现数据可视化的初学者与工程人员。它通过一个可运行的对话框程序演示如何借助ChartCtrl控件完成波形数据的采集、绘制与实时刷新解决传统绘图方式代码繁琐、交互性差的问题。压缩包共80个文件约41.86MB以h头文件与cpp源文件为主体辅以inl内联实现、rc资源脚本、vcxproj工程配置及sln解决方案等构成完整的VS2015工程结构。目前已有3852人学习下载说明其在同类实例中具有较好的参考价值。源码覆盖控件初始化、数据组织、坐标轴设定、定时器刷新与鼠标事件响应等关键环节读者可据此掌握折线波形绘制、实时更新与界面美化的基本套路并在此基础上扩展出多系列曲线、缩放拖动等交互功能适合作为MFC图表开发的入门练手项目。1. 从一块“画不出波形”的对话框说起接手过一个工业采集上位机的维护对话框上摆着四个静态文本框原本想用它们轮流刷数字来模拟波形结果数据一上到 200Hz界面直接卡成幻灯片。后来翻到这份 MFC 利用 ChartCtrl 快速实现波形显示的实例源码才意识到问题不在采集线程而在绘图方式——用 GDI 手搓折线每帧重画整个客户区不卡才怪。这份源码干的事很明确在 MFC 对话框工程里集成 ChartCtrl 这个开源图表控件把实时数据点喂进去让控件自己负责坐标轴、网格、缩放和刷新。适合两类人一类是刚接触 MFC、需要快速给采集程序加波形窗口的新手另一类是老手想找一个不依赖第三方商业库、能直接嵌进现有对话框工程的轻量绘图方案。它不解决数据采集和协议解析只解决“数据有了怎么稳定画出来”这一段。2. 工程结构与 ChartCtrl 集成方式先看清控件是怎么塞进对话框的2.1 目录里都有什么拿到源码包第一件事不是编译是看目录。这类 MFC 实例通常长这样ChartCtrlDemo/ ├── ChartCtrlDemo.sln ├── ChartCtrlDemo/ │ ├── ChartCtrlDemo.h/.cpp // 主对话框 │ ├── ChartCtrlDemo.rc // 资源含对话框模板 │ ├── ChartCtrl/ // 控件源码目录 │ │ ├── ChartCtrl.h/.cpp │ │ ├── ChartAxis.h/.cpp │ │ ├── ChartSerie.h/.cpp │ │ ├── ChartGrid.h/.cpp │ │ └── ChartMouseListener.h │ └── res/ChartCtrl目录是整个方案的核心它不是一个预编译的 DLL而是以源码形式参与编译。这意味着你可以直接在里面下断点、改绘制逻辑不用去猜黑匣子里发生了什么。常见做法是把ChartCtrl文件夹整个拷进自己的工程目录然后在 VS 里“添加现有项”把.h和.cpp全部加进来而不是去配什么库路径。2.2 对话框上怎么放控件ChartCtrl 不是标准控件资源编辑器里找不到它。集成方式有两种源码里一般用的是第一种方式一用静态文本框占位代码里子类化替换。在对话框模板上拖一个 Static TextID 改成IDC_STATIC_CHART然后在对话框类的头文件里声明成员// ChartCtrlDemoDlg.h #include ChartCtrl/ChartCtrl.h class CChartCtrlDemoDlg : public CDialogEx { // ... protected: CChartCtrl m_ChartCtrl; // 图表控件对象 CChartSerie* m_pSerie; // 数据序列指针后面往里塞点 // ... };在OnInitDialog里做子类化BOOL CChartCtrlDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 把资源里的静态框替换成 ChartCtrl CWnd* pPlaceholder GetDlgItem(IDC_STATIC_CHART); if (pPlaceholder) { CRect rect; pPlaceholder-GetWindowRect(rect); ScreenToClient(rect); m_ChartCtrl.Create(this, rect, IDC_STATIC_CHART); } // 创建一条折线序列返回指针用于后续加点 m_pSerie m_ChartCtrl.CreateSerie(_T(采样通道), 0); // 设置 X 轴为时间轴范围 0~10 秒 m_ChartCtrl.GetBottomAxis()-SetMinMax(0, 10); m_ChartCtrl.GetLeftAxis()-SetMinMax(-1.5, 1.5); return TRUE; }这里几个参数值得说清楚。Create的第一个参数是父窗口传this表示挂在对话框上第二个是客户区矩形用占位控件的位置这样布局跟资源编辑器里拖的一致第三个是控件 ID。CreateSerie的第一个参数是序列名会显示在图例里第二个参数是序列类型0 通常代表折线具体取值要看ChartSerie.h里的枚举定义。坐标轴范围不设的话控件会自动缩放但实时波形场景下自动缩放会导致画面抖动所以一般手动固定。方式二纯代码创建不依赖资源占位。直接算好矩形调Create适合动态生成的界面。两种方式没有优劣看工程习惯。2.3 控件内部的分层ChartCtrl 的类结构是分层的理解这个对后面改代码很重要类职责CChartCtrl总控管理坐标轴、序列、网格负责整体绘制调度CChartAxis单条坐标轴管刻度、范围、标签CChartSerie一条数据序列存点、管自己的画笔和样式CChartGrid背景网格线绘制时CChartCtrl::OnPaint先画网格再画坐标轴最后遍历所有序列画数据。你要改波形颜色、线宽动的是CChartSerie要改刻度密度动的是CChartAxis。这个分层让改动点很清晰不会牵一发动全身。3. 数据刷新链路从定时器到屏幕像素3.1 最朴素的刷新方式源码里最常见的做法是定时器驱动。在OnInitDialog里起一个定时器SetTimer(1, 50, nullptr); // 50ms 刷新一次约 20 帧然后在OnTimer里追加数据点void CChartCtrlDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { static double t 0.0; t 0.05; // 模拟一个正弦采样值 double value sin(t * 2 * 3.14159); // 往序列里追加一个点 m_pSerie-AddPoint(t, value); // 让 X 轴跟着数据滚动保持最近 10 秒可见 if (t 10.0) m_ChartCtrl.GetBottomAxis()-SetMinMax(t - 10.0, t); // 请求重绘 m_ChartCtrl.RefreshCtrl(); } CDialogEx::OnTimer(nIDEvent); }AddPoint是往序列尾部追加内部用std::vector之类的容器存点。RefreshCtrl触发重绘不是Invalidate因为控件内部做了双缓冲直接Invalidate会闪。X 轴滚动这段是实时波形的关键如果不滚动点会一直往右跑出可视区滚动范围设成t-10到t画面就始终显示最近 10 秒。3.2 点数一多就卡问题出在哪上面这段代码跑几分钟没问题跑半小时就完了——序列里的点只增不减AddPoint每次追加绘制时遍历全部点。几万个点之后每帧遍历开销上来界面开始顿。源码里如果带了环形缓冲或点数上限的处理通常是这样// 在追加前检查超过上限就删掉最老的 void CChartCtrlDemoDlg::AppendPoint(double x, double y) { const int MAX_POINTS 2000; // 可视区最多保留 2000 个点 m_pSerie-AddPoint(x, y); // 超出上限时移除最早的点 while (m_pSerie-GetPointsCount() MAX_POINTS) { m_pSerie-RemovePoint(0); // 移除索引 0即最老的点 } }MAX_POINTS怎么定跟刷新率和可视时间窗有关。50ms 一帧10 秒窗口就是 200 个点留 2000 是十倍余量足够应对突发。RemovePoint(0)是移除头部如果内部是vector这个操作是 O(n)频繁调用反而更慢。更稳的做法是用std::deque或在序列内部维护环形索引但那是改控件源码的事了后面二次开发部分再说。3.3 多通道怎么画一个对话框上画四路波形不是创建四个 ChartCtrl而是在同一个控件里创建四条序列m_pSerieCh1 m_ChartCtrl.CreateSerie(_T(通道1), 0); m_pSerieCh2 m_ChartCtrl.CreateSerie(_T(通道2), 0); m_pSerieCh3 m_ChartCtrl.CreateSerie(_T(通道3), 0); m_pSerieCh4 m_ChartCtrl.CreateSerie(_T(通道4), 0); // 分别设置颜色避免四条线糊在一起 m_pSerieCh1-SetColor(RGB(255, 0, 0)); m_pSerieCh2-SetColor(RGB(0, 128, 0)); m_pSerieCh3-SetColor(RGB(0, 0, 255)); m_pSerieCh4-SetColor(RGB(128, 0, 128));四条序列共享同一套坐标轴所以量程要统一。如果四路信号量级差很多要么在数据层做归一化要么给每条序列挂独立的 Y 轴——ChartCtrl 支持多 Y 轴但配置起来麻烦实例源码里一般不做这么复杂。4. 避坑与排查编译能过但画不出来的几种情况4.1 控件区域一片灰什么都没有现象程序跑起来对话框上该显示波形的地方是灰的没有坐标轴也没有网格。原因最常见的是Create的矩形算错了。用GetWindowRect拿到的是屏幕坐标没做ScreenToClient转换控件被创建到了客户区外面。另一种是占位静态框的Visible属性没去掉它盖在 ChartCtrl 上面。解决确认ScreenToClient调了在资源编辑器里把占位静态框的Visible取消勾选。如果还不行在Create之后加一句m_ChartCtrl.ShowWindow(SW_SHOW)强制显示排查是不是创建时被隐藏了。4.2 加了点但曲线不出现现象AddPoint返回值正常序列里也有点但屏幕上就是没线。原因坐标轴范围没设或者设反了。比如数据 X 在 0 到 100 之间但轴范围是 0 到 10点全在可视区外。另一种是序列的画笔宽度为 0 或颜色跟背景一样。解决在加点之前先把轴范围打出来看GetBottomAxis()-GetMin()和GetMax()。临时把轴范围设成数据实际范围确认线能出来再调回正常范围。颜色问题就SetColor设个显眼的红。4.3 刷新时界面闪烁现象每次RefreshCtrl整个对话框都闪一下。原因对话框本身没开双缓冲或者RefreshCtrl内部调了Invalidate而不是InvalidateRect只刷控件区域。解决在对话框的OnInitDialog里加ModifyStyleEx(0, WS_EX_COMPOSITED)给整个对话框开双缓冲。如果还闪检查是不是在OnPaint里做了额外绘制。ChartCtrl 自身是双缓冲的闪的通常是父窗口。4.4 长时间运行后内存涨现象任务管理器里内存曲线一直往上走几小时不回落。原因序列只加点不删点容器无限增长。如果每次OnTimer还new了临时对象没释放涨得更快。解决按 3.2 节加点数上限。另外检查OnTimer里有没有new出来的CString或数组没delete。MFC 里CString是值语义一般不用手动释放但new char[]这种就要自己管。4.5 多线程往里塞数据崩溃现象采集线程直接调AddPoint跑一会儿随机崩崩的位置在vector扩容附近。原因ChartCtrl 不是线程安全的AddPoint改容器UI 线程同时在遍历容器绘制读写冲突。解决采集线程把数据丢进一个线程安全的队列UI 线程的OnTimer从队列取数据再AddPoint。不要跨线程直接操作控件这是 MFC 的铁律不是 ChartCtrl 特有的问题。5. 二次开发把 ChartCtrl 改成能扛住长时间运行的波形控件5.1 先解决点数上限的性能问题前面提到RemovePoint(0)在vector上是 O(n)2000 个点每次删头部一帧删几十个累积起来就是可观的耗时。我一般会直接改CChartSerie内部的存储结构把std::vector换成std::deque或者更彻底一点用环形缓冲// 在 ChartSerie.h 里把存储容器换掉 // 原来可能是std::vectorCChartPoint m_vPoints; // 改成环形缓冲固定容量头尾指针移动不搬数据 class CChartSerie { // ... private: std::vectorCChartPoint m_buffer; // 预分配固定大小 int m_nHead; // 下一个写入位置 int m_nCount; // 当前有效点数 int m_nCapacity; // 容量上限 };AddPoint变成写m_buffer[m_nHead]然后m_nHead (m_nHead 1) % m_nCapacity。RemovePoint变成移动尾指针。这样加点、删点都是 O(1)跟点数无关。代价是要改绘制循环从“遍历 0 到 count”变成“从尾指针开始绕一圈”。改动量不大但收益很明显——我实测过同样 2000 点环形缓冲下 CPU 占用从 8% 降到 2% 左右。5.2 加一个“暂停/继续”按钮实时波形经常需要暂停下来看细节。实现很简单加一个BOOL m_bPaused成员在OnTimer开头判断void CChartCtrlDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1 !m_bPaused) { // ... 正常追加数据 } CDialogEx::OnTimer(nIDEvent); }按钮的响应函数里翻转m_bPaused就行。注意暂停时不要停定时器停了再恢复会有时间跳变数据时间戳对不上。让定时器继续跑只是不追加点这样恢复时时间轴是连续的。5.3 导出当前波形为图片ChartCtrl 内部有绘制到 DC 的能力可以复用它把当前画面存成 BMPvoid CChartCtrlDemoDlg::OnBnClickedExport() { CClientDC dc(m_ChartCtrl); CRect rect; m_ChartCtrl.GetClientRect(rect); // 创建内存 DC 和位图 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rect.Width(), rect.Height()); CBitmap* pOld memDC.SelectObject(bmp); // 让控件把自己画到内存 DC 上 m_ChartCtrl.DrawToDC(memDC, rect); // 保存为文件 // ... 用 CImage 或 GDI 编码保存 memDC.SelectObject(pOld); }DrawToDC不一定是控件现成的方法可能需要自己加一个把OnPaint里的绘制逻辑抽出来接受一个CDC*参数。这是二次开发里很典型的一步把绘制和窗口解耦之后导出、打印、缩略图都能复用同一套代码。5.4 改造后的验证方法改完控件源码别急着往正式工程里合。先跑一个压力测试起一个定时器每 10ms 往里塞 100 个点连续跑两小时看内存和 CPU 曲线是否平稳。再跑一个边界测试数据全为 0、全为最大值、突然从最大值跳到最小值看坐标轴和曲线有没有异常。最后跑一个交互测试暂停、恢复、导出、缩放确认没有状态残留。这三步走完基本能保证改动是稳的。从那以后我每次动绘图控件的底层都强制走一遍“压力 边界 交互”的验证流程不再凭感觉合代码。希望这份拆解能帮你少走点弯路。本文还有配套的精品资源点击获取