简介:这是一套面向C++开发者的工业曲线图绘制控件及完整实例工程,适合需要数据可视化的工业监控、设备调试和科学分析场景,核心解决在非GUI原生环境下借助Qwt等第三方库完成数据预处理、坐标映射、曲线渲染与缩放交互等问题。压缩包共36个文件,以11个h头文件和8个cpp源文件为主,辅以lib库、dll、ico图标、bmp位图等项目资源,包体仅949KB,结构清晰,便于直接编译与二次开发。已有237人学习下载。工程除核心曲线绘制模块外,还包含ToolTipWnd提示窗口、FaderWnd窗口渐隐、CoolControlsManager控件管理及皮肤换肤等功能,可帮助开发者快速构建工业级交互界面。结合PEGRP32C库与示例代码,读者能直观理解Qwt控件的应用流程,掌握曲线图从数据读取到动态刷新、交互操作的完整实现思路。
1. 工业上位机里,C++曲线图控件为什么容易翻车
工业上位机里,C++写的曲线图控件是最容易翻车的模块之一。现场数据实时刷新、历史数据回放、量程越限、鼠标缩放这几件事叠加在一起,通用图表控件根本接不住;要么是刷新时闪烁,要么是坐标轴数据一多就计算不过来。这套VC6工程把绘制、交互、皮肤三块拆开,曲线绘制交给PEGRP32C控件,悬停提示用ToolTipWnd,窗口动画用FaderWnd,界面皮肤用SkinMagic配合DETOURS接管。资源里带完整可编译的.dsw/.dsp、皮肤文件和PEGRP32C.DLL,适合正在维护MFC老项目的人拆开读,也适合刚接到“做个工业曲线图”任务的开发者抄作业。如果你的目标平台还是Windows,这套代码里的双缓冲、坐标逆映射、消息分发思路今天依然可以用。
2. PEGRP32C与VC6工程文件的分工和初始化链路
拿到包后,我用Beyond Compare扫了一遍目录。Graphic.dsw是VC6工作区,Pegrpapi.h是曲线库的对外头文件,Tusk.smf是皮肤描述文件。整个工程不是把曲线代码堆进View,而是把绘制任务拆给了PEGRP32C这个动态库,View只负责组装数据。下面从文件角色讲初始化链路。
2.1 解包文件角色分工
| 文件 | 角色 |
|---|---|
| GraphicView.cpp / GraphicView.h | 曲线视图、鼠标交互、定时器刷新 |
| GraphicDoc.cpp / GraphicDoc.h | 数据源管理和预处理 |
| MainFrm.cpp / MainFrm.h | 主框架窗口、菜单与工具栏布局 |
| ToolTipWnd.cpp / ToolTipWnd.h | 鼠标悬停提示的顶层小窗口 |
| FaderWnd.cpp / FaderWnd.h | 淡入淡出动画窗口 |
| CoolControlsManager.cpp / CoolControlsManager.h | 按钮等控件的自绘管理 |
| Graphic.cpp / Graphic.h | 应用类入口与初始化 |
| Pegrpapi.h / PEGRP32C.lib / PEGRP32C.DLL | 曲线绘制库接口与运行时 |
| SkinMagicLib.h / SkinMagicLib.lib | 界面皮肤加载库 |
| DETOURS.lib | 钩子库,用于截获系统窗口绘制 |
| Tusk.smf | 皮肤描述文件 |
| Graphic.rc / res | 资源脚本、图标、工具栏位图 |
PEGRP32C.DLL是运行时依赖,必须放在exe目录或者PATH里。DETOURS.lib是静态库,链接进程序,不需要额外DLL。CoolControlsManager从名字看是自绘控件管理器,把按钮、工具栏的绘制统一接管。这样划分有一个好处:换肤时只动界面层,不影响曲线库。
2.2 从App到View的曲线窗口创建
在MFC程序里,CGraphicApp的InitInstance会在单文档模式下创建MainFrame,MainFrame通过CreateView创建GraphicView。GraphicView的OnCreate是曲线出现的起点。我按这套代码的典型流程写了最小版本:
int CGraphicView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) == -1) return -1; // 创建一个子窗口作为曲线宿主 // 第二个参数是窗口类名,PEGRP32C 可能已经注册同名类 if (!m_wndCurveHost.Create(NULL, _T("CurveHost"), WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS, CRect(0, 0, 0, 0), this, 0x1010)) { TRACE("Create CurveHost failed\n"); return -1; } // 初始化曲线库:设定默认背景和初始坐标范围 // 具体函数名以 Pegrpapi.h 实际导出为准 ::PgInitialize(m_wndCurveHost.GetSafeHwnd()); ::PgSetBackColor(m_wndCurveHost.GetSafeHwnd(), RGB(0, 0, 0)); return 0; }Create的第四参数先用(0,0,0,0),后续在OnSize里把宿主窗口铺满整个客户区。WS_CLIPSIBLINGS是必加的,否则同级窗口在重绘时会互相覆盖造成闪烁。0x1010是控件的子窗口ID,用于在消息反射中区分PEGRP32C发来的通知。PgSetBackColor是背景色接口,黑色背景适合工业屏幕。
2.3 皮肤和钩子:SkinMagic与DETOURS的配合
SkinMagic的典型用法是加载一个.smf文件并设置皮肤窗口。DETOURS在这里的作用不是反调试,而是钩住系统按钮和菜单的绘制函数,让皮肤库能接管非自绘控件。我当时调试这个工程时,把DETOURS.lib从工程里移除后,工具栏按钮全部变回灰色凸起,曲线区没影响,这验证了它只作用于界面层。
BOOL CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CFrameWnd::OnCreate(lpCreateStruct) == -1) return -1; // 加载皮肤文件,Tusk.smf 要和工作目录相对 SkinMagic_LoadSkin("Tusk.smf"); // 让 SkinMagic 接管指定窗口的按钮绘制 // 这个函数内部会用到 Detours 做子类化 SkinMagic_Attach(m_hWnd, "+toolbar,menubar"); return 0; }SkinMagic_LoadSkin失败时会返回一个错误码,现场部署常因为工作目录不对导致皮肤加载失败,最稳妥的是把.smf的绝对路径写入配置文件。SkinMagic_Attach的第二个参数是风格列表,用逗号分隔控制项。
2.4 编译链接依赖
| 库文件 | 类型 | 链接说明 |
|---|---|---|
| PEGRP32C.lib / PEGRP32C.DLL | 动态库导入库 | 运行时将DLL放到exe目录 |
| SkinMagicLib.lib | 静态库 | 链接后还需要包含路径 |
| DETOURS.lib | 静态库 | 配合SkinMagic使用 |
VC6下默认使用单线程或多线程调试库,如果你拿到的是Release版lib,需要确保项目设置的_MT匹配,否则链接会报不相容。函数调用约定也要确认,PEGRP32C这类面向C的库通常使用__stdcall,而MFC默认是__cdecl,遇到LNK2019时需要检查头文件里有没有WINAPI或CALLBACK声明。
3. 曲线绘制核心:数据预处理、坐标映射与双缓冲
绘制性能瓶颈通常不在画点,而在数据预处理和坐标转换。工业数据可能是非等间隔、带毛刺、量程跳变的,直接塞给曲线控件会出现曲线出界或锯齿严重。
3.1 工业数据的插值和抽稀
// 工业数据往往不是等间隔的,常见做法是先按采集时间排序 // 然后线性插值到统一的采样周期,避免曲线出现锯齿 void CMainFrame::PrepareCurveData(const std::vector<float>& rawTs, const std::vector<float>& rawVal, double samplePeriod, std::vector<double>& outTs, std::vector<double>& outVal) { outTs.clear(); outVal.clear(); if (rawTs.empty()) return; // 找到有效时间范围 double t0 = rawTs.front(); double t1 = rawTs.back(); int n = static_cast<int>((t1 - t0) / samplePeriod) + 1; outTs.reserve(n); outVal.reserve(n); size_t segment = 0; for (int i = 0; i < n; ++i) { double target = t0 + i * samplePeriod; while (segment < rawTs.size() - 2 && rawTs[segment + 1] < target) ++segment; double dt = rawTs[segment + 1] - rawTs[segment]; double ratio = (dt == 0.0) ? 0.0 : (target - rawTs[segment]) / dt; outVal.push_back(rawVal[segment] + ratio * (rawVal[segment + 1] - rawVal[segment])); outTs.push_back(target); } }这个函数做等间隔线性插值。samplePeriod由实际采集周期决定,设备是100ms就传0.1。注意dt == 0.0必须保护,否则浮点除零。如果原始点太密不建议插值,直接抽稀更高效。工业开关量信号不能用线性插值,应该用阶梯线,否则会在跳变沿产生虚假的中间值。
3.2 坐标轴范围:固定量程与自适应量程的取舍
工业曲线图有两种模式:实时滚动只显示最近一分钟,全览显示整个批次。自适应量程会让曲线看起来“很满”,但操作员无法判断绝对数值变化,所以我更常做双量程:默认自动,超过设定阈值后自动切到固定量程。
void CGraphicView::UpdateAxis(double newMin, double newMax) { // 设置X轴范围 CRect rcClient; GetClientRect(&rcClient); // 这里调用PEGRP32C的坐标范围接口,具体名字以Pegrpapi.h为准 ::PgSetAxisRange(m_wndCurveHost.GetSafeHwnd(), PgAxisX, newMin, newMax, TRUE); // 保留15%的余量,方便看图 double span = newMax - newMin; if (span < 1e-9) span = 1.0; // 防止除零 ::PgSetAxisRange(m_wndCurveHost.GetSafeHwnd(), PgAxisY, newMin - 0.15 * span, newMax + 0.15 * span, TRUE); }PgAxisX和PgAxisY是枚举,最后一个参数TRUE表示自动重新计算刻度。Y轴余量固定15%在非线性量程下会造成刻度不好认。我一般会把Y轴刻度间隔设为量程的1/5或1/10,同时把网格绘制绑定在刻度值上。
3.3 关键绘制参数表
| 参数 | 建议值 | 说明 |
|---|---|---|
| 线宽 | 1-2像素 | 工业图线条不要过粗,数据密集时会糊 |
| 颜色 | 与背景对比度大于3:1 | 白底用#1E40AF,深色底用#60A5FA |
| 网格线 | 虚线,低饱和度 | 实线网格会干扰读数 |
| 抗锯齿 | 开启 | 但高帧率数据采集时关闭抗锯齿能省30%内存带宽 |
| 标尺 | 显示最后点数值 | 操作员需要“当前值”而不是看Y轴估 |
代码调用示例:
void CGraphicView::DrawCurveSeries(const std::vector<double>& ts, const std::vector<double>& val) { if (ts.empty() || val.empty() || ts.size() != val.size()) return; int id = 0; // 曲线ID ::PgBeginUpdate(m_wndCurveHost.GetSafeHwnd()); // &ts[0] 和 &val[0] 要求容器非空,已经由上方判断保证 if (::PgSetCurveData(m_wndCurveHost.GetSafeHwnd(), id, &ts[0], &val[0], (int)ts.size()) < 0) { // 数据长度或数据指针不合法时,控件会返回负值 TRACE("PgSetCurveData failed\n"); } ::PgSetCurveStyle(m_wndCurveHost.GetSafeHwnd(), id, (id % 2 == 0) ? PgLineStyleSolid : PgLineStyleDash); ::PgEndUpdate(m_wndCurveHost.GetSafeHwnd()); }PgBeginUpdate和PgEndUpdate必须成对调用,批量操作期间控件不会重绘,等EndUpdate后再统一渲染。曲线ID从0开始,最多支持多少条取决于PEGRP32C编译时的定义。注意在VC6里vector::data()不可用,用&ts[0]才能编译通过。
3.4 双缓冲与刷新节流
MFC的OnDraw里不要直接画大量数据。常见做法是利用控件内部双缓冲,View只负责把设备上下文传给控件。高频数据更新时,用SetTimer控制刷新频率在20-30Hz,不要每个数据点都立即重绘。
void CGraphicView::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == kCurveRefreshTimer) { // 每次定时器只取当前缓冲区的最后N个点 // 在夜里或高采样下,抽帧比全量重绘要平滑得多 if (m_pDoc && m_pDoc->HasNewData()) { std::vector<double> ts, val; m_pDoc->GetLatestSeries(5000, ts, val); // 只取最近5000个点 DrawCurveSeries(ts, val); } } CView::OnTimer(nIDEvent); }5000是一个经验值。点数过多时曲线被压缩在像素级,肉眼无法分辨,反而浪费CPU。工业组态软件里常见做法是变精度采样:放大时用全部点,缩小时按屏幕宽度均匀抽点。
4. 交互层:ToolTipWnd、FaderWnd和缩放定位
光能画不能看是关键。工业操作员需要悬浮提示、窗口切换动画、框选缩放。这层最容易出bug的是坐标转换方向,Windows的客户坐标Y轴向下,数学坐标Y轴向上,一旦混淆,所有鼠标相关的功能都会错位。
4.1 用ToolTipWnd把像素坐标映射成数据坐标
ToolTipWnd实现了一个顶层小窗口。关键思路是捕获View的MouseMove,把鼠标位置换算成数据坐标,然后让ToolTip窗口显示。
BOOL CGraphicView::OnMouseMove(UINT nFlags, CPoint point) { // 客户区内的点,坐标原点在左上角,而曲线坐标原点在左下角 double dataX, dataY; ::PgScreenToData(m_wndCurveHost.GetSafeHwnd(), point.x, point.y, &dataX, &dataY); CString tipText; tipText.Format(_T("t=%.2fs V=%.3fV"), dataX, dataY); m_tooltip.Show(tipText, point, 3000); // 3秒后自动隐藏 return CView::OnMouseMove(nFlags, point); }坐标转换是关键接口:
| 接口 | 方向 | 典型用途 |
|---|---|---|
| PgScreenToData | 屏幕像素 → 数据坐标 | 鼠标悬停、框选缩放 |
| PgDataToScreen | 数据坐标 → 屏幕像素 | 画十字光标、定位报警点 |
m_tooltip.Show内部会调用SetWindowPos调整Z序,避免被曲线窗口盖住。ToolTipWnd要设置WS_EX_TOPMOST和透明背景,否则会遮住旁边数据。
4.2 FaderWnd的逐帧淡入淡出
FaderWnd.cpp用SetLayeredWindowAttributes实现透明度动画。工业HMI里,报警弹窗和参数变化提示用淡入淡出比硬弹窗更自然,不容易让操作员感觉被打断。
void CFaderWnd::FadeIn(int ms) { // 先设置WS_EX_LAYERED扩展样式 SetWindowLong(m_hWnd, GWL_EXSTYLE, GetWindowLong(m_hWnd, GWL_EXSTYLE) | WS_EX_LAYERED); BYTE alpha = 0; const BYTE maxAlpha = 200; const DWORD step = max(1, ms / 30); // 30帧 while (alpha < maxAlpha) { alpha = min(alpha + 16, maxAlpha); SetLayeredWindowAttributes(m_hWnd, 0, alpha, LWA_ALPHA); Sleep(step); // 用Sleep会导致UI卡顿,正式项目应放在定时器里 MSG msg; while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } } }这段代码故意用Sleep演示原理,实际项目要改成定时器逐帧推进,否则拖动窗口时会有明显掉帧。SetLayeredWindowAttributes的关键点是:窗口一旦设置成分层窗口,BeginPaint会触发额外内存拷贝,所以FaderWnd只用于小尺寸提示窗,不要对主曲线视图开启。
4.3 框选缩放和十字光标
缩放是曲线图最核心的交互。常见做法是OnLButtonDown记录起点,OnMouseMove画虚线框,OnLButtonUp时把框选区域映射为新的坐标范围。
void CGraphicView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bZooming) { CRect rc(m_zoomStart, point); rc.NormalizeRect(); double zoomX0, zoomY0, zoomX1, zoomY1; ::PgScreenToData(m_wndCurveHost.GetSafeHwnd(), rc.left, rc.top, &zoomX0, &zoomY0); ::PgScreenToData(m_wndCurveHost.GetSafeHwnd(), rc.right, rc.bottom, &zoomX1, &zoomY1); // 禁止把宽度或高度缩成0,否则控件内部会除零 if (fabs(zoomX1 - zoomX0) < 1e-9 || fabs(zoomY1 - zoomY0) < 1e-9) { m_bZooming = FALSE; return; } // 用新的数据范围重设坐标轴 UpdateAxis(zoomX0, zoomX1); m_bZooming = FALSE; } CView::OnLButtonUp(nFlags, point); }缩放后要同时更新网格刻度和工具提示的坐标转换。不要只在View里存一个新范围,必须调用控件的坐标轴设置接口,否则后续MouseMove会拿旧范围映射,出现“鼠标位置对不上曲线”的问题。十字光标实现类似:在View客户区画两条交叉线,需要保存上一帧光标位置并Invalidate掉,否则会留下残影。
5. 从示例到产品:运行时依赖与Qwt迁移路线
5.1 部署老工程需要的运行时项
这套VC6工程编译出的exe在Windows 10/11上通常能直接跑,但如果你是拿到源码重新编译,目标机器需要装有对应的Microsoft Visual C++ Redistributable。PEGRP32C.DLL和Tusk.smf必须和exe在同一目录,建议用相对路径读取皮肤文件,并增加失败提示。
5.2 替换到Qwt/QCustomPlot时的模块映射
如果不想继续依赖闭源的PEGRP32C.DLL,迁移到Qwt或QCustomPlot是常见路径。模块对应关系如下:
| 本工程模块 | Qwt替代方案 |
|---|---|
| PgSetCurveData | QwtPlotCurve::setSamples |
| PgSetAxisRange | QwtPlot::setAxisScale |
| ToolTipWnd | QwtPicker + QwtPlotPicker |
| FaderWnd | QPropertyAnimation + QGraphicsOpacityEffect |
| SkinMagic / DETOURS | QSS样式表,无需钩子 |
迁移时最花时间的不是替换绘制接口,而是消息分发逻辑。MFC里鼠标坐标通过OnMouseMove拿到,Qwt里则用QwtPicker回调,坐标转换方向要从“屏幕到数据”改为Qwt的invTransform。
5.3 用QueryPerformanceCounter验证重绘耗时
迁移或压测时,我习惯用QueryPerformanceCounter测量单帧耗时。构造60000个点,循环调用重绘100次,如果平均单帧超过300ms,就需要先抽稀再绘制:
LARGE_INTEGER freq, start, end; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&start); // 循环调用DrawCurveSeries for (int i = 0; i < 100; ++i) pView->DrawCurveSeries(ts, val); QueryPerformanceCounter(&end); double avgMs = (end.QuadPart - start.QuadPart) * 1000.0 / freq.QuadPart / 100; TRACE("avg %.2f ms/frame\n", avgMs);如果测出来的速度和预期差很多,先检查是否在OnDraw里做了插值,再把插值结果缓存起来。实际项目中,曲线绘制卡顿大多是刷新节流没做好,而不是最后一个画点的API太慢。
本文还有配套的精品资源,点击获取