简介ChartCtrl 是一套面向 MFC 与 VS 开发者的老牌图表控件源码已整理为 VS2015 工程适合需要在桌面程序中嵌入折线图、柱状图、点图、K线图等基础图表的开发者参考与二次开发。压缩包共 86 个文件约 154KB以 41 个 .h 头文件和 32 个 .cpp 实现文件为主配合少量 .inl 内联实现、图标、rc 资源及 vcxproj、sln、dsp 等新旧工程文件完整保留控件类、演示程序和属性对话框等模块。源码包含标准轴、日期轴、对数轴、网格、图例、光标、滚动条、标签及折线、柱状、点、K线、甘特、曲面等多种序列类型等核心组件并附带示例工程与配色选择控件便于理解绘制流程、消息响应和属性配置方式界面风格偏传统可作为功能基线继续优化。目前已有 1009 人学习下载适合具备一定 C/MFC 基础、希望快速获得可运行图表控件参考实现的中高级开发者。1. 老 MFC 图表控件 ChartCtrl 值不值得拆一条曲线背后的选型账维护了好几年的 MFC 上位机要临时加一条温度曲线界面早排满了引入一套重型绘图库会牵动整个渲染层还得跟现有消息循环、打印逻辑、权限模块挨个对接。这种时候ChartCtrl 图表控件这类纯 GDI 实现的源码控件就很合适它不依赖任何第三方二进制源码直接编进工程折线、柱状、散点、网格、图例、缩放平移都在里面画出来的东西不算炫但足够稳。这份资源是一套老版本控件源码已经做过 VS2015 的编译适配适合三类人手上是 MFC 对话框程序、需要把曲线嵌进已有窗口的想改控件内部行为、不希望被闭源库卡住的以及要接手老项目、得先能编译通过的。它解决的不是“画得好看”而是“在别人的老工程里画得出来、改得动、不添乱”。2. ChartCtrl 的类结构与绘制链路先搞清楚到底谁在画源码拿到手别急着往工程里塞先花二十分钟把类的层次捋一遍。老 MFC 控件的通病是命名朴素但耦合不浅不看清楚就动手后面调一个坐标轴要翻五个文件。2.1 从 CChartCtrl 到 CChartSerie 的职责划分ChartCtrl 的设计思路很直白一个窗口对象当容器下面挂若干功能子对象谁负责什么写在类名上。类职责你平时会调的接口CChartCtrl控件窗口本体负责消息、重绘调度、子对象持有Create、AddSerie、RefreshCtrl、RemoveAllSeriesCChartSerie数据序列基类持有点集与样式AddPoint、AddPoints、SetColor、SetWidth、SetNameCChartLineSerie / CChartBarSerie / CChartPointsSerie折线、柱状、散点的具体画法由 AddSerie 的枚举参数决定一般不用自己 newCChartStandardAxis标准线性坐标轴管范围、刻度、标签SetMinMax、SetDiscreteCChartGrid网格跟随主轴刻度绘制SetVisibleCChartLegend图例读取各序列的 NameSetVisibleCChartCrossHair十字游标取点读数用默认关闭需要时开CChartBalloon气泡标注挂在某个点上显示文字数据告警场景常用关键认知是序列对象的所有权在 CChartCtrl 手里。你调 AddSerie 拿到的指针只是借用一旦 RemoveAllSeries 或控件析构指针立刻失效。老代码里那种把CChartSerie*存成成员变量、下次刷新直接往里 AddPoint 的写法是后面崩溃的主要来源第 5 章会展开说。2.2 绘制链路OnPaint、双缓冲与坐标变换打开 CChartCtrl 的 OnPaint 实现能看到典型的老派双缓冲建一个兼容内存 DC铺背景然后按固定顺序调用各子对象的绘制方法最后一次性 BitBlt 到屏幕。这个顺序不能乱因为网格要垫在曲线下面坐标轴要压在曲线上面图例和游标在最顶层。绘制顺序大致是背景填充 → 网格 → 各数据序列 → 坐标轴与刻度文字 → 图例 → 十字游标 / 气泡标注。真正值得看的是坐标变换那一段。数据点从数值域到像素域要过两道手先由坐标轴的 MinMax 把值归一化到 0~1再乘以控件绘图区的宽高Y 轴还要反过来屏幕 Y 向下增长。所以有两个高频问题都出在这里坐标轴的范围没覆盖数据点算出负数像素被裁剪区切掉曲线看起来“断头”。数据里混进 NaN 或极大值归一化结果溢出整条线画飞或者干脆不画。排查这两类问题时我习惯先在 AddPoint 之前把数据打个日志确认送进去的都是有限值再看 SetMinMax 的参数是不是比数据范围宽一点点。控件本身不做数据校验脏数据会一路带到 GDI 的 LineTo 里。2.3 把源码挂进 VS2015 工程文件、包含路径与预编译头挂载过程有三处容易卡住按顺序做基本一次过。第一步在解决方案目录下建个子文件夹比如ThirdParty\ChartCtrl把源码的 .h 和 .cpp 全部拷进去保持原有相对路径不变有些文件互相以相对路径 include拆散会报找不到头文件。第二步在 VS2015 里右键项目 → 添加 → 现有项把 .cpp 和 .h 都加进来。加 .cpp 这一步不能省只包含头文件会在链接期报一长串 LNK2019。第三步配置包含路径项目属性 → C/C → 常规 → 附加包含目录加上$(ProjectDir)ThirdParty\ChartCtrl。用$(ProjectDir)而不是绝对路径换机器或换 CI 才不会炸。第四步最容易踩坑预编译头。老控件源码通常不写#include stdafx.h或pch.h而工程默认是“使用预编译头”编译时直接报 C1010意外的文件结尾是否忘记在源文件中添加 #include pch.h。两种处理方式要么在每个控件 .cpp 顶部补一行#include pch.h要么选中这些 .cpp → 右键属性 → C/C → 预编译头 → 预编译头选项改成“不使用预编译头”。我一般选后者改动只落在项目配置里不污染第三方源码将来整体替换版本也省事。做完这四步先别急着写业务代码直接编译一遍。能过说明环境层面没问题过了之后再加界面出问题就只可能是用法问题排查范围小一半。3. 在对话框里画出第一条曲线ChartCtrl 的创建、喂数与坐标轴配置环境通了接下来把曲线真正画出来。这一段是全文最值得抄的部分顺序错了会出现“编译通过、运行空白”的情况。3.1 控件创建与初始化顺序ChartCtrl 是动态创建的不是在资源编辑器里拖出来的。常见做法是在对话框上先摆一个静态框或图片控件当占位运行时取它的位置把图表创建在同一块区域然后隐藏占位控件。先看头文件里怎么声明// ChartDemoDlg.h #pragma once #include ChartCtrl.h // 控件的总头路径由附加包含目录解决 class CChartDemoDlg : public CDialogEx { // ... 省略消息映射与其它成员 protected: CChartCtrl m_Chart; // 作为成员对象随对话框一起析构避免手动 delete };再看在 OnInitDialog 里的创建逻辑BOOL CChartDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 1. 取占位控件的矩形转成父窗口客户区坐标 CRect rc; GetDlgItem(IDC_CHART_HOST)-GetWindowRect(rc); ScreenToClient(rc); // 2. 创建图表控件第三个参数是控件 ID后续可用 GetDlgItem 取回 if (!m_Chart.Create(rc, this, IDC_CHART_CTRL)) { TRACE(_T(ChartCtrl create failed\n)); return FALSE; // 创建失败直接终止别带着半初始化状态往下跑 } // 3. 创建成功后再配置子对象顺序不能颠倒 m_Chart.GetLegend()-SetVisible(true); m_Chart.GetGrid()-SetVisible(true); m_Chart.GetBottomAxis()-SetMinMax(0.0, 100.0); m_Chart.GetLeftAxis()-SetMinMax(-10.0, 50.0); return TRUE; }几个参数的取舍值得说清楚。rc必须是父窗口的客户区坐标而 GetWindowRect 拿到的是屏幕坐标中间必须过一次 ScreenToClient漏了这一步控件会跑到屏幕左上角或者干脆看不见这是最高频的“创建成功但空白”原因。第三个参数nID是控件 ID自己定义一个#define或者用资源符号都行作用等同于普通子窗口 ID方便GetDlgItem拿句柄。至于为什么用成员对象而不是 new因为 MFC 窗口类在析构时会走 DestroyWindow成员对象生命周期跟对话框绑定不用操心泄漏用 new 就得记得在 OnDestroy 里 delete忘了就是内存泄漏。3.2 添加序列、喂数据与刷新控件有了接下来塞数据。ChartCtrl 的序列由枚举参数决定类型不直接 new 具体类void CChartDemoDlg::RebuildSeries(const std::vectordouble xs, const std::vectordouble ys) { if (xs.size() ! ys.size() || xs.empty()) return; // 1. 清掉旧序列。注意之前保存的 CChartSerie* 在这之后全部失效 m_Chart.RemoveAllSeries(); // 2. 建一条折线序列 CChartSerie* pSerie m_Chart.AddSerie(CChartSerie::csLine); if (pSerie nullptr) return; pSerie-SetColor(RGB(0, 120, 215)); // 线条颜色COLORREF 格式 pSerie-SetWidth(2); // 线宽像素为单位 pSerie-SetName(_T(温度)); // 图例里显示的名字 // 3. 批量灌点比逐点 AddPoint 快很多 pSerie-AddPoints(xs.data(), ys.data(), (size_t)xs.size()); // 4. 请求重绘。这一步只是打标记真正绘制在消息循环空闲时发生 m_Chart.RefreshCtrl(); }这段代码里有三个参数要盯住。csLine是序列类型枚举同族还有csBar柱状、csPoints散点选错画出来就是另一回事。SetWidth(2)的单位是像素跟显示器 DPI 无关高分屏上会显得偏细需要按 DPI 缩放系数自己乘一下。AddPoints的两个指针是double*如果你的数据是float或者int得先转成std::vectordouble再传直接强转指针类型会读出乱码数值曲线形状完全对不上。刷新时机也有讲究。RefreshCtrl()内部是 Invalidate会触发一次异步重绘。在循环里逐点调用它相当于每个点都请求一次重绘界面会卡到没法看。正确节奏是数据全部灌完调一次。3.3 坐标轴、网格与图例的参数化配置图表能不能看八成取决于坐标轴配得对不对。常用的配置项整理成一张表按需要挑配置项典型调用关键说明数值范围GetBottomAxis()-SetMinMax(0.0, 100.0)参数是数据值不是像素务必包住全部数据连续/离散轴GetBottomAxis()-SetDiscrete(false)浮点或时间轴必须关离散否则按整数等距排列网格可见GetGrid()-SetVisible(true)网格线跟随主轴刻度主刻度稀疏时网格也会稀疏图例可见GetLegend()-SetVisible(true)文字取自serie-SetName()没设名字就是空条目序列颜色serie-SetColor(RGB(r,g,b))多序列时手动配色控件不会自动错开颜色轴标题常见做法是在父窗口叠静态文本老版本坐标轴自带标题接口差异较大不强依赖坐标轴范围我一般这么处理遍历一遍数据算出 min/max然后上下各留 5% 的余量再 SetMinMax。留余量的目的是让曲线的峰值不贴着边框视觉上能看清拐点在哪如果贴着边最大值那个点会被坐标轴线的宽度压掉一半。SetDiscrete(false)这条特别要提。这组源码里离散轴的模式默认是开的用来画“第 1 次、第 2 次、第 3 次”这种整数刻度很直观但一旦 X 轴是时间戳比如 1690000000 这种量级离散模式会试图给每个整数值都排一个刻度结果要么编译不出刻度文本要么直接把绘制拖慢。画连续数据前先关离散是我养成的肌肉记忆。网格和图例都是子对象用之前必须先确保 Create 已经返回成功。在构造函数里就调GetLegend()是拿不到有效对象的因为那时窗口还没创建。初始化顺序永远是这个链条Create → 子对象配置 → 数据填充 → 刷新。4. 迁到 VS2015字符集、MFC 版本与 C11 的三处硬碰硬老控件源码能编译进 VS2015但不代表一点不改。这一章把迁移过程中会撞上的三类问题摊开每条都给可落地的处理方式。4.1 字符集与 TCHAR 相关报错最典型的现象是编译报 C2664无法将参数 2 从 const char [6] 转换为 LPCTSTR。原因是控件源码里混用了 ANSI 字符串字面量和 TCHAR 接口而 VS2015 工程默认字符集是 UnicodeLPCTSTR展开成const wchar_t*两种类型对不上。处理方式分两条路。如果工程本来是 Unicode新项目基本都是就改源码里的字符串全部用_T()包一层如果工程本来就是多字节那就在项目属性 → 配置属性 → 常规 → 字符集里选“使用多字节字符集”与源码保持一致。不要一半 Unicode 一半 ANSI混着来迟早出乱码。从 std::string 转过来的数据走显式转换别用 C 风格强转// 统一使用 TCHAR 版本避免在不同字符集下反复改代码 CString sTip; sTip.Format(_T(点数: %d), nCount); m_Chart.GetLegend()-SetVisible(true); // 外部传进来的 UTF-8 字符串显式转换别直接 (LPCTSTR) 强转 std::string utf8Text ...; CStringW strW(CA2W(utf8Text.c_str(), CP_UTF8));CA2W是 ATL 转换宏第二个参数指定源编码不指定的话按当前系统代码页解释中文环境下遇到 UTF-8 数据就会变问号。这类转换宏内部有栈上缓冲区不要把它返回的指针长期保存用完即转成 CString。4.2 MFC 版本、afx 头与链接顺序VS2015 对应 MFC 14.0_MSC_VER是 1900。老源码里常见的#if _MSC_VER 1300分支是为 VC6 准备的里面可能调用了后来被移除的 API编译时会报“未声明的标识符”。逐个检查这类版本判断把针对 VC6 的兼容代码整段删掉比一个个补声明快得多。链接期报 LNK2001 / LNK2019 时先查两件事一是所有 .cpp 是不是都加进项目了漏一个就会缺实现二是 afx 头的包含是否齐全。MFC 的头有包含顺序要求标准写法是在预编译头里按这个顺序// pch.h —— 顺序不能乱且必须在任何 windows.h 之前定义宏 #define NOMINMAX // 防止 windows.h 定义 min/max 宏与 std::min 冲突 #define _CRT_SECURE_NO_WARNINGS // 只关警告不解决 strcpy 越界本身的问题 #define _WIN32_WINNT 0x0601 // 目标平台 Windows 7 及以上 #include afxwin.h // MFC 核心必须最先 #include afxext.h // 扩展控件 #include afxcmn.h // 通用控件用到列表/树时需要NOMINMAX一定要放在 windows.h 之前否则不生效_WIN32_WINNT决定了一部分 API 的可用性设低了会在调用较新接口时报未定义。改完这些宏建议做一次完整重新生成别用增量编译避免旧的中间文件干扰判断。4.3 警告清理与 C11 兼容性VS2015 默认开 C14 的一部分特性老代码里几处写法会触发警告甚至语义变化register关键字被弃用报 C4996。直接删掉这个关键字现代编译器自己会做寄存器分配。auto的语义变了。老代码里auto常用作存储类说明符auto int iC11 之后是类型推导语义完全不同。看到auto修饰基本类型的地方一律删掉。min/max与 windows.h 的宏冲突报“宏参数不足”。解决方案就是上面那个NOMINMAX或者调用时写成(std::min)(a, b)加括号绕开宏展开。double 隐式转 int 报 C4244。这类警告在坐标计算里很常见别一股脑全关逐个加显式static_castint顺便检查一下是不是真的想截断。我一般的处理原则是C4996、C4244 这类逐个看改完再编只有确认无害的才用#pragma warning(disable:xxxx)局部关掉并且注明原因。整段关闭警告等于把后面真正的信号一起屏蔽了。5. 避坑与排查六个让曲线画不出来的真实现场这一章全是踩过的坑按“现象 → 原因 → 解决”记录遇到问题时可以直接对着排查。5.1 编译与初始化阶段的三个坑现象一Create 返回 TRUE但窗口里什么都没有。原因通常是坐标没转。GetWindowRect 取的是屏幕坐标直接传给 Create控件就被创建到屏幕坐标系的对应位置上相对对话框可能偏出去很远看起来就是“空白”。另一种可能是控件被同一区域里 Z 序更靠前的兄弟窗口盖住了。 解决确认调用过ScreenToClient再检查资源编辑器里占位控件的 Tab 顺序把图表控件用SetWindowPos(wndTop, ...)提到前面或者干脆在创建后ShowWindow(SW_SHOW)显式显示一次。现象二Unicode 工程下坐标轴中文标签显示成方块或问号。原因是控件内部创建字体时用了 ANSI 版本接口或者 LOGFONT 的lfCharSet没设成兼容值导致 GDI 找不到能渲染中文的字体。 解决在控件创建完成后自己构造一个 CFont 传进去lfCharSet设为DEFAULT_CHARSET字体名用一个系统里确定存在的中文 UI 字体同时确认字符串本身是宽字符_T(温度)而不是温度。现象三编译报 C1010提示是否忘记包含预编译头。原因是工程开了“使用预编译头”而控件源码里没有对应的 include。 解决选中控件相关的 .cpp右键属性 → C/C → 预编译头 → 改成“不使用预编译头”。改项目级设置会影响所有文件只改这几个文件更精准。5.2 运行与绘制阶段的三个坑现象四曲线末端被切掉或者第一个点看不见。原因是 SetMinMax 的范围没包住全部数据边界点落在绘图区外被裁剪区切掉。 解决喂数据前先扫一遍 min/max范围上下各留 5% 余量如果数据是动态增长的每次追加数据后重新 SetMinMax别只在初始化时设一次。现象五实时刷新时界面闪烁严重CPU 占用飙升。原因是每个数据点到达都调一次 RefreshCtrl重绘频率跟着数据频率走GDI 扛不住。 解决数据先攒在缓冲区里用定时器按 100~200ms 的节奏批量灌入再一次刷新另外检查 OnPaint 里有没有反过来调用 RefreshCtrl那会形成重绘递归界面直接卡死。现象六Debug 下跑得好好的Release 一跑就崩。两个高发原因一是成员变量没在构造函数里初始化Debug 模式下栈上恰好是 0Release 下是随机值二是数据里出现 NaN 或除零坐标变换算出的像素值溢出GDI 调用异常。 解决构造函数里给所有数值成员显式赋初值AddPoint 之前做一次有限性检查std::isfinite发现异常值就跳过或者替换成前一个有效值别让脏数据流进绘制层。这六条里前三条出现频率最高也最容易在别人的老工程里复现。排查顺序建议固定先确认控件真的创建成功且可见再确认数据真的送进去了最后才怀疑绘制逻辑。反过来查往往在绘制代码里绕半天问题其实在第一步。6. 三万点不卡的进阶做法抽稀、增量刷新与导出位图曲线点数一多最先崩的是重绘耗时。GDI 画三万个 LineTo单帧就得几十毫秒加上定时刷新界面立刻拖影。我的处理办法是画之前先抽稀按控件像素宽度决定保留多少点每个像素列只保留该列内的最大值和最小值两个点形态上的尖峰不会被抹掉点数却能压到屏幕宽度的两倍以内。// 按像素列做 min/max 抽稀targetPoints 一般取绘图区宽度的 1~2 倍 static void DecimateMinMax(const std::vectordouble xs, const std::vectordouble ys, size_t targetPoints, std::vectordouble ox, std::vectordouble oy) { ox.clear(); oy.clear(); if (xs.size() targetPoints || targetPoints 4) { ox xs; oy ys; return; } size_t bucket xs.size() / (targetPoints / 2); // 每桶点数至少为 1 if (bucket 0) bucket 1; for (size_t i 0; i xs.size(); i bucket) { size_t end (std::min)(i bucket, xs.size()); size_t iMin i, iMax i; for (size_t k i; k end; k) // 桶内找极值 { if (ys[k] ys[iMin]) iMin k; if (ys[k] ys[iMax]) iMax k; } // 按 x 顺序输出保证折线不乱连 if (iMin iMax) { ox.push_back(xs[iMin]); oy.push_back(ys[iMin]); ox.push_back(xs[iMax]); oy.push_back(ys[iMax]); } else { ox.push_back(xs[iMax]); oy.push_back(ys[iMax]); ox.push_back(xs[iMin]); oy.push_back(ys[iMin]); } } }targetPoints这个参数直接决定观感取绘图区像素宽度的 1 倍曲线细节刚好取 2 倍以上肉眼已经分辨不出和原始数据的差别只是白费 GDI 调用。bucket算出每桶点数后桶内同时找最小和最大两个点按 x 顺序入队这一步不能省否则折线会在桶内来回穿插画出来是一团锯齿。导出位图是另一个常用需求用来塞进报告或者发邮件。不用截屏直接让控件往内存 DC 上打一份void CChartDemoDlg::ExportChart(CDC memDC, int cx, int cy) { // PRF_CLIENT 只绘制客户区不会带上窗口边框和标题 m_Chart.SendMessage(WM_PRINTCLIENT, (WPARAM)memDC.GetSafeHdc(), PRF_CLIENT); }调用方自己建兼容位图、选进内存 DC、设好尺寸再调这个函数最后用 CImage 或 GDI 存成文件。注意PRF_CLIENT这个标志用PRF_WINDOW会把非客户区也画进来导出的图会多一圈边框。从那以后我每次接老 MFC 工程都强制走一遍这个流程先只挂源码编译通过再画一条静态曲线确认坐标系没问题最后才接实时数据。跳过中间任何一步后面就得花三倍时间回头定位。希望帮到你。本文还有配套的精品资源点击获取