ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MFC实时曲线绘制:双缓冲与环形队列解决闪烁卡顿

MFC实时曲线绘制:双缓冲与环形队列解决闪烁卡顿 简介面向 VC 数据可视化与监控场景的动态实时曲线绘制工程适合需要掌握 MFC 自定义视图、GDI 绘图、数据库取数与线程同步的开发者。工程以 clPlot 类为核心在 CView 派生类中重写 OnDraw利用 FillRect 清屏、MoveTo/LineTo/Polyline 绘制曲线并给出后台线程定期取数、临界区保护、主线程刷新显示的处理框架同时管理多条曲线的颜色、线型等属性预留了数据源扩展、图例与鼠标交互接口便于后续改造。压缩包共 60 个文件32.28MB除 cpp/h 源码、rc 界面资源外也包含 dll/lib 等构建产物以及 vcxproj、dsw、sln 工程配置和 PDB 调试文件既能看清实现逻辑也可在 VS/VC6 中打开继续修改。使用时可从资源内多个 .cpp/.h 文件快速定位核心绘制类、数据库封装与线程函数便于在实际项目中按需裁剪。已有 1394 人学习下载适合具备一定 MFC 基础、希望快速理解实时曲线整体封装思路并复用到监控界面的开发者。 做工控上位机的时候实时曲线几乎是个绕不开的功能。温度、压力、流量、转速……任何一路传感器数据最后都要在界面上化成一条不断向前滚动的动态实时曲线。MFC这技术虽然老但胜在稳定直接尤其VS2013下维护老项目的人还是不少。这篇文章把我在MFC里画动态实时曲线的完整思路、代码和踩坑记录整理出来适合刚接手MFC项目、又正好碰到曲线绘制卡顿闪烁这类问题的人参考。1. 为什么你的实时曲线一拖就闪成白板先从GDI绘图机制说起1.1 WM_PAINT与无效区域窗口重绘的基本逻辑MFC里最标准的绘图入口是OnPaint它响应的本质是Windows发来的WM_PAINT消息。这个消息只有在窗口的某个区域被标记为无效时才会触发常见的触发方式包括窗口尺寸变化、被其他窗口遮挡后重新露出、或者代码里调用了Invalidate。这里有个很容易被忽视的机制Invalidate只是把一个区域标记为无效并不会立刻重绘。系统把WM_PAINT放进消息队列等到程序的消息循环有空闲时才去处理。所以你调了Invalidate图形马上更新这种直觉其实是不准确的中间还隔着一个消息泵。实时曲线每隔几十毫秒就要刷一次如果每次都走完整的标记无效-等待消息-擦背景-重绘流程性能损耗非常大。MFC默认的擦背景行为又是用背景色把整个客户区填一遍再在上面画曲线这一擦一画之间用户看到的就是白屏闪烁。1.2 闪烁的根源擦背景和画前景的时间差闪烁的核心原因就一句话先擦干净再画上去中间有一段空白画布暴露在用户眼前。刷新率越高这个空档出现得越频繁闪烁感越强。很多入门代码会这样写void CCurveDlg::OnPaint() { CPaintDC dc(this); // 画数据点到客户区 for (int i 0; i m_dataCount; i) { dc.SetPixel(x[i], y[i], RGB(255, 0, 0)); } }这套逻辑单独看没错但配合默认的背景擦除就是灾难。SetPixel画几千个点本来就慢用户看到的是一大片白底先闪出来然后红点一个个冒出来。解决的思路也比较统一一是用双缓冲先在内存里把整幅图画好一次性拷贝到屏幕二是让系统不擦背景避免白屏空档。这两步组合起来闪烁基本能压到肉眼不可见的程度。具体代码后面第4节给全先把前端的准备工作做掉。2. 数据缓冲区的设计环形队列能解决什么2.1 用CArray存实时数据为什么会越用越卡新手最爱直接用CArraydouble或者std::vectordouble存采集数据来一个数据就Add一个。在数据量小的时候确实没毛病但实时曲线有一个特点窗口只关心最近一段时间的数据更早的数据必须丢。于是问题来了。如果固定存1000个点每来一个新点就得把前面的数据整体挪一个位置// 错误示范每插入一次就把1000个double整体搬一次家 for (int i 1; i n; i) data[i - 1] data[i]; data[n - 1] newValue;1000个double还好1万个、10万个点的时候每次刷新都要挪动大量内存。再加上刷新频率大几十赫兹CPU全耗在这种无意义的搬运上了。曲线窗口越长、数据密度越高程序就越卡越卡越显得掉帧严重。2.2 环形队列实现与线程安全环形队列又叫循环缓冲区用一块固定大小的数组配合两个下标头、尾循环复用空间。新数据永远写到当前位置写满后再从头覆盖旧数据。整个过程不用挪动任何已有的数据插入复杂度是O(1)。// RingBuffer.h #pragma once class CRingBuffer { public: CRingBuffer(int capacity 2048) : m_capacity(capacity) , m_head(0) , m_count(0) { m_data new double[capacity]; ZeroMemory(m_data, sizeof(double) * capacity); } ~CRingBuffer() { delete[] m_data; } // 写入一个数据点 void Push(double value) { // 如果满了head向前移动覆盖最旧数据 if (m_count m_capacity) { m_head (m_head 1) % m_capacity; m_count m_capacity - 1; } int idx (m_head m_count) % m_capacity; m_data[idx] value; m_count; } // 按逻辑顺序读取index0是最旧的数据 double GetAt(int index) const { if (index 0 || index m_count) return 0.0; return m_data[(m_head index) % m_capacity]; } int GetCount() const { return m_count; } void Clear() { m_head 0; m_count 0; } private: double* m_data; int m_capacity; int m_head; int m_count; };如果采集工作和界面刷新不在同一个线程Push和GetAt之间会有竞争。稳妥的做法是包一层临界区或者直接用CRITICAL_SECTION保护读改写。实际项目中我用的是采集线程只调PushUI线程只在OnTimer里GetAt配合临界区把冲突概率降到最低。2.3 采集频率与刷新频率的取舍在MFC里使用SetTimer建立定时器它最小精度受系统消息循环限制大约在10到55ms之间。如果数据变化很快比如电流、振动信号超过100Hz建议用多媒体定时器timeSetEvent或者开一个子线程专门采集。刷新到界面上的频率反而是另一回事。我习惯把采集和刷新拆成两个频率采集频率尽量高刷新频率锁定在20到30Hz。人眼和显示器能感知到的流畅程度是有上限的30Hz已经非常顺滑没必要60Hz去重绘。采集频率高是为了不丢数据点刷新频率低是为了减少不必要的GDI绘制。这组参数我常用这套组合场景采集频率界面刷新频率说明温度、压力等慢变信号2-5Hz5-10Hz定时器足够振动、电流等中速信号50-200Hz20-30Hz建议子线程采集高速采样1kHz1k-10kHz20-30Hz先抽稀再显示采集放在OnTimer1号定时器里刷新放在2号定时器里互不干扰逻辑也清晰。3. 从数据到像素坐标映射与动态自适应缩放3.1 客户区坐标系与绘图区的边距规划MFC窗口客户区左上角是(0,0)X轴向右Y轴向下。这个坐标和数学里的坐标系是反着的Y轴向下意味着数值越大屏幕坐标的Y值反而要越小。实时曲线通常不会把整个客户区全部用于画曲线四边要留出边距给坐标轴刻度、单位、时间标签留位置。我的经验值是左边60像素放Y轴刻度右边20像素上方30像素下方30像素放时间轴。窗口尺寸小时这些边距也不能随便压缩所以绘制前要检查客户区宽度是否大于m_leftMargin m_rightMargin 20否则绘图区可能变成负数直接导致绘制异常。void CCurveDlg::GetPlotRect(CRect rcClient, CRect rcPlot) { GetClientRect(rcClient); rcPlot.left rcClient.left m_leftMargin; rcPlot.right rcClient.right - m_rightMargin; rcPlot.top rcClient.top m_topMargin; rcPlot.bottom rcClient.bottom - m_bottomMargin; // 防止窗口太小时出现负宽度 if (rcPlot.right rcPlot.left 10) rcPlot.right rcPlot.left 10; if (rcPlot.bottom rcPlot.top 10) rcPlot.bottom rcPlot.top 10; }3.2 动态范围计算与自动缩放实时曲线的数据范围不是固定的。比如电压信号可能一直在±5V之间波动偶尔跳到6V。如果每次都把Y轴固定死曲线会贴边很难看。所以每帧绘制前要重新算一遍当前缓冲区里的最大值和最小值再根据这个范围决定Y轴刻度。bool CCurveDlg::GetDataRange(double dMin, double dMax) { int nCount m_buffer.GetCount(); if (nCount 0) return false; dMin dMax m_buffer.GetAt(0); for (int i 1; i nCount; i) { double v m_buffer.GetAt(i); if (v dMin) dMin v; if (v dMax) dMax v; } if (dMax - dMin 1e-6) // 防止除零 dMax dMin 1.0; // 上下留20%余量曲线不贴边 double padding (dMax - dMin) * 0.1; dMin - padding; dMax padding; return true; }这里的1e-6是防呆处理。当数据长时间不变时max和min相等减法除零坐标转换会崩。加一个下限保证分母永远不为零这是实时仪表的常见防御性写法。3.3 数据到屏幕的坐标转换有了绘图区和数据范围就可以把逻辑数据映射到屏幕坐标了。X方向第i个数据点在绘图区里均匀分布Y方向把数据范围线性映射到绘图区高度注意Y轴取反。void CCurveDlg::DataToScreen(double value, int index, int nTotal, const CRect rcPlot, double dMin, double dMax, int x, int y) { double xRatio (nTotal 1) ? (double)index / (nTotal - 1) : 0.0; x rcPlot.left (int)(xRatio * rcPlot.right 0.5); x max(rcPlot.left, min(rcPlot.right, x)); double yRatio (value - dMin) / (dMax - dMin); y rcPlot.top (int)((1.0 - yRatio) * rcPlot.Height() 0.5); y max(rcPlot.top, min(rcPlot.bottom, y)); }这里有一个细节xRatio用(double)index / (nTotal - 1)而不是i / nTotal这样保证最后一个数据点恰好落在右边界上曲线不会在右边差一截。代码里我做了边界裁剪防止数据异常时绘图越界。3.4 网格和坐标轴刻度怎么画网格是为了让读数更直观平时用浅灰色虚线能看清但不抢曲线风头。我通常画5条水平线、8条垂直线的网格并顺手标上刻度和单位。void CCurveDlg::DrawGrid(CDC* pDC, const CRect rcPlot, double dMin, double dMax) { CPen penDot(PS_DOT, 1, RGB(200, 200, 200)); CPen* pOldPen pDC-SelectObject(penDot); // 横向网格线数值方向 int hDiv 4; // 分成4格 for (int i 0; i hDiv; i) { int y rcPlot.top (int)((double)i * rcPlot.Height() / hDiv 0.5); pDC-MoveTo(rcPlot.left, y); pDC-LineTo(rcPlot.right, y); // 左侧刻度文字 double tag dMax - (dMax - dMin) * i / hDiv; CString strText; strText.Format(_T(%.1f), tag); pDC-TextOut(rcPlot.left - 50, y - 8, strText); } // 纵向网格线时间方向 int vDiv 8; for (int i 0; i vDiv; i) { int x rcPlot.left (int)((double)i * rcPlot.Width() / vDiv 0.5); pDC-MoveTo(x, rcPlot.top); pDC-LineTo(x, rcPlot.bottom); CString strText; strText.Format(_T(-%d s), (vDiv - i) * m_displaySeconds / vDiv); pDC-TextOut(x - 16, rcPlot.bottom 6, strText); } pDC-SelectObject(pOldPen); }刻度文字的位置取决于字体高度我写的y - 8和rcPlot.bottom 6是配合默认字体的经验值。如果你用了大字体这里要跟着调不然会重叠或者跑出绘图区。4. 双缓冲绘图全流程一套能让曲线不闪的代码4.1 内存DC的经典三段式双缓冲的思路是把屏幕当作黑板把内存DC当作演算纸。先在演算纸上把背景、网格、曲线全部画好然后整块拷贝到黑板上。中间省去了先擦掉黑板再逐笔绘制的过程所以几乎不会闪。三段式代码是MFC绘图的基本功// 第一段创建与屏幕DC兼容的内存DC CDC memDC; memDC.CreateCompatibleDC(dc); // 第二段创建兼容位图并选入内存DC CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 第三段在内存DC上绘制最后整体拷贝 // ...各种绘制代码... dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); // 恢复旧位图 memDC.SelectObject(pOldBmp);SelectObject返回的是之前默认的位图对象画完一定要恢复。否则memDC析构时可能尝试删除还在DC里的对象轻则资源泄漏重则崩溃。4.2 完整OnPaint实现结合前面所有准备工作这里给出完整的OnPaint。这套代码我在VS2013的MFC对话框程序里跑过直接拷贝改一下数据来源就能用。void CCurveDlg::OnPaint() { CPaintDC dc(this); CRect rcClient, rcPlot; GetPlotRect(rcClient, rcPlot); // ------- 双缓冲开始 ------- CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 1. 清空画布背景色白色 CBrush bgBrush(RGB(255, 255, 255)); memDC.FillRect(rcClient, bgBrush); // 2. 画网格和刻度 double dMin 0.0, dMax 1.0; if (GetDataRange(dMin, dMax)) { DrawGrid(memDC, rcPlot, dMin, dMax); DrawCurve(memDC, rcPlot, dMin, dMax); } // 3. 整体拷贝到屏幕 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); // 恢复并释放 memDC.SelectObject(pOldBmp); // bmp、memDC会随作用域析构自动释放 // CDialogEx::OnPaint(); // 如果你有自绘控件这行放在最后 }FillRect用白色把整个客户区填干净。有些人会在这里省掉但如果不填白下一次绘图时旧内容会残留出现拖影。尤其双缓冲是整幅重建的前景背景都画进内存DC了一次性覆盖必须保证背景是干净的。4.3 曲线绘制Polyline 比 SetPixel 快一个量级画曲线时最容易踩的坑是用SetPixel一个点一个点地描。我在自己机器上测过同样的1000个数据点SetPixel画完大约需要几毫秒到十几毫秒Polyline一次调用只耗费不到一毫秒。实时刷新频率到了30Hz这点差距就变得很明显。Polyline接收一个CPoint数组和点数自动把所有点用直线连接一条指令画完整条曲线。void CCurveDlg::DrawCurve(CDC* pDC, const CRect rcPlot, double dMin, double dMax) { int nCount m_buffer.GetCount(); if (nCount 2) return; std::vectorCPoint points(nCount); for (int i 0; i nCount; i) { double v m_buffer.GetAt(i); int x 0, y 0; DataToScreen(v, i, nCount, rcPlot, dMin, dMax, x, y); points[i] CPoint(x, y); } CPen curvePen(PS_SOLID, 2, RGB(255, 0, 0)); CPen* pOldPen pDC-SelectObject(curvePen); pDC-Polyline(points.data(), (int)points.size()); pDC-SelectObject(pOldPen); }std::vectorCPoint在每次绘制时临时创建数据量大时可以改成成员变量复用省掉反复分配内存的开销。不过1000个点也就几KB对性能影响不大关键是别用SetPixel。4.4 减少背景擦除的细节双缓冲画完之后还有两个配套细节第一个OnEraseBkgnd里直接返回TRUE。这样系统就不会额外用背景色擦除客户区减少一次多余的重绘。BOOL CCurveDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 阻止系统擦除背景 }第二个刷新时用Invalidate(FALSE)而不是Invalidate()。Invalidate(FALSE)表示标记区域无效但不擦背景。双缓冲本来就会整体覆盖画面所以背景被系统擦一遍属于白白浪费。我把这两处配合起来用之后曲线区域可以说完全不闪了窗口拖拽时也只会看到画面整体重绘没有白屏闪烁。5. 实测中的坑与优化经验5.1 刷新定时器和采集定时器打架一开始我把采集和刷新放在同一个SetTimer里。采集频率是50Hz刷新也50Hz一个1000点缓冲区每次刷新要遍历一遍界面已经有点卡了。后面改成采集100Hz、刷新25Hz采集和刷新分开同样的数据量CPU占用明显下降界面也流畅很多。更稳妥的做法是把高频采集放到子线程里通过PostMessage通知界面线程去拉数据。注意不要用SendMessage它会阻塞调用线程直到界面处理完高频采集场景下会拖慢采集。5.2 窗口最小化和尺寸变化时的处理窗口最小化时GetClientRect依然会返回一个有效值但绘制区域往往很小。更麻烦的是窗口最小化时WM_PAINT还在持续触发白费CPU。我习惯在OnTimer里先判断IsIconic()最小化就直接跳过刷新逻辑。窗口尺寸变化时绘图区的边距和比例都会变。坐标转换函数每次都用当前的rcPlot计算所以只要OnTimer里调用Invalidate缩放后的曲线会自动重新映射不需要额外处理。唯一要注意的是内存DC的位图尺寸必须跟着客户区大小重建所以OnPaint里不能用固定的CBitmap成员变量每次临时创建最省心。5.3 数据范围跳变导致的曲线跳崖自动缩放有它的副作用当数据突然出现一个大的尖峰时Y轴范围会被瞬间拉大整条曲线会被压扁看起来就像数据跳崖了。这是实时仪表常见的观感问题解决思路是限制Y轴范围的变化速率让范围平滑过渡。// 范围平滑过渡的简化实现 double prevMin m_displayedMin; double prevMax m_displayedMax; double targetMin dataMin, targetMax dataMax; // 每次只允许范围变化20% m_displayedMin prevMin (targetMin - prevMin) * 0.2; m_displayedMax prevMax (targetMax - prevMax) * 0.2;这样效果是尖峰过来时曲线会猛地抬头但不会贴顶稍后范围跟上后恢复。视觉上比裸用全范围缩放舒服很多也更像专业示波器、工控软件的感觉。5.4 多通道曲线的扩展画多通道比如同时显示温度和压力两条曲线时电路看起来复杂其实很简单每种数据一个CRingBuffer坐标转换逻辑不变只是用不同颜色的画笔多画几条折线。为了区分我会给每条曲线留一个RGB颜色参数网格和坐标轴颜色不变曲线颜色决定视觉优先级。void CCurveDlg::DrawCurve(CDC* pDC, const CRect rcPlot, double dMin, double dMax, CRingBuffer buffer, COLORREF color) { CPen curvePen(PS_SOLID, 2, color); CPen* pOldPen pDC-SelectObject(curvePen); // 与单通道相同逻辑只是数据源换成buffer // 最后 pDC-SelectObject(pOldPen); }通道多的时候最好给每条通道单独配置Y轴范围不然范围差异大的信号画在一起会互相挤压。实现里就是给每条曲线维护dMin、dMax绘制时各自按自己的范围映射。5.5 曲线末尾加一个实时数值标签很多仪表在曲线右上角会显示最新值用户不需要去对照Y轴读数。实现非常便宜// 在OnPaint末尾追加 double lastValue m_buffer.GetAt(m_buffer.GetCount() - 1); CString str; str.Format(_T(%.2f), lastValue); CFont font; font.CreatePointFont(90, _T(Arial)); CFont* pOldFont pDC-SelectObject(font); // 用半透明白底或黄底把文字垫出来避免和网格重叠看不清 pDC-SetBkMode(TRANSPARENT); pDC-SetTextColor(RGB(200, 0, 0)); pDC-TextOut(rcPlot.right - 80, rcPlot.top 6, str); pDC-SelectObject(pOldFont);SetBkMode(TRANSPARENT)是关键否则文字会带一个白底块把网格线盖掉虽然只是视觉细节但专业软件不会犯这种错。实时曲线画起来不算难但要把性能、观感、稳定性一起考虑到容易踩的坑基本都在刷新节奏、数据结构和绘图方式上。我最开始那一版就卡在直接往客户区画点上改成双缓冲加环形队列以后代码看起来多了一点运行起来反而干净利落。后来我把这套逻辑抽成了一个独立类内部管缓冲区、坐标映射、绘制三段界面只需要喂数据和调刷新函数再接到别的项目时改改参数就能用省了太多事。本文还有配套的精品资源点击获取
返回列表