☰
MFC动态实时曲线绘制:双缓冲、无锁环形缓冲与GDI优化
2026/10/28 6:20:19 网站建设 项目流程

简介:面向 MFC 与 VC++ 开发者的一份动态实时曲线绘制工程示例,适合正在制作数据可视化、工业监控界面或设备采集上位机的中级学习者参考。内容围绕自定义 CView 视图重写 OnDraw、GDI 线条与折线绘制、后台线程取数与界面刷新同步、多曲线属性管理以及鼠标交互等环节展开,可对照理解实时刷新与线程安全的实现思路。压缩包共 60 个文件、约 32.28MB,包含 h/cpp 源码、sln 与 vcxproj 工程文件、rc/res 资源脚本,以及 dll、lib、pdb、tlog 等编译输出与中间文件,便于直接打开工程调试或抽取绘图模块复用。目前已有 1398 人学习下载,可作为搭建曲线监控界面的参考样本,并从中梳理数据采集、缓存更新与绘制分离的工程组织方式。

1. 上位机里那条会动的线:MFC 动态实时曲线难在哪

接手一台老旧采集设备的显示模块时,我发现界面上那条 MFC 动态实时曲线每隔几秒就闪一下,鼠标一拖窗口直接白屏,任务管理器里 CPU 还稳稳压在 30% 以上。这类需求听起来简单:定时取数、画折线、滚动更新。真做起来,MFC 动态实时曲线的难点根本不在画线,而在三件事——刷新节奏怎么和采集节奏解耦、GDI 对象怎么做到不泄漏、数据缓冲怎么在每秒上千点的写入下不阻塞界面线程。这篇笔记按我自己重写这套模块的顺序展开,适合手上已经有 MFC 工程、需要把曲线做稳的开发者,也适合被闪烁和卡顿折腾过一轮、想弄清底层机制的人。

2. 绘图底座:CDC 双缓冲与定时器刷新怎么选

动手写第一行代码之前,有个决定比任何函数调用都重要:这条曲线由谁推动、以什么频率推动。这个决定选错了,后面无论怎么优化 OnPaint 都是白费力气。我见过不止一个项目,把Sleep(10)直接写进 OnPaint 里做“实时更新”,结果窗口一被遮挡就假死,因为 WM_PAINT 是靠消息队列驱动的,你在里面睡,主线程就跟着停。

2.1 三条刷新路径:WM_TIMER、多媒体定时器、采集线程

SetTimer是最省事的路子,也是绝大多数 MFC 工程的默认选择。它的真实精度并不由你传的毫秒数决定:系统时钟节拍通常是 15.6ms,你传 10ms 实际会落到 15~16ms,传 33ms 大概率变成 31ms 或 47ms。对于 30fps 的曲线滚动,这个抖动完全能接受,人眼看不出来。

常见做法是把刷新和数据采集分成两条时间线:

路径典型精度回调所在线程适用场景主要风险
SetTimer/ WM_TIMER15.6ms 起步,有抖动UI 线程30fps 以内的曲线滚动消息队列被占满时丢节拍
timeSetEvent多媒体定时器1ms 量级系统回调线程需要严格等间隔采样回调里碰 UI 就是线程事故
独立工作线程 + 阻塞等待由等待对象决定自建线程采集与渲染彻底解耦需要自己做线程同步

我一般的分工是这样:采集线程只管往环形缓冲里塞数据,不碰任何 GDI;UI 线程挂一个 30~50ms 的SetTimer,每次触发时从缓冲里取增量、更新视图窗口、InvalidateRect一块区域。这样即使某次绘制慢了,下一拍也不会因为消息堆积而雪崩——WM_TIMER 是合成消息,队列里最多只留一个。

// 在曲线控件的创建阶段挂上刷新定时器 BOOL CCurveWnd::PreSubclassWindow() { // 33ms 对应约 30fps,人眼对曲线滚动已经足够顺滑 // 注意:这里不要设成 5ms,WM_TIMER 打不到这个精度,只会白白增加消息开销 m_uTimer = SetTimer(IDT_CURVE_REFRESH, 33, nullptr); return CWnd::PreSubclassWindow(); } void CCurveWnd::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent != IDT_CURVE_REFRESH) { CWnd::OnTimer(nIDEvent); return; } // 关键:只取“上次绘制之后新增”的那一段,不做全量重扫 m_tRenderEnd = m_ring.LatestTime(); // 只失效绘图区,标题栏、图例区不动,能省一大截重绘量 InvalidateRect(&m_rcPlot, FALSE); CWnd::OnTimer(nIDEvent); }

