简介:面向VC6.0环境下从事MFC界面开发的初学者与进阶者,这份资源聚焦动态曲线绘制这一典型场景,演示如何借助SetMapMode、SetWindowOrg、SetViewportOrg、SetWindowExt与SetViewportExt等映射模式接口完成坐标变换,并配合双缓冲思路组织绘图流程,帮助读者理解GDI坐标映射与曲线刷新之间的关系。压缩包共36个文件、约2.3MB,包含5个cpp与6个h源码文件、工程配置dsp与dsw、资源脚本rc与rc2、图标位图及编译产物等,源码注释较为详细,便于对照阅读各函数调用与参数含义。已有468人学习下载。读者可从中获取一套可直接编译运行的MFC动态曲线示例工程,理清映射模式设置与视口窗口原点调整的配合方式,并参考作者对双缓冲刷新仍存在闪烁问题的说明,作为后续优化绘图性能与消除闪烁的排错起点。
1. 动态曲线为什么一刷新就闪:从 SetMapMode 与双缓冲说起
在 VC6.0 的 MFC 里画一条实时刷新的动态曲线,很多人第一次跑起来都会遇到同一个现象:曲线是画出来了,但每次定时器触发重绘,整个客户区先白一下再出现线条,鼠标拖动窗口时更是闪成一片。直觉上会以为是绘图代码写得太慢,于是拼命优化算法,结果毫无改善。真正的原因不在算法,而在两个更底层的地方:一是坐标映射,二是绘制路径。SetMapMode决定了逻辑坐标到设备坐标的换算规则,双缓冲决定了像素是直接怼到屏幕上还是先画到内存里再一次性贴上去。把这两件事搞明白,动态曲线的闪烁、抖动、坐标错位这些玄学问题基本都能定位。这也是为什么这套 VC6.0 MFC 动态曲线示例值得逐行拆开看——它把 GDI 坐标体系和绘图缓冲这两块老底子摊在了明面上。
2. SetMapMode:逻辑坐标是怎么变成屏幕像素的
2.1 默认映射模式埋下的坑
MFC 的CDC封装了 Win32 GDI 的设备上下文,而设备上下文里有一组映射参数:窗口范围(window extent)、视口范围(viewport extent)和窗口原点、视口原点。默认的MM_TEXT映射模式下,1 个逻辑单位等于 1 个设备像素,y 轴向下为正,原点在客户区左上角。这个默认值对画控件、画文字够用,但对画曲线极不友好:数据值往往是几百上千的量级,y 轴还习惯向上为正,直接拿数据当坐标画,线条要么跑出客户区,要么上下颠倒。
常见的实现思路是在OnDraw或OnPaint里先调SetMapMode切换映射模式,再用SetWindowExt和SetViewportExt建立比例关系。核心公式是:
设备坐标 = (逻辑坐标 - 窗口原点) * (视口范围 / 窗口范围) + 视口原点理解这个公式,就理解了 GDI 坐标变换的全部。窗口范围是你「声称」的逻辑坐标跨度,视口范围是这段跨度「实际」占多少像素。两者之比就是缩放因子。很多人调SetWindowExt时传了负数却没意识到它在翻转 y 轴方向,结果曲线上下颠倒还找不到原因。
2.2 用映射模式建立数据坐标系
假设客户区宽 800、高 400,要显示 x 从 0 到 100、y 从 0 到 50 的数据,且 y 轴向上为正。一种写法是:
void CCurveView::SetupMapping(CDC* pDC, const CRect& rcClient) { // 逻辑坐标 y 轴向上为正,故窗口范围 y 传负值实现翻转 pDC->SetMapMode(MM_ANISOTROPIC); // 允许 x、y 独立缩放 pDC->SetWindowExt(100, -50); // 逻辑跨度:x 0~100,y 0~50(负号翻转) pDC->SetViewportExt(rcClient.Width(), rcClient.Height()); // 映射到整个客户区 pDC->SetWindowOrg(0, 0); // 逻辑原点在数据 (0,0) pDC->SetViewportOrg(0, rcClient.Height()); // 设备原点挪到左下角 }MM_ANISOTROPIC与MM_ISOTROPIC的区别在于前者允许 x、y 缩放比例不同,后者会强制等比。画曲线通常要铺满客户区,所以选各向异性的MM_ANISOTROPIC。SetWindowExt的 y 传-50是关键:负的窗口范围会让逻辑 y 增大时设备 y 减小,正好把数学坐标系翻转成屏幕坐标系。SetViewportOrg把设备原点从左上角挪到左下角,这样逻辑 (0,0) 落在左下角,符合直觉。
提示:
SetWindowExt和SetViewportExt的调用顺序有讲究,先设窗口范围再设视口范围,否则某些驱动下比例会算错。这是血泪经验,不是文档里写死的。
2.3 映射模式切换后的连锁反应
一旦切到MM_ANISOTROPIC,所有绘图操作的坐标含义都变了,包括MoveTo、LineTo、TextOut、Rectangle。这意味着画坐标轴刻度文字时,如果还用像素思维传坐标,文字会跑到莫名其妙的位置。正确做法是把文字位置也换算成逻辑坐标,或者临时切回MM_TEXT画完文字再切回来。
另一个连锁反应是线宽。CPen的宽度参数在映射模式下会被缩放因子影响,1 像素的线在缩放比例大的时候可能变成几像素粗。如果希望线宽固定为屏幕像素,需要在创建画笔时考虑当前映射比例,或者干脆在MM_TEXT下画线、只在计算坐标时手动做变换。这两种策略各有取舍:前者代码统一但线宽不可控,后者线宽精确但坐标换算要自己写。示例里采用的是映射模式统一管理坐标,线宽用较细的固定值,牺牲一点精确性换取代码简洁。
3. 双缓冲:把闪烁从绘制路径上掐掉
3.1 闪烁的真正来源:WM_ERASEBKGND
窗口重绘时,系统默认先发WM_ERASEBKGND用背景色刷一遍客户区,再发WM_PAINT让你画内容。这两步之间有一个时间窗口,屏幕上的旧内容已经被擦掉、新内容还没画上,人眼就看到白闪。动态曲线定时刷新,这个窗口被反复触发,闪烁就变成了持续抖动。
所以双缓冲的第一层含义不是「画到内存再拷贝」这么简单,而是「别让系统擦背景」。常见做法是在OnEraseBkgnd里直接返回TRUE,告诉框架「背景我自己处理」,把擦除动作省掉。但光省掉擦除还不够,因为OnPaint里如果直接往屏幕 DC 上逐段画线,画到一半时屏幕已经显示了半条曲线,视觉上仍有撕裂感。
3.2 内存 DC 的创建与生命周期
双缓冲的标准套路是创建一个与屏幕兼容的内存 DC 和位图,所有绘制先落到内存位图,最后用一次BitBlt整体贴到屏幕。关键在于这些 GDI 对象的生命周期管理——VC6.0 时代没有 RAII 的便利,CreateCompatibleDC、CreateCompatibleBitmap、SelectObject必须成对出现,漏一个就是资源泄漏。
void CCurveView::OnPaint() { CPaintDC dc(this); // 屏幕 DC CRect rcClient; GetClientRect(&rcClient); CDC memDC; memDC.CreateCompatibleDC(&dc); // 内存 DC,兼容屏幕 CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 选入位图,保存旧位图 // 以下所有绘制都发生在内存 DC 上 memDC.FillSolidRect(&rcClient, RGB(255, 255, 255)); SetupMapping(&memDC, rcClient); // 映射模式同样作用于内存 DC DrawCurve(&memDC); // 画曲线、坐标轴 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); // 一次性贴到屏幕 memDC.SelectObject(pOldBmp); // 恢复旧位图,避免泄漏 }CreateCompatibleDC创建的内存 DC 初始只有 1x1 像素的默认位图,必须SelectObject一张尺寸匹配的位图进去才有意义。CreateCompatibleBitmap以屏幕 DC 为参照,保证颜色格式一致,否则BitBlt时可能出现颜色失真。SelectObject返回的旧位图指针必须保存并在最后恢复,这是 GDI 对象管理的铁律,漏掉会导致位图句柄泄漏,长时间运行后绘图直接失败。
3.3 BitBlt 与映射模式的交互细节
一个容易被忽略的点是:BitBlt本身不受映射模式影响,它按设备像素操作。但内存 DC 上如果设置了MM_ANISOTROPIC,BitBlt的源坐标参数含义会不会变?答案是BitBlt的坐标始终是设备坐标,映射模式只影响LineTo这类绘图函数。所以贴图时传(0,0)到(宽,高)是安全的,不需要做逻辑坐标换算。
另一个细节是内存位图的尺寸。如果客户区大小会变化(比如窗口可缩放),每次OnPaint都重新创建位图开销不小。常见的优化是把位图缓存成成员变量,只在尺寸变化时重建。示例里为了代码清晰每次重建,实际工程中会加一层尺寸判断。这个取舍在动态曲线场景下值得注意:定时器刷新频率高时,频繁创建销毁 GDI 对象的开销会累积成可感知的卡顿。
4. 动态刷新:定时器、数据窗口与重绘节奏
4.1 定时器驱动与 Invalidate 的代价
动态曲线靠SetTimer周期性触发,回调里更新数据缓冲区然后调Invalidate请求重绘。Invalidate只是标记客户区为无效,真正的WM_PAINT要等消息队列空闲才处理。这意味着如果定时器间隔设得太短(比如 10ms),而绘制一帧的耗时超过间隔,消息队列会堆积WM_PAINT,系统会合并无效区域,最终表现为刷新不均匀、曲线一顿一顿。
合理的做法是让定时器间隔略大于单帧绘制耗时,或者在OnTimer里用标志位防止重入。示例里定时器间隔设在几十毫秒量级,配合双缓冲,视觉上足够流畅。这里没有银弹,间隔多少要靠实测,跟机器性能和曲线点数都有关。
4.2 数据缓冲区的滚动窗口
动态曲线的数据是持续追加的,但屏幕只能显示有限个点。常见实现是维护一个固定长度的环形缓冲区,新数据覆盖最旧的数据,绘制时按当前窗口范围取最近 N 个点。这样内存占用恒定,绘制点数可控。
class CCurveBuffer { public: static const int MAX_POINTS = 500; void Push(double value) { m_data[m_head] = value; m_head = (m_head + 1) % MAX_POINTS; // 环形推进 if (m_count < MAX_POINTS) m_count++; } // 按时间顺序取出,供绘制使用 void GetOrdered(double* out, int& n) const { n = m_count; int start = (m_head - m_count + MAX_POINTS) % MAX_POINTS; for (int i = 0; i < m_count; i++) out[i] = m_data[(start + i) % MAX_POINTS]; } private: double m_data[MAX_POINTS]; int m_head = 0; int m_count = 0; };环形缓冲区的关键在于GetOrdered里的取模运算:m_head指向下一个写入位置,最旧的数据在(m_head - m_count + MAX_POINTS) % MAX_POINTS。这个偏移计算如果写错,曲线会出现数据错位或跳变,而且因为数据本身是连续的,错位后看起来像「曲线突然抖了一下」,极难排查。我一般会在这里加断言校验start的范围。
4.3 坐标换算与曲线绘制的完整链路
把数据点画成曲线,需要把数据值映射到逻辑坐标,再交给 GDI 变换成设备坐标。假设 x 轴表示时间序号,y 轴表示数值,绘制循环大致是:
void CCurveView::DrawCurve(CDC* pDC) { double pts[CCurveBuffer::MAX_POINTS]; int n = 0; m_buffer.GetOrdered(pts, n); if (n < 2) return; CPen pen(PS_SOLID, 1, RGB(0, 0, 255)); CPen* pOldPen = pDC->SelectObject(&pen); // 逻辑坐标下 x 跨度 0~100,y 跨度 0~50(与 SetupMapping 对应) double xStep = 100.0 / (n - 1); pDC->MoveTo(0, (int)pts[0]); for (int i = 1; i < n; i++) { int x = (int)(i * xStep); int y = (int)pts[i]; pDC->LineTo(x, y); // 逻辑坐标,由映射模式换算 } pDC->SelectObject(pOldPen); }MoveTo和LineTo接收的是逻辑坐标,映射模式负责换算。这里pts[i]直接当 y 逻辑坐标用,前提是数据范围与SetWindowExt声明的跨度一致。如果数据超出 0~50,曲线会画到客户区外被裁掉;如果数据范围远小于跨度,曲线会挤在底部一小条。所以SetWindowExt的跨度应该根据数据实际范围动态调整,或者对数据做归一化。示例里跨度写死是为了演示清晰,实际工程中会根据缓冲区里的最大值最小值重新计算。
5. 避坑与排查:那些让曲线画不对的细节
5.1 曲线闪烁依旧:OnEraseBkgnd 没处理
现象:加了双缓冲,曲线还是闪。原因:OnEraseBkgnd仍然用背景色刷屏,双缓冲只解决了「画」的撕裂,没解决「擦」的白闪。解决:重写OnEraseBkgnd直接return TRUE,背景在内存 DC 里用FillSolidRect自己填。
5.2 曲线上下颠倒:SetWindowExt 的 y 符号
现象:数据值越大曲线越靠下。原因:SetWindowExt的 y 传了正值,逻辑 y 增大对应设备 y 增大(向下)。解决:y 传负值翻转方向,同时确认SetViewportOrg把原点挪到了左下角,两者要配套。
5.3 长时间运行后绘图失效:GDI 对象泄漏
现象:程序跑几分钟后曲线不再刷新,或者CreateCompatibleDC返回 NULL。原因:SelectObject选入的位图、画笔没有恢复旧对象,GDI 句柄耗尽(每个进程有上限)。解决:每次SelectObject都保存返回值,用完恢复;内存 DC 和位图在不需要时DeleteDC、DeleteObject。
5.4 曲线抖动不均匀:定时器与重绘节奏失配
现象:曲线刷新时快时慢,像卡顿。原因:定时器间隔小于单帧绘制耗时,WM_PAINT堆积后被合并。解决:加大定时器间隔,或在OnTimer里用m_bDrawing标志防止重入,绘制未完成时跳过本次刷新。
5.5 文字刻度错位:映射模式下的 TextOut
现象:坐标轴刻度数字跑到客户区外或位置诡异。原因:TextOut的坐标也受映射模式影响,用像素思维传坐标必然错位。解决:文字位置换算成逻辑坐标,或临时SetMapMode(MM_TEXT)画完文字再切回。
6. 设计取舍:为什么老代码偏爱手动管理 GDI
VC6.0 时代的 MFC 绘图代码有个鲜明特征:GDI 对象的创建、选入、恢复、销毁全靠手写,没有现代 C++ 的 RAII 兜底。这不是前人不懂封装,而是当时的编译器和标准库对异常安全、智能指针的支持有限,手写反而更可控。代价是每一处SelectObject都是一次「后悔药」机会——忘了恢复就泄漏,而且泄漏是渐进的,测试时看不出来,上线跑久了才翻车。
映射模式的选择同样是一种取舍。用MM_ANISOTROPIC统一管理坐标,代码简洁、坐标含义直观,但线宽和文字位置要额外照顾;全程MM_TEXT手动换算坐标,控制精确但每处绘图都要写换算代码,容易漏。示例选了前者,是因为动态曲线的核心诉求是坐标直观、代码可读,线宽和文字的精度损失可以接受。这个判断放到今天依然成立:先想清楚哪部分精度可以牺牲,再决定映射策略,比一上来就追求「全都精确」要务实得多。
验证一套动态曲线绘制是否真的稳,我的习惯是跑三个场景:窗口反复缩放看位图重建有没有泄漏、连续运行半小时看 GDI 句柄数是否稳定、数据突变时看曲线有没有越界或错位。这三个场景过一遍,基本能把映射模式和双缓冲的坑踩干净。从那以后我每次碰 GDI 绘图,都强制先把OnEraseBkgnd和SelectObject的恢复路径走一遍再写业务逻辑,省下的排查时间远超这点前置成本。希望帮到你。
本文还有配套的精品资源,点击获取