简介:一份基于MFC的ChartCtrl老式图表控件源码,已完成VS2015适配,面向需要自绘图表或扩展图表功能的C++/MFC开发者。控件内置折线图、柱状图、甘特图、蜡烛图、曲面图等多种系列,涵盖坐标轴、标题、图例、游标、滚动条等基础组件,设计上功能实用,界面风格偏旧,正好适合在此基础上做二次美化与定制。压缩包共86个文件,包括41个h头文件、32个cpp源文件、4个inl内联模板实现,以及sln/vcxproj/dsp等工程配置文件和rc资源文件,便于在VS2015中直接打开编译,包体仅154KB,轻量且模块划分清晰。目前已有1005人学习/下载,适合需要研究MFC自绘控件事例、图表交互逻辑或希望快速集成图表能力的开发者。从源码中可重点学习控件架构设计、坐标轴与刻度计算、鼠标交互反馈、序列数据绑定等实用技术,具备不错的参考价值。
为什么还要跟"老古董"控件较劲
接手项目的时候,仓库里躺着一个"上了年纪"的ChartCtrl图表控件源码,注释里还带着VC6年代的习惯,代码缩进和命名风格一看就是当年的CodeProject风格。项目却要用VS2015编译,同事的第一反应是"重写一个吧",但我坚持把它移植优化过来。原因很简单:这个控件已经在上线系统里跑了七八年,所有的业务逻辑、坐标轴定制、曲线样式都是基于它调的,重写等于把曾经踩过的坑再踩一遍。
ChartCtrl是典型的MFC自绘控件,核心类CChartCtrl继承自CWnd,能在对话框里划出一块区域,绘制折线、柱状、坐标轴和网格。它不依赖任何第三方图表库,所有图形都是GDI一笔一笔画出来的。正是因为不依赖外部库,它在老项目里极其稳定,也正因为是GDI绘制,到了VS2015时代有很多细节需要调整。
这篇内容不是什么大工程复盘,就是一个实实在在的迁移记录:源码结构怎么梳理、环境怎么搭、编译期遇到了哪些坑、编译通过之后还有哪些运行期的雷,以及最终我验证"迁移成功"的标准是什么。如果你手头也有类似的MFC老控件、老代码需要搬到新工具链,希望这份记录能帮你少走弯路。
1. 先弄清楚ChartCtrl的价值,再决定怎么动它
1.1 ChartCtrl到底是个什么控件
ChartCtrl这个控件在当年的MFC圈子里流传很广,很多人从CodeProject上下载过。它的基本设计是:
- CChartCtrl:主控件类,负责整个绘图区域的窗口管理和绘制调度
- CChartLineSerie、CChartPointSerie:数据序列类,分别对应折线序列和散点序列
- CChartAxis:坐标轴类,管理X轴、Y轴的范围、刻度和标签
- CChartTitle:标题类,负责绘制图表标题
控件对外暴露的接口很直接。AddLine加入一条折线,SetData设置数据点,SetRange设置坐标范围,SetTitle设置标题。内部则是在OnPaint里把网格、坐标轴、数据序列依次画到内存DC上,最后一次性BitBlt到窗口。
它最务实的一点是:所有绘制逻辑都在源码里,出了问题可以直接断点调试,不存在"调了参数还是不生效"的黑盒问题。这在工业控制类软件里特别重要。很多现场问题最后都变成"你帮我看看这个曲线为什么不对",如果用的是闭源商业控件,只能干瞪眼。
1.2 为什么锁定VS2015而不是更高的版本
有人会问,为什么不用VS2019或者VS2022?这里有个很现实的原因:项目里大量代码依赖的是早期第三方库,这些库的库文件是按旧工具链编译的,ABI兼容性很微妙。VS2015对应的平台工具集v140,对老的C运行库兼容性做得比较好,尤其是对于使用动态链接MFC的项目,迁移风险最小。
另一个原因是团队成员的工作环境已经统一在VS2015上,包括构建服务器、CI脚本、代码审查工具链,全都围绕这一套搭建。为一个控件单独升级整个工具链,不划算。
这个选择也符合我处理老代码的一贯原则:能用工具集解决的,不动代码;能小范围改动的,不碰架构。锁定VS2015,意味着改动范围被最大程度地收窄了。
2. 优化前先看清源码底层逻辑
2.1 拿到源码后先做"结构拆解"
打开源码包,不要急着F5编译。先把文件结构的逻辑关系理清楚。ChartCtrl源码通常包含这几个层次:
- 底层基础类:负责坐标转换、颜色管理、字体管理
- 序列类:管理数据点和绘制样式
- 坐标轴类:管理刻度计算和标签绘制
- 控件主类:整合以上所有元素,处理窗口消息
我习惯先把所有类的继承关系画在一张草图上,标注哪些类之间有组合关系。CChartCtrl内部组合了坐标轴对象和序列对象,而不是继承它们,这是典型的"组合优于继承"设计。搞清楚这个结构,后面遇到编译错误时,你能迅速定位是哪个层次出了问题,而不是从头到尾翻文件。
2.2 检查老代码里的"时代特征"
VC6、VS2008时期的代码有几个明显特征,直接看代码就能判断它的"年龄":
- 大量使用TCHAR宏而没有显式处理宽窄字符转换
- 在头文件里直接用#pragma warning(disable: ...)关警告
- 消息映射函数里混着BOOL和LRESULT的返回值
- 全局using namespace std和Windows头文件宏定义冲突
- 资源文件里大量使用旧的控件ID和过时的样式
这些特征不是错误,但在新工具链下会成为编译错误的集中爆发点。
2.3 搭一个"最小验证工程"
我的做法是:新建一个干净的MFC对话框工程,只把ChartCtrl源码加进去,不做业务逻辑,先把控件本身跑起来。这个最小工程的作用是隔离问题。如果最小工程能编译,说明控件本身没问题,业务代码的问题另算;如果最小工程也编译不过,就集中精力改控件源码。
这个最小工程不要复用项目现有工程,因为大型项目的预编译头、宏定义、依赖关系会干扰判断。独立工程能让你清楚地看到"哪一行代码在哪个环境下报什么错"。
3. 编译阶段真正要动手改的坑
3.1 字符集问题:全工程切换到Unicode之后的连锁反应
VS2015默认新建工程的字符集是Unicode,而ChartCtrl的老代码是给多字节环境写的。最典型的问题:
// 老代码写法 char buf[100]; sprintf(buf, "%d", value); CString str = buf;在Unicode环境下,CString是宽字符版本,把char赋给CString不会报错,但反过来把CString传给char参数就会报C2440。解决办法不是到处用(TCHAR)强转,而是分清楚哪些地方真的是字节流,哪些地方是界面文本。
我的处理原则是:
- 界面显示、日志输出:一律使用CString,保持Unicode
- 文件读写、网络协议:明确使用char或BYTE,显式转换
控件里的坐标轴刻度标签、标题文本都属于界面显示,需要把内部的char缓存改成CString。而数据文件解析部分的临时缓冲区,保留char*反而更安全。
3.2 min/max宏和std命名空间的冲突
这是老代码在VS2015下最经典的编译错误之一。Windows.h头文件里定义了min和max宏,而源码里在其他地方include了 或者 ,一编译就是一堆"error C2589: '(': illegal token on right side of '::'"。
报错的地方往往在坐标轴刻度计算、数据范围归一化这些用到了min/max的代码。
两种解决思路:
第一种,在预编译头或者stdafx.h里加上:
#define NOMINMAX这个宏告诉Windows.h不要定义min/max宏,从而让std::min和std::max正常工作。但这有个风险:如果代码里大量使用了min和max的宏形式,去掉宏定义后会引发"找不到标识符"的错误。
第二种,把代码里的min/max调用改为显式命名空间:
// 改前 double v = min(axisMin, axisMax); // 改后 double v = (std::min)(axisMin, axisMax);加括号是为了防止宏展开,也是业内常用技巧。我建议按第二种方式逐个改,虽然繁琐,但保留了old代码的宏定义不影响其他部分。
3.3 消息映射函数返回值必须修正
MFC的消息映射函数,根据消息类型不同,处理函数的返回值要求也不同。老代码里常见的问题是:用LRESULT写WM_PAINT处理函数,或者用BOOL写WM_LBUTTONDOWN处理函数。
在VS2015的MFC版本里,头文件中的函数签名定义更严格。比如:
// 错误示例 BOOL CChartCtrl::OnEraseBkgnd(CDC* pDC); // 正确示例 BOOL CChartCtrl::OnEraseBkgnd(CDC* pDC);这里看不出问题,真正容易报错的是OnTimer。新版本MFC中OnTimer的返回值应该是void,老代码里如果写成了LRESULT,编译时类型不匹配会直接报错。
编译错误信息可能会很长,但只要看到"cannot convert parameter"或者"return type is not identical",基本就是消息映射签名问题。逐个类和头文件对照修改即可,不用慌。
3.4 资源文件的编码问题
VS2015对.rc资源文件的编码处理和老版本不太一样。老版本资源文件常是GB2312编码,在VS2015的资源编辑器里打开,中文注释或者中文标题会变成乱码,严重时会导致RC编译错误。
最简单的处理方式是用VS2015自带的资源编辑器打开,另存为UTF-8带BOM的格式。另存之后逐个检查对话框资源、字符串表,确认中文正常显示。
这个坑不报错则已,一旦出现,就是满屏的"error RC2135: file not found"或者资源ID错乱,属于表面看不出来、实际影响构建的重灾区。一定要在项目一创建就处理编码,不要等编译报错再排查。
4. 编译通过只是开始:运行期问题与绘制稳定性
4.1 先把最影响感受的闪烁问题解决
编译通过不等于界面就能直接看。ChartCtrl默认的OnPaint实现是直接往窗口DC上绘制,在数据刷新频率高的时候(比如实时曲线每秒更新几十次),窗口会闪得厉害。
原因在于:控件没有双缓冲绘制,每次刷新都是先擦除背景再重画,中间的过程肉眼可见。
老代码里可能有一个局部DC的尝试,但不够彻底。我的改造方式是在OnPaint里建立内存DC,先绘制到内存位图,再一次性贴到窗口:
void CChartCtrl::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(&rcClient); CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap memBmp; memBmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOld = memDC.SelectObject(&memBmp); // 原有绘制逻辑全部改为往memDC里画 DrawGrid(&memDC, rcClient); DrawAxis(&memDC, rcClient); DrawSeries(&memDC, rcClient); dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }同时,OnEraseBkgnd直接返回TRUE,告诉系统"我已经自己处理背景了,不要再擦":
BOOL CChartCtrl::OnEraseBkgnd(CDC* pDC) { return TRUE; }这两个改动组合起来,实时刷新的闪烁问题基本能根治。
4.2 坐标轴和数据映射的精度调整
ChartCtrl老代码里坐标映射用的是浮点数乘除,理论上没问题,但实际使用时会发现一个问题:当数据范围特别大(比如0到100000)而绘图区域只有几百像素时,Y轴小刻度标签密集重叠,根本看不清。
原因在于控件把"实际数据范围"直接映射到"像素范围",没有做刻度步长优化。所谓步长优化,就是根据像素空间估算合适的坐标轴刻度间距,使标签数量大致保持在一个可读范围内。
我的做法是引入一个简单的刻度计算函数:
double CalcNiceStep(double dataRange, int pixelLength, int minPixelsPerTick) { double roughStep = dataRange * minPixelsPerTick / pixelLength; double magnitude = pow(10, floor(log10(roughStep))); double normalized = roughStep / magnitude; double niceStep; if (normalized < 1.5) niceStep = 1; else if (normalized < 3) niceStep = 2; else if (normalized < 7) niceStep = 5; else niceStep = 10; return niceStep * magnitude; }这个函数的思路是:根据像素空间反推一个"大概步长",然后规范化到1、2、5、10这类的"整齐数字"上。这样无论数据范围怎么变,坐标轴的刻度数量都会保持在一个相对稳定的区间。
4.3 对话框缩放和DPI问题
另一个运行期问题是DPI。VS2015时代Windows已经普遍支持高DPI,但老的ChartCtrl没有做缩放适配,在125%、150%缩放下字体会模糊,绘图区域和坐标轴文字也会有错位。
控件是自绘的,字体和绘制逻辑全部按96DPI设计。最简单的修复是让整个进程声明DPI感知,然后通过系统提供的缩放比例重新计算字体大小:
// 在InitInstance中调用 SetProcessDPIAware();再加上消息WM_DPICHANGED的处理,在DPI变化时重新计算字体、重绘控件。如果项目里只有ChartCtrl一个控件需要DPi适配,这样做就够用;如果整个对话框都需要,建议优先处理对话字体。
5. 迁移完成后的验证思路与长期维护原则
5.1 我用来判断"迁移成功"的标准
代码改完、程序能跑,不等于迁移结束。我给自己定的验证标准有三条:
第一,长时间运行时无句柄泄漏。用任务管理器观察GDI对象数量,反复打开关闭包含图表的窗口,GDI对象数量应该回落稳定。自绘控件最容易漏GDI对象,一个画笔忘记DeleteObject,长时间运行就会黑屏。
第二,不同数据量级下绘制性能稳定。10个数据点和10万个数据点,控件都应该流畅响应。老代码如果每条数据都重新计算所有点的像素坐标,大数据量下一定会卡顿。
第三,原有业务接口完全不变。我们改了内部实现,但CChartCtrl的对外接口一个都没动,之前的业务代码不需要改一行。这一点非常重要,它保证了迁移风险被控制在单个控件内部。
5.2 维护老控件的几条原则
这次迁移让我重新总结了一遍老控件的维护经验:
- 能通过工程配置解决的,不改源码。比如字符集、工具集、警告级别,这些属于环境层面的调整。
- 能通过小函数封装的,不大改设计。比如刻度计算、坐标映射,把这些逻辑收拢到独立函数里,方便以后替换。
- 每个改动都要有明确的动机记录。我习惯在函数头注释里写清楚"为什么改",这比"谁改的、什么时候改的"更有价值。
5.3 后续可以扩展的方向
ChartCtrl这个控件的底子是好的,但GDI绘制毕竟是老技术。如果后续有需求,值得考虑的方向包括:
- 用GDI+替代裸GDI,可以让曲线带抗锯齿效果,线条外观明显提升
- 加入数据点的局部刷新机制,比如只重绘新增数据区域,减少全量重绘
- 如果项目迁移到更高版本,可以把坐标轴计算部分抽取为独立的工具类,方便单元测试
不过这些都是后话,前提是先让老控件在新工具链下稳定存活。我这个项目里,从开始动手到稳定运行,大约是三个工作日,其中一半时间花在资源编码和DPI适配这两个"非编译错误"问题上。如果你也在做同样的迁移,建议把精力重点放在这类问题上,它们比改代码更隐蔽,也更费时间。
本文还有配套的精品资源,点击获取