ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC动态实时曲线绘制:从双缓冲到环形缓冲的完整实践

MFC动态实时曲线绘制:从双缓冲到环形缓冲的完整实践 简介一份面向VC/MFC开发者的动态实时曲线绘制项目针对数据可视化与监控系统中实时刷新、多曲线管理等问题提供完整可运行的示例工程适合有MFC基础、希望提升GDI绘图和并发数据处理能力的开发者参考学习。压缩包共60个文件除.sln、.vcxproj、.dsp等工程文件外还包含核心.h/.cpp源码、调试生成的.obj/.pdb/.tlog以及.rc资源文件等包体约32.28MB结构清晰便于使用Visual Studio直接加载并阅读代码。已有1394人学习下载。项目通过自定义CView类并重写OnDraw()完成GDI曲线绘制整合了MoveTo/LineTo/Polyline等绘图API并结合CDatabase/CRecordset完成数据库取数以临界区或同步锁保障后台线程与界面刷新间的数据安全读者可借此掌握多条曲线的颜色线型管理、画布清除重绘以及鼠标交互等关键实现是理解MFC绘图机制与实时监控系统设计的实用范例。 做上位机、做仪表、做实验室数据采集的基本都会撞上一个雷打不动的需求用MFC绘制动态实时曲线。我从毕业设计第一次接触串口数据开始到后来给车间设备写监控界面前前后后写过至少三版这种代码踩过的坑几乎可以开个专栏。为什么要单拿这个需求出来写因为它是很多人的第一个“带业务量”的MFC功能也是你从“能画出静态界面”迈向“能处理动态数据展示”的一道坎。动态实时曲线本身不难无非是定时取数、算坐标、画点连线。可一旦涉及数据刷新频率、窗口闪烁、坐标缩放、数据缓冲很多细节就来了。这篇文章我把一个可以直接跑的MFC实时曲线控件拆开讲从数据缓冲到双缓冲绘制再到运行时怎么调参尽量让你少走弯路。1. 先理清楚实时曲线这件事到底难在哪写代码之前先明确几个关键问题数据从哪里来串口、TCP、共享内存、模拟信号还是自己现场算出来的数据量多大1秒1个点还是1毫秒1个点这直接决定你用不用得着环形缓冲。显示窗口多长是要最近100个点还是最近10秒还是无限滚动记录有没有交互是否需要缩放拖动、游标定位、量程自动还是手动1.1 不要一上来就写画图函数很多新手接到需求第一反应是去写Plot函数。其实先分析需求才是关键。比如最典型的上位机监控场景下位机每20ms发一帧数据一帧里包含温度、转速、电流。界面上要显示最近10秒的曲线10秒就是500个点刷新频率用25fps40ms画一帧。这种情况下双缓冲位图大小、点数量都不是问题。但如果采样率变成100kHz、一秒钟10万个点那前端首先要考虑的是抽稀和聚合而不是画图本身。我写过一版不太成熟的上位机程序一开始把下位机传来的每个点都往矩形里塞结果曲线变成一坨黑乎乎的东西看起来好像数据很多实际啥也看不清。后来改成按像素列抽稀——每个像素列只取该列内点的最大值和最小值分别连成上下包络线画面反而清晰得多。这个技巧在波形显示里叫Min/Max抽稀是示波器软件的标准做法。所以在做实时曲线前先把上面这几个问题想明白再定技术方案。1.2 GDI、GDI还是OpenGLMFC里画曲线技术选择不外乎这三类方案优势劣势适用场景GDI快、系统自带、无额外依赖、和MFC兼容最好无抗锯齿缩放和透明处理不方便单条或多条曲线点数在几千以内GDI有抗锯齿、支持渐变和半透明、API友好比GDI稍慢需要初始化Gdiplus曲线数量不多追求显示效果OpenGL / Direct2D性能上限最高可做大量点渲染和平滑缩放线程模型和GDI不同接入复杂度高百万级点、大规模可视化我的建议是传统工业上位机数据量不过几千点别折腾OpenGLGDI完全够用。要是想让曲线稍微圆润一点、抗锯齿好看一点用GDI的Graphics DrawLines代码也就差几行。经常有人说“GDI慢”其实几十个点画一帧开销可以忽略不计。真正慢的是那种一画就new几千个Gdiplus::PointF、然后在里面做大量字符串拼接的写法。那为什么不用TeeChart、ChartCtrl这些现成控件因为MFC官方没这个第三方控件要么收费要么维护停滞。而且很多时候你只需要最简单的一条线加网格自己写50行代码就搞定还能避免和项目里其他模块的依赖冲突。当然如果需求里带上了缩放、坐标轴标签自动调整、数据导出这些“重功能”建议考虑现成控件——能省很多时间。2. 数据流与缓冲设计实时曲线的本质是“源源不断产生的新数据 固定窗口的旧数据回放”。数据从采集端到界面中间必须有一个设计合理的缓冲层。2.1 从采集线程到界面线程真实项目里画图的数据很少是UI线程自己生成的。一般是一个采集线程在while循环里读设备基于socket或serial收包解析把值填进共享区。界面线程则通过定时器或事件去取数据。这里有几个原则不要在采集线程里直接Invalidate。虽然MFC里Invalidate本身是线程安全的但容易和UI线程的画图逻辑产生竞态。数据共享必须加锁或者用锁保护的容器比如mutex vector。采集线程往尾部pushUI线程在定时器里读出当前长度并做快照。判断数据是否就绪建议用Event或原子标志简单情况用查询就行。我见过第一版代码采集线程里直接调用一个全局函数画图。窗口还活着的时候能跑一旦窗口关闭、控件销毁线程再去访问已经析构的窗口句柄就会崩。所以要养成“数据 - 消息 - 主线程绘图”的习惯跨线程访问UI对象是大忌。2.2 环形缓冲区而不是无限膨胀的vector动态曲线意味着数据源源不断你不能让vector无限增长。最简单的做法是用std::dequepush_back之后如果size超过容量就pop_front。这个方案写起来快但每次pop和front都有内存操作数据频率上万时容易出问题。工程里我更推荐固定大小的环形数组。环形数组的原理很简单预分配一块固定长度内存用一个head指针标记下一次要写入的位置写入后head往后走走到尾部再绕回开头。当缓冲区满了新数据会覆盖最旧的数据。读的时候从head开始按顺序读count个点得到的就是按时间排列的序列。这个方案的好处是整个生命周期内不分配内存、不产生碎片高频写入非常友好。2.3 定时刷新和Invalidate(FALSE)界面刷新节奏我习惯用SetTimer(nID, 40, NULL)40ms一帧相当于每秒25帧肉眼看起来已经很流畅了。如果曲线刷新要跟采集频率强同步也可以在采集线程每攒够一批数据就PostMessage到UI线程UI线程收到消息再更新。关键细节是Invalidate(FALSE)而不是Invalidate(TRUE)。TRUE会让窗口在OnPaint之前做背景擦除也就是把整个区域填成背景色然后再重绘这一擦一画就是闪烁的主要来源。FALSE表示不擦背景直接在原图上重画。配合双缓冲你会看到非常干净的画面。3. 绘制核心代码与实现细节理论说完开始落地。我推荐把曲线封装成一个独立的CStatic子类控件这样在资源编辑器里拖一个Static控件把ID指定给自定义类绘图逻辑封装在内部多个窗口可以复用同一个控件类。3.1 曲线控件的类声明// CurveCtrl.h #pragma once #include vector #include RingBuffer.h class CCurveCtrl : public CStatic { public: CCurveCtrl(); virtual ~CCurveCtrl(); void SetCapacity(int nCapacity); void PushData(double v); void Clear(); protected: afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); DECLARE_MESSAGE_MAP() private: CRingBuffer m_buffer; void DrawGrid(CDC* pDC, const CRect rc); void DrawCurve(CDC* pDC, const CRect rc); void DrawAxisText(CDC* pDC, const CRect rc); };对话框初始化时这样绑定m_curve.SubclassDlgItem(IDC_CURVE_PLOT, this); m_curve.SetCapacity(500);为什么用CStatic子类而不是直接画在对话框上因为直接画在对话框上一旦调整窗口大小、加别的控件绘制逻辑就会纠缠在一起。子类化控件是独立的一块界面布局清晰也能在多个位置复用。3.2 环形缓冲区的完整实现// RingBuffer.h #pragma once #include vector class CRingBuffer { public: explicit CRingBuffer(int capacity 500) : m_capacity(capacity) , m_data(capacity, 0.0) , m_head(0) , m_size(0) { } void Push(double v) { m_data[m_head] v; m_head (m_head 1) % m_capacity; if (m_size m_capacity) m_size; } int Size() const { return m_size; } int Capacity() const { return m_capacity; } double At(int index) const { if (index 0 || index m_size) return 0.0; return m_data[(m_head index) % m_capacity]; } double GetMin() const { if (m_size 0) return 0.0; double v m_data[0]; for (int i 0; i m_size; i) if (At(i) v) v At(i); return v; } double GetMax() const { if (m_size 0) return 1.0; double v m_data[0]; for (int i 0; i m_size; i) if (At(i) v) v At(i); return v; } void Clear() { m_head 0; m_size 0; } private: int m_capacity; std::vectordouble m_data; int m_head; int m_size; };我在第一版时在“满了之后”的判断上犯过错写成if (m_size m_capacity) { m_data[m_size]v; m_size; } else { m_head... }结果读数据的顺序乱掉。上面这个版本统一先存值再移动head简单不容易错。GetMin和GetMax每次扫描全部点500个double扫描一次在微秒级完全不用过度优化。3.3 坐标映射与Polyline绘制这是核心中的核心。假设曲线区域是rcGraph缓冲区里有n个点横坐标把这n个点均匀铺在rcGraph宽度上double stepX (double)rcGraph.Width() / (n - 1); // 注意n1时除零 int x rcGraph.left (int)(i * stepX);Y方向把数据值映射到区域高度。先算数据min和max如果min和max相等就把范围扩成min-1和max1否则曲线就是一条贴在中间的直线double minVal m_buffer.GetMin(); double maxVal m_buffer.GetMax(); if (maxVal - minVal 1e-9) { minVal - 1.0; maxVal 1.0; } int y rcGraph.bottom - (int)((v - minVal) / (maxVal - minVal) * rcGraph.Height());绘制曲线时用Polyline一次画完不要用MoveTo/LineTo一条条循环void CCurveCtrl::DrawCurve(CDC* pDC, const CRect rc) { if (m_buffer.Size() 2) return; double minVal m_buffer.GetMin(); double maxVal m_buffer.GetMax(); if (maxVal - minVal 1e-9) { minVal - 1.0; maxVal 1.0; } int nCount m_buffer.Size(); std::vectorPOINT points(nCount); double stepX (double)rc.Width() / (nCount - 1); for (int i 0; i nCount; i) { double v m_buffer.At(i); int x rc.left (int)(i * stepX); int y rc.bottom - (int)((v - minVal) / (maxVal - minVal) * rc.Height()); points[i].x x; points[i].y y; } CPen pen(PS_SOLID, 2, RGB(0, 102, 204)); CPen* pOldPen pDC-SelectObject(pen); pDC-Polyline(points.data(), nCount); pDC-SelectObject(pOldPen); }为什么用Polyline一次调用就把整条线画完比500次LineTo少很多次GDI调用而且中间不会出现“画了半截”的闪烁感知。这是我从一个老工程师那学到的他说做MFC性能优化第一件事就是减少GDI函数调用次数而不是纠结CPU主频。3.4 网格、边框与数值显示网格要在画曲线之前画不然曲线会被网格线压住。网格的作用不只是装饰而是让读图的人能大致估出数值void CCurveCtrl::DrawGrid(CDC* pDC, const CRect rc) { CPen gridPen(PS_SOLID, 1, RGB(220, 220, 220)); CPen* pOld pDC-SelectObject(gridPen); // 横线 for (int i 0; i 4; i) { int y rc.top rc.Height() * i / 4; pDC-MoveTo(rc.left, y); pDC-LineTo(rc.right, y); } // 竖线 for (int j 0; j 10; j) { int x rc.left rc.Width() * j / 10; pDC-MoveTo(x, rc.top); pDC-LineTo(x, rc.bottom); } pDC-SelectObject(pOld); }Y轴量程文字写在左侧或右上角。可以把上下两个端点值也写在网格两端。注意绘制文字前把背景模式设为TRANSPARENT否则每个文字都是一个白色小方块pDC-SetBkMode(TRANSPARENT);3.5 双缓冲OnPaint完整模板单讲每一步不够我把OnPaint整体贴出来这是最容易被“顺手写错”的地方void CCurveCtrl::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap memBmp; memBmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(memBmp); // 1. 背景 memDC.FillSolidRect(rc, RGB(255, 255, 255)); // 2. 曲线绘图区留边距左侧和底部留给坐标轴 CRect rcGraph rc; rcGraph.DeflateRect(45, 25, 15, 25); // 3. 分层绘制网格、曲线、标注依次叠加 DrawGrid(memDC, rcGraph); DrawCurve(memDC, rcGraph); DrawAxisText(memDC, rcGraph); // 4. 一次性拷贝到屏幕 dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }这段代码里有个容易忽略的点FillSolidRect、画网格、画曲线都画在memDC里最后只做一次BitBlt。如果忘了双缓冲直接在CPaintDC的dc上画画面就会闪烁、撕裂。4. 常见问题与实战排坑说几个我在实际项目里真实踩过、也帮别人排查过的坑。4.1 窗口闪烁双缓冲也压不住闪烁最根本的原因是“擦背景”和“画内容”分两步进行人眼看到中间暴露出来的空白就感觉在闪。MFC默认的OnEraseBkgnd会真的去擦。最简单的处理是在控件里重写OnEraseBkgnd直接返回TRUEBOOL CCurveCtrl::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; }再加上双缓冲基本能彻底解决。注意别去用AdjustWindowEx改窗口样式那是绕远路还会带来莫名其妙的残留和重影。如果刷新率很高但画面还是颤抖检查窗口大小变化时位图有没有同步重建。OnPaint里每次根据当前客户区大小CreateCompatibleBitmap是动态创建的没问题。但如果为了性能把位图缓存成成员变量那WM_SIZE到来时要记得和旧位图分离并重新创建否则画出来要么花屏要么尺寸不对。4.2 曲线一跳一跳看不出趋势大多数时候这不是bug是量程没处理好。我见过有人直接把整个缓冲区从开始到现在的min/max作为Y轴范围结果数据段里只要出现一次瞬间尖峰整个量程就会被拉得很大正常信号变成一条几乎水平的线啥也看不清。解决办法是按可视窗口动态算量程。缓冲区里只保留最近N个点量程就是这些点的min和max趋势自然清楚。如果担心连续几个尖峰让量程跳来跳去可以加一点平滑minVal_new minVal_old * (1-a) minVal_calc * aa取0.3到0.5曲线变化就不会猛跳。还有一个土办法Y轴参考线不画全范围而是追踪最近5秒内的峰值范围。这个改动对现场监控非常管用操作工一眼能看到正常波动区间。4.3 数据点多或者刷新率高时卡顿卡顿大概率不是DrawLines慢而是OnPaint里做了太多无关操作。举几个典型每次画文字都调用CString::Format尤其Format里还有浮点数转字符串开销比Polyline大得多。解决方案是500ms才更新一次数值文字曲线照样25fps刷新。每次OnPaint里new一个std::vector 。虽然500个点不大但频繁内存分配也会抖动。可以把points数组作为成员变量OnPaint里resize重用。如果数据量固定直接在控件构造时分配。真到了几万甚至几十万个点的场景再考虑分块绘制、Min/Max抽稀或者用OpenGL来做。在此之前GDI Polyline 双缓冲这套组合是性价比最高的方案。4.4 线程安全问题最后强调一个真实项目里最常见的崩溃场景采集线程正在往缓冲区Push数据UI线程OnPaint正遍历缓冲区读数据两个线程同时操作同一个vector轻则读到半个数据重则直接访问越界崩溃。这种bug是“偶尔出现”的到客户现场才冒出来极度难查。所以数据缓冲要做成加锁版本void CCurveCtrl::PushData(double v) { std::lock_guardstd::mutex lock(m_mutex); m_buffer.Push(v); Invalidate(FALSE); }OnPaint里要拿数据前对整个缓冲区做一份快照std::lock_guardstd::mutex lock(m_mutex); std::vectordouble snapshot; snapshot.reserve(m_buffer.Size()); for (int i 0; i m_buffer.Size(); i) snapshot.push_back(m_buffer.At(i));画图线程持有锁的时间尽量短拷贝完就释放。我一般用mutable修饰锁这样在const成员函数里也能加锁。4.5 窗口大小变了曲线不能不变窗口大小变化时曲线应该跟着等比缩放而不是空出一大片或挤在一起。用前面的坐标映射公式rcGraph已经随客户区变化曲线会自动适应。需要留意的是CreateCompatibleBitmap在OnPaint里按当前尺寸创建所以尺寸变化自然没问题。但如果你用了固定尺寸的缓存位图就要记得在OnSize里重新创建。关于扩展比如多条曲线、游标、暂停继续、历史回放这些其实都是在单条曲线框架上堆功能。多条曲线就是循环调用DrawSeries游标就是鼠标消息加画竖线暂停就是定时器到点时不再Push。我一直觉得把单条实时曲线写透了后面扩展都是体力活。希望这篇能帮你在MFC绘制动态实时曲线的路上少踩几个坑。这套代码在我的几个项目里从XP跑到Win11都没出过问题如果细节在你们机器上表现不一样欢迎回来一起讨论。本文还有配套的精品资源点击获取
返回列表