ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++工业上位机曲线图控件开发:从MFC到PEGRP32C的完整实践

C++工业上位机曲线图控件开发:从MFC到PEGRP32C的完整实践 简介这是一套面向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会在单文档模式下创建MainFrameMainFrame通过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::vectorfloat rawTs, const std::vectorfloat rawVal, double samplePeriod, std::vectordouble outTs, std::vectordouble outVal) { outTs.clear(); outVal.clear(); if (rawTs.empty()) return; // 找到有效时间范围 double t0 rawTs.front(); double t1 rawTs.back(); int n static_castint((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::vectordouble ts, const std::vectordouble 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::vectordouble 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替代方案PgSetCurveDataQwtPlotCurve::setSamplesPgSetAxisRangeQwtPlot::setAxisScaleToolTipWndQwtPicker QwtPlotPickerFaderWndQPropertyAnimation QGraphicsOpacityEffectSkinMagic / DETOURSQSS样式表无需钩子迁移时最花时间的不是替换绘制接口而是消息分发逻辑。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太慢。本文还有配套的精品资源点击获取
返回列表