
简介这份MFC曲线控件示例程序面向需要通过图形化方式实时展示高速采集数据的上位机开发者解决MFC缺少易用二维曲线控件的问题。程序基于开源ChartCtrl封装在VC2017环境下编译通过仅少量历史警告不影响使用并演示了曲线自适应填满绘图区、静止状态下拉框放大局部曲线等关键功能。压缩包共77个文件主要以.h与.cpp源码文件构成合计65个另含工程配置、资源脚本和图标等整体仅245KB结构清晰便于按需查看。示例还说明了Custom Control作为承载控件时的样式设置要点包含WS_CHILD、WS_VISIBLE、WS_CLIPCHILDREN等标志位组合能帮助读者快速理解控件挂载机制并避免常见配置失误。已有1654人学习下载适合需要快速集成曲线显示能力的MFC开发者参考复用。1. 高速二维曲线控件MFC项目里绕不开的实时显示关卡MFC高速二维曲线控件示例程序VC2017编译通过——这个标题背后对应着一类很具体的需求采集程序每秒涌进来上万甚至几十万个浮点数据你需要在界面上把它们变成一条能实时滚动、能缩放拖拽的曲线而且不能把界面卡成 PPT。很多 MFC 开发者在这里栽跟头直接往屏幕 DC 上画数据一多就闪烁、CPU 飙高用 Picture Control 当画布重绘逻辑怎么安排都别扭。这个方向要解决的就是两件事——怎么把数据高效画出来以及怎么把交互做得顺手。适合正在维护工控、数据采集、示波器、医疗检测类界面的 MFC 程序员也适合想把手绘折线改成正规控件的朋友。我下面按自己的实现习惯从双缓冲绘制框架讲起一直说到数据管道、坐标变换、交互和 VC2017 下的编译坑。代码是我按常见方案重写的最小示例不是某个特定项目的源码但每一步都能直接在 VS2017 里建个 MFC 对话框工程跑通。2. 双缓冲绘制框架从闪烁到流畅的第一道门槛2.1 为什么双缓冲是高速绘制的底线直接用CPaintDC往窗口画线当Invalidate触发频繁时你会看到曲线在闪。原因很直白屏幕 DC 是直接映射到显示表面的每次绘制操作都被显示驱动实时处理GDI 把背景抹掉、再逐条画线的过程用户肉眼可见。对于实时曲线这个闪烁会被数据更新频率放大到不可用。双缓冲的思路是把绘制目标从屏幕换到内存先在内存 DC 上把整帧画完画的过程中用户看不到任何中间状态最后用一次BitBlt把整幅位图拷到屏幕。一帧画面只暴露给用户两次操作——清屏前的旧画面和绘制完成后的新画面中间所有 GDI 动作都被屏蔽掉了。MFC 里常规做法是CreateCompatibleDC配CreateCompatibleBitmap再用SelectObject把位图选进内存 DC。这里有一个经常被忽略的配套动作重写OnEraseBkgnd并让它返回TRUE。如果不拦截窗口在BeginPaint之前会先用背景色擦一遍客户区缓冲区的第一帧画面等于白画了而且擦除本身也是一次可见的闪烁。返回TRUE的意思是“背景擦除已经处理完了”MFC 就不会再调默认的擦除逻辑。2.2 一个能编译通过的最小 Custom Control 框架在 MFC 里做曲线控件一般不从CStatic派生而是直接从CWnd派生一个自定义窗口类。原因有两个CStatic的行为和样式继承太多处理消息时要小心别跟静态文本的默认行为打架CWnd派生足够干净WM_PAINT、WM_ERASEBKGND、WM_MOUSEWHEEL这些消息全部自己接管行为完全可控。下面是最小框架的头文件重点看窗口创建和消息映射的写法// CurveCtrl.h #pragma once #include afxwin.h #include vector class CCurveCtrl : public CWnd { public: CCurveCtrl(); virtual ~CCurveCtrl(); // 创建控件窗口dwStyle 建议带 WS_CHILD | WS_VISIBLE BOOL Create(DWORD dwStyle, const CRect rect, CWnd* pParent, UINT nID); // 外部注入数据内部只拷贝指针内容 void SetData(const double* pData, int nCount); protected: afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); DECLARE_MESSAGE_MAP() private: std::vectordouble m_data; // 数据缓存 CRect m_rcClient; // 客户区尺寸OnPaint 里刷新 };对应的实现文件是这个样子的注意OnEraseBkgnd的处理位置// CurveCtrl.cpp #include pch.h #include CurveCtrl.h BEGIN_MESSAGE_MAP(CCurveCtrl, CWnd) ON_WM_PAINT() ON_WM_ERASEBKGND() END_MESSAGE_MAP() CCurveCtrl::CCurveCtrl() {} CCurveCtrl::~CCurveCtrl() {} BOOL CCurveCtrl::Create(DWORD dwStyle, const CRect rect, CWnd* pParent, UINT nID) { // 注册窗口类CS_DBLCLKS 允许双击消息白色画刷兜底 static CString strClass AfxRegisterWndClass( CS_DBLCLKS, ::LoadCursor(nullptr, IDC_ARROW), reinterpret_castHBRUSH(::GetStockObject(WHITE_BRUSH)), nullptr); return CWnd::Create(strClass, _T(), dwStyle, rect, pParent, nID); } void CCurveCtrl::OnPaint() { CPaintDC dc(this); GetClientRect(m_rcClient); int cx m_rcClient.Width(); int cy m_rcClient.Height(); // 双缓冲内存 DC 兼容位图 CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, cx, cy); CBitmap* pOldBmp memDC.SelectObject(bmp); // 清背景 memDC.FillSolidRect(m_rcClient, RGB(255, 255, 255)); // 画曲线下一章展开这里先画个示例折线 if (m_data.size() 1) { memDC.MoveTo(0, cy / 2); memDC.LineTo(cx, cy / 2); } // 一帧画完一次性拷贝到屏幕 dc.BitBlt(0, 0, cx, cy, memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); } BOOL CCurveCtrl::OnEraseBkgnd(CDC* pDC) { return TRUE; // 背景擦除统一在内存 DC 里做避免闪烁 } void CCurveCtrl::SetData(const double* pData, int nCount) { m_data.assign(pData, pData nCount); Invalidate(FALSE); // FALSE不擦背景直接触发 WM_PAINT }上面这段代码里Create函数中的AfxRegisterWndClass是 MFC 自定义窗口类注册的标准入口四个参数分别是风格、光标、背景画刷和类图标。这里背景画刷传了白色但实际绘制时我们并不依赖它真正决定背景颜色的是FillSolidRect那一步——画刷只是防止窗口创建初期露出难看的默认底色。OnPaint里CreateCompatibleDC和CreateCompatibleBitmap的顺序有讲究位图必须和屏幕 DC 兼容而不是和内存 DC 兼容。如果传memDC给CreateCompatibleBitmap在部分显卡驱动下会得到一张奇怪的位图画出来是乱码。这一点我踩过坑后面避坑章节还要展开。3. 数据管道与批量绘制万点每帧不掉帧的关键3.1 环形缓冲区数据生产速度和绘制速度解耦实时曲线控件的数据源往往不是一个稳定的向量而是不断追加的流式数据。如果每次数据到达都直接往std::vector后面push_back再触发重绘会有两个问题一是数据到达频率和 UI 线程的绘制耗时互相拖累数据到达越快绘制堆得越多二是内存会持续增长长时间运行根本扛不住。我一般用环形缓冲区ring buffer来解耦。数据采集线程只管往缓冲区写UI 线程按固定节奏去读读取时会拿到从起点到当前位置的一段完整数据。这样数据生产和 UI 绘制各自按自己的节奏走不会互相阻塞。下面是一个环形缓冲区的实现容量固定为 2 的幂方便用位运算取模// RingBuffer.h #pragma once #include vector class CRingBuffer { public: CRingBuffer(size_t capacity) : m_data(capacity) , m_head(0) , m_tail(0) , m_count(0) , m_capacity(capacity) { // 容量强制为 2 的幂位运算取模比 % 快不少 } void Push(const double value) { // 满了就覆盖最老的数据这是实时曲线的常规策略 if (m_count m_capacity) { m_head (m_head 1) (m_capacity - 1); } else { m_count; } m_data[m_tail] value; m_tail (m_tail 1) (m_capacity - 1); } size_t Size() const { return m_count; } void GetLatest(std::vectordouble out, size_t count) const { out.clear(); if (count m_count) count m_count; out.resize(count); // 从头部开始拷贝 count 个点 for (size_t i 0; i count; i) { out[i] m_data[(m_head i) (m_capacity - 1)]; } } private: std::vectordouble m_data; size_t m_head; // 最早的数据位置 size_t m_tail; // 下一个写入位置 size_t m_count; // 当前有效数据个数 size_t m_capacity; // 容量2 的幂 };容量m_capacity必须是 2 的幂代码里用了 (m_capacity - 1)代替% m_capacity这是位运算优化比取模快一个量级。在数据量达到百万级别时这个差异能明显拉开帧率。Push时如果缓冲区满直接覆盖最老的数据这是实时曲线的常规选择——用户关心的是最近一段时间的波形不是所有历史数据。这个缓冲区要配合上一章的SetData使用采集线程往 ring buffer 里PushUI 线程在定时器或空闲时调用GetLatest取出数据再SetData。注意GetLatest是 const 方法它读取尾部时不会影响采集线程的写入因为写入只改变m_tail和m_count读取只读m_head到m_tail之间的数据单生产者单消费者场景下不需要加锁。3.2 Polyline 批量绘制一次 GDI 调用画完两万个点很多新手用MoveToLineTo循环画折线一帧画两万个点就跑不动了。原因是 GDI 调用的开销不在“画一条线”本身而在每次函数调用的用户态到内核态切换。LineTo一次只画一条线两万个点就是两万次切换帧率上不去是必然的。正确做法是用Polyline一次传入整个点数组GDI 内部一次性完成所有线段的光栅化只需要一次函数调用。下面这段代码是上一章OnPaint里绘制部分的替换void CCurveCtrl::OnPaint() { CPaintDC dc(this); GetClientRect(m_rcClient); int cx m_rcClient.Width(); int cy m_rcClient.Height(); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, cx, cy); CBitmap* pOldBmp memDC.SelectObject(bmp); memDC.FillSolidRect(m_rcClient, RGB(255, 255, 255)); if (m_data.size() 1) { // 把数据点映射到客户区坐标 std::vectorPOINT points(m_data.size()); // 计算当前数据的最小值、最大值用于纵向映射 double minVal *std::min_element(m_data.begin(), m_data.end()); double maxVal *std::max_element(m_data.begin(), m_data.end()); if (maxVal - minVal 1e-12) { maxVal minVal 1.0; // 全等值数据时避免除零 } double xScale static_castdouble(cx - 1) / (m_data.size() - 1); double yScale static_castdouble(cy - 1) / (maxVal - minVal); for (size_t i 0; i m_data.size(); i) { points[i].x static_castLONG(i * xScale 0.5); points[i].y static_castLONG((maxVal - m_data[i]) * yScale 0.5); } memDC.Polyline(points.data(), static_castint(points.size())); } dc.BitBlt(0, 0, cx, cy, memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }Polyline的第二个参数是点数量类型是int数据量超过 20 亿才会溢出曲线控件用不到这个上限。真正需要注意的是点数组的内存布局std::vectorPOINT在微软 STL 实现里是连续内存可以直接用points.data()传给 GDI不会产生额外拷贝。min_element和max_element每次绘制都遍历一边数据这个成本在几十万点量级是可接受的几十毫秒以内。如果未来数据量要继续往上翻可以把极值计算下沉到采集线程避免重复遍历。当前这个写法在 2 万点、60 FPS 的数据流下绘制耗时大约能压到 3 到 8 毫秒这点余量对实时界面是够用的。4. 坐标变换、缩放与平移曲线控件的交互骨架4.1 世界坐标到客户区坐标的映射公式上一章代码里已经出现了雏形横轴是数据点索引纵轴是数据值域。这里把它提炼成通用的映射结构。下面是一个封装好的视图状态结构体我的曲线控件里一直用这组参数struct CCurveViewState { double dXStart; // 显示范围起点数据索引 double dXEnd; // 显示范围终点数据索引 double dYMin; // Y 轴显示范围最小值 double dYMax; // Y 轴显示范围最大值 CCurveViewState() : dXStart(0.0) , dXEnd(100.0) , dYMin(0.0) , dYMax(1.0) { } // 世界坐标转客户区坐标 void WorldToClient(const double wx, const double wy, int cx, int cy, POINT pt) const { double xRatio (wx - dXStart) / (dXEnd - dXStart); double yRatio (wy - dYMin) / (dYMax - dYMin); pt.x static_castLONG(xRatio * (cx - 1) 0.5); pt.y static_castLONG((1.0 - yRatio) * (cy - 1) 0.5); // Y 轴翻转 } };这里最容易被忽略的是 Y 轴翻转客户区的原点在左上角Y 轴正方向朝下而数据坐标系里我们习惯 Y 轴正方向朝上。如果不翻转曲线会上下颠倒看起来就跟常见示波器相反。pt.y那一行用(1.0 - yRatio)做的就是这个翻转。dXStart和dXEnd用的是数据索引而不是实际时间戳好处是缩放和拖拽时只需要操作索引范围不需要关心采样率。如果要在标题栏显示实际时间等映射完成后再乘上采样间隔就行。4.2 鼠标滚轮缩放与左键拖拽平移交互是曲线控件的分水岭能缩放拖拽的曲线曲线控件和只能看完整波形的 Picture 控件完全是两个层级的工具。缩放和平移都基于 4.1 的CCurveViewState只是要处理好“缩放锚点”的问题。鼠标滚轮缩放时用户期望的是“鼠标指哪哪里就放大”滚轮前鼠标所在位置对应的数据点在缩放后仍然停留在鼠标下。实现方法是以当前鼠标位置对应的数据索引为中心重新计算显示范围BOOL CCurveCtrl::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { // 鼠标位置对应的数据索引世界坐标 double mouseDataX m_view.dXStart (pt.x / static_castdouble(m_rcClient.Width())) * (m_view.dXEnd - m_view.dXStart); // 缩放因子滚轮向上放大向下缩小 double factor (zDelta 0) ? 0.8 : 1.25; // 新范围宽度 double newWidth (m_view.dXEnd - m_view.dXStart) * factor; // 让鼠标锚点对应的数据索引保持不动 m_view.dXStart mouseDataX - (mouseDataX - m_view.dXStart) * factor; m_view.dXEnd m_view.dXStart newWidth; Invalidate(FALSE); return TRUE; }zDelta是滚轮的相对位移Windows 下每次常规滚轮大约 120。factor取 0.8 表示放大到原来的 80% 视野范围1.25 表示缩小。连续滚动时指数效应明显滚三下就能把视野缩小一半这个阻尼系数我用了很多年手感最自然。拖拽平移的实现稍微简单一些左键按下时记录起点鼠标移动时计算像素位移换算成数据索引位移后更新dXStart和dXEndvoid CCurveCtrl::OnLButtonDown(UINT nFlags, CPoint point) { m_bDragging TRUE; m_ptLast point; SetCapture(); // 捕获鼠标保证拖出窗口也能收到消息 } void CCurveCtrl::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDragging (nFlags MK_LBUTTON)) { int dx point.x - m_ptLast.x; m_ptLast point; // 像素位移换算成数据索引位移 double dataRangePerPixel (m_view.dXEnd - m_view.dXStart) / m_rcClient.Width(); double shift dx * dataRangePerPixel; m_view.dXStart - shift; m_view.dXEnd - shift; Invalidate(FALSE); } } void CCurveCtrl::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDragging) { m_bDragging FALSE; ReleaseCapture(); } }dx为正值表示鼠标向右移动此时应当向左平移视野所以dXStart减去shift。如果你发现拖拽方向和手感是反的检查一下这个符号就好。注意SetCapture和ReleaseCapture成对出现漏掉ReleaseCapture会导致释放鼠标后窗口一直抢鼠标消息这是交互上最讨厌的隐藏 bug 之一。5. MFC 高速曲线控件升级避坑5 条血泪经验5.1 一拖就闪OnEraseBkgnd 没拦截Invalidate 用了 TRUE现象曲线在鼠标拖拽缩放时剧烈闪烁背景色的残影在连续刷新之间清晰可见。原因OnEraseBkgnd没有重写Windows 默认擦除背景。Invalidate(TRUE)显式要求擦除背景等于告诉窗口“把旧的画面全部抹掉再重画”这会触发一次额外的背景填充闪烁在所难免。解决OnEraseBkgnd统一返回TRUE所有背景绘制交给双缓冲里的FillSolidRect。调用Invalidate时传FALSE让系统只更新WM_PAINT区域不触发擦除逻辑。这两处配合起来闪烁能消掉九成。5.2 数据量到几万点就卡顿LineTo 循环和全量排序的双重拖累现象数据量从几千点到几万点时帧率断崖式下跌CPU 占用跑到 30% 以上。原因绘制用了MoveToLineTo循环GDI 调用次数随点数线性增长同时每帧都执行min_element和max_element遍历全量数据绘制开销被放大成两遍全量扫描。解决Polyline一次调用替代循环。对于极值计算可以考虑在数据写入时同步更新极值而不是每帧重算。如果数据量超过 50 万点再考虑降采样策略——保留极值的分段抽样而不是均匀隔点抽取否则会丢失波形尖峰。5.3 VC2017 编译报错字符集设置和工具集版本不匹配现象在 VS2017 里打开项目编译时报一堆_T未定义、LPCSTR和LPCTSTR转换报错或者提示工具集找不到。原因VC2017 对应的平台工具集是 v141。如果项目文件里写的是 v120VS2013或 v140VS2015VS2017 需要弹窗提示升级直接编译就会失败。字符集同理工程默认可能是“多字节字符集”但新工程向导用的是“Unicode 字符集”_T宏两边不完全兼容。解决在项目属性 → 常规 → 平台工具集里选 v141在配置属性 → 常规 → 字符集里选“使用 Unicode 字符集”。代码里统一用_T包字符串字面量不要混用L和裸字符串。这样在 2017 和之后的 2019、2022v142/v143下都能顺利编译。5.4 曲线边缘锯齿严重画笔宽度和坐标没对齐现象画出来的曲线边缘有明显的“楼梯”感线条粗细不均匀缩放时锯齿会跳动。原因GDI 默认画笔是 1 像素宽但如果你设置了PS_SOLID且宽度为 2绘制时 GDI 会把像素覆盖区域按照中心对齐还是边对齐的不同规则处理导致线条边缘发虚。另一个原因是变换后的坐标用四舍五入相邻点会映射到同一个像素上造成阶梯。解决画曲线时用CreatePen(PS_SOLID, 0, color)——宽度传 0 代表最细的 1 像素边缘绘制比传 1 更锐利。坐标映射时用0.5四舍五入避免int直接截断。我在这条上翻过车某次升级把画笔宽度从 0 调成 2用户立刻反馈波形看起来“糊了”。5.5 窗口缩放时内存位图反复重建导致卡顿现象拖动窗口边缘调整大小时界面卡顿明显尤其是窗口变大后再变小明显感觉到交互响应变慢了。原因OnPaint里每次CreateCompatibleBitmap都重新分配一块和客户区等大的内存位图。频繁 resize 时这个分配和释放会调用大量的内存管理器操作耗时集中在 GDI 的堆分配上。解决把内存 DC 和位图做成控件成员变量只有尺寸变化时才重建。常见做法是记录上次的cx、cy在OnPaint里比较尺寸没变就复用旧位图。这个优化在实际项目里能把 resize 的卡顿感从明显降到几乎无感代价是增加几十行成员变量管理代码但值得。6. 进阶用定时器合并刷新再用量化数据验证性能6.1 数据到达即刷 vs 定时合并刷新当数据采集频率超过 50Hz 时每次数据到达都触发Invalidate会带来一个问题界面线程被绘制任务占满采集线程的数据写入反而会被反压。更合理的做法是设置一个 15 到 30ms 的定时器所有到达的数据先在缓冲区攒着定时器一到一次性取出全部新数据并刷新画面。void CCurveCtrl::StartRefreshTimer() { // 16ms 约等于 60FPS低于 1000/60 的间隔会导致画面跳帧 SetTimer(1, 16, nullptr); } void CCurveCtrl::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent 1) { // 把环形缓冲区里的新数据一次性取出 std::vectordouble latestData; m_ringBuffer.GetLatest(latestData, m_ringBuffer.Size()); SetData(latestData.data(), (int)latestData.size()); } CWnd::OnTimer(nIDEvent); }SetTimer的第三个参数传nullptr表示走WM_TIMER消息不走回调函数。MFC 里推荐这种方式因为回调方案要自己去管理线程上下文还容易跟CWnd的消息循环冲突。16ms 对应约 60FPS数据更新频率高于 60Hz 时画面会自动合并低于 60Hz 时定时器空转开销可以忽略。6.2 用 QueryPerformanceCounter 画出真实的帧耗时不用靠感觉判断控件“流畅不流畅”。我在OnPaint前后用两个性能计数器卡时间把耗时累加到一个滑动窗口里标题栏直接显示最近 100 帧的平均耗时。当平均耗时超过 16ms说明绘制线程已经跑不赢显示刷新率了这时候就得回头查数据量和绘制策略。void CCurveCtrl::OnPaint() { // 前一个完整实现省略这里只展示计时埋点 LARGE_INTEGER freq, tStart, tEnd; ::QueryPerformanceFrequency(freq); ::QueryPerformanceCounter(tStart); // ... 原有绘制逻辑 ... ::QueryPerformanceCounter(tEnd); double elapsedMs (tEnd.QuadPart - tStart.QuadPart) * 1000.0 / freq.QuadPart; // 维护一个长度为 100 的滑动平均值低于 16ms 算健康 m_frameTimeSum elapsedMs; m_frameCount; if (m_frameCount 100) { CString strTitle; strTitle.Format(_T(平均帧耗时: %.2f ms), m_frameTimeSum / 100.0); SetWindowText(strTitle); m_frameTimeSum 0.0; m_frameCount 0; } }QueryPerformanceCounter是 Windows 高精度计时器的标准接口精度在微秒级比GetTickCount适合做性能埋点。要注意把LARGE_INTEGER的除法放到最后的 double 转换里避免整数除法截断丢精度。我现在的习惯是每个曲线控件都保留这个计时逻辑上线前跑一遍压力数据看到平均耗时稳定在 8ms 以下才敢交付——这也算是我做曲线控件这么多年留下的职业习惯了希望帮到你。本文还有配套的精品资源点击获取