ARTICLE DETAIL

资讯详情

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

MFC动态曲线绘制:SetMapMode与双缓冲实战解析

MFC动态曲线绘制:SetMapMode与双缓冲实战解析 简介这是一份采用VC6.0 MFC编写的动态曲线绘制示例工程面向正在学习MFC图形编程或需要完成类似课程设计、毕业设计题目的开发者。工程围绕坐标映射API SetMapMode、SetWindowOrg/SetViewportOrg、SetWindowExt/SetViewportExt展开完整展示如何通过自定义逻辑坐标实现曲线随窗口大小自适应缩放并采用双缓冲技术减少绘图闪烁代码注释较详细便于逐行理解坐标映射机制、客户区坐标切换与绘图基本流程。资源包共36个文件除6个头文件和5个.cpp实现文件外还包括工程配置文件dsp/dsw、图标位图等界面资源文件以及编译好的exe和调试信息压缩包仅2.3MB可直接用VC6.0打开工程查看运行效果。目前已有465人学习下载。作者在说明中也坦承当前刷新仍使用全局刷新方式画面存在轻微闪烁这反而为读者提供了一个很好的改进练手点可在此基础上优化为真正的局部双缓冲刷新。 很长时间没碰 VC6.0 了上个月翻一个老工业监测项目里面正好有一块用 MFC 画动态曲线的代码核心组合就是 SetMapMode 双缓冲。当时为了把这条曲线画顺我翻了不少资料也踩了不少坑。今天把这部分整理出来给还在用 VC6.0/MFC 写上位机、数据监控界面的朋友一个参考。如果你正在被动态曲线闪烁、坐标错位、数据刷新卡顿这几个问题折磨这篇文章应该能直接帮你省下几天时间。先说结论动态曲线本身不难难的是三点——怎么让画面不闪、怎么让坐标换算出得准、怎么处理好定时刷新和数据累积的关系。把这三点拆开看其实每一块都很清晰。1. 动态曲线实际要面对的三个核心问题1.1 绘图流程在 MFC 里是怎么被触发的MFC 的窗口重绘由 WM_PAINT 驱动View 类里通常对应 OnDrawDialog 里可以重写 OnPaint。动态曲线说白了就是“数据一直在变窗口就得跟着变”。最简单粗暴的做法是在 OnTimer 里不断调用Invalidate()让系统觉得整个窗口都脏了然后重新跑一遍 OnDraw。这个写法在小数据量时没问题但数据点一多、窗口一复杂问题立刻暴露画面闪烁、CPU 占用高、鼠标操作卡顿。原因不复杂。Invalidate(TRUE)会先擦除背景再触发重绘擦除和重绘之间有一个短暂的空白期高速反复执行人眼就会看到闪。如果再加上复杂的网格、坐标轴、多条曲线单帧绘制时间变长闪烁就更明显。1.2 坐标、闪烁、数据量其实是互相影响的很多人以为画曲线就是拿到数据直接LineTo其实不够。屏幕上显示的区域是有限的一块矩形而数据可能是温度 0 到 100、压力 0 到 1.6MPa、频率 0 到 50kHz量纲完全不同。你不可能为每个量纲都写一套像素换算所以才需要理解 GDI 的坐标映射。数据量的问题会在长时间运行后暴露。如果每秒采集 10 个点一条曲线一小时就是 36000 个点。如果每次重绘都重新画这 36000 个点再叠加坐标变换和双缓冲效率自然会下来。所以动态曲线的实现必须同时考虑坐标映射方案、重绘机制和数据缓冲区设计。这三个问题不是独立的。比如用了SetMapMode之后画笔宽度、文字大小都会跟着逻辑单位变化如果没意识到这一点画出来的网格线可能粗得像柱子。后面我会专门讲这些坑。2. SetMapMode把逻辑坐标系拉到你想要的样子2.1 逻辑坐标与设备坐标的换算关系GDI 默认的 MM_TEXT 模式逻辑坐标和设备坐标是一一对应的单位就是像素原点在左上角y 轴向下。这是最符合人直觉的坐标系但缺点也很明显你画一条 y50 的水平线在不同的窗口尺寸下位置完全不一样。窗口拉大线就跑到上面去了不会跟着缩放。SetMapMode 的作用就是改变逻辑坐标到设备坐标的换算规则。比较常用的映射模式有映射模式逻辑单位特点MM_TEXT像素原点左上y 向下最直观MM_ISOTROPIC自定义x/y 等比例缩放图形不变形MM_ANISOTROPIC自定义x/y 可分别缩放适合曲线填满区域MM_HIMETRIC0.01mmy 向上适合打印动态曲线最常用的是 MM_ANISOTROPIC。它允许你分别设置 x 方向和 y 方向的比例让曲线填满整个绘图区不需要自己手工算每个像素点对应多少工程量。2.2 为什么我偏向 MM_ANISOTROPIC 而不是 MM_ISOTROPICMM_ISOTROPIC 的好处是 x、y 保持相同比例画圆形、饼图不会变扁。但动态曲线恰恰不需要这种限制。你希望横轴代表时间、纵轴代表数值两者本身没有固定的比例关系。曲线要尽量铺满整个绘图区域观察起来才舒服。所以我的选择是默认情况下直接用 MM_ANISOTROPIC并且把逻辑窗口范围固定成一个虚拟坐标系。比如横轴固定为 0 到 1200纵轴固定为 0 到 600。这样不管窗口实际多大图形的比例都保持一致客户区一拉伸曲线自动跟随缩放。这里有一个容易混淆的点SetWindowExt设置的是逻辑范围SetViewportExt设置的是设备范围SetViewportOrg设置的是原点位置。它们不是“设置窗口位置”而是“设定坐标转换的参照系”。2.3 把 y 轴倒过来画是动态曲线的常见需求设备坐标系里 y 轴向下但数据量的趋势一般希望“越大越高”。在MM_ANISOTROPIC下实现 y 轴向上很容易把逻辑范围的高度设为负值即可。void CCurveDlg::SetupMapping(CDC* pDC, CRect rcPlot) { // 逻辑坐标系x 0~1200y 0~600负号表示 y 轴向上 pDC-SetMapMode(MM_ANISOTROPIC); pDC-SetWindowExt(1200, -600); pDC-SetViewportExt(rcPlot.Width(), rcPlot.Height()); // 原点放到绘图区左下角 pDC-SetViewportOrg(rcPlot.left, rcPlot.bottom); }这段代码是整套绘图的地基。逻辑坐标 (0,0) 对应客户区的左下角逻辑坐标 (1200,600) 对应右上角。之后画网格、画曲线、画标注全部按照逻辑坐标来不再跟具体的像素值纠缠。3. 双缓冲绘图消除闪烁的正路3.1 闪烁的根因不只是 Invalidate(TRUE)闪烁的直接原因是擦除与重绘不同步。Invalidate(TRUE)告诉系统要擦掉背景系统在背景擦掉之后、OnDraw 完成之前窗口会露底。如果这个过程在几十毫秒内反复发生视觉上就是高频闪烁。另一个隐藏原因是绘制耗时。如果 OnDraw 直接在屏幕 DC 上逐点LineTo每条线段都经过 GDI 计算再输出整个过程窗口还可能被其他窗口遮挡、被系统截图、被拖拽都会产生不可控的重绘。双缓冲的核心思路是先在内存里把整张图画好再一次BitBlt拷贝到屏幕减少屏幕 DC 的绘制次数。3.2 双缓冲的标准写法用 MFC 做双缓冲通常四步void CCurveDlg::DrawPlot(CDC* pDC) { CRect rc; GetClientRect(rc); // 1. 创建内存 DC 和位图 CDC memDC; CBitmap memBmp; memDC.CreateCompatibleDC(pDC); memBmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(memBmp); // 2. 在内存 DC 上完成所有绘制 memDC.FillSolidRect(rc, RGB(248, 248, 248)); // ... 网格、坐标轴、曲线全画到 memDC // 3. 一次性拷回屏幕 pDC-BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); // 4. 恢复原对象注意顺序 memDC.SelectObject(pOldBmp); }第一次写双缓冲的人最容易犯的错是位图选择进内存 DC 之后函数结束时直接让CBitmap析构。此时位图对象还在 DC 里析构可能导致资源没有正确释放。先用SelectObject(pOldBmp)把老位图换回去再让局部对象析构顺序不能反。3.3 双缓冲和 Invalidate 的搭配技巧双缓冲画完一帧后重绘触发方式也有讲究。如果定时器每 100ms 刷新一次用Invalidate(FALSE)就够了。FALSE表示不擦除背景背景由双缓冲里的FillSolidRect统一处理。这样能减少一次背景擦除操作CPU 占用更低。另一个技巧是只在有数据更新时才重绘而不是让定时器一直空转。比如数据源没有新点时可以设置一个m_bDataDirty标志只有标志为真才调用Invalidate。虽然 MFC 的Invalidate不会立即触发 OnDraw但无谓的 GDI 重绘依然会浪费资源。4. 动态曲线代码落地从数据到画面4.1 用 CArray 做一个有容量的数据缓冲VC6.0 时代的 MFC 对 STL 支持不算友好用 CArray 更顺手。画曲线需要的是环形队列那样的行为数据满了就丢掉最老的数据新数据继续追加。#define MAX_POINTS 1200 class CCurveDlg : public CDialog { public: CArraydouble, double m_data; UINT_PTR m_nTimer; int m_nPhase; void AddData(double val); virtual void OnTimer(UINT_PTR nIDEvent); void DrawPlot(CDC* pDC); };添加数据的方法要处理“超出容量”的情况void CCurveDlg::AddData(double val) { if (m_data.GetSize() MAX_POINTS) m_data.RemoveAt(0); // 丢掉最老的 m_data.Add(val); }这里有个容易忽略的问题RemoveAt(0)在 CArray 里是 O(n) 操作。数据量 1200 个点时没问题但如果 MAX_POINTS 设成几万每次移除第一个元素就会有点痛。更好的做法是用固定数组加头尾指针实现真正的环形缓冲。不过 1200 个点以内CArray 最直观也够了。4.2 OnTimer 里做什么定时器不需要做复杂绘制只负责“取数据、存数据、请求重绘”三件事。void CCurveDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent m_nTimer) { // 模拟采样值真实项目中这里换成串口/网口取数 double val 100 50 * sin(m_nPhase * 0.1) (rand() % 20 - 10); m_nPhase; AddData(val); Invalidate(FALSE); } CDialog::OnTimer(nIDEvent); }注意不要在 OnTimer 里直接OnDraw。定时器回调可能在非绘制状态执行直接绘图容易和系统重绘冲突。标准做法是Invalidate(FALSE)把绘制请求交给消息循环让 MFC 在合适的时候调用 OnPaint/OnDraw。4.3 完整绘制函数网格、曲线、一次到位把前面的坐标映射和双缓冲合到一起就是一个可以直接用的动态曲线绘制核心void CCurveDlg::DrawPlot(CDC* pDC) { CRect rc; GetClientRect(rc); CDC memDC; CBitmap memBmp; memDC.CreateCompatibleDC(pDC); memBmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOldBmp memDC.SelectObject(memBmp); // 白色背景 memDC.FillSolidRect(rc, RGB(250, 250, 250)); // 绘图区左边留 40 像素放 y 轴刻度下方留 20 放 x 轴 CRect rcPlot(40, 20, rc.right - 20, rc.bottom - 20); SetupMapping(memDC, rcPlot); // 网格线 CPen penGrid(PS_SOLID, 0, RGB(220, 220, 220)); CPen* pOldPen memDC.SelectObject(penGrid); for (int i 0; i 12; i) { memDC.MoveTo(i * 100, 0); memDC.LineTo(i * 100, 600); } for (int j 0; j 6; j) { memDC.MoveTo(0, j * 100); memDC.LineTo(1200, j * 100); } // 画数据曲线 if (m_data.GetSize() 1) { CPen penCurve(PS_SOLID, 2, RGB(0, 102, 204)); memDC.SelectObject(penCurve); memDC.MoveTo(0, (int)(m_data[0] * 5.0)); for (int k 1; k m_data.GetSize(); k) { memDC.LineTo(k, (int)(m_data[k] * 5.0)); } } memDC.SelectObject(pOldPen); // 一次性拷贝到屏幕 pDC-BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }这段代码里m_data[k] * 5.0是把实际数据范围 0~100 映射到逻辑坐标 0~500。如果数据范围是 0~1.6MPa你需要把5.0换成600.0 / range。实际项目里这个比例建议做成动态计算的不要写死。5. 我踩过的坑坐标、线宽、刷新率5.1 SetMapMode 之后画笔和文字会被等比放大这是最容易被忽略的一类问题。在 MM_ANISOTROPIC 下画笔宽度也是按逻辑单位换算的。CPen(PS_SOLID, 2, ...)里的 2 不是像素而是逻辑单位。窗口小的时候可能看不出区别窗口一旦拉大2 逻辑单位的线宽会被放大成好几像素甚至更粗。解决办法有三个方向第一网格线用宽度 0 的画笔GDI 对宽度 0 的画笔按最细线处理第二把曲线绘制和网格绘制拆分网格在设备坐标下画曲线在逻辑坐标下画第三切换到 MM_TEXT 手工计算坐标彻底绕开映射模式对画笔宽度的影响。文字同样会遇到这个问题。在逻辑坐标下画字符串字体大小会随窗口缩放有时候文字会被拉变形。所以我建议刻度文字单独用SetTextAlign配合设备坐标绘制或者先临时把映射模式切回 MM_TEXT。我在项目里采用后者逻辑清晰没那么多意外。5.2 定时器精度不够怎么办SetTimer在 Windows 下并不保证精确间隔尤其是系统繁忙时WM_TIMER 消息会被合并或延迟。对动态曲线来说10ms 级别的误差通常看不出来但如果你的数据源本身就是高频采样比如每 1ms 来一个点用SetTimer就完全不行。这时可以把取数和画图拆开工作线程负责从设备取数写入环形缓冲然后通过PostMessage通知 UI 线程刷新。刷新频率不需要跟采样频率一样高比如数据每 5ms 一个UI 每 50ms 重绘一次一次画最近 10 个点。曲线看起来还是连续的但 CPU 占用会低很多。5.3 GDI 对象泄漏最早演不出来跑一天就崩双缓冲里创建 CBitmap、CPen、CBrush 这些对象如果忘记释放短时间内看不出来程序跑上几小时可能突然崩溃或者界面花掉。尤其是 OnTimer 高频刷新时每次创建 2 到 3 个 GDI 对象一个小时就是数万个对象累积。MFC 的局部对象离开作用域会自动析构但前提是你先把它从 DC 里换出来。最常见的泄漏写法就是CPen* pOldPen memDC.SelectObject(penCurve); // 画完忘记 memDC.SelectObject(pOldPen);虽然 penCurve 是局部变量会析构但 DC 里还保存着它的指针这个 DC 又是局部变量析构顺序和DC的引用关系很容易出问题。习惯写法是收尾时统一SelectObject换回原来的 GDI 对象。5.4 Invalidate(TRUE) 和 Invalidate(FALSE) 的差别用双缓冲之后必须用Invalidate(FALSE)。否则系统先擦背景再进入 OnDraw双缓冲绘制的白底背景就等于被擦了两遍而且多了一次背景填充CPU 和重绘区域的闪烁问题都会回来。一个更精细的做法是只刷新曲线区域不刷新整个客户区。比如刻度、标题这些静态内容不经常变只有曲线区域需要更新可以用InvalidateRect(rcPlot)。配合双缓冲效果很稳。6. 再往前一步让动态曲线更稳、更快6.1 用局部失效代替全窗口刷新现场应用里客户区往往还带着按钮、状态栏、多个曲线子窗口。全窗口重绘看似简单其实这些静态控件也在反复绘制。更好的做法是把曲线区域单独划出来重绘时只让这块区域失效CRect rcPlot(40, 20, rc.right - 20, rc.bottom - 20); InvalidateRect(rcPlot, FALSE);OnDraw 里可以先判断pDC-GetClipBox得到需要重绘的矩形只绘制相交区域的内容。数据量大的时候这个优化收益非常明显。6.2 数据点太多时别把所有点都画上去长时间运行后1200 个点的环形缓冲会装满。如果缓冲上限设成几万点每次重绘都画几万条LineTo就算双缓冲也救不了性能。工业曲线不需要把每个采样点都画出来屏幕宽度就那么一千多个像素画几千个点基本看不出差别。超出屏幕分辨率的数据可以做降采样常见做法是等间隔抽点或者取每个像素区间内的最大值和最小值保证曲线尖峰不丢失。后者的视觉效果比简单抽点好很多尤其处理高频噪声数据时。6.3 把“画曲线”和“画背景”分离缓存静态层网格、边框、坐标刻度这些元素几乎不变每次刷新都重新画其实是在浪费 CPU。可以在首次初始化时把网格画到一张持久的内存位图里每次重绘只叠加曲线再通过BitBlt合成输出。这个思路本质上还是双缓冲只是从“整帧重建”变成“分层合成”。VC6.0 下实现不复杂定义两个CBitmap成员变量一个存静态背景一个存当前曲线帧绘制时先拷背景再画曲线。如果以后界面里加了多曲线、图例、游标这种分层结构会好扩展得多。用 SetMapMode 和双缓冲这套组合之前我也纠结过是不是直接用 MM_TEXT 把所有坐标手工算出来。说实话手工换算应付单条曲线完全够用代码也可以很直白。如果你觉得坐标变换绕那就先用 MM_TEXT把曲线的数据换算写成独立函数等真有缩放需求时再引 SetMapMode 也不晚。反而是后来处理线宽、字体的过程中我才真正感觉到理解坐标映射有多重要。工具能不能用好不在于 API 多熟而在于你愿不愿意去推演那一层“坐标从哪来、到哪去”的关系。本文还有配套的精品资源点击获取
返回列表