简介:ChartCtrl 是一套经典的 MFC 图表控件源码,面向需要在桌面程序中嵌入折线图、柱状图、饼图、K线图等可视化能力的 C++ 开发者,尤其适合维护老项目、无法引入第三方商业控件或现代图表库的技术人员。压缩包共 86 个文件,约 154KB,以 41 个 .h 头文件与 32 个 .cpp 实现文件为主体,另有 4 个 .inl 内联实现,并附带 sln、vcxproj、vcproj、dsp 等工程与解决方案文件、rc 资源脚本与 ico 图标,源码已调整为 VS2015 可直接编译的形态。控件按职责拆分为坐标轴、数据系列、图例、标题、网格、滚动条、光标与拖拽线、标签气泡、渐变与字体等模块,并配套 ChartDemo 演示对话框和曲线、系列、点、颜色等属性配置对话框,方便对照修改。项目已有 1009 人学习下载,读者可借此理解图表坐标系换算、数据绘制与鼠标交互的实现思路,并在此基础上继续优化界面风格、补充功能或排错调试。
1. 一条曲线背后的成本账:我为什么翻出了 ChartCtrl
项目现场遇到过这种需求:工控上位机要加一条实时趋势曲线,横轴时间、纵轴温度,能缩放、能看点值。客户预算里没有商业图表库的位置,装个体积不小的第三方控件包又会把安装包撑大几倍。翻了半天,翻出一份传了很多年的老源码——ChartCtrl 图表控件,纯 MFC 自绘,几十个 cpp 文件,无外部依赖,专为 Windows 桌面程序里的曲线绘制而生。麻烦在于它是上个世代的工程,直接拿 VS2015 打开一路飘红红色波浪线。下面这份笔记,就是把它迁到 VS2015、跑通、再改造成能扛实时数据的过程,适合手上有 MFC 老项目、又不想引重依赖的同学。
2. 架构拆解:一个自绘控件是怎么把曲线画出来的
2.1 控件继承链与三层对象模型
ChartCtrl 的骨架不复杂,但分层比想象中清晰。最外层是CChartCtrl,直接public CWnd,意味着它跟普通按钮、编辑框一样,可以是对话框上的一个子控件,也可以SubclassDlgItem接管已有的静态框;往里一层是CChartPanel,一个控件里可以并排几块绘图区,每块有自己的横纵轴;再往里是CChartAxis(轴)、CChartGrid(网格)、CChartSerieBase及其派生(曲线、散点、柱状、曲面)。
这套三层结构的好处是:绘图区、坐标系、数据序列各自独立。你想在同一个控件里画左右两条不同量纲的曲线,不需要写两套绘制逻辑,只要挂两个 Panel,或者一个 Panel 里挂左右两根 Y 轴。老控件能把这件事想清楚,比现在不少只有一个大而全 API 的库要讲道理。
关键成员大概是这样组织的:
// 简化后的类关系,看清楚"谁持有谁" class CChartCtrl : public CWnd // 控件本体,窗口消息入口 { std::vector<CChartPanel*> m_pPanels; // 多个绘图区 bool m_bEnableRefresh; // 是否每次改数据就重绘 // ... }; class CChartPanel { CChartAxis* m_pAxis[4]; // 下/左/上/右四根轴 std::vector<CChartSerieBase*> m_pSeriesList; // 数据序列 CChartGrid* m_pGrid; // 网格线 CRect m_PlotRect; // 去掉轴标签后的绘图区 }; class CChartSerieBase { std::vector<CPoint2D<double>> m_vPoints; // 原始数据(逻辑坐标,不是像素) COLORREF m_SerieColor; virtual void Draw(CDC* pDC, CRect rcPlot) = 0; // 派生类各自实现 };m_vPoints里存的是真实值(比如 23.5℃、1712345678 秒),不是像素坐标。所有像素换算都发生在Draw里,靠轴的ValueToScreen完成。这个约定非常重要——一旦你把数据提前换算成像素存起来,窗口一缩放整条曲线就全废了,我踩过这个坑。
2.2 绘制管线:OnPaint 到双缓冲位图
自绘控件最容易翻车的地方就是闪烁。ChartCtrl 采用的是经典 GDI 双缓冲:OnPaint里不直接往屏幕 DC 上画,而是先建一个内存 DC 和兼容位图,把所有东西画完,一次性BitBlt到屏幕。这条链路自己抄一遍,比看十篇讲双缓冲的文章管用。
void CChartCtrl::OnPaint() { CPaintDC dcScreen(this); CRect rcClient; GetClientRect(&rcClient); // 1. 建离屏画布,尺寸跟客户区一致 CDC dcMem; dcMem.CreateCompatibleDC(&dcScreen); CBitmap bmpMem; bmpMem.CreateCompatibleBitmap(&dcScreen, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = dcMem.SelectObject(&bmpMem); // 2. 背景先铺一遍,避免残影 dcMem.FillSolidRect(&rcClient, m_BackColor); // 3. 逐个绘图区绘制:网格 -> 轴 -> 序列,顺序不能反 // 网格和轴在底层,曲线压在上面,否则轴线会被数据盖住 for (size_t i = 0; i < m_pPanels.size(); ++i) m_pPanels[i]->Draw(&dcMem); // 4. 一次性贴回屏幕 dcScreen.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &dcMem, 0, 0, SRCCOPY); dcMem.SelectObject(pOldBmp); }几个参数和调用顺序值得说明。CreateCompatibleBitmap必须用屏幕 DC 而不是内存 DC 来创建,否则拿到的会是 1bpp 单色位图,画出来全是黑白剪影,这个坑非常隐蔽。FillSolidRect这一步不能省,因为BitBlt是覆盖式的,内存位图里未绘制区域是未初始化内容,不铺底会出现随机噪点。
再补一刀:光有OnPaint还不够,OnEraseBkgnd必须返回TRUE吃掉系统默认的背景擦除。系统默认擦背景是先用背景色刷一遍再画,两次绘制之间就会闪。重写它其实只有一行:
BOOL CChartCtrl::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景由 OnPaint 里的双缓冲统一处理 }2.3 数据模型:Panel、Axis、Serie 的职责边界
刚上手最容易糊涂的是"我要设一个最大值,到底调谁"。把职责列清楚,后面写代码基本不用查:
| 对象 | 管什么 | 典型操作 |
|---|---|---|
CChartCtrl | 窗口级:创建、刷新控制、序列工厂 | Create、EnableRefresh、RefreshCtrl、CreateLineSerie |
CChartPanel | 布局级:绘图区矩形、四根轴的归属 | GetBottomAxis、GetLeftAxis、SetPlotRect |
CChartAxis | 刻度级:值域、刻度密度、标签格式 | SetMinMax、SetAutomaticMode、SetTextPrecision |
CChartSerieBase | 数据级:点集、颜色、线型、绘制实现 | AddPoint、SetSeriesColor、ClearSerie |
有一个设计选择值得注意:轴默认是自动模式(SetAutomaticMode(true)),它会根据所有挂在它上面的序列的数据范围,自己算一个合适的值域。这在静态图上很舒服,但在实时滚动场景里是灾难——每来一个新点,最大值变了,整条曲线的形状跟着抖一下,看起来像在抽搐。所以实时场景第一件事就是把自动模式关掉,手动SetMinMax,然后在数据侧做窗口滑动。这个决定我在第三个项目上才想明白,前面两个项目一直被"曲线会跳"困扰。
3. 跑起来:VS2015 下的编译配置与第一张图
3.1 源码目录与工程文件清单
拿到手的源码包通常是一堆散的.h/.cpp,没有现成的.sln,或者带一个很老的.vcproj。文件大致可以按职责分成四组,先认清楚再动手:
| 分组 | 文件 | 说明 |
|---|---|---|
| 控件主体 | ChartCtrl.cpp/h、ChartPanel.cpp/h | 窗口消息、绘图区管理,必加 |
| 坐标与网格 | ChartAxis.cpp/h、ChartGrid.cpp/h、ChartLabel.cpp/h | 轴、刻度标签、网格线 |
| 序列族 | ChartSerieBase.cpp/h、ChartLineSerie.cpp/h、ChartPointsSerie.cpp/h、ChartBarSerie.cpp/h、ChartSurfaceSerie.cpp/h | 折线、散点、柱状、曲面,用到哪个加哪个 |
| 交互与装饰 | ChartCursor.cpp/h、ChartCrossHairCursor.cpp/h、ChartBalloonLabel.cpp/h、ChartGradient.cpp/h、ChartObject.cpp/h | 光标、十字线、气泡提示、渐变填充 |
我的处理方式:新建一个空的 Win32 静态库工程,把这些文件全塞进去,头文件目录设成源码根目录,然后主程序引用这个 lib。比每次往 exe 工程里拷贝 cpp 干净得多,尤其是团队里几个人同时改界面的时候,改一次源码全组生效,不用挨个同步。
3.2 VS2015 编译配置:那几条躲不开的宏
平台工具集选Visual Studio 2015 (v140),MFC 的使用方式选"在静态库中使用 MFC"或"在共享 DLL 中使用 MFC"看你主程序怎么配,但两边必须一致,一边静态一边动态,链接期就会报一堆重复定义。字符集必须统一成Unicode,老源码里那些CString到const char*的隐式转换,在多字节字符集下能过,切到 Unicode 会集体报警。
真正让人头大的是下面这几个宏,建议直接写进工程的预处理器定义里,一次解决:
// 工程属性 -> C/C++ -> 预处理器 -> 预处理器定义,追加: // _CRT_SECURE_NO_WARNINGS 关掉 strcpy/sprintf 的安全警告 // NOMINMAX 阻止 windows.h 定义 min/max 宏 // _AFX_NO_MFC_CONTROLS_IN_DIALOGS 按需,避免某些 MFC 控件初始化顺序问题 // 如果不想改工程设置,也可以在包含 windows.h 之前压住 min/max: #define NOMINMAX #include <windows.h>NOMINMAX这条是老 MFC 迁新编译器的头号杀手。windows.h里定义的min/max是宏,一旦你的代码里写了std::min(a, b)或者某个 STL 头里的std::max,预处理器会把它展开成(((a) < (b)) ? (a) : (b)),然后编译器报一堆"缺少模板参数"或者语法错误,报错位置还在 STL 头文件里,看着像标准库坏了。我第一次遇到时盯着<algorithm>看了半小时才反应过来。
还有一个隐性依赖:如果源码里用了 GDI+(渐变填充、抗锯齿那部分会用到),需要额外链接gdiplus.lib,并且在InitInstance里调一次GdiplusStartup,退出时GdiplusShutdown。别忘了在stdafx.h里加上:
#include <gdiplus.h> #pragma comment(lib, "gdiplus.lib") using namespace Gdiplus;3.3 最小可运行示例:对话框上挂一条静态曲线
配置搞定之后,先在对话框上跑通一张静态图,别一上来就搞实时。头文件里声明控件和序列指针,注意序列指针用派生类,因为基类没有AddPoint的便利重载:
// MyDlg.h #include "ChartCtrl.h" #include "ChartLineSerie.h" class CMyDlg : public CDialogEx { CChartCtrl m_Chart; // 控件对象,直接作为成员 CChartLineSerie* m_pSerie; // 由控件工厂创建,生命周期交给控件 };OnInitDialog里创建控件、配置轴、挂序列、灌数据,一条龙:
BOOL CMyDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 1. 创建控件,绑定到对话框上的静态占位区域 CRect rcChart(20, 20, 620, 380); m_Chart.Create(this, rcChart, IDC_CHART_PLACEHOLDER); // 2. 先关自动刷新,配置期间避免反复重绘 m_Chart.EnableRefresh(false); m_Chart.SetBackColor(RGB(255, 255, 255)); // 3. 关掉轴的自动量程,手动指定值域 CChartAxis* pAxisX = m_Chart.GetBottomAxis(); CChartAxis* pAxisY = m_Chart.GetLeftAxis(); pAxisX->SetAutomaticMode(false); pAxisY->SetAutomaticMode(false); pAxisX->SetMinMax(0.0, 100.0); pAxisY->SetMinMax(-50.0, 150.0); pAxisY->SetTextPrecision(1); // 纵轴保留 1 位小数 // 4. 通过工厂创建折线序列,参数 true 表示由控件负责 delete m_pSerie = m_Chart.CreateLineSerie(true); m_pSerie->SetSeriesColor(RGB(0, 114, 198)); m_pSerie->SetLineWidth(1); // 5. 灌一段正弦数据 for (int i = 0; i <= 100; ++i) m_pSerie->AddPoint((double)i, 50.0 * sin(i * 0.1)); // 6. 打开刷新并重绘一次 m_Chart.EnableRefresh(true); m_Chart.RefreshCtrl(); return TRUE; }几个参数别写反。CreateLineSerie(true)里的true是"交给控件托管",如果你自己new出来再AddSerie,并且传的是false,那销毁控件时不会释放这块内存,每开关一次窗口漏一份,跑久了内存曲线是锯齿状上升。SetTextPrecision不设的话,纵轴可能出现0.30000000000000004这种浮点尾巴,看着非常业余。
EnableRefresh(false)到EnableRefresh(true)这段包裹很重要。它内部维护的是一批刷新标志,配置期间每次改属性都触发重绘的话,灌 10 万个点会变成 10 万次重绘,界面直接假死。
4. 核心模块细读:刻度算法、序列绘制与交互拾取
4.1 坐标轴刻度:1-2-5 漂亮数与值域映射
轴的绘制可以说是整个控件里最考基本功的一块。要做三件事:把值域线性映射到像素区间、算出该画几根刻度线、给每根刻度配一个好看的标签。
映射本身是一行线性变换,但要注意 Y 轴方向相反——GDI 的 y 轴向下增长,数据值向上增长,所以斜率取负:
// 逻辑值 -> 屏幕像素(以 Y 轴为例,X 轴去掉这个负号即可) int CChartAxis::ValueToScreen(double dValue) const { double dRange = m_MaxValue - m_MinValue; if (dRange < 1e-12) return m_ScreenMin; // 防止值域为 0 时除零 double dRatio = (dValue - m_MinValue) / dRange; // 归一化到 [0,1] return m_ScreenMin + (int)(dRatio * (m_ScreenMax - m_ScreenMin)); }m_ScreenMin/m_ScreenMax是这根轴占据的像素范围,在 Panel 算完m_PlotRect之后注入,注意左轴是"下大上小",m_ScreenMin对应绘图区底边,m_ScreenMax对应顶边,这样dRatio = 0时的像素落在底部,符合直觉。
刻度数量不能硬编码。常见做法是先用固定像素步长(比如 60px 一个刻度)反推需要几根,再把这个数字喂给 1-2-5 算法,得到"漂亮"的步长:
// 输入大致想要的刻度数,输出 1/2/5 * 10^n 形式的步长 double CalcNiceStep(double dRawStep) { double dExp = floor(log10(dRawStep)); // 数量级,如 1.7 -> 0 double dBase = pow(10.0, dExp); // 10^exp double dNorm = dRawStep / dBase; // 归一化到 [1,10) double dNice; if (dNorm <= 1.0) dNice = 1.0; else if (dNorm <= 2.0) dNice = 2.0; else if (dNorm <= 5.0) dNice = 5.0; else dNice = 10.0; return dNice * dBase; // 还原数量级 }举个具体数字:值域 0~97,绘图区宽 500px,希望每 60px 一个刻度,得到大约 8 格,原始步长97/8 ≈ 12.1。归一化后是1.21,落进<=2.0区间,取 2.0,还原数量级得到 20。最后刻度就是 0、20、40、60、80、100,比 0、12.1、24.2、36.3 这种好看太多。这套算法在几乎所有绘图库中反复出现,自己手写一遍是值得的。
4.2 序列绘制:从点集到剪裁后的折线
序列的Draw是最吃 CPU 的地方。原始实现通常是遍历全部点、逐个MoveTo/LineTo,点一多就卡。两个优化必须先做:其一是把点先转成像素坐标数组,用Polyline一次性提交,减少 GDI 调用次数;其二是按m_PlotRect做区间剪裁,屏幕范围外的点根本不参与绘制。
void CChartLineSerie::Draw(CDC* pDC, CRect rcPlot) { if (m_vPoints.size() < 2) return; CChartAxis* pAxisX = GetXAxis(); CChartAxis* pAxisY = GetYAxis(); // 1. 只取落在可视范围内的一段,加上前后各一个点保证线段不断 size_t iBegin = 0, iEnd = m_vPoints.size() - 1; // 这里用二分查找定位首尾(点集按 X 递增时有效) // 略去查找细节,核心是别把全量点都塞进 GDI // 2. 批量转换像素坐标 std::vector<CPoint> vScreen; vScreen.reserve(iEnd - iBegin + 2); for (size_t i = iBegin; i <= iEnd; ++i) { int x = pAxisX->ValueToScreen(m_vPoints[i].X()); int y = pAxisY->ValueToScreen(m_vPoints[i].Y()); vScreen.push_back(CPoint(x, y)); } // 3. 剪裁区限制在绘图区内,防止曲线溢出盖住轴标签 int nSaved = pDC->SaveDC(); pDC->IntersectClipRect(&rcPlot); CPen pen(PS_SOLID, m_LineWidth, m_SerieColor); CPen* pOldPen = pDC->SelectObject(&pen); pDC->Polyline(vScreen.data(), (int)vScreen.size()); pDC->SelectObject(pOldPen); pDC->RestoreDC(nSaved); }SaveDC/RestoreDC这对调用是最容易被忽略的。如果不做IntersectClipRect,当数据超出轴值域时,线段会画到绘图区外面,压住轴刻度文字和标题,看着像渲染错乱。而如果不在最后恢复 DC,剪裁区会一直留在 DC 上,后续绘制全被限制住,会出现"画了但看不见"的玄学现象。第二次恢复失败的时候我才老老实实每次都配对写。
还有一个细节:Polyline第二个参数的int类型。点集超过约 2.1 亿个理论上会溢出,实际不影响,但如果你做的是批量导出图片,vScreen用std::vector分配会触发堆碎片,建议在序列对象里复用一个成员std::vector<CPoint>,clear()后接着用,避免高频分配。
4.3 交互层:鼠标拾取与滚动缩放
光看图不够,用户要能把鼠标移到某一点上看具体数值。这块在CChartCtrl里接窗口消息,OnMouseMove拿到屏幕坐标,反向调用轴的ScreenToValue转回逻辑值,再做最近点查找。
void CChartCtrl::OnMouseMove(UINT nFlags, CPoint point) { CWnd::OnMouseMove(nFlags, point); if (!m_pPanels.empty()) { CChartPanel* pPanel = m_pPanels[0]; if (!pPanel->GetPlotRect().PtInRect(point)) { // 移出绘图区,收起气泡提示,别让它挂在轴上 HideTooltip(); return; } CChartAxis* pAxisX = pPanel->GetBottomAxis(); double dX = pAxisX->ScreenToValue(point.x); // 找横坐标最接近的那个点 CChartSerieBase* pSerie = nullptr; double dY = 0.0; if (pPanel->FindNearestPoint(point, pSerie, dY)) ShowTooltip(point, pSerie, dX, dY); } }ScreenToValue是ValueToScreen的逆运算,实现时注意把m_ScreenMin对回去,别直接按客户区坐标算——绘图区往往不是从 (0,0) 开始的,左边要给 Y 轴标签留位置,下边给 X 轴标签留位置。这个偏移如果忘了减,鼠标指示的位置和实际数值会整体偏一段,偏差大小跟你设置的边距一致,很容易误判成"数据错位"。
缩放我一般用滚轮:OnMouseWheel里取nDelta,按比例缩小或放大当前值域,以鼠标位置对应的值为中心保持不动。中心保持不动这件事不做,缩放时会感觉图像在往左上角跑,体验很差。实现思路是把鼠标点的逻辑值作为锚点,缩放前后分别算出它对应的像素偏移,差值补偿回值域的左端。
5. 避坑与排查:老控件迁 VS2015 的五个典型故障
5.1 编译通过但一运行就白屏
现象:工程能编过,对话框弹出来,放图表的区域一片空白,连背景色都不对,鼠标拖动窗口也刷不出来。
原因:九成是控件创建失败但没检查返回值。CChartCtrl::Create走的CWnd::CreateEx需要注册窗口类,如果对话框资源里那个占位控件 ID 被别的控件占用了,或者Create调用的时机在OnInitDialog返回之后,窗口句柄是空的,后续所有绘制调用静默失败,表现出来就是白屏。
解决:养成检查返回值的习惯,Create之后立刻ASSERT(m_Chart.GetSafeHwnd() != NULL);如果用的是SubclassDlgItem,确认占位控件在资源编辑器里是"静态文本"或"图片控件"这种能被接管的类型,而不是一个按钮。另外把创建代码放在OnInitDialog里CDialogEx::OnInitDialog()之后,别放构造函数。
5.2 曲线像是被橡皮擦过,一动窗口就闪
现象:静态显示时没问题,一拖动窗口或者鼠标划过,曲线闪烁明显,甚至出现半条曲线消失又出现的情况。
原因:两个地方没配对。OnEraseBkgnd没有返回TRUE,系统在WM_PAINT之前先用背景刷清了一次屏,两次绘制之间读者看到的就是"空";或者双缓冲的内存位图是用内存 DC 创建的,得到的是单色位图。
解决:确认OnEraseBkgnd返回TRUE;确认CreateCompatibleBitmap的第一个参数是CPaintDC(屏幕 DC)而不是CDC内存 DC;最后检查BitBlt的坐标,宽高用的是rcClient.Width()/Height()而不是right/bottom,后者包含了原点偏移,大尺寸窗口下边缘会少刷一块。
5.3 关窗口时崩溃,或者 GDI 对象数一路涨到 9999
现象:调试模式下退出程序时ASSERT失败,或者任务管理器里进程的 GDI 对象数持续增长,最后绘图整个变成灰色。
原因:序列、轴、画笔这些 GDI 对象没释放。CreateLineSerie(true)传true时由控件托管,但如果你自己new了一个CChartLineSerie再AddSerie(pSerie, false),那这份内存和它内部持有的CPen就得你自己delete。另外SelectObject之后没有恢复原对象,每次重绘都 new 一个CPen又不释放,几十秒就能耗光 GDI 句柄配额。
解决:统一走工厂方法创建序列,托管标志一律传true;所有SelectObject成对写RestoreDC或者手动恢复旧对象;用CPen pen; pen.CreatePen(...)的栈对象形式,出了作用域自动析构,比手动管理省心。
5.4 实时刷新时界面卡死,鼠标点不动
现象:数据一来就调一次RefreshCtrl,数据频率稍高(几十 Hz),界面就完全无响应,任务管理器显示 CPU 单核跑满。
原因:每次RefreshCtrl都会触发一次完整的Invalidate+UpdateWindow,同步重绘整个控件,包括所有序列的所有点。点越多越慢,重绘频率越高越慢,两个因素乘起来直接把 UI 线程堵死。
解决:把刷新频率和数据写入频率解耦。数据侧只往缓冲区写,不动界面;用一个SetTimer或者WM_APP自定义消息,按 20~30Hz 的节奏触发一次整体重绘。同时确认序列绘制做了可视区剪裁,别让离屏的点也参与Polyline。这两条改完,同样的数据量下 CPU 占用能降一个数量级。
5.5 纵轴标签显示成一长串小数或者科学计数法
现象:纵轴刻度文字变成1.4999999999999998或者1.5e+003,挤在一起看不清。
原因:没有设置文本精度,控件用了默认的全精度格式化;或者值域范围特别大时自动切到了科学计数法。另外还有一种情况是SetMinMax的两个参数写反了,m_MinValue大于m_MaxValue,刻度算出来是负步长,标签自然乱套。
解决:SetTextPrecision(n)明确指定小数位;值域很大时改用自定义标签回调,把 10000 显示成10k;调用SetMinMax前先保证第一个参数小于第二个,实在不确定就先std::swap。这个坑不致命但很显眼,交付前一定要看一眼轴标签长什么样。
6. 二次开发:把它改造成能扛实时数据的控件
真上产线,静态图那套 API 很快就撑不住了。我做的第一件事是给序列加环形缓冲——固定容量的数组加一个写指针,写满就覆盖最老的点,内存占用恒定,不会因为跑了一整夜就把内存吃满。第二件事是把"数据写入"和"界面刷新"彻底拆开:采集线程只负责调AddPoint,加锁保护写指针;UI 线程按 25Hz 定时取当前窗口内的数据段,重绘一次。第三件事是关掉轴的自动量程,改用滑动窗口,让横轴始终显示最近 60 秒。
自定义序列的扩展点也很实用。派生一个类重写Draw,就能画出带填充色的面积图或者带告警色段的曲线:
class CThresholdSerie : public CChartLineSerie { public: double m_dUpper; // 上限阈值,超过的部分标红 virtual void Draw(CDC* pDC, CRect rcPlot) override { CChartLineSerie::Draw(pDC, rcPlot); // 先画基础折线 // 再叠一层:把超限区间用红色粗线上色,实现见仁见智 } };验证方式我用的是最笨但最有效的压测:造 5 万个点灌进去,用GetTickCount掐一次完整重绘的耗时,超过 30ms 就说明该优化了。另外一个必做的检查是连跑四小时看 GDI 对象数,只要它有单调上升趋势,就一定是某处SelectObject没恢复或者CPen没析构。
这份源码我前后迁过三次,每次都是同一批坑把人绊一回,所以后来养成习惯:任何老 MFC 控件接手,先建静态库工程、先统一 Unicode、先把NOMINMAX和_CRT_SECURE_NO_WARNINGS写进预处理器、先跑通一张静态图,再谈功能。省下来的排查时间,远比多写几行配置值钱。希望帮到你。
本文还有配套的精品资源,点击获取