简介:一套针对VS2015优化过的老牌ChartCtrl图表控件源码,适合需要在MFC桌面应用中嵌入曲线、柱状、蜡烛、甘特等图表的Windows开发者,也适合希望研究自定义绘图控件实现与迁移老代码的进阶学习者。压缩包共86个文件,以41个头文件、32个C++实现文件和4个内联模板文件为主,同时附带.sln、.vcxproj、.dsp等工程文件,方便在VS2015中直接打开编译;整体仅154KB,代码量紧凑,便于快速阅读和二次修改。通过源码可了解图表控件整体架构,包括坐标轴、标题、图例、十字光标、滚动条等基础组件,以及折线、柱状、蜡烛、甘特、曲面等多种图表序列的绘制逻辑;虽是老界面,但功能实用,适合在此基础上美化交互或扩展新图表类型。已有1007人学习,可作为MFC图表开发中的参考实现与启动模板。
1. ChartCtrl重构:为什么一个“老掉牙”的图表控件还要在VS2015里继续跑
接手一个维护了多年的MFC桌面包,最尴尬的不是业务逻辑复杂,而是老图表控件在新编译器下根本编不过去。很多人第一反应是换商业图表库或现代开源组件,但真动起来才发现:旧工程里十几个对话框都绑着ChartCtrl,控件创建方式、数据回调、打印预览全都耦合在一起,短时间内换不掉。与其冒着上线风险重写图表层,不如把这份老ChartCtrl源码优化到VS2015环境下继续用。
ChartCtrl的好处恰恰在“老”:它不依赖第三方图表库,核心类就集中在控件窗口、坐标轴、曲线序列三个层次里,绘制逻辑直接落在GDI上,代码量不大,读起来比现在很多框架容易理解。把这类源码移植到VS2015,你不需要重写架构,只需要解决编译器差异、字符集、SDK版本和少量运行时绘制问题。
这篇笔记就是沿着“先判断源码结构 → 再改编译问题 → 最后优化绘制与交互”的顺序,把ChartCtrl在VS2015下的移植路径讲透。适合正在维护MFC老项目的开发者,也适合那些刚把开发环境从VC6或VS2008迁到VS2015、被一堆编译错误卡住的人。
2. 先别急着改代码:ChartCtrl源码的模块划分与VS2015下的兼容性判断
很多人拿到源码第一步就直接把整个目录拖进VS2015,然后看着上百个编译错误发呆。ChartCtrl这类老控件源码通常包含控件本体、坐标轴、曲线类和一个Demo工程,其中不少文件是演示程序用的,进了正式项目只会引入多余的资源依赖。我在动手前会先把目录拆一遍,分清哪些文件进工程、哪些文件只做参考,再决定项目属性怎么设。
2.1 老控件源码的典型目录结构与文件职责
常见目录结构里,核心文件一般有五个层次:控件主窗口类负责创建窗口、处理WM_PAINT和WM_SIZE;坐标轴类管理数据范围、刻度绘制和网格线;曲线序列类保存数据点并负责把数据映射到窗口坐标;标签或图例类负责绘制标题、单位等文本;剩下的Demo文件只是调用示例。
| 文件类别 | 职责 | 移植优先级 |
|---|---|---|
| ChartCtrl主类 | 控件窗口、消息路由、整体重绘入口 | 最高,先确保它能编译 |
| 坐标轴类 | X/Y轴范围、刻度、网格、标签 | 高,和主类强耦合 |
| 曲线序列类 | 数据存储、坐标映射、折线绘制 | 高,图表核心 |
| 标注/图例类 | 标题、单位、标识文本 | 中,缺了也能跑 |
| Demo工程 | 对话框外壳、初始化调用 | 低,只作为调用参考 |
我的习惯是只导入前三类文件和必要的标注类,Demo工程完全丢弃。因为Demo里引用的资源文件、图标、工具栏都是旧工程残留,带进新工程后经常冒出ID冲突。你只需要在Demo的对话框代码里查一下ChartCtrl的创建参数和初始化调用顺序,把它抄到自己的MFC对话框里即可。
还有一个容易被忽略的点:旧源码多半有“afxwin.h、afxext.h、afxcmn.h”等头文件的依赖顺序混乱问题。VS2015对头文件包含顺序更敏感,建议在预编译头里统一先包含MFC公共头,再包含ChartCtrl头文件,可以少掉一批“未声明的标识符”编译错误。
2.2 VS2015与旧编译器的三处默认差异
VC6和VS2008时代写出来的代码,在VS2015下之所以大面积报错,主要是三处默认行为变了。第一处是变量作用域。旧编译器下for循环里的循环变量在循环结束后仍然可见,VS2015默认按C++标准处理,循环变量只在循环体内有效。旧代码如果在for后面继续使用这个变量,就会报“未声明的标识符”。
第二处是字符集。VC6默认使用多字节字符集,很多字符串直接写char数组;VS2015新建的MFC项目默认使用Unicode,CString内部是wchar_t。混用char*和CString会导致跨平台编译警告、参数类型不匹配,运行期还有可能让中文坐标轴变成乱码。
第三处是CRT安全函数。VS2015对sprintf、strcpy、fopen这类旧函数默认给出C4996警告,虽然把警告级别压低也能编过去,但缓冲区溢出问题仍然存在。我建议在迁移过程中顺手把此类调用替换成CString格式化或带_s的安全版本,因为后续维护的人面对旧代码时,往往会误以为这些警告无关紧要。
此外,VS2015的Windows SDK版本比旧工具集高,部分Win32 API要求使用带Ptr后缀的新版本,比如GetWindowLongPtr、SetWindowLongPtr。这些问题在Win32平台编译时可能只表现为警告,但在x64平台会直接编译失败,属于必须处理的项目。
2.3 什么时候该走ChartCtrl迁移路线
不是所有项目都适合留在老控件上。ChartCtrl的优势是轻量、无第三方依赖、调试时可以直接看到GDI绘制链路;缺点是默认没有双缓冲,曲线不经抗锯齿处理,数据点超过一定规模后性能下降明显,并且它对高DPI屏幕支持不足。
我一般按四个条件来判断:项目本身是MFC对话框或单文档程序,不跨平台;曲线数量在五条以内,单曲线数据点不超过一万;界面需要保留旧打印、导出等逻辑,临时替换控件会破坏这些联动;团队没有精力引入新图形框架并重写交互。只要其中两条不满足,我就建议换现代方案,否则硬把这套老控件撑起来,后续功能扩展反而更吃力。
反过来讲,如果条件和上面吻合,ChartCtrl迁移路线非常划算。它天然支持鼠标消息处理,你可以快速给控件加坐标拾取、局部缩放,而不需要额外封装一层跨平台抽象。接下来的章节按步骤把移植工作拆开,从项目创建一路走到运行时优化。
3. 把ChartCtrl移植到VS2015环境:六步构建与关键代码修改
真正开始迁移时,不要用VS2015直接打开旧工程。旧工程的.dsp、.vcproj文件与新IDE的工程格式差异很大,向导转换后通常保留一堆过时的编译器选项,例如旧的预处理宏、过时库路径、所谓“调试信息格式”等。我建议在VS2015里新建一个干净的MFC工程,再把ChartCtrl源文件手动导入,这样可控性最高。
3.1 建立干净项目并导入ChartCtrl源码文件
我一般先在磁盘上建一个新目录,把要用的ChartCtrl源文件单独复制出来,排掉Demo相关的冗余代码。目录结构不需要复杂,只要保证头文件和实现文件放在统一位置即可。
# 在VS2015开发人员命令提示符里执行,先建目录再拷贝源码 mkdir C:\work\ChartCtrlPort xcopy C:\old\ChartCtrlDemo\ChartCtrl\*.h C:\work\ChartCtrlPort\src\ /Y xcopy C:\old\ChartCtrlDemo\ChartCtrl\*.cpp C:\work\ChartCtrlPort\src\ /Y这段命令只是把ChartCtrl的核心源文件剥离到新目录,xcopy后面加/Y是为了避免文件覆盖提示阻塞脚本。复制完以后,在VS2015中新建一个“MFC应用程序”工程,然后在解决方案资源管理器里右键“添加→现有项”,把这些.h和.cpp选进去。
导入后需要在预编译头里确认MFC头文件的顺序。老代码经常把windows.h放在最前面,这会让MFC的afxwin.h产生重复定义问题。稳妥的做法是先包含afxwin.h、afxext.h、afxcmn.h,再包含ChartCtrl的头文件。
// pch.h 或 stdafx.h 中调整包含顺序 #pragma once #define VC_EXTRALEAN #define WIN32_LEAN_AND_MEAN #include <afxwin.h> #include <afxext.h> #include <afxcmn.h> #include "../src/ChartCtrl.h"这里的VC_EXTRALEAN和WIN32_LEAN_AND_MEAN用于剔除不常用的Windows API定义,加快编译速度。把ChartCtrl.h放在MFC头文件之后,是为了保证它内部用到的CWnd、CDC等类型已经完整声明,避免出现找不到基类的问题。
3.2 设置项目属性:Unicode字符集与运行库
源码导入后,先不要急着编译,先把项目属性按VS2015的推荐值调整一遍。重点看字符集、平台工具集和MFC使用方式三项。
| 项目属性 | 推荐值 | 旧项目常见值 | 原因 |
|---|---|---|---|
| 字符集 | 使用Unicode字符集 | 使用多字节字符集 | VS2015默认窗口和文本API走宽字符,避免宽窄转换 |
| 平台工具集 | Visual Studio 2015 (v140) | v60、v90等 | 统一编译器版本和CRT依赖 |
| MFC的使用 | 在共享DLL中使用MFC | 在静态库中使用MFC | 调试更直观,发布前可再改静态 |
| 警告级别 | Level3或Level4 | Level2 | 尽早暴露类型转换和未初始化问题 |
如果旧工程确实依赖多字节字符集,可以安装VS2015的“多字节字符集库”可选组件。但我建议不要走这条路,因为后续如果调用某个直接使用LPWSTR的API,又要把char数组转成宽字符,问题反而更多。
运行库方面也要对齐。VS2015的CRT版本与VS2008、VC6完全不同,如果项目里混入了用旧编译器生成的静态库,会在链接阶段报大量LNK2038或LNK2019错误。要么把第三方库也换成VS2015重新编译,要么先注释掉相关调用,保证ChartCtrl本体能编出干净的exe。
3.3 旧式文件API的替换:从sprintf到CString或swprintf_s
ChartCtrl源码里最常见的是用sprintf格式化刻度字符串或提示文本。这些代码在VC6下没有任何警告,到VS2015会触发C4996,而且char缓冲区一旦不够长,刻度数字一多就会截断。MFC工程里我直接改成CString格式化,既省去缓冲区管理,又自动适配Unicode。
// 旧写法:sprintf(buf, "%.3f", value); // VS2015 下推荐改成 CString 或 swprintf_s double value = 12.345; CString str; str.Format(_T("%.3f"), value); // 如果你需要的是 wchar_t 缓冲区,用安全版本 wchar_t buf[64]; swprintf_s(buf, 64, L"%.3f", value);这段代码里,str.Format是CString的格式化方法,_T宏会根据项目字符集自动切换字符类型。swprintf_s多了一个缓冲区长度参数,这个参数是元素个数而不是字节数,写成64说明缓冲区最多容纳64个wchar_t。还要注意,格式化字符串里的%.3f和printf一致,代表保留三位小数。
在迁移过程中,不要为了省事在项目里全局定义_CRT_SECURE_NO_WARNINGS把警告压掉。因为该警告背后的缓冲区溢出风险是真实存在的,尤其刻度标签这类文本经常需要拼接单位,缓冲区大小不好估算。顺手改成CString之后,很多潜在崩溃其实就一并消失了。
3.4 修正GetWindowLong等32/64位兼容API
ChartCtrl如果是从VC6时代传下来的代码,多半调用GetWindowLong、SetWindowLong或GetClassLong这些老API。在32位编译下这些函数还能正常工作,但到了64位平台,GWL_ID、GWL_STYLE这些返回值被截断,窗口样式或控件ID丢失,程序行为就变得诡异。VS2015的Windows SDK推荐使用带Ptr后缀的版本。
// 兼容32/64位窗口信息获取 DWORD_PTR GetWindowStyle(HWND hWnd) { #ifdef _WIN64 return (DWORD_PTR)GetWindowLongPtr(hWnd, GWL_STYLE); #else return (DWORD_PTR)GetWindowLong(hWnd, GWL_STYLE); #endif }这里用预编译宏_WIN64区分平台,64位下调用GetWindowLongPtr,32位下回退到老函数。强制类型转换为DWORD_PTR,是为了让返回值在两种平台上都占同一个大小,避免比较和传参时发生隐式截断。
如果你的工程以后要生成x64版本,建议把所有出现GetWindowLong、SetWindowLong的地方都同步替换。特别要注意那些把返回值直接赋值给int的代码,即使编译不报错,运行时控件坐标也可能莫名偏移。
除了窗口信息函数,还有一处隐蔽的差异是消息处理函数的返回值类型。老代码可能把LRESULT写成long,在64位下会截断指针。VS2015编译时通常不会直接报错,但你会看到关于“从long转换为LRESULT”的警告,这类警告建议清零处理,而不是忽略。
3.5 编译通过后先做一个空的SetRange调用验证
核心文件导入并完成API修正后,可以先把编译目标锁定在“能生成一个可运行对话框”。不要急着展示曲线数据,先在对话框初始化函数里创建ChartCtrl控件,设一个数据范围,确认控件窗口能正常显示。
// 在对话框类的 OnInitDialog 中创建图表控件 BOOL CMyChartDlg::OnInitDialog() { CDialog::OnInitDialog(); // 创建子窗口控件,覆盖图表显示区域 m_wndChart.Create(_T("STATIC"), WS_CHILD | WS_VISIBLE | WS_BORDER, CRect(10, 10, 460, 260), this, IDC_CHART_CONTROL); m_wndChart.SetRange(0.0, 100.0, -20.0, 20.0); return TRUE; }Create函数里的第一个参数类型写成“STATIC”,是因为ChartCtrl继承自CWnd,实际创建时会注册自己的窗口类,这里的类名参数更多是占位作用。四个参数分别是扩展样式、父窗口指针和控件ID。SetRange用于设定X轴和Y轴的数据范围,四个值依次是X最小值、X最大值、Y最小值、Y最大值。
这一步能跑通,说明字符集、消息映射和基础绘制链路都没有大问题。如果在调试输出里看到“未捕获的WM_PAINT异常”或“非法GDI对象句柄”,先回到OnPaint函数检查是否有DC选择对象后未恢复的情况,这个问题在下一章会详细展开。
4. ChartCtrl移植的避坑指南:从编译失败到闪烁、乱码的常见问题排查
把ChartCtrl从旧环境搬进VS2015,编译错误只是第一关,运行时问题更隐蔽。我整理五条常见的踩坑记录,每条都按现象、原因、解决三个层次说明。这几条几乎覆盖了我在多个MFC项目里遇到的高频问题,照着排查能省下大量时间。
4.1 编译错误C2065:未声明的标识符
现象:VS2015编译ChartCtrl源文件时,报error C2065: “CDC”: 未声明的标识符,或者“CString”: 未声明的标识符。
原因:旧工程可能把ChartCtrl.h直接放在最外层,而这个头文件里用到CDC、CString但并没有包含afxwin.h。在VC6时代,系统头文件间接包含了MFC头文件,VS2015的头文件依赖树更精简,这种隐式依赖就断了。
解决:在ChartCtrl.h顶部显式包含MFC头文件,或者把ChartCtrl.h放到预编译头之后统一包含。检查顺序时看“生成布局文件”即可,凡是报错的标识符都能在afxwin.h或afxext.h中找到定义。我一般习惯在pch.h里包含MFC公共头,然后在所有.cpp文件的第一行只包含pch.h,这样依赖关系最单一。
4.2 链接错误LNK2019:无法解析的外部符号
现象:编译全部通过,链接时提示“无法解析的外部符号”,符号名看起来像某个ChartCtrl类成员函数,但后面带着一串奇特的字符修饰。
原因:其中一个常见原因是头文件里声明了函数,但对应的.cpp文件没有加入工程。另一个常见原因是调用约定不一致,比如头文件里用了__cdecl,实现文件却默认是__stdcall。VS2015对符号修饰命名更严格,VC6时代偶尔能混过去的情况在新工具集下直接暴露。
解决:先展开符号信息查看完整修饰名,确认是哪类问题。若是缺少实现文件,把对应.cpp加入工程;若是调用约定不一致,统一在类成员函数的声明和定义处都使用相同调用约定。特别提醒,不要用“把这个符号忽略掉”的办法绕过链接错误,因为那通常意味着某个功能彻底没编译进来,图表控件会呈现缺失状态。
4.3 运行时控件区域持续闪烁
现象:控件能显示,但每次窗口移动、尺寸变化或数据更新时,图表区域闪得厉害,甚至能看到短暂拖影。
原因:ChartCtrl最初的实现没有双缓冲,OnPaint直接往窗口DC画,系统先擦背景再画前景,两个动作有时间差,人眼就感觉到闪烁。同时OnEraseBkgnd默认用白色背景刷一遍,等OnPaint再绘制曲线时,中间状态被暴露出来。
解决:在控件类里重写OnEraseBkgnd,让背景擦除动作不再覆盖画布,再配合双缓冲把绘制内容先画到内存DC。
BOOL CChartCtrl::OnEraseBkgnd(CDC* pDC) { // 只屏蔽系统擦背景,真正的绘制由OnPaint统一完成 return TRUE; }这段代码的作用是告诉系统“背景我已经自己清理过,不需要你默认处理”。这样系统不会先把画面涂成背景色,闪烁就少了一半。另外一半需要靠OnPaint里的内存DC解决,后面章节专门讲双缓冲时再给出完整实现。
4.4 坐标轴中文变成问号或乱码
现象:刻度数字正常,但X轴标题、图例里的中文全部显示为问号,或者编译时源码里的中文注释直接变成乱码。
原因:旧源码文件可能是GB2312或GBK编码,VS2015默认以当前系统代码页打开,保存时如果被转成了UTF-8但项目字符集还是多字节,读取字符串就会错位。运行期乱码则是因为直接把char数组赋给CString,项目使用Unicode字符集后类型不匹配。
解决:先把旧源码文件用VS2015另存为“Unicode (UTF-8带签名)”。然后在所有文本字面量处使用_T宏包装。
// 错误的旧写法 CString axisTitle = "实时曲线"; // VS2015 下正确写法 CString axisTitle = _T("实时曲线");_T宏会根据项目字符集自动决定字符串类型;Unicode下它就是L“实时曲线”,多字节下它退化为普通字符串。若看到“无法从const char*转换为CString”的编译错误,原因就是字符串宏没加。还有一点,MFC的调试版本会在输出窗口显示“警告: 在Unicode模式下使用ANSI字符串函数”,这也可以作为排查线索。
4.5 数据点一多就卡顿
现象:数据点只有几百个时很流畅,到了几千个甚至上万个,拖动窗口或刷新曲线时明显迟滞,CPU占用率升高。
原因:ChartCtrl原始绘制代码往往是逐点调用MoveToEx和LineTo,每个点还可能在绘制循环里重新创建画笔。GDI对象创建和销毁是重量级操作,数据量增大后,单次OnPaint的执行时间呈线性上升,拖拽时重绘又频繁触发,整体就卡住。
解决:把逐点LineTo改成分段Polyline,并把画笔创建移出绘制循环。这样能显著降低GDI调用次数,让性能瓶颈转移到数据映射计算上,后文5.4会给出具体代码模板。这里先说结论:上万点以下,这个优化足够;超过十万点,还要再做数据抽稀。
5. 把ChartCtrl的源码吃透:绘图模型、双缓冲与性能优化实践
如果你只是想把控件编过去,前两章已经够用;如果想长期维护并给控件加功能,需要理解它的绘图顺序和坐标映射。ChartCtrl的源码风格是典型的MFC老派写法:数据范围、坐标转换、绘制函数都在类内部直接操作DC,只要吃透这条链路,优化空间很大。
5.1 从SetRange到OnPaint:一次重绘的完整调用链
当你调用SetRange时,控件内部做的第一件事是保存新的数据范围,然后触发Invalidate,让系统在空闲时发送WM_PAINT消息。OnPaint被调用后,ChartCtrl创建CPaintDC,获取客户区矩形,接着按照“绘制网格→绘制坐标轴→绘制曲线→绘制标签”的顺序逐层画上去。
理解这个顺序很重要。网格和坐标轴要在曲线之前绘制,这样曲线才能覆盖在网格线上方,保证可读性。如果先画曲线再画网格,网格线会切断曲线,看上去像曲线被分成了好几段。标签类通常放在最后,避免被数据曲线遮挡。
原始代码在OnPaint里直接操作窗口DC,这会带来两个问题:一是闪烁,二是每次重绘都要从头画一遍所有对象。后者的优化不是省去绘制,而是减少GDI对象创建。前者用双缓冲解决。
5.2 在OnPaint里实现双缓冲
双缓冲的核心思想是先在一个内存位图上完成全部绘制,再一次BitBlt到窗口。这样用户看到的是完整画面,而不是绘制过程中的中间状态。
void CChartCtrl::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(&rcClient); if (rcClient.IsRectEmpty()) return; // 创建与窗口DC兼容的内存DC和位图 CDC memDC; CBitmap memBmp; memDC.CreateCompatibleDC(&dc); memBmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); // 将内存位图选入内存DC,并记住旧对象以便恢复 CBitmap* pOldBmp = memDC.SelectObject(&memBmp); // 先绘制背景,再按ChartCtrl原有顺序绘制各层 memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); DrawGrid(&memDC, rcClient); DrawAxis(&memDC, rcClient); DrawSeries(&memDC, rcClient); // 一次性复制到窗口 dc.BitBlt(rcClient.left, rcClient.top, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); // 恢复内存DC中的旧位图 memDC.SelectObject(pOldBmp); }CreateCompatibleBitmap的参数分别是源DC、位图宽度和位图高度,单位是像素。BitBlt的参数里,前四个是目标区域,第五个是源DC,第六和第七是源坐标,最后一个SRCCOPY表示直接复制像素。代码最后的SelectObject恢复非常关键,遗漏后,memDC析构时会试图删除一个仍被选中的位图,轻则资源泄漏,重则调试版直接断言失败。
双缓冲后的ChartCtrl在窗口拖动和曲线刷新时不再闪烁,代价是每帧多一次整图复制。对于数据量不大的MFC应用,这个代价完全可接受。
5.3 曲线画笔预先创建并重复使用
绘制循环里频繁创建GDI对象最大的问题不是速度,而是资源泄漏。GDI对象数量有系统上限,长时间运行的程序如果每帧都新建画笔而不删除,最终会导致所有窗口绘制异常。
我在迁移时会把常驻的画笔、画刷、字体作为控件类的成员变量,在控件初始化时创建,在析构时释放。OnPaint里只负责选入和恢复,不再创建新对象。
// 类成员变量 CPen m_penSeries; CBrush m_brushBackground; // 初始化时创建一次 m_penSeries.CreatePen(PS_SOLID, 2, RGB(60, 120, 220)); m_brushBackground.CreateSolidBrush(RGB(255, 255, 255)); // OnPaint 中使用 CPen* pOldPen = memDC.SelectObject(&m_penSeries); CBrush* pOldBrush = memDC.SelectObject(&m_brushBackground); // 绘制逻辑... memDC.SelectObject(pOldPen); memDC.SelectObject(pOldBrush);PS_SOLID表示实线,第二个参数2是线宽,单位是逻辑单位,在默认MM_TEXT映射模式下就是像素。SelectObject之后务必将旧对象选回,GDI对象才能被安全释放。这条规则和双缓冲中的位图恢复是同一条原则:谁选入,谁负责恢复。
5.4 数据量过万时:用Polyline替代MoveTo和LineTo
老代码常见的曲线绘制方式是对每个数据点调用LineTo,这样会产生大量GDI绘制调用。更高效的方式是把若干个点组成POINT数组,一次性调用Polyline完成整段折线绘制。
void DrawCurvePolyline(CDC* pDC, const std::vector<double>& xData, const std::vector<double>& yData) { int nCount = (int)xData.size(); if (nCount < 2) return; // 先把数据坐标映射为像素坐标 std::vector<POINT> ptArray(nCount); for (int i = 0; i < nCount; ++i) { ptArray[i].x = MapToPixelX(xData[i]); ptArray[i].y = MapToPixelY(yData[i]); } // 一次调用绘制整条折线 ::Polyline(pDC->GetSafeHdc(), ptArray.data(), nCount); }Polyline的第三个参数是点的个数,它会把相邻点依次连成线段。和LineTo的区别在于,整个绘制过程由GDI在内部完成,应用层只需要一次调用。数据坐标映射放在循环里做,是保证后续如果改用折线点集或数据抽稀,可以只改映射这段,而不动绘制接口。
如果画的是平滑曲线而非折线,Polyline并不够。但ChartCtrl的定位就是数据监控曲线,客户只要看清趋势即可,换成Polyline是投资回报最高的优化方式。
5.5 刻度标签文字防重叠的技巧
坐标轴刻度多了之后,标签文字经常挤在一起,尤其是X轴,时间戳字符串较长,缩放后密集排列更加严重。老办法是盲目减小字体,但字体太小又看不见。
我一般会根据当前像素宽度决定绘制步长。如果X轴占400像素,刻度总数50个,那必须每隔若干刻度跳过标签绘制,否则一个标签宽度大约48像素,全部画出来必然重叠。
// 假设刻度索引从0开始,只有偶数索引才绘制文字 if ((tickIndex % 2) == 0) { pDC->DrawText(strLabel, CRect(x, y, x + 48, y + 18), DT_CENTER | DT_TOP | DT_NOPREFIX | DT_SINGLELINE); }DrawText的最后一个参数组合里,DT_CENTER让文字水平居中,DT_NOPREFIX告诉MFC不要解析“&”快捷键前缀,DT_SINGLELINE强制单行显示,避免换行打乱排版。这个“跳绘”策略不算精致,但对MFC老控件来说最简单稳定。更进一步的自动避让算法需要计算每个标签的实际像素宽度,如果标签内容来自DB或网络,再考虑按需实现。
6. 给ChartCtrl补上最后一块体验:鼠标拾取与坐标提示
移植和优化完成后,图表控件在对话框里通常还需要鼠标交互。最常见的需求是鼠标移动时显示当前数据坐标,点击时定位到特定曲线点。这一章讲一个最实用的技巧:把鼠标位置转换成数据坐标。
6.1 用ScreenToData把鼠标点换算成数据坐标
ChartCtrl原始类不一定自带屏幕坐标转数据坐标的函数,但我们可以根据已知的绘制区矩形和数据范围推算出来。换算逻辑是先用ScreenToClient把鼠标坐标从屏幕坐标转成客户区坐标,再减去绘制区左上角偏移,最后除以像素比例。
// 在对话框的鼠标移动消息中处理坐标提示 void CMyChartDlg::OnMouseMove(UINT nFlags, CPoint point) { double dX = 0.0, dY = 0.0; if (m_wndChart.ScreenToData(point, &dX, &dY)) { CString tip; tip.Format(_T("x = %.3f, y = %.3f"), dX, dY); SetDlgItemText(IDC_STATIC_COORD, tip); } CDialog::OnMouseMove(nFlags, point); }这里假设控件类里实现了ScreenToData成员函数,内部做坐标逆变换。取得数据坐标后,把字符串显示在静态文本控件上,用户就能实时看到鼠标所在位置的物理值,而不是只看像素坐标。
6.2 把坐标换算封装成独立函数
我给ChartCtrl扩展时不会把换算代码塞进鼠标消息里,而是在控件类内部封装一个公共接口,输入屏幕点,输出数据坐标。这样同一个接口可以被鼠标消息、触摸事件、键盘焦点移动复用。
封装时要注意一个坑:控件实际绘图区域可能不是整个客户区,因为坐标轴标题和刻度文字占用了边缘空间。换算数据坐标时,必须使用实际绘图区矩形,而不是直接拿GetClientRect强算。否则鼠标在绘图区边缘时,看到的坐标和曲线位置偏差几个像素。
还有一个习惯值得养成:凡是改了坐标范围或控件尺寸,都要重新计算绘图区矩形并触发一次刷新。ChartCtrl这类老控件在这一点上往往做得不严格,导致窗口拉大后曲线和坐标轴错位。修复办法是在OnSize里调用RecalcPlotRect,再Invalidate。这个细节很不起眼,但却是很多“图线和刻度对不上”问题的根源。
我自己维护这类老图表控件的习惯是:先让它在最小对话框里稳定运行三天,再逐步加入交互功能。不要一上来就改鼠标拾取和缩放,因为基础绘制如果存在GDI泄漏,交互越复杂,问题越难定位。ChartCtrl的优势在于代码透明,出问题时可以直接看OnPaint链路,不需要像商业控件那样黑盒猜测。
最后说一句:老控件不是包袱,只要把编译环境、字符集、GDI资源管理这三关守住,它在新工程里仍然能稳定支撑业务。希望本章的内容能在你自己动手迁移时,帮你少踩几个坑。
本文还有配套的精品资源,点击获取