InvalidateRect的第二个参数传FALSE,表示不擦除背景——这一步是消除闪烁的第一道闸门,后面还会再讲。另外注意m_tRenderEnd这种“渲染游标”的写法:渲染侧永远只追到某个时间点,采集侧继续往前跑,两者用时间戳对齐,而不是用“第几条数据”对齐。后者在丢包或者补点时会对不上号。

2.2 双缓冲落地:内存 DC 的创建、贴图与资源回收

闪烁的根源是“先擦背景、再画前景”这个两步动作被眼睛看到了。MFC 默认的OnEraseBkgnd会拿背景刷把整块区域刷白,紧接着 OnPaint 再画线,中间那一帧白屏就是闪烁。解决办法是把所有绘制都丢进一个内存 DC,画完之后用一次BitBlt整体贴上去。

int CCurveWnd::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CWnd::OnCreate(lpCreateStruct) == -1) return -1; CClientDC dc(this); // 后台 DC 只创建一次,跟随窗口生命周期 m_memDC.CreateCompatibleDC(&dc); return 0; } void CCurveWnd::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); if (rc.IsRectEmpty()) return; // 位图尺寸变了才重建,这是避免每帧 CreateCompatibleBitmap 的关键 if (m_bmp.GetSafeHandle() == nullptr || m_szBmp != rc.Size()) { if (m_bmp.GetSafeHandle() != nullptr) m_bmp.DeleteObject(); m_bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); m_szBmp = rc.Size(); } CBitmap* pOldBmp = m_memDC.SelectObject(&m_bmp); // ---- 以下全部在内存里完成 ---- m_memDC.FillSolidRect(&rc, RGB(16, 18, 22)); // 背景一次性铺满 DrawGrid(&m_memDC, rc); // 网格线 DrawAxis(&m_memDC, rc); // 坐标轴与刻度 DrawSeries(&m_memDC, m_rcPlot); // 曲线本体 // ---- 一次性贴到屏幕 ---- dc.BitBlt(0, 0, rc.Width(), rc.Height(), &m_memDC, 0, 0, SRCCOPY); m_memDC.SelectObject(pOldBmp); // 必须还原,否则位图删不掉 }

这段代码有三个容易出错的地方,值得单独拆开说。

第一,SelectObject必须成对。把位图选进内存 DC 之后,DC 持有这个位图的一个引用,此时调用DeleteObject会失败(返回 FALSE,但很多人不检查返回值,就当它删掉了)。所以每次贴图完成后立刻把旧位图选回去。

第二,后台位图要缓存,不要每帧重建。CreateCompatibleBitmap是会真正分配显存/内存的,30fps 下每秒创建 30 张 800×600 的位图,性能直接崩掉。只有在OnSize改变客户区尺寸时才需要重建。

第三,OnEraseBkgnd要直接返回 TRUE:

BOOL CCurveWnd::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景已经在双缓冲里画过了,不让系统再刷一遍 }

少了这一句,双缓冲只能减少闪烁,不能根除——因为系统仍然会在 OnPaint 之前刷一次底色。

2.3 坐标映射与 Y 轴自适应:把物理量换成像素

曲线控件里所有的坐标计算都应该收敛到一个地方,写成两个内联函数。散落在各个绘制函数里的(int)(x * 3.7 + 12)这种魔法数字,是后期改缩放功能时最大的阻碍。

// 时间 t(秒)映射到像素 X;视图右端对齐最新时间,宽度为 m_viewSpan 秒 inline int CCurveWnd::XOf(double t) const { const double tLeft = m_tRenderEnd - m_viewSpan; // 视图左端时间 const double pxPerSec = m_rcPlot.Width() / m_viewSpan; return m_rcPlot.left + (int)((t - tLeft) * pxPerSec + 0.5); } // 数值 v 映射到像素 Y;plotRect 的 bottom 是数值下界 inline int CCurveWnd::YOf(double v) const { const double span = m_vMax - m_vMin; if (span <= 1e-12) return m_rcPlot.CenterPoint().y; // 全等值时防除零 return m_rcPlot.bottom - (int)((v - m_vMin) / span * m_rcPlot.Height() + 0.5); }

