简介:面向MFC开发者的高速二维曲线绘制示例程序,针对数据采集场景中实时曲线显示需求,借助ChartCtrl控件弥补MFC原生缺乏易用曲线控件的短板。资源包共77个文件,以38个h头文件与27个cpp源文件为主体,另有4个inl内联实现及vcxproj、sln等工程配置文件和rc资源脚本,压缩包约245KB,结构完整可直接编译运行。示例重点演示了曲线自适应填充、静止状态下拉框放大局部曲线等实用功能,并说明以Custom Control为载体时style属性需设为0x52010000,其中WS_CLIPCHILDREN等标志位的作用值得留意;同时记录了VC2017下取消预编译头以规避旧版stdafx.h兼容警告的处理方式。已有1659人学习下载,适合需要快速回忆ChartCtrl用法或搭建采集曲线界面的开发者参考。
1. 从一条“画不动的曲线”说起:这个 MFC 控件到底解决什么问题
做过工控上位机或者仪器采集界面的开发者大概都遇到过这种场景:下位机每 10 毫秒推一帧数据上来,界面要实时把这条二维曲线画出来,横轴是时间、纵轴是采样值,跑上几分钟之后点数累积到几十万,界面开始卡、鼠标拖动窗口出现白块、CPU 占用一路飙高。用 MFC 自带的CStatic加OnPaint里手撸MoveTo/LineTo,几百个点还行,上万点就开始翻车。这个示例程序就是冲着这个痛点来的——一个基于 MFC 的高速二维曲线控件,VC2017 编译通过,拿来就能跑,跑起来就能看到一条流畅刷新的实时曲线。
它适合两类人:一类是手上有个 MFC 老项目、需要在对话框里嵌一个高性能绘图区间的开发者;另一类是想搞清楚“GDI 绘图怎么做到不闪、不卡、点数再多也不掉帧”的 MFC 学习者。整个示例把控件封装、双缓冲绘制、坐标映射、数据追加这几件事拆得比较清楚,不是那种只贴一个OnDraw的玩具代码。下面按“能干什么 → 怎么跑起来 → 核心怎么实现 → 怎么改”的顺序拆。
2. 工程结构与技术选型:一个对话框程序里藏了哪些模块
2.1 技术栈与运行环境
这个示例是纯 Win32 桌面路线,没有引入任何第三方绘图库,依赖的是 MFC 框架自带的 GDI 绘制能力。技术选型上可以列成一张表看得更清楚:
| 层面 | 选型 | 说明 |
|---|---|---|
| 开发框架 | MFC(对话框程序) | 单文档/对话框均可承载,示例用对话框宿主 |
| 编译工具 | Visual Studio 2017 | 工程属性里平台工具集为 v141 |
| 字符集 | Unicode | 工程默认,字符串用_T()包裹 |
| 绘图 API | GDI(CDC、CPen、CBitmap) | 双缓冲靠内存 DC 实现 |
| 数据组织 | 环形缓冲 / 定长数组 | 控制内存增长,避免无限累积 |
| 刷新机制 | 定时器 + 失效重绘 | SetTimer驱动,Invalidate触发 |
选 GDI 而不是 GDI+ 或 Direct2D,原因很实际:MFC 老项目里 GDI 的兼容性最好,CDC封装成熟,双缓冲的写法大家都能看懂,改起来门槛低。GDI+ 画抗锯齿曲线更漂亮但开销大,Direct2D 性能好但和 MFC 的窗口体系结合要多写一层。对于“高速刷新 + 中等点数”的场景,GDI 双缓冲是性价比最高的做法,这也是这个示例值得参考的地方。
2.2 目录与类结构
工程解压后大致是标准的 VS 解决方案布局,核心文件集中在控件类和宿主对话框上。常见结构如下:
CurveDemo/ ├── CurveDemo.sln # 解决方案文件 ├── CurveDemo/ │ ├── CurveDemo.vcxproj # 工程文件,工具集 v141 │ ├── CurveDemoDlg.h/.cpp # 宿主对话框,负责放控件、起定时器 │ ├── CurveCtrl.h/.cpp # 曲线控件本体,绘制逻辑都在这 │ ├── resource.h # 资源 ID 定义 │ ├── CurveDemo.rc # 对话框资源、控件占位 │ └── stdafx.h/.cpp # 预编译头控件本体CCurveCtrl一般继承自CWnd或CStatic,通过对话框上的占位控件做子类化(SubclassDlgItem)挂上去。这样设计的好处是控件和宿主解耦:宿主只管往里塞数据,控件自己管坐标、管缓冲、管重绘。宿主对话框CCurveDemoDlg则负责模拟数据源——用一个定时器周期性生成正弦或随机数据,调用控件的追加接口。
2.3 数据流走向
把数据流捋一遍,后面看代码就不会迷路:定时器触发 → 宿主生成一帧数据 → 调用CCurveCtrl::AppendData(value)→ 控件把点写进内部缓冲、更新最大值最小值 → 调用Invalidate(FALSE)标记脏区 → Windows 发WM_PAINT→OnPaint里先画到内存 DC,再一次性BitBlt到屏幕。整条链路里,真正耗时的只有最后那次整块拷贝,前面的绘制都在内存里完成,所以看不到闪烁。这个“先画到内存再贴屏”的思路就是双缓冲,是这个控件不闪的根本原因。
3. 跑起来:从编译到看到曲线动起来
3.1 编译前的环境确认
拿到工程先别急着按 F5,有两件事要先确认。第一是 VS2017 是否装了“使用 C++ 的桌面开发”工作负载,MFC 组件默认不一定勾选,需要在安装器里补上“适用于最新 v141 生成工具的 C++ MFC”。第二是打开.vcxproj后看属性页里的“平台工具集”是不是 v141,如果本机只装了更高版本的 VS,工具集对不上会直接报错,可以在项目属性里改成当前可用的工具集,或者装 v141 生成工具。
确认无误后,解决方案资源管理器里右键工程 → 生成。首次编译会走预编译头,稍慢,之后增量编译很快。如果报afxwin.h找不到,基本就是 MFC 组件没装,回去补装即可。
3.2 运行与观察
编译通过后直接调试运行,会弹出一个对话框,里面是曲线绘制区域。正常情况下能看到一条曲线在持续刷新,横轴随时间推进,纵轴按数据范围自适应。这时候可以做几个观察验证控件质量:
- 拖动窗口改变大小,曲线应该跟着重绘且不出现残影;
- 快速拖动窗口边缘,观察是否有大面积白块(有白块说明双缓冲没生效);
- 打开任务管理器看 CPU 占用,稳定刷新时不应该持续高位。
如果曲线不动,先查宿主对话框的OnInitDialog里定时器有没有起来;如果曲线画出来是空白,查控件的矩形区域是否取到了有效尺寸(GetClientRect返回的宽高是否为 0)。
3.3 关键初始化代码
宿主对话框里挂控件、起定时器的代码大致是这样,这段是跑通的第一步:
BOOL CCurveDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 把对话框上的占位控件子类化为曲线控件 m_curveCtrl.SubclassDlgItem(IDC_CURVE_PLACEHOLDER, this); // 初始化坐标范围:X 轴显示最近 N 个点,Y 轴先给个默认区间 m_curveCtrl.SetXRange(0, 500); m_curveCtrl.SetYRange(-1.0, 1.0); // 50ms 一帧,约 20fps,兼顾流畅和 CPU SetTimer(TIMER_ID_DATA, 50, nullptr); return TRUE; }SubclassDlgItem的作用是把资源里那个静态占位控件替换成我们自己的CCurveCtrl实例,之后所有消息都走控件类。SetXRange和SetYRange决定坐标映射的基准,X 轴给 500 表示窗口里最多显示 500 个点,超出的老点会被挤出去。定时器周期 50ms 是个经验值,太快了 CPU 吃不消,太慢了曲线一顿一顿的,实际项目里按数据源频率调。
3.4 数据喂入与定时器回调
定时器回调里模拟数据源,真实项目里这里换成从串口、网口或共享内存取数:
void CCurveDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_ID_DATA) { // 模拟一帧采样值,真实场景替换为下位机数据 static double phase = 0.0; phase += 0.1; double value = sin(phase); // 正弦测试信号 m_curveCtrl.AppendData(value); // 追加一个点并触发重绘 } CDialogEx::OnTimer(nIDEvent); }AppendData内部做三件事:写缓冲、更新 Y 轴极值、Invalidate(FALSE)。注意这里传FALSE表示不擦背景,因为双缓冲会整块覆盖,擦背景反而多一次填充、还容易闪。这个细节很多人第一次写会漏,传了TRUE之后画面就开始抖。
4. 核心实现拆解:双缓冲绘制与坐标映射的完整链路
4.1 双缓冲绘制的骨架
控件不闪的关键全在OnPaint里。标准写法是建一个和客户区等大的内存 DC,所有绘制先落到内存位图上,最后一次贴到屏幕:
void CCurveCtrl::OnPaint() { CPaintDC dc(this); // 屏幕 DC CRect rc; GetClientRect(&rc); if (rc.IsRectEmpty()) return; // 1. 创建内存 DC 和兼容位图 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 2. 背景填充(避免残影) memDC.FillSolidRect(&rc, RGB(255, 255, 255)); // 3. 画坐标轴、网格、曲线 DrawGrid(&memDC, rc); DrawCurve(&memDC, rc); // 4. 一次性贴到屏幕 dc.BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }逐段说:CreateCompatibleDC建的内存 DC 和屏幕 DC 格式一致,画出来的像素才能直接拷。CreateCompatibleBitmap按客户区尺寸建位图,尺寸必须和窗口一致,否则贴图会错位或拉伸。SelectObject把位图选进内存 DC,之后所有绘制都作用在这张位图上。FillSolidRect先铺白底,这一步替代了OnEraseBkgnd,能省掉背景擦除带来的闪烁。最后BitBlt用SRCCOPY把整块内存位图拷到屏幕,一次拷贝完成,中间过程用户看不到。
提示:内存 DC 和位图每次
OnPaint都创建销毁,高频刷新时会有一定开销。点数特别大、刷新特别快的场景,可以把内存 DC 和位图缓存成成员变量,只在窗口尺寸变化时重建,这是常见的优化点。
4.2 坐标映射:数据值怎么变成屏幕像素
数据是浮点数,屏幕是整数像素,中间要做一次映射。核心公式就两个:
// 数据坐标 -> 屏幕坐标 int CCurveCtrl::XToScreen(double x, const CRect& rc) { double ratio = (x - m_xMin) / (m_xMax - m_xMin); // 归一化到 0~1 return rc.left + (int)(ratio * rc.Width()); } int CCurveCtrl::YToScreen(double y, const CRect& rc) { double ratio = (y - m_yMin) / (m_yMax - m_yMin); // Y 轴屏幕方向向下,所以要反过来 return rc.bottom - (int)(ratio * rc.Height()); }m_xMin/m_xMax和m_yMin/m_yMax是当前显示区间的边界,由SetXRange/SetYRange设定,Y 轴还会根据数据极值动态调整。X 轴映射直接线性缩放;Y 轴要注意屏幕坐标原点在左上角、Y 向下增大,所以用rc.bottom减去偏移量,否则曲线会上下颠倒——这是新手最容易翻车的地方,画出来发现波形反了,八成就是这里符号写错。
映射函数里没有做边界裁剪,如果数据超出当前区间,算出来的屏幕坐标会跑到客户区外面。GDI 绘制时会自动裁掉客户区外的部分,不会崩,但会浪费绘制调用。数据量大的时候建议在DrawCurve里先判断点是否在区间内再画。
4.3 曲线绘制与数据缓冲
真正画线的时候,把缓冲里的点逐个映射、连成折线:
void CCurveCtrl::DrawCurve(CDC* pDC, const CRect& rc) { if (m_data.size() < 2) return; CPen pen(PS_SOLID, 1, RGB(0, 120, 215)); CPen* pOldPen = pDC->SelectObject(&pen); bool bFirst = true; for (size_t i = 0; i < m_data.size(); ++i) { int sx = XToScreen((double)i, rc); int sy = YToScreen(m_data[i], rc); if (bFirst) { pDC->MoveTo(sx, sy); bFirst = false; } else { pDC->LineTo(sx, sy); } } pDC->SelectObject(pOldPen); }m_data是控件内部的数据容器,AppendData往里 push 新值。这里用std::vector<double>演示,实际高速场景更常见的是环形缓冲:固定容量,写满之后覆盖最老的点,避免 vector 不断扩容导致内存增长和拷贝开销。环形缓冲的写法是维护一个m_head写指针和一个m_count计数,读的时候从(m_head - m_count + capacity) % capacity开始遍历。
DrawCurve里每个点都调一次LineTo,点数上万时 GDI 调用次数多,会有性能压力。优化方向有两个:一是用Polyline一次性传点数组,减少 API 调用;二是做抽稀,屏幕上横向像素就那么多,同一列像素上的多个点只保留极值,画出来视觉上没差别但点数能降一个量级。这两招在真实项目里很常用,示例代码先把基础链路讲清楚,优化留给自己加。
4.4 Y 轴自适应与刷新触发
Y 轴范围如果写死,数据一超出就画到框外。常见做法是在AppendData里顺手更新极值,再按需调整区间:
void CCurveCtrl::AppendData(double value) { m_data.push_back(value); // 更新极值,用于 Y 轴自适应 if (value > m_yDataMax) m_yDataMax = value; if (value < m_yDataMin) m_yDataMin = value; // 留 10% 余量,避免曲线贴边 double margin = (m_yDataMax - m_yDataMin) * 0.1; if (margin < 1e-6) margin = 1.0; SetYRange(m_yDataMin - margin, m_yDataMax + margin); Invalidate(FALSE); // 标记脏区,触发重绘 }Invalidate(FALSE)只是标记,真正的绘制在消息队列空闲时由WM_PAINT处理,所以连续多次AppendData不会每次都重绘,Windows 会合并。这也是为什么定时器频率可以比实际帧率高一点而不会线性增加开销。SetYRange里如果发现区间没变,可以不触发重绘,减少无谓刷新。
5. 避坑与排查:这类控件最容易踩的五个坑
5.1 画面闪烁、拖动窗口出现白块
现象:窗口一拖动或改变大小,曲线区域闪白,严重时整块变白再重画。原因通常是没做双缓冲,或者OnEraseBkgnd还在擦背景,擦完再画,中间那一帧就是白的。解决:重写OnEraseBkgnd直接返回TRUE表示背景已处理,绘制全部走内存 DC 双缓冲;Invalidate传FALSE不擦背景。这三处配合起来,闪烁基本消失。
5.2 曲线上下颠倒或左右反向
现象:数据明明是上升的,画出来是下降的。原因在 Y 轴映射,屏幕坐标 Y 向下增大,如果直接rc.top + ratio * height就会反。解决:Y 映射用rc.bottom - ratio * height,X 轴一般不用反。改完还不对,就检查m_yMin/m_yMax的赋值顺序有没有写反。
5.3 点数一多就卡、CPU 飙高
现象:跑几分钟后界面明显变卡,任务管理器里进程 CPU 占用持续走高。原因多半是数据容器无限增长,DrawCurve每次遍历全部历史点,绘制量随时间线性上升。解决:换成环形缓冲固定容量,只保留窗口内可见的点;绘制时用Polyline批量提交;必要时做像素级抽稀。这三步做完,长时间运行的性能曲线会平下来。
5.4 控件尺寸为 0 导致绘制异常
现象:程序启动后曲线区域空白,调试发现GetClientRect返回宽高为 0。原因通常是控件子类化时机太早,对话框还没完成布局,或者占位控件本身尺寸没设。解决:把子类化和初始化放到OnInitDialog里CDialogEx::OnInitDialog()之后;占位控件在资源编辑器里给个非零尺寸;OnPaint开头加IsRectEmpty判断直接返回,避免拿 0 尺寸去建位图。
5.5 高 DPI 下曲线模糊或错位
现象:在缩放比例 125% 或 150% 的显示器上,曲线发虚、坐标轴和曲线对不齐。原因是程序没声明 DPI 感知,系统做了位图拉伸。解决:在工程里加应用程序清单声明 Per-Monitor DPI 感知,或者在InitInstance里调SetProcessDpiAwareness;坐标映射统一用客户区实际像素尺寸,不要写死常量。MFC 老工程对高 DPI 支持一般,这一条要单独测。
6. 二次开发:把示例改成能接真实数据源的控件
拿到这个示例,多数人不是原样用,而是往自己的项目里搬。改造的第一步是把数据源换掉:把OnTimer里的sin(phase)换成从串口或网口读到的真实采样值,注意读数据的线程和 UI 线程要分开,工作线程只负责把数据塞进一个线程安全的队列,UI 线程定时取一批再AppendData,避免在绘制线程里做阻塞 IO。第二步是接口化:给CCurveCtrl补上Clear()、SetLineColor()、SetMaxPoints()这类方法,让宿主能配置外观和容量,而不是改控件源码。第三步是性能兜底:把std::vector换成环形缓冲,DrawCurve换成Polyline批量绘制,再按屏幕宽度做抽稀。
验证改造是否成功,我一般看三个指标:连续跑 30 分钟内存是否稳定(不涨说明缓冲没泄漏)、拖动窗口是否无残影(双缓冲有效)、CPU 占用是否平稳(绘制量没随时间增长)。这三个都过了,基本可以往生产环境放。
| 改造点 | 原示例 | 生产建议 |
|---|---|---|
| 数据容器 | std::vector追加 | 环形缓冲固定容量 |
| 绘制方式 | 逐点LineTo | Polyline+ 抽稀 |
| 数据来源 | 定时器模拟 | 工作线程 + 线程安全队列 |
| 外观配置 | 硬编码 | 暴露 Set 接口 |
| 高 DPI | 未处理 | 清单声明 DPI 感知 |
有个血泪教训值得说:早期我把AppendData直接放在工作线程里调,结果偶发崩溃,查了半天才发现Invalidate跨线程调用窗口对象不安全。从那以后我每次接实时数据源,都强制走“工作线程入队、UI 线程出队绘制”这一遍,再没在这上面翻过车。希望这个示例的拆解能帮你少走几步弯路,把它改成自己项目里那条稳稳刷新的曲线。
本文还有配套的精品资源,点击获取