拓冰建站拓冰建站
首页 / 资讯中心 / 正文

MFC ActiveX控件实战:工控画面实时曲线与柱状图绘制

简介基于MFC ActiveX开发的曲线、折线、柱状图绘制控件专为工控行业软件开发者与MFC二次开发人员提供用来在Windows应用中快速集成数据可视化、实时监控和报表展示能力。压缩包共158个文件约2.32MB包含核心控件源码h/cpp、编译生成的ocx/dll/lib文件、VS工程与解决方案sln/vcxproj以及bmp/ico/cur等界面资源随附pdf/docx/md说明文档和示例项目便于对照编译、调用和二次扩展。包内结构覆盖从源码到可运行控件的完整链路能帮助理解MFC ActiveX绘图控件的实现机制开发者可修改颜色、线条样式、坐标轴格式等外观和行为满足不同项目的定制化需求。目前已有85人学习下载适合需要从零集成三类图表、注重实时性与稳定性的工控项目可显著缩短图表功能的开发周期。1. 为什么工控画面仍然绕不开 MFC ActiveX 绘制控件在 DCS 操作员站、老式上位机和组态工程里实时趋势、历史曲线和柱状图至今大量以 ActiveX 控件的形式嵌在画面中。原因很现实现场执行环境绝大多数是 Windows 工业版画面容器是 WinCC、组态王这类按 OLE/COM 扩展的软件它们对 Web 图表和 Qt 插件的接纳很慢而对实现了 IDispatch 的 ActiveX 控件可以做到“放进去就能用”——脚本直接调 AddPoint属性页里配颜色画面存盘时控件状态跟着工程走。真正难的从来不是把曲线画出来而是数据缓冲、双缓冲、模式切换和部署注册这些工程化细节。本文从控件框架搭建讲到实时曲线绘制再展开折线柱状图的模式设计与事件回调最后收在注册、安全与 GDI 泄漏这几个长时间运行时最容易踩的坑上适合要维护存量工控画面或者给 DCS 做扩展组件的嵌入式软件工程师。2. 用 MFC ActiveX 向导搭建控件框架先让自绘窗口不闪屏2.1 为什么选 COleControl 而不是自己写 CWndActiveX 控件本质上是 COM 服务加窗口的组合MFC 的 COleControl 已经完成了这两者的黏合。它内部实现了 IUnknown、IDispatch、IPersistStreamInit、IPropertyPageSite 等 COM 接口的样板代码也把 WM_PAINT、WM_ERASEBKGND 等消息自动路由到 OnDraw。如果从普通 CWnd 子类自己做起接口表、类型库、调度映射、属性持久化、事件源全都要手写一个绘图控件光 COM 代码就占掉一大半后续维护成本非常高。选 MFC 而不是 ATL 的理由也很直接工控画面上的控件数量一般只有十几个不需要 ATL 那种极致精简的对象模型MFC 的 CDC、CPen、CBrush、CString、定时器和对话框资源处理更贴近“画图”这个需求。ATL 写控件更适合无窗口、轻交互的 UI 元素而带实时曲线的控件要处理鼠标框选缩放、双缓冲绘制、属性页、事件触发这些在 MFC 里都有现成支持踩坑记录也多遇到问题容易找到参考。2.2 向导生成的代码里真正要动的是 OnDraw从 Visual Studio 的 MFC ActiveX ControlWizard 生成工程后第一个要改的就是 OnDraw。ActiveX 控件被容器刷新时系统按需调用 OnDraw传入的设备上下文可能是屏幕 DC也可能是打印或图元文件 DC。最小可用的实现是先把双缓冲和背景擦除解决掉void CPlotCtrl::OnDraw(CDC* pdc, const CRect rcBounds) { // rcBounds 是控件边界打印、图元文件里也应使用它而不是 GetClientRect CMemDC memDC(*pdc, rcBounds); CDC dc memDC.GetDC(); dc.FillSolidRect(rcBounds, m_crBackground); // 网格、曲线、柱体、游标文本统一放到 DoRender DoRender(dc, rcBounds); } BOOL CPlotCtrl::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 拦截默认擦背景由 OnDraw 双缓冲统一输出 }逻辑说明CMemDC 是 VS2008 之后 MFC 自带的内存 DC 包装类构造时创建兼容位图析构时把内容一次性 BitBlt 回目标 DC。OnEraseBkgnd 返回 TRUE 是消除闪屏的关键——曲线按 50ms 周期刷新如果不拦窗口会先擦成白底再画新帧在工控大屏上就是明显的横向水纹闪动。OnDraw 里直接用 rcBounds 作为绘图范围是因为控件未激活或被打印时没有窗口句柄调用 GetClientRect 会得到全零矩形。提示如果工程还停留在 VC6 或 VS2005CMemDC 不可用需要手写 CreateCompatibleDC、CreateCompatibleBitmap、SelectObject、BitBlt 四件套核心思路一致。另一个老坑是 OnDraw 里不要再 new 一个 CPaintDC。控件活跃时系统已缓存 DC自己取反而会和 OLE 的绘帧机制抢消息轻则画面不更新重则 GDI 句柄泄漏。三种刷新方式对工控画面最直观的区别在闪烁和 CPU 占用上见下表。刷新方式闪烁CPU用途直接 FillSolidRect 后画线明显低静态对比图不适合实时刷新整窗双缓冲 Invalidate(FALSE)无中50ms 实时趋势最常用局部 InvalidateRect 双缓冲无低游标跟踪、框选缩放实时刷新用整窗双缓冲已经足够游标拖动或框选缩放时改局部刷新。一米宽的操作员站上如果每次整窗重绘所有轴文字和底图跟着闪视觉上很难接受。2.3 定时刷新和数据来源不是一条线程实时曲线的数据频率由采集系统决定画面刷新频率由定时器决定两者不该绑在同一个函数里。常见做法是控件持有一个环形缓冲外部通过 AppendData 追加数据OnTimer 只负责让窗口失效真正的重绘发生在 WM_PAINT 流程中。数据到达的线程可能是容器脚本线程也可能来自独立采集线程所以追加时要用临界区保护。void CPlotCtrl::AppendData(double dVal) { CComAutoCriticalSection guard(m_csData); // 作用域结束时自动解锁 m_bufHead (m_bufHead 1) % TREND_POINT_COUNT; m_data[m_bufHead] dVal; m_nPoints; if (m_nPoints TREND_POINT_COUNT) m_nPoints TREND_POINT_COUNT; } void CPlotCtrl::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent kTimerRefresh) Invalidate(FALSE); // 只做无效化不在这里画 COleControl::OnTimer(nIDEvent); }参数说明TREND_POINT_COUNT 建议 4096 起步能覆盖 250ms 采样周期下约 17 分钟的数据窗口低配工控机每帧画 16K 个设备点会明显掉帧所以不是越大越好。m_bufHead 是覆盖写位置环形满时丢最旧点画线时从 m_bufHead 倒序取 nPoints 个点转设备坐标。锁用 CComAutoCriticalSection 而不是 MFC 的 CCriticalSection它开销小进程退出时也不容易因为句柄顺序问题报 0xC0000005。OnTimer 内不要直接画图画图交给 WM_PAINT 线程否则定时器和 OnDraw 各画一遍CPU 直接翻倍。3. 实时曲线环形缓冲、自动量程与滚动重绘的核心算法3.1 逐点追加与批量装载两个都要有工控上位机取数分两类过程量实时采集用逐点 AddPoint历史曲线查询是批量读一段 OPC 记录。逐点方式保持最小接口批量方式避免脚本在容器里写 for 循环一个个调尤其是几百个点以上的取数跨 COM 边界调用一次的开销远大于传递数组本身。调度映射写好后两者都要暴露给脚本引擎。DISP_FUNCTION_ID(CPlotCtrl, AddPoint, 1, AddPoint, VT_EMPTY, VTS_R8) DISP_FUNCTION_ID(CPlotCtrl, SetDataArray, 2, SetDataArray, VT_EMPTY, VTS_VARIANT)容器脚本侧的使用方式plot.SetSamplePeriod(100); // 每 100ms 一个点 plot.SetAutoRange(true); // 自动量程 plot.AddPoint(23.5); // 每个采集周期追加一次 plot.SetDataArray(rawValues); // 历史查询时一次喂入数组逻辑说明AddPoint 只传数值不传时间戳时间轴由控件按 SetSamplePeriod 计算这符合工控等间隔采样的习惯也省去每次跨进程传一个 FILETIME 的开销。如果数据确实是不等间隔的就得在内部存双时间的点对再按时间索引做查询复杂度会增加一档没有特殊要求不建议默认实现。量程上自动量程不要直接用窗口内的最大值和最小值。一个干扰毛刺会让量程瞬间跳到异常范围曲线被压成一条平线操作员第一反应是“系统坏了”。常见做法是统计窗口内的 95% 分位或者对极值做一阶惯性滤波量程变化平滑曲线不会出现突然“压扁”的视觉跳变。3.2 把采样点变成屏幕像素的坐标变换坐标变换是绘图的公共基础实时曲线、折线图、柱状图都要复用。先定一个有效绘图区 m_rcPlot左边留刻度文本、下边留时间轴、右边留最新值显示在 OnSize 里算一次缩放和游标操作都基于它。变换函数要单独抽出来不要在 OnDraw 里散落写加减乘除。CPoint CPlotCtrl::TransformXY(double dVal, int nIndex) { // nIndex 是可视区第几个点从 0 到 m_nVisible-1 int cx m_rcPlot.Width(); int cy m_rcPlot.Height(); double range m_dMaxY - m_dMinY; if (range 0) range 1.0; // 防止除零 int px m_rcPlot.left ::MulDiv(nIndex, cx, m_nVisible - 1); int py m_rcPlot.bottom - (int)((dVal - m_dMinY) / range * cy); return CPoint(px, py); }参数说明px 用 MulDiv 而不是先乘后除是因为当可视点数到 4096、窗口宽度到 1920 时乘积接近 786 万int 内不溢出但老工控工程里 16-bit int 环境并不少见MulDiv 内部用 64 位中间值保底稳妥。py 必须用 bottom 减屏幕 y 轴向下而数值 y 轴向上方向写反所有数据会倒过来。调用前要保证 m_nVisible 2否则除数为零。网格刻度线按 1、2、5 的十次幂切分而不是固定 10 等分。用 NiceScale 算法算出刻度步长后取整量程 0.3 到 0.9 时步长自动落在 0.2刻度值就是 0.4、0.6、0.8 这种容易读的数操作员隔两米看画面也能一眼读出数值。固定 10 等分出来的 0.36、0.48 这种刻度在现场根本没人愿意看。3.3 局部重绘、框选缩放与游标交互实时刷新时最容易踩的性能坑是每帧整窗 Invalidate(FALSE)。曲线数据区变化本身不大但左右刻度文本、底部时间轴会跟着重绘高频刷新下 CPU 占用高字体还会轻微抖动。优化做法是游标和缩放这类高频交互时只 InvalidateRect 数据区让轴文本保持稳定。void CPlotCtrl::OnMouseMove(UINT nFlags, CPoint point) { if (m_rcPlot.PtInRect(point)) { m_bCursorActive TRUE; m_ptCursor point; InvalidateRect(m_rcPlot, FALSE); // 只刷数据区不刷坐标文本 } else if (m_bCursorActive) { m_bCursorActive FALSE; InvalidateRect(m_rcPlot, FALSE); } COleControl::OnMouseMove(nFlags, point); }游标激活后别在每次 mousemove 里 new 新的画笔和字体。十字线画笔、数值提示背景这些 GDI 对象在 OnCreate 里建好移动时只改位置再重绘。工控机尤其是无风扇小主机 CPU 不强高频 mousemove 下每次 new CPen 会让句柄数缓慢上涨跑几天后画面出现花块这是 GDI 泄漏最典型的前兆。框选缩放用右键拖拽画矩形OnMouseMove 里实时更新拖拽区域WM_LBUTTONUP 时依据矩形反变换出新的 Y 轴量程双击还原。实际经验是拖拽过程必须 SetCapture否则指针拖出控件区会丢 WM_LBUTTONUP矩形停在半空后续交互状态全乱。游标读数不要只放状态栏控件嵌在组态画面里不一定有状态栏更稳的做法是在数据区右上角画一个小背景矩形显示当前时间和数值截图也能看见。3.4 双缓冲画线还是引入 GDI实时曲线用 GDI 直接 Polyline 就够了。GDI 的反走样对曲线边缘确实是质变但 Graphics 对象和 Pen 对象每帧创建再释放在 50ms 刷新周期上 CPU 会明显升高。折线图的轴文字和柱状图的渐变背景更适合用 GDI 预先渲染一幅位图缓存数据区仍用 GDI 更新既保留反走样的静态元素又保证实时部分的性能。柱状图渐变也推荐预生成一张 8 位渐变位图用 StretchBlt 拉伸而不是每根柱子建 LinearGradientBrush。绘制方式锯齿每帧开销适合GDI Polyline轻微锯齿低实时曲线、历史折线GDI AntiAlias平滑中高静态轴、离线图元预渲染位图 StretchBlt平滑低柱状渐变、背景网格把轴文字和网格预渲染到位图里数据部分用双缓冲 GDI 画是开销和观感最平衡的方案不建议一上来就把整个控件切成 GDI。GDI 的 Pen 在抗锯齿模式下线宽小于 1 像素时会被忽略报警虚线画出来可能变成实线现场画面里明显的虚线缺失比锯齿更影响判读。4. 折线、柱状图模式切换与属性页持久化4.1 用一个 DrawMode 属性管理三种图形曲线是实时滚动折线图面对的是历史数据整体柱状图则面向产量统计这类分组数据。用同一个控件按枚举属性切换模式比做三个独立 ActiveX 控件对组态工程维护更友好。工控行业常见做法是把模式定义为 0 曲线、1 折线、2 柱状图控件内部按模式分发渲染函数数据缓冲和 Y 轴量程的外接接口保持统一容器脚本不需要关心当前是哪种图。void CPlotCtrl::SetDrawMode(int nMode) { if (nMode 0 || nMode 2) AfxThrowInvalidArgException(); // 非法模式直接抛掉 if (nMode m_nDrawMode) return; // 切换模式时清空实时缓冲避免旧数据以错误坐标系显示 { CComAutoCriticalSection guard(m_csData); m_nPoints 0; } m_nDrawMode nMode; Invalidate(FALSE); }切换模式时清屏而不是保留是因为三种图的坐标语义差异太大曲线是时间滚动轴折线图按数据点序号柱状图按分组坐标。硬要复用缓冲会画出完全错位的图形不如切干净。调度接口用属性导出脚本里直接写 plot.DrawMode 1 即可切到折线。4.2 三种模式的参数差异与典型配置三种图面对的场景完全不同参数也要分开处理不能一套量程逻辑通吃。下表是实际工程里最常用的一组配置关系模式典型场景关键参数数据组织实时曲线调节回路趋势、DCS 过程值SamplePeriod、AutoRange、BufferPoints等间隔追加折线图历史趋势查询、报表回放StartIndex、EndIndex、VerticalZoom批量装载一段记录柱状图班产量、故障次数统计BarGroupCount、MaxValue、LabelVisible分组统计数组折线图模式里横向轴不是时间滚动而是数据点索引查询历史库时把整段记录按时间排序灌进去。柱状图只要求按组提供数值组名文本由控件底部绘制放不下时自动缩小字体。三种模式的 Y 轴都要支持画红色虚线作为报警参考线工控画面里这几乎成了默认标配现场操作员看惯了没有反而不适应。量程行为也要随模式调整。曲线模式用自动量程跟随数据折线图模式必须能手动固定 Y 轴范围否则历史回放时刻度来回跳操作员没法对比不同时段。柱状图模式按数据最大值自动推算分组高度也允许在属性页设固定上限超限柱体画成红色警示。柱状图的组名和数值标签最好做成可配置开关现场常规监控时只显示柱体需要读具体数字时再打开标签避免满屏数字影响判读。4.3 事件回调越限通知与游标拾取ActiveX 相比普通 DLL 控件最大的优势是事件回调容器只需要实现事件接收接口就能收到控件内部变化。工控上最常用的两个事件是数据越限和游标数值变化。事件在类型库里声明后控件内部在条件满足时调用对应的 Fire_ 函数。越限是持续状态必须做边沿检测只在状态翻转时触发一次。void CPlotCtrl::ApplyAlarmLogic() { CComAutoCriticalSection guard(m_csData); if (m_nPoints 0) return; double latest m_data[m_bufHead]; if (latest m_dHighLimit || latest m_dLowLimit) { if (!m_bAlarmActive) { // 上升沿触发一次 m_bAlarmActive TRUE; Fire_AlarmChanged(VARIANT_TRUE); } } else { m_bAlarmActive FALSE; } }容器侧 VBScript 不需要额外依赖库就能接到报警并刷新画面上的报警牌Sub Plot_AlarmChanged(bActive) If bActive Then AlarmPanel.BackColor vbRed End If End Sub事件触发必须做边沿检测是因为越限状态会持续很多个采集周期连续每周期触发会淹没容器脚本现场脚本里一个死循环可能拖垮整个画面。事件回调里只发状态变化不要在回调里做重绘或长循环。4.4 属性页与工程文件持久化属性页是组态工程师唯一会碰的界面DrawMode、线宽、曲线颜色、背景色、报警高低限、字体大小都要有配置项。属性页代码在 OnApply 里通过 GetControlUnknown 拿到控件接口再写属性不能直接访问控件类的 C 成员因为属性页和控件主窗口的生命周期并不同步应该通过控件定义的自定义接口调 Set 方法。STDMETHODIMP CPlotPropPage::OnApply() { UpdateData(TRUE); CComPtrIPlotCtrl spCtrl; if (SUCCEEDED(GetControlUnknown()-QueryInterface(IID_IPlotCtrl, (void**)spCtrl))) { spCtrl-put_DrawMode(m_nDrawMode); spCtrl-put_SamplePeriod(m_nSamplePeriod); } return CPropertyPage::OnApply(); }属性状态要跟着组态存盘走统一在 DoPropExchange 里处理void CPlotCtrl::DoPropExchange(CPropExchange* pPX) { COleControl::DoPropExchange(pPX); ExchangeVersion(pPX, MAKELONG(1, 0)); PX_Long(pPX, _T(DrawMode), m_nDrawMode, 0); PX_Long(pPX, _T(SamplePeriod), m_nSamplePeriod, 100); PX_ULong(pPX, _T(LineColor), (ULONG)m_crLine, RGB(0, 90, 255)); PX_ULong(pPX, _T(BackColor), (ULONG)m_crBackground, RGB(255, 255, 255)); PX_Double(pPX, _T(HighLimit), m_dHighLimit, 100.0); }PX_XXX 宏负责属性在容器和控件之间的双向传递组态画面把控件连同属性一起存盘时 Load/Store 都会进这个函数。没写进 DoPropExchange 的成员变量不会跟着工程保存下次打开画面就变回默认值这是属性页明明配好了、工程重开后却全部丢失的最常见原因。5. 部署、安全与长时间运行的排错技巧5.1 注册与 32/64 位路径ActiveX 控件必须注册才能被容器识别基础命令是 regsvr32 PlotCtrl.ocx卸载用 regsvr32 /u PlotCtrl.ocx。64 位 Windows 上多数工控上位机进程是 32 位控制台默认的 regsvr32 是 64 位版注册完 32 位容器反而找不到必须显式用 SysWOW64 下的 32 位注册器%SystemRoot%\SysWOW64\regsvr32.exe PlotCtrl.ocx注意SysWOW64 是 32 位模拟层的系统目录不是放 64 位文件的目录。控件编译成什么位宽就注册到对应的单元进程位宽不匹配时容器一律报“创建控件失败”。注册成功后第一时间用 tstcon32 验证。tstcon32 是 VS 自带的 ActiveX Control Test Container插入控件后能直接看属性页、事件日志、调用方法很多在网页容器里调不通的问题在 tstcon32 里能分清是控件本身问题还是容器限制。容器报 0x80040154 时先查注册表里有没有对应 CLSID再查注册器位宽脚本调用报“成员不存在”时先重新生成类型库并确认容器没有缓存旧接口。5.2 IObjectSafety容器安全检查这一关老版本组态软件一般不校验 ActiveX但新版本和部分网页容器会先查询 IObjectSafety要求控件声明脚本安全与初始化安全否则直接拒绝创建。实现上让控件类实现 IObjectSafety 接口GetInterfaceSafetyOptions 对请求的所有接口返回支持安全标记即可。绘制控件本身不做文件操作、不写注册表声明安全是合理的接口里也不要暴露任意操作本地资源的导出方法这是安全审查的实际边界。5.3 用 GDI 句柄数做 7x24 小时巡检工控机几个月不关机的场景很常见GDI 泄漏是绘图控件最常见的慢性病。排查不依赖外部工具直接在刷新逻辑里定时采一次 GDI 句柄数写进循环日志DWORD CPlotCtrl::GetGDICount() { return GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); }在 OnTimer 里每 1000 次刷新取一次值和当时的时间连续观察一小时句柄数曲线持续上涨就说明有对象没释放。常见泄漏点集中在 OnDraw 里 new CPen/CBrush 后异常分支没有选回旧对象以及每次 OnMouseMove 都 new 一次字体。一条实用纪律把静态 GDI 对象在控件创建时全部建好OnDraw 内只做 SelectObject 和恢复。容器启动后画面报“无法创建 ActiveX 控件”、曲线不动但背景正常、中文文字显示成方块、打印出来一片空白这四类问题分别对应注册位宽不正确、定时器被容器暂停或缓冲被模式切换清空、字体没有指定 GB2312_CHARSET、以及打印路径没有走 OnDraw 的 IsPrinting 分支。按这个顺序排查比盲目重装控件快得多。巡检 GDI、核对注册位宽、确认事件边沿这三步做完控件在工控现场连续跑几个月不重启基本就没问题了。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门