简介面向 MFC 界面开发者的动态绘图示例工程重点演示 TeeChart 与 CChartCtrl 两种常用绘图控件在对话框程序中绘制实时曲线的方法适合需要快速搭建数据监控或波形显示界面的中初级 MFC 开发者学习、改造。压缩包共 88 个文件以 38 个头文件、27 个 C 源文件为主还包含内联实现、Visual Studio 工程配置、说明文档和资源文件整体约 343KB结构紧凑可直接导入工程参考。目前已有 5052 人学习下载。工程内含完整的 TeeChart 封装示例与 CChartCtrl 全套 Chart 系列源码覆盖控件初始化、坐标轴与图例设置、数据更新、曲线动态刷新等关键环节同时附带多篇绘图控件文章链接可延伸了解 Hight-Speed Charting 等更多高频绘图方案对需要掌握 MFC 动态绘图思路的开发者很有参考价值。 在MFC下做动态曲线绘制说句实在话很多老项目到了这一步都会纠结一阵子。不是没得选而是可选方案太多——TeeChart功能全但体积重HightSpeedChart轻快但收敛两个凑在一起正好覆盖了从“仪表盘大杂烩”到“专业高速数据监视器”两头的需求。这篇文章就把我实际用这两款控件做动态曲线的过程掰开揉碎讲清楚包括控件集成、数据推送、坐标轴滚动、防闪烁、性能优化以及那些代码文档里不会告诉你的坑。适合正在用MFC做数据采集、设备监控、波形显示或者被“曲线一多就卡死”折磨的朋友参考。1. 为什么偏偏要在MFC下做动态曲线1.1 这类需求最常见的业务场景我见过最多的场景是工控领域的数据采集和设备监测串口或网口不断上报电压、温度、转速、振动信号界面需要把这些实时数据画成滚动曲线同时用户还得能回看历史、缩放局部。另一个常见场景是实验室里的波形显示采样率动不动就是每秒几千上万点曲线不能掉帧界面还不能闪烁。这两类需求放在一起就是“动态绘制”最真实的写照。动态和静态的最大区别在于数据是持续追加的图形每秒钟都要刷新并且图表窗口要么跟着最新数据自动滚动要么在固定窗口内显示最新一段。这意味着绘图控件不能简单调用一次Invalidate就完事必须考虑数据缓存、增量追加、范围重算和渲染效率。MFC 至今还在被大量工业项目使用原因很好理解老代码积累多、稳定性要求高、团队熟悉度高。在这种环境下引入第三方绘图控件首先就要考虑它能不能和 CView、CDialog、CFormView 这些经典架构融合。TeeChart 和 HightSpeedChart 走的恰好是两条不同的融合路线正好适合做对比选型。1.2 两个控件的定位差异TeeChart 是 Steema 公司出品的商业图表控件对应 MFC 的版本主要通过 ActiveX 或原生类库方式集成。它最大的优势是图表类型丰富折线、面积、柱状、饼图、极坐标、甘特图基本都齐了还有坐标轴、图例、标注、多系列、3D 效果这些完整配套。你可以把它理解成“图表界的大超市”想要什么基本都能找到。HightSpeedChart 指的通常是 CodeProject 上那款开源的 High-Speed Charting 控件作者是 Timothy Weber以一个 CChartCtrl 类为核心整套源码可以直接编进 MFC 工程里。它没有那么多花哨的图表类型专注做高频数据曲线内部做了数据减采样和局部重绘优化特别适合每秒几千上万个点的实时数据流。它的定位更像“专攻一件事的赛车”跑得快但别指望它给你画仪表盘。两者不冲突。我个人的选型经验是如果项目里除了实时曲线还要做报表统计、历史趋势分析直接上 TeeChart省得自己拼好几个控件如果项目目标很单纯就是高性能实时监视HightSpeedChart 体积小、上手快、还免费性价比极高。2. TeeChart的MFC集成配置、FastLine与滚动窗口2.1 工程里引入TeeChart的几种方式TeeChart 在 MFC 里的集成大体分两条路。一条是 ActiveX 控件方式在对话框资源编辑器里把 TeeChart 组件拖到窗体上它会自动生成一个 CWnd 派生包装类通常是 CTChart。这种方式最直观界面设计阶段就能看到图表效果适合交互复杂、需要多次试调布局的场景。另一条是原生类库方式TeeChart 也提供 C 类接口通过头文件和静态库直接链接进工程运行时不需要 ActiveX 注册。这条路线隐藏了 COM 的复杂度部署时少了一堆注册环节对讲究装机即用的工业软件更友好。我实际项目里大多采用 ActiveX 动态创建的方式。原因有两个一是开发调试时可以借助 ActiveX 自带的右键“Edit Chart”快速调整样式和坐标轴改完代码立刻能验证二是多语言资源切换时ActiveX 的属性接口封装得更完整。关键代码如下// 在对话框头文件中声明包装对象 CTChart m_wndChart; // OnInitDialog 中创建控件 CRect rcChart(10, 10, 800, 400); BOOL bOK m_wndChart.CreateControl( _T(TeeChart.TChart.9), WS_CHILD | WS_VISIBLE | WS_BORDER, rcChart, this, IDC_TEE_CHART); if (!bOK) { AfxMessageBox(_T(TeeChart 控件创建失败请检查是否已安装授权。)); return FALSE; }注意上面用的 ProgID 里的版本号要跟你装的一致装的是 8 就写成TeeChart.TChart.8装的是 9 就写成.9版本对不上会直接创建失败。2.2 动态数据推送的核心调用控件创建出来之后第一步是准备一个曲线系列。实时曲线建议用 FastLine它是 TeeChart 专门为高速折线提供的系列类型底层对大量数据点的渲染做了特殊优化比普通 Line 快得多。// 获取第一个系列并设置为 FastLine 类型也可以用 AddSeries 新增 CSeries series m_wndChart.Series(0); series.SetSeriesType(cdoFastLine); series.SetColor(RGB(0, 128, 255)); // 清空旧数据准备新一轮动态推送 series.Clear();之后每次采集到新数据就执行一次追加double dX GetTickCount64() / 1000.0; // 以秒为单位作为 X 轴时间 double dY CurrentValue; // 来自传感器或采集卡的实际数值 CSeries series m_wndChart.Series(0); series.AddXY(dX, dY, _T(), clTeeColor);这一句AddXY是整个动态绘制的核心。要注意 ActiveX 接口在每次调用时都要重新获取Series(0)不要把它长期缓存成一个变量尤其是当图表序列在运行中被动态增删时缓存的引用可能失效轻则数据画不上重则直接抛异常。2.3 关键参数滚动、压缩与坐标轴只往里加数据还不够还要让坐标轴动起来。实时监视曲线最常见的需求是X 轴跟随当前时间滚动Y 轴根据数据范围自动缩放。TeeChart 的坐标轴对象提供了一组专门用于滚动和自动范围的方法。// 获取底部 X 轴和左侧 Y 轴 CAxis bottomAxis m_wndChart.GetAxis().GetBottom(); CAxis leftAxis m_wndChart.GetAxis().GetLeft(); // 开启自动缩放确保 Y 轴随数据上下浮动 leftAxis.SetAutomatic(TRUE); // 设置滚动模式只显示最近 10 秒的数据窗口 bottomAxis.SetAutomatic(FALSE); bottomAxis.SetMinimum(CurrentTime - 10); bottomAxis.SetMaximum(CurrentTime);这段代码解决了两个常见问题一是 Y 轴不做自动缩放的话数据跑出可见范围后就只能看到一条顶天立地的直线二是 X 轴如果不固定滚动窗口曲线只在靠近右边一小块区域显示没有滚动效果。把SetMinimum和SetMaximum放到一个OnTimer或者采集线程里每次更新就能看到曲线像示波器一样平滑右移。数据量一旦很大FastLine 自身会触发压缩模式。它不会把每一个点都原样画到屏幕上而是根据屏幕像素宽度计算每个像素列上需要保留的关键极值点其他点直接丢弃。这种“像素级降采样”是 TeeChart 能在数据量很大时依然保持流畅的根本原因。你可以通过GetAspect().SetView3D(FALSE)把 3D 效果关掉进一步降低绘图开销。3. HightSpeedChart实操从源码到高性能曲线3.1 控件源码的集成步骤High-Speed Charting 用起来比 TeeChart 简单得多因为整个控件就是一组源代码文件一般是 ChartCtrl.h、ChartCtrl.cpp 以及配套的 AxisCtrl 等文件。把这些文件拷到工程目录下添加到 MFC 工程里编译就能用不需要注册任何 COM 组件。集成时有个细节容易踩坑这个控件最初是针对纯 MFC 窗口设计的如果你的工程用了 Unicode 字符集而下载的版本里字符串处理没做宽窄字符适配编译时会报一堆C2664类型转换错误。解决方法是全局搜索代码里的TCHAR、CString和_T()宏把硬编码的char*字符串和strcpy全部替换为_tcscpy之类的安全版本。我的做法是直接在工程里关闭_UNICODE或者统一转成宽字符哪个省事用哪个。控件创建方面有两种常见方式。一是在资源编辑器里放一个 Static Text 控件当占位符运行时把图表控件父子化到那个位置// 或者直接动态创建 CRect rcChart(10, 10, 800, 400); m_wndChart.Create(_T(STATIC), _T(), WS_CHILD | WS_VISIBLE | WS_BORDER, rcChart, this, IDC_CHART);静态文本占位的方式更灵活因为 Design Time 阶段可以随意拖动位置和大小运行后只需一句SubclassDlgItem就把控件“贴”上去了。3.2 数据添加与显示范围控制HightSpeedChart 的数据接口和 TeeChart 风格差异很大。它以“添加一个点”或“设置整段数据”为基本操作并且特别强调通过设置显示范围来限制绘图区域而不是把所有数据都丢给系统让它自己决定。// 先设置显示范围X 轴 0~100Y 轴 0~100 m_wndChart.SetDisplayRange(0, 100, 0, 100); // 向图表中添加数据点 for (int i 0; i nCount; i) { m_wndChart.AddData(dX[i], dY[i]); }AddData内部不是直接把所有点写入屏幕而是在绘制时通过可见范围做裁剪只画落在当前矩形区域内的数据点。这就是它高性能的真正秘密数据再多每次重绘也只处理当前窗口里能看到的那部分。动态实时场景下配合SetDisplayRange向右平移窗口double dNewMax CurrentTime; double dNewMin dNewMax - 10; // 同样的 10 秒窗口 m_wndChart.SetDisplayRange(dNewMin, dNewMax, yMin, yMax);这套组合拳打下来效果和 TeeChart 的滚动窗口几乎一样但底层计算量小得多。我在一台老旧双核工控机上实测过每秒追加 2000 个点CPU 占用依然能稳定在个位数百分比。3.3 自适应缩放与交互体验HightSpeedChart 虽然没有 TeeChart 那种“双击弹出编辑器”的杂技但基础交互还是齐全的。它支持鼠标框选放大、右键拖拽平移、双缓冲重绘尤其可贵的是它在缩放时依然只渲染可见点所以你放大到局部之后曲线精度不会因为缩减采样而丢失。交互相关的核心逻辑集中在鼠标消息的处理里。如果不想要默认交互可以在预处理消息的地方拦掉 WM_LBUTTONDOWN 和 WM_MOUSEMOVE。如果希望保留缩放功能但不想用户缩到完全没法看可以在鼠标释放后强制做一次范围校验void CMyView::OnLButtonUp(UINT nFlags, CPoint point) { // 缩放过头的保护确保 X 轴范围不小于 0.1 秒 double dLeft, dRight, dTop, dBottom; m_wndChart.GetDisplayRange(dLeft, dRight, dTop, dBottom); if (dRight - dLeft 0.1) { double dCenter (dLeft dRight) / 2.0; m_wndChart.SetDisplayRange(dCenter - 0.05, dCenter 0.05, dTop, dBottom); } CView::OnLButtonUp(nFlags, point); }这个细节看起来不起眼但操作体验差别很大。没有范围保护的话用户在高速数据流上多框选几次就会把坐标轴缩到小数点后八位图像变成一堆杂乱无章的色块极其劝退。4. 动态绘制核心环节采样、线程与防闪烁4.1 为什么数据一多就卡先说结论卡顿的根源绝大多数不是绘图本身而是无效重绘和被无限放大的数据点。GDI 把一条折线渲染出来的开销和点的数量成正比。如果你每次刷新都把历史所有点重新画一遍数据量翻一倍耗时就翻一倍。假如历史数据累计了 20 万个点即便每帧只画 1000 个可见点如果代码里没做范围裁剪实际还是要遍历全部 20 万个点去判断哪些落在可见区域性能就这么白白消耗掉了。TeeChart 和 HightSpeedChart 解决这个问题的思路一致压缩或裁剪。TeeChart 的 FastLine 在点数超过像素宽度时会启用在每个像素列抽取最大最小值的策略HightSpeedChart 则通过显示范围裁剪配合内置的下采样机制确保重绘点数不超过可见区域容量。动态绘制场景里还有一个容易被忽略的点Invalidate的触发频率不要无脑调到最高每秒钟刷新 30 次以上人眼基本分辨不出来还白白占掉 CPU。我一般把刷新率限制在每秒 20 到 25 次。4.2 定时器与后台线程协同MFC 里最直接的动态绘制驱动方式是SetTimer在OnTimer里读取缓冲区数据并推送给图表控件。这个方式简单可靠适合数据采集频率不高、UI 操作不复杂的场景。如果数据采集来自单独的采集线程问题就来了。Windows 窗口消息必须在 UI 线程处理直接跨线程调用AddData或者Invalidate是危险的轻则偶发崩溃重则界面假死。我踩过这个坑几次之后总结出一套稳定的做法采集线程只负责写入数据到带锁的环形缓冲区然后通过PostMessage通知 UI 线程“有新数据了”UI 线程在消息响应里把缓冲区数据一次性取出并推送给图表控件。#define WM_UPDATE_CHART (WM_APP 101) // 采集线程 void DataAcquisitionThread() { for (;;) { // 读取硬件数据写入环形缓冲区... ::PostMessage(g_hMainWnd, WM_UPDATE_CHART, 0, 0); } } // UI 线程消息响应 LRESULT CMainFrame::OnUpdateChart(WPARAM wParam, LPARAM lParam) { // 从缓冲区批量取出数据调用 AddData / AddXY return 0; }这样既避免了跨线程访问窗口对象的风险又把界面刷新任务归拢到了 UI 线程UI 响应速度一直很稳。如果想进一步降低 UI 开销可以让消息响应函数控制刷新频率比如积攒一定量的数据再刷一次而不是每条数据都发一条消息。4.3 双缓冲与防闪烁处理动态曲线最容易让用户吐槽的就是“闪瞎眼”。闪烁的本质是窗口在擦除背景和重绘前景之间产生了明显的空白时刻。MFC 的CWnd::OnEraseBkgnd默认会用窗口背景色填充整个客户区如果控件没有做双缓冲每一次重绘都会先白屏再画线肉眼感知就是闪烁。TeeChart 控件自身对闪烁处理得比较完善内部具备缓冲机制一般不需要额外干预。HightSpeedChart 源码里也包含了双缓冲逻辑但如果集成时被工程里的某些设置干扰比如父窗口设置了WS_CLIPCHILDREN而子控件没有正确配合就可能出现闪烁。一个通用的加固方案是重写绘图消息强制在内存 DC 上绘制完成后整体贴到屏幕void CMyChartView::OnPaint() { CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, m_rcClient.Width(), m_rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 在这里调用图表控件的绘制功能 // ... dc.BitBlt(0, 0, m_rcClient.Width(), m_rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }另外还有一个看起来蠢但非常有效的土办法如果曲线是在一个静态背景上滚动就把背景画到一张持久化的位图里每次重绘只更新曲线区域别让系统去重画那些完全没变的网格线。5. 常见问题与排查技巧实录5.1 数据量上来以后界面明显卡顿先按数据数量级判断每秒几百点时卡多半是刷新机制有问题比如每条数据都触发一次Invalidate每秒几千点以上时卡就要检查有没有开压缩或裁剪。我用 TeeChart 时遇到过一种情况FastLine 已经开了但因为我给曲线添加了过多装饰属性比如每点显示标记、每点有颜色渐变FastLine 的优化就被绕过了绘制性能直线下降。去掉这些“非必要且高开销”的属性之后性能立刻恢复正常。HightSpeedChart 方面最要命的操作是在每帧重绘前调用RemoveAll再AddData。这个操作会把内部数据结构整个清空重建点越多越慢。正确做法是持续追加数据只在真正需要清理历史时才清空。5.2 曲线闪烁严重优先确认双缓冲是否真的生效。HightSpeedChart 的源码里有对应的缓冲开关有的版本是通过宏控制的有的版本是默认开启但会被父窗口OnEraseBkgnd干扰。排查时可以在窗口 OnEraseBkgnd 里直接返回 TRUE禁止擦除背景配合控件的缓冲绘图能解决大部分闪烁问题。如果是 TeeChart检查一下是否开启了硬件加速或者 D2D 渲染模式没有的话在数据量大的情况下也可能出现局部闪烁。不过老实说TeeChart 大部分闪烁问题都出在创建控件的窗口样式上——缺少WS_CLIPCHILDREN会导致子窗口和父窗口之间的背景重叠刷新。5.3 多线程更新导致崩溃几乎所有的崩溃都来自 UI 控件在工作线程中被直接调用。MFC 控件归根结底封装了 HWND窗口句柄和消息队列都绑定在创建它的线程上。解决思路很固定工作线程负责数据UI 线程负责控件。我用PostMessage配合自定义消息之后再也没有出现过这块的崩溃。还有一个隐蔽的坑程序退出时采集线程还在运行定时器还在触发此时对话框已经销毁控件句柄失效再调用控件接口就会访问已释放的内存。退出前一定要先 KillTimer、通知采集线程停止再销毁控件。5.4 控件自适应窗口大小与高DPI动态曲线窗口免不了被用户拖拽改变尺寸。如果控件不支持跟随父窗口缩放就会出现大面积空白或者曲线被截断的尴尬画面。MFC 对话框里的控件跟随窗口缩放最常规的做法是在 OnSize 里用 SetWindowPos 实时调整控件位置和大小。高 DPI 下还要注意坐标换算。同样的数据点在 100% 缩放和 150% 缩放下像素位置都不一样。TeeChart 对 DPI 的处理相对成熟控件内部有比较完善的坐标换算HightSpeedChart 这类源码控件往往需要自己在 WM_DPICHANGED 里做额外处理最省事的方案是让工程整体走系统 DPI 缩放不做手动缩放虽然字体和位图会轻微模糊但坐标关系始终正确。追求高清晰度再考虑逐控件适配这个成本不低要有心理准备。5.5 常见问题速查表问题现象常见原因处理思路曲线不定时崩溃跨线程调用控件接口PostMessage 切到 UI 线程再更新数据多了就卡没有启用压缩裁剪开启 FastLine、SetDisplayRange、下采样画面闪烁背景擦除 无缓冲重写 OnEraseBkgnd、开启双缓冲滚动不平滑刷新频率过高限制刷新率到 20~25 FPS缩放后失焦或空白范围超出数据覆盖增加范围保护限制缩放极限字体模糊高 DPI 下系统缩放按需适配 DPI或统一走系统缩放控件创建失败ProgID 版本号不对检查 TeeChart 版本并替换版本号6. 选型建议与个人体会如果你现在正在纠结用哪个我总结一下最直观的判断标准。项目里已经有 TeeChart 授权或者需要大量柱状图、面积图、仪表盘来配合曲线展示直接用它就好功能全面解决后续扩展问题。项目目标只有“快”一个字数据量大、界面单调、预算有限HightSpeedChart 是最合适的选择。我个人在多数中等复杂度项目里会把两者搭配使用。TeeChart 做主窗体的综合趋势分析和历史报表展示HightSpeedChart 做实时数据监视子窗口。两个控件协作得很自然各取所长。最后再补一个小技巧动态曲线的开发调试阶段别拿真实设备数据来测一定要先用程序模拟一段正弦波、方波或噪声信号作为数据源。一方面能稳定复现问题另一方面可以对照标准波形确认坐标轴缩放和滚动逻辑是否正确。等基础功能跑通了再接真实数据这样排错效率高得多。写好一套通用的数据接入接口模拟数据和真实数据切换也就是一个条件编译的事这个前期投入非常值。本文还有配套的精品资源点击获取