Y 轴范围如果写死,数据一超出量程整条线就跑出可视区,用户看到的就是“曲线没了”。自动量程要做得好看,不能简单取当前窗口的 min/max——那样曲线会随着数据抖动不停地上下伸缩,像呼吸一样,看久了头晕。我的做法是三层过滤:

  • 取当前视图时间窗内的极值,上下各留 5% 的余量,避免曲线贴着边框;
  • 给量程设一个最小跨度,比如满量程的 2%,防止数据近似恒定时被放大成噪声;
  • 目标量程和当前量程之间做指数缓动,每帧只走差值的 20%,让缩放是“滑”过去而不是“跳”过去。
void CCurveWnd::UpdateYRange(double lo, double hi) { const double pad = (hi - lo) * 0.05; lo -= pad; hi += pad; const double minSpan = 1e-3; // 按实际物理量纲调整 if (hi - lo < minSpan) { double c = (hi + lo) * 0.5; lo = c - minSpan * 0.5; hi = c + minSpan * 0.5; } // 指数缓动,0.2 是手感参数:越大跟随越快,越小越平滑 m_vMin += (lo - m_vMin) * 0.2; m_vMax += (hi - m_vMax) * 0.2; }

这套映射写完之后,缩放、平移、游标取值全都变成对m_viewSpan、m_tRenderEnd、m_vMin/m_vMax三个量的操作,绘制代码一行都不用改。

3. 数据侧:环形缓冲与跨线程传数的落地写法

绘图问题解决之后,下一个容易出问题的地方是数据通路。采集线程每秒往缓冲里写 1000 个点,UI 线程每秒只读 30 次,两边的节奏差了一个数量级。中间这层缓冲设计得不好,要么撑爆内存,要么在某个临界点上死锁一次,而且这种死锁往往只在连续跑几个小时之后才出现,极难复现。

3.1 为什么不能直接用 std::vector 加 push_back

很多示例代码是这样写的:一个std::vector<double> m_data,采集线程m_data.push_back(v),UI 线程遍历m_data全部重绘。小数据量下能跑,上了规模就是三个问题同时爆发。

第一是重分配。push_back触发扩容时,整个数组会被搬到一个新地址,容量翻倍。这个过程中如果 UI 线程正在遍历,读到的就是已经被释放的内存,表现为随机崩溃或者画出毫无意义的怪线。加锁能解决,但会引入第二个问题。

第二是锁竞争。采集线程在push_back时持锁,UI 线程在遍历时也持锁,遍历 10 万个点大概要几毫秒,这几毫秒里采集线程全部阻塞,采样间隔就被拉长了,采集侧的时序精度直接毁掉。这类系统里,采集线程的实时性优先级高于显示,绝不能让它等 UI。

第三是绘制成本。每帧重绘全量数据的复杂度是 O(n),而屏幕上横向只有 800 个像素,画 10 万个点里 99% 是重叠在白费力气。正确的思路是只画可见区间的数据,而且要做抽稀。

所以选型结论明确:定长环形缓冲,写入侧覆盖最旧数据,读取侧按时间戳区间取增量,读写之间用原子索引同步,不加互斥量。

3.2 无锁环形缓冲的写法与边界

// 单生产者 / 单消费者场景下的定长环形缓冲 // N 必须是 2 的幂,用位与代替取模 template <typename T, size_t N> class TRingBuffer { static_assert((N & (N - 1)) == 0, "N must be power of 2"); public: void Push(const T& v) { const size_t head = m_head.load(std::memory_order_relaxed); m_buf[head & (N - 1)] = v; // release 语义:保证数据写入先于索引更新对其他线程可见 m_head.store(head + 1, std::memory_order_release); } // 返回 [tail, head) 之间可读的数量 size_t Readable() const { return m_head.load(std::memory_order_acquire) - m_tail.load(std::memory_order_relaxed); } // 从游标 from 开始拷最多 maxCount 条到 out,返回实际拷贝数 size_t CopyFrom(size_t from, T* out, size_t maxCount) const { const size_t head = m_head.load(std::memory_order_acquire); if (from > head) return 0; // 游标被覆盖过,调用方需重置 size_t n = (std::min)(head - from, maxCount); for (size_t i = 0; i < n; ++i) out[i] = m_buf[(from + i) & (N - 1)]; return n; } size_t Head() const { return m_head.load(std::memory_order_acquire); } size_t Tail() const { return m_tail.load(std::memory_order_relaxed); } void SetTail(size_t t) { m_tail.store(t, std::memory_order_release); } private: T m_buf[N]; std::atomic<size_t> m_head{0}; std::atomic<size_t> m_tail{0}; };

参数说明和几个必须注意的边界:

N取 2 的幂不只是为了位运算快,更重要的是保证索引回绕时head & (N-1)和head - tail的数量计算始终一致。取 65536 条、每条 16 字节,占用 1MB,对现代机器可以忽略。如果采样率是 1kHz,这个容量能缓存 65 秒,足够 UI 卡顿几次不丢数据。

memory_order不能全省。写入侧必须用 release,读取侧必须用 acquire,否则编译器或者 CPU 可能把数据写和索引写的顺序调换,读者会拿到“索引说有新数据、但内容是旧的”这种幽灵状态。这个 bug 在 x86 上几乎测不出来,换到某些 ARM 平台上就稳定复现。

生产者的速度超过消费者是常态,head迟早会越过tail + N,也就是最旧的数据被静默覆盖。此时from会小于head - N,CopyFrom里那个from > head判断挡不住这种情况,必须额外检查:如果head - from > N,说明游标已经失效,调用方要把游标重置为head - N并且接受一段数据缺失,而不是画出一条横跨整个屏幕的假线。这一条我当初漏了,测试跑了两小时才在长时间断点调试后暴露出来。

3.3 跨线程通知:PostMessage 的两种用法差异

数据进了缓冲,UI 线程怎么知道该画了?这里有个反直觉的结论:不要每来一条数据就PostMessage一次。

采集 1kHz 的时候,每秒往 UI 线程投递 1000 条自定义消息,主线程光处理消息就忙不过来了,消息循环里排着队,用户的点击、拖动全部延迟。更糟的是这些消息本身不携带有效信息(数据已经在共享缓冲里了),纯粹是浪费。

正确做法是“合并通知”:采集线程判断距上次通知是否超过阈值(比如 20ms),超过才投递一次;UI 线程收到消息后不去逐条处理,而是把游标推到当前head,一次性取走这一段。

// 采集线程侧 void CCollector::OnSample(double value) { m_ring.Push(Sample{ m_clock.Now(), value }); const auto now = m_clock.NowMs(); // 合并通知:20ms 内只投递一次,避免消息洪泛 if (now - m_lastNotifyMs >= 20) { m_lastNotifyMs = now; ::PostMessage(m_hWndCurve, WM_CURVE_DATA_READY, 0, 0); } }

用PostMessage而不是SendMessage是硬性要求。SendMessage是同步的,采集线程会阻塞等到 UI 线程处理完才返回;万一 UI 线程此刻正在等采集线程持有的某个锁,两边就锁死了。PostMessage把消息丢进队列立刻返回,采集线程的时序不受影响。

还有一个细节:m_hWndCurve这个窗口句柄要在采集线程启动之前就有效,并且在窗口销毁之前通知采集线程停下。我习惯在OnDestroy里先置一个m_bStop原子标志,等采集线程 join 完成,再调CWnd::OnDestroy。顺序反了,线程可能往一个已经销毁的句柄投递消息,虽然PostMessage对无效句柄只是返回 FALSE 不会崩,但会静默丢数据,排查起来很费劲。

4. 控件化:把曲线封装成可复用的 CWnd 子类

一次性画在对话框上,和封装成一个能拖到任何窗口里的控件,工作量差得不多,但后者的复用价值高一个数量级。某个项目里我把这套曲线做成了独立控件,后面三个模块直接引用,连参数都不用改。

4.1 子类化注册与创建:别让样式位帮倒忙

从CWnd派生一个自绘控件,需要在Create时注册一个窗口类。很多人顺手就写AfxRegisterWndClass(CS_HREDRAW | CS_VREDRAW, ...),觉得“尺寸变了要重绘”很合理。这两个样式位确实会在窗口尺寸变化时触发整窗重绘,但它触发的是全窗口擦除 + 全窗口重绘,配合双缓冲虽然不至于闪,但会白白多画一遍。曲线控件自己在OnSize里处理布局就够了。

BOOL CCurveWnd::Create(CWnd* pParent, const CRect& rc, UINT nID) { // 不加 CS_HREDRAW/CS_VREDRAW:尺寸变化由 OnSize 自己处理,避免多余的全窗重绘 LPCTSTR lpszClass = AfxRegisterWndClass( CS_DBLCLKS, // 支持双击复位视图 ::LoadCursor(nullptr, IDC_CROSS), // 十字光标,方便读数 nullptr, // 无背景刷,背景自己画 nullptr); return CWnd::Create(lpszClass, nullptr, WS_CHILD | WS_VISIBLE | WS_CLIPCHILDREN | WS_CLIPSIBLINGS, rc, pParent, nID); } void CCurveWnd::OnSize(UINT nType, int cx, int cy) { CWnd::OnSize(nType, cx, cy); if (cx <= 0 || cy <= 0) return; // 重新计算绘图区:左侧留给 Y 轴刻度,底部留给 X 轴时间标签 m_rcPlot.SetRect(kAxisLeft, kAxisTop, cx - kRightPad, cy - kAxisBottom); // 客户区变了,后台位图要跟着失效,下一次 OnPaint 会重建 m_szBmp = CSize(0, 0); Invalidate(FALSE); }

AfxRegisterWndClass的背景刷参数传nullptr是故意的。传了系统背景刷,窗口类在擦背景时就会用那把刷子刷一遍,等于绕过了我们的双缓冲。传nullptr之后,背景完全由OnPaint负责。

4.2 缩放、平移与游标取值:交互都收敛到三个变量

交互功能的实现要克制。我见过一个版本,每次滚轮缩放都去重建数据缓存、重算所有点的像素坐标,滚一下卡三秒。实际上只要改m_viewSpan一个数,下一帧自然全变了——因为所有坐标都是通过XOf/YOf现算的。

BOOL CCurveWnd::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { CPoint client = pt; ScreenToClient(&client); if (!m_rcPlot.PtInRect(client)) return FALSE; // 以鼠标位置为锚点缩放:鼠标下方的时刻在缩放前后保持不动 const double tAnchor = m_tRenderEnd - (m_rcPlot.right - client.x) / (double)m_rcPlot.Width() * m_viewSpan; double factor = (zDelta > 0) ? 0.8 : 1.25; // 每格 25% 的缩放步进 m_viewSpan = m_viewSpan * factor; m_viewSpan = (std::max)(0.1, (std::min)(m_viewSpan, 3600.0)); // 限幅 0.1s ~ 1h // 重新对齐锚点 m_bFollowLatest = false; // 手动缩放后停止自动跟随 m_tRenderEnd = tAnchor + (m_rcPlot.right - client.x) / (double)m_rcPlot.Width() * m_viewSpan; Invalidate(FALSE); return TRUE; }

这里有个交互上的取舍值得讲:一旦用户手动缩放或者拖动,就要关掉“自动跟随最新数据”,否则视图会被不断拽回去,用户根本没法看历史段。恢复跟随的方式是双击或者提供一个“回到实时”的按钮,m_bFollowLatest = true,然后在定时器里把m_tRenderEnd重新钉到m_ring.LatestTime()。

游标取值则是反过来的映射:把鼠标像素 X 反算成时间,再在缓冲里二分查找最近的采样点。因为采样时间戳是单调递增的,二分比线性扫快两个数量级,鼠标移动才不会卡。

4.3 GDI 对象管理:笔和刷子必须缓存

GDI 对象是内核对象,每个进程默认上限是 10000 个句柄。每帧CreatePen一支笔、用完DeleteObject,理论上不会泄漏,但每帧几次对象创建销毁的系统调用开销在 30fps 下会累积成可观的 CPU 占用,更糟的是如果某条分支提前return忘了删,句柄数就会缓慢爬升,几小时后程序开始画不出东西。

// 在 OnCreate 里一次性建好,跟着窗口生命周期走 void CCurveWnd::InitGdiResources() { m_penGrid.CreatePen(PS_SOLID, 1, RGB(48, 52, 60)); m_penAxis.CreatePen(PS_SOLID, 1, RGB(120, 126, 138)); m_penCurve.CreatePen(PS_SOLID, 2, RGB(64, 196, 255)); m_penCursor.CreatePen(PS_DOT, 1, RGB(200, 200, 200)); m_fontAxis.CreatePointFont(90, _T("Segoe UI")); } // 在 OnDestroy 里统一释放 void CCurveWnd::OnDestroy() { m_penGrid.DeleteObject(); m_penAxis.DeleteObject(); m_penCurve.DeleteObject(); m_penCursor.DeleteObject(); m_fontAxis.DeleteObject(); if (m_uTimer) { KillTimer(m_uTimer); m_uTimer = 0; } CWnd::OnDestroy(); }

如果一定要按数据状态换颜色(比如超限变红),那就预先建好两支笔,绘制时SelectObject切换,而不是在循环里CreatePen。这个习惯一旦养成,句柄数就永远是一条平线。

5. 避坑清单:实时曲线模块最常翻车的五处

下面这几条都是我在真实调试中撞过的,按“现象 → 原因 → 解决”记录,方便对着排查。

5.1 窗口一拖动就白屏,曲线整片消失

现象:拖动窗口边缘改变大小时,客户区变成一片纯白,松手之后曲线才回来。

原因:OnPaint里用了CPaintDC但绘制区域被裁剪到了无效矩形,而代码里又假设GetClientRect拿到的永远是完整客户区,用完整尺寸去BitBlt到一个小 DC 上,越界部分被丢弃;同时拖动过程中系统频繁发送 WM_PAINT,每次都卡在重建后台位图上。

解决:OnPaint开头判断m_szBmp != rc.Size()才重建位图,其余情况复用;BitBlt只贴dc.m_ps.rcPaint这块区域,而不是整个客户区。另外把WS_CLIPCHILDREN加上,避免子控件区域被重复绘制。

5.2 跑几小时之后曲线不刷新了,但数据还在进

现象:程序挂机一整夜,第二天回来界面静止不动,重启就恢复。

原因:GDI 句柄泄漏。曲线绘制里每帧CreatePen,遇到某条提前返回的分支(比如数据点数小于 2 直接 return)就没删,一小时累积几万个句柄,达到进程上限后所有绘图 API 静默失败,LineTo返回 FALSE 但代码没检查。

解决:笔刷字体统一在OnCreate建、OnDestroy删;调试期在任务管理器里加一列“GDI 对象”盯着看,正常应该是一条水平线,只要斜率向上就说明有泄漏。这个指标比任何性能计数器都直观。

5.3 采样率提到 2kHz 之后界面卡成幻灯片

现象:低频时很流畅,把采样率调高一档,界面响应立刻变慢,鼠标拖动都有延迟。

原因:采集线程每来一个点就PostMessage一次,UI 线程的消息队列被几千条自定义消息灌满,正常的鼠标键盘消息排不上队。

解决:改成合并通知,采集侧按时间窗口(20ms)节流投递;UI 侧收到消息后按游标批量取数,而不是一条消息处理一个点。改完之后消息队列深度稳定在个位数。

5.4 数据恒定时曲线跑到屏幕外面

现象:传感器断开,输出恒定值,原本正常的曲线突然变成一条看不见的线,或者满屏噪点。

原因:Y 轴自动量程里做了(v - vMin) / (vMax - vMin)的除法,恒定值时vMax - vMin == 0,浮点除零得到 inf 或 nan,转成int就是未定义值。

解决:YOf里先判断span <= 1e-12,直接返回绘图区中线;同时在UpdateYRange里给量程设最小跨度。这两个防御缺一不可,因为缓动过程本身也会产生极小的瞬时跨度。

5.5 高分屏上字糊成一片,线条发虚

现象:在 4K 屏的笔记本上运行,曲线和坐标文字明显模糊,像被拉伸过。

原因:程序没有声明 DPI 感知,系统把整个窗口当成低分辨率位图,按 150% 或 200% 缩放后拉伸,细节全丢。

解决:在工程清单里声明 per-monitor DPI 感知,然后在OnDpiChanged里按新的 DPI 重建字体(CreatePointFont的字号要跟着缩放系数走),后台位图尺寸也要按物理像素重算。这一步不做,前面所有关于抗锯齿的努力都会被系统拉伸抹平。

6. 进阶调优:抗锯齿、抽稀与帧耗时实测

GDI 的Polyline画出来是硬边,斜线有明显的阶梯。要好看,两条路:一是换 GDI+,二是保持 GDI 但做超采样。我一般选前者,因为改动局部——只要把DrawSeries内部换成 GDI+ 绘制,其余代码不动。

void CCurveWnd::DrawSeriesGdiPlus(CDC* pDC, const CRect& rc) { Gdiplus::Graphics gfx(pDC->GetSafeHdc()); gfx.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); // 抗锯齿 gfx.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); // 半像素偏移,线条更锐 Gdiplus::Pen pen(Gdiplus::Color(255, 64, 196, 255), 1.6f); pen.SetLineJoin(Gdiplus::LineJoinRound); // 抽稀:屏幕上每个像素列只保留该列的最小值和最大值 // 800 像素宽的图,最多 1600 个点就够了,10 万点直连纯属浪费 std::vector<Gdiplus::PointF> pts; pts.reserve(rc.Width() * 2); int lastX = INT_MIN; double colMin = 0, colMax = 0; for (size_t i = 0; i < m_visibleCount; ++i) { const int x = XOf(m_visible[i].t); const double v = m_visible[i].v; if (x != lastX) { if (lastX != INT_MIN) { pts.push_back({(REAL)lastX, (REAL)YOf(colMin)}); pts.push_back({(REAL)lastX, (REAL)YOf(colMax)}); } lastX = x; colMin = colMax = v; } else { colMin = (std::min)(colMin, v); colMax = (std::max)(colMax, v); } } if (lastX != INT_MIN) { pts.push_back({(REAL)lastX, (REAL)YOf(colMin)}); pts.push_back({(REAL)lastX, (REAL)YOf(colMax)}); } if (pts.size() >= 2) gfx.DrawLines(&pen, pts.data(), (INT)pts.size()); }

这个“每列取 min/max”的抽稀策略比等间隔抽样靠谱得多:等间隔抽样会把尖峰整个漏掉,一个持续 1ms 的过冲在 1000:1 的抽样比下几乎必然被跳过,而 min/max 保留了包络,视觉上和原始波形一致。代价是点数变成两倍列宽,对 GDI+ 来说毫无压力。

帧耗时必须实测,不能凭感觉。我习惯在OnPaint首尾各取一次QueryPerformanceCounter,把最近 60 帧的耗时做滑动平均,显示在控件右上角(调试版才开)。正常值应该在 3~8ms,超过 16ms 就说明一帧画不完 60fps,得回头查是不是位图重建了、点数没抽稀、或者有隐藏的SelectObject没还原。

// 简易帧耗时统计,只在 _DEBUG 下启用 #ifdef _DEBUG LARGE_INTEGER f, t0, t1; QueryPerformanceFrequency(&f); QueryPerformanceCounter(&t0); // ... 绘制代码 ... QueryPerformanceCounter(&t1); double ms = (t1.QuadPart - t0.QuadPart) * 1000.0 / f.QuadPart; m_frameAvg = m_frameAvg * 0.9 + ms * 0.1; // 指数滑动平均 #endif

有个参数值得单独提醒:GDI+ 的DrawLines一次能接受的点数没有硬上限,但超过几万个点之后,路径细分的开销会反超 GDI 的Polyline。所以抽稀不是可选项,是前置条件——先抽到两倍列宽,再谈抗锯齿,顺序反了性能会很难看。

还有一条边界:老版本 GDI 的坐标是 16 位有符号数,范围 -32768~32767。如果视图时间跨度设得极大(比如显示 24 小时),XOf算出来的像素值会溢出成负数,曲线直接画到反方向去。这也是我坚持先缩放视图、再算像素,而不是反过来用像素宽度去撑时间跨度原因。

从那以后我每次做这类实时曲线模块,都会先把“采集线程只写缓冲、UI 线程只读增量、绘制只在内存 DC 里发生”这三条写成注释钉在文件头,然后在第一次联调时就把 GDI 对象数和帧耗时这两个指标挂到界面上盯着看——闪烁和卡顿能靠眼睛发现,句柄泄漏和性能劣化只能靠数字。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询