基于MFC ActiveX的工控绘图控件:双缓冲与GDI资源管理实战
简介基于MFC ActiveX开发的曲线、折线、柱状图绘制控件面向Windows平台工控软件开发者与自动化系统集成工程师可直接嵌入现有软件界面用于工业实时监控、历史数据分析与报表展示帮助开发者快速搭建数据可视化模块避免从零封装绘图逻辑。压缩包共2.32MB内含158个文件40个头文件与26个C源文件构成完整工程源码38个位图资源用于界面美化还带有编译好的ocx控件及配套lib、dll以及sln、vcxproj示例项目和PDF、docx说明文档兼顾二次开发与直接部署。目前已有85人学习。通过研究源码和示例工程可以掌握ActiveX控件的封装思路按需调整坐标轴、线条样式、颜色等显示参数将传感器实时数据或历史数据动态绘制为曲线、折线和柱状图控件在处理大量数据时能保持高效性能并具备安全认证机制对提升工控系统的监控效率与交互体验很有帮助。1. 基于MFC ActiveX的曲线、折线、柱状图绘制控件工控上位机绕不开的图元工控上位机里最不起眼也最不能出问题的就是“画线”这件事。趋势曲线、实时棒图、多路参数对比组态软件自带的图元往往样式死板、刷新一快就闪脚本喂数据的方式也别扭。而厂内成套上位机里的宿主页面多是 IE 内核或 VB 窗体组态软件则通过 ActiveX 容器调用第三方控件你若直接上 Qt 或纯 Web 图表容器根本不认这批接口。于是把曲线、折线、柱状图统一包进一个基于 MFC ActiveX 的绘制控件拖到页面里用脚本传数据、用事件回传坐标成了工控行业里最容易复现、兼容面最宽的做法。这篇文章不推销任何花哨特效只把这种控件怎么搭、怎么向脚本暴露属性和方法、三种图怎么画到肉眼下不闪、参数在哪里调按一线工程师常用的套路讲清。读者手里最好已有 MFC 工程基础没有也能顺着代码走完注册和验证流程重点是把绘图控件背后那套 COM/ActiveX 约束先立住再动手。2. 为什么选 MFC ActiveX 而不是 Qt/Web 图表工控容器的四大硬约束2.1 宿主决定选型组态软件、IE 内核页面与 VB 窗体只认 COM/ActiveX工控上位机的现状很割裂老的 DCS/SCADA 组态平台还在用 OCX 插件部分设备调试工具是 VB6 写的窗体厂里的数据门户则开着 IE 模式嵌 ActiveX 页面。这三类宿主的共同语言不是图表库而是 COM/ActiveX。控件只要实现 IDispatch 和必要的接口就能被组态画面、脚本语言和 VB 窗体共同调用。把需求压缩成四个硬约束选型就清楚了容器必须装得上接口必须让脚本叫得动刷新必须让肉眼看不闪现场维护必须少依赖外部运行库。对照这四条Qt 的绘制能力更强Web 图表的样式更现代但它们要么让宿主多带一套框架要么多加一层浏览器进程。MFC ActiveX 编译出来就是一个进程内 DLL宿主进程直接创建控件窗口资源和开销都最小这是它至今还留在工控现场的重要原因。2.2 进程内模型决定绘制入口OnDraw 和消息泵不是可有可无的ActiveX 控件是进程内 COM 组件in-proc server。宿主调用 CoCreateInstance 把 ocx 拉到自己的地址空间控件窗口作为子窗口嵌进宿主页面消息循环完全依赖宿主的消息泵。换句话说控件不能像普通 EXE 那样自己开一个顶级窗口再往上贴图所有绘制都得走 COleControl 的 OnDraw 约定宿主需要重绘时才回调。这也解释了为什么工程里不能把数据三秒一刷的代码硬塞进 OnTimer 里直接画。OnDraw 只负责“当前数据画成什么样子”数据由外部线程或脚本往里塞。绘制入口和刷新节拍分离是这类控件不闪、不卡、不崩的前提。MFC 已经替你把类型库、属性页、空闲时更新这些脏活做好了剩下要操心的就是数据缓冲和 GDI 资源释放。2.3 MFC、ATL、ActiveQt 三条路怎么选一张对照表和一个现场结论实现方式工程模板类型库生成绘图侧工作量部署体积老现场维护MFC ActiveXVS 向导一键生成 OCX自动生成 .idl/.tlbCOleControl 自带 OnDraw 和消息链可静态链接 MFC单 DLL 交付资料最多出问题好查ATL ActiveXATL 向导生成需手工维护 idl控件窗口、绘制、双缓冲都要自己写DLL 体积更小常见但代码量大Qt ActiveQt先建 Qt Widget 再包 ActiveQt自动桥接属性QPainter 画图体验更好需 Qt 运行库和 VC 运行库版本升级容易在旧系统翻车我的现场结论是如果只是画曲线、折线、柱状图这种二维图元MFC ActiveX 的工程量最小。MFC 的 GDI 接口写起来直接双缓冲代码在控件里就是几行ActiveQt 的优势在更复杂的交互图表但工控机装一堆运行库后维护成本往往比开发成本更心疼。3. 工程搭建与接口设计让脚本能 SetData让页面能保存 TagName3.1 VS 里新建 MFC ActiveX 控件的默认入口与文件结构打开 Visual Studio建项目时选“MFC ActiveX 控件”模板。如果没有这个模板一般是安装 VS 时没勾选“适用于最新 v143 生成工具的 C MFC”组件补装后重启即可。向导里“控件名称”填 CurveChart向导会自动生成窗口类、属性页类、类型库和 .ocx 输出文件这个名称也是后面注册和调试时看到的 ProgID。生成的项目里重点看三个文件CurveChartCtrl.h/.cpp 是控件主类CurveChartCtrl.idl 是类型库源文件CurveChartPropPage 是右键属性页。调试宿主设为 IE 或组态软件的 exe 都行断点能进 OnDraw 就算跑通。编译平台要选 x86因为多数工控组态软件和 VB 窗体还是 32 位进程后面注册时也要按 32 位去注册。3.2 调度映射把属性、方法、事件暴露给组态脚本工控脚本里最终要出现的效果是Ctrl.SetData(arr, 0, 100)、Ctrl.ChartType 2、Ctrl.ChartType这类调用这些都由 MFC 的调度映射完成。以下代码放在 CurveChartCtrl.cpp 中BEGIN_DISPATCH_MAP(CCurveChartCtrl, COleControl) // 属性图类型0曲线 1折线 2柱状图 DISP_PROPERTY_EX_ID(CCurveChartCtrl, ChartType, dispidChartType, GetChartType, SetChartType, VT_I2) // 属性量程上限/下限配合坐标换算 DISP_PROPERTY_EX_ID(CCurveChartCtrl, MaxValue, dispidMaxValue, GetMaxValue, SetMaxValue, VT_R8) DISP_PROPERTY_EX_ID(CCurveChartCtrl, MinValue, dispidMinValue, GetMinValue, SetMinValue, VT_R8) // 方法一次传入整组数据参数为数组量程 DISP_FUNCTION_ID(CCurveChartCtrl, SetData, dispidSetData, SetData, VT_EMPTY, VTS_VARIANT VTS_R8 VTS_R8) // 方法追加一个点用于实时趋势 DISP_FUNCTION_ID(CCurveChartCtrl, AddData, dispidAddData, AddData, VT_EMPTY, VTS_R8) // 方法清空缓冲 DISP_FUNCTION_ID(CCurveChartCtrl, Clear, dispidClear, Clear, VT_EMPTY, VTS_NONE) END_DISPATCH_MAP() BEGIN_EVENT_MAP(CCurveChartCtrl, COleControl) // 事件鼠标点击时回传时间轴位置和当前值 EVENT_CUSTOM_ID(PointClicked, dispidPointClicked, FirePointClicked, VTS_R8 VTS_R8) END_EVENT_MAP()参数说明VT_I2和VT_R8分别对应脚本里的整数和浮点MFC 会自动转换SetData 的第一个参数用VTS_VARIANT是为了接收 JScript 或 VBScript 传来的数组进入 C 后再用 SafeArray 解包。事件VTS_R8 VTS_R8表示 JScript 里这样绑定监听Ctrl.PointClicked function(t, v) { ... }。3.3 属性持久化页面重启后量程和缓冲长度不丢组态软件保存画面文件时会通过 IPersistPropertyBag 向控件索要属性。MFC 把这层封装成了 DoPropExchange里面写 PX_ 宏就能把成员变量持久化到页面文件里。忘记这一段画面保存再打开ChartType 和量程会全部回到默认值。void CCurveChartCtrl::DoPropExchange(CPropExchange* pPX) { ExchangeVersion(pPX, MAKELONG(1, 0)); COleControl::DoPropExchange(pPX); PX_Long(pPX, _T(ChartType), m_nChartType, 0); PX_Double(pPX, _T(MaxValue), m_dMaxValue, 100.0); PX_Double(pPX, _T(MinValue), m_dMinValue, 0.0); PX_Long(pPX, _T(BufferSize), m_nBufferSize, 1000); }这套机制也适用于在组态软件里给同一页面放多个控件实例不同实例通过属性页设不同 TagName 和量程保存后各存各的配置互不干扰。属性不仅要能被脚本运行时修改更要能被持久化记住这是工控控件和普通 Web 组件最明显的差别之一。3.4 注册命令与 HTML 加载32 位注册表路径不能搞错ocx 编译完成后首先要注册到注册表。工控容器多为 32 位Windows 10/11 上用 64 位 regsvr32 注册 32 位 ocx 会静默失败应直接调用系统目录下的 32 位版本# 管理员命令行执行/s 表示静默去掉可看弹窗 C:\Windows\SysWOW64\regsvr32.exe /s C:\Release32\CurveChartCtrl.ocx # 卸载时用 /u C:\Windows\SysWOW64\regsvr32.exe /u C:\Release32\CurveChartCtrl.ocx注册后用 regedit 查 HKEY_CLASSES_ROOT能看到 CurveChart 相关项。页面侧的嵌入代码只有一个 object 标签object idtrend1 classidclsid:你的控件CLSID width640 height320/object script var arr [12.5, 13.0, 12.8, 14.1, 15.2]; trend1.ChartType 0; // 0曲线 trend1.MaxValue 20; trend1.MinValue 0; trend1.SetData(arr, 0, 20); // 数组在脚本侧自动打包为 VARIANT /script注意IE 默认安全级别会阻止未标记“对脚本安全”的 ActiveX 控件本地 file:// 打开容易直接白屏。工控内网页面一般存在 IE 信任站点或兼容模式里调试时先用 127.0.0.1 起一个本地站点比双击文件省事得多。4. 曲线、折线、柱状图绘制实现OnDraw、双缓冲与坐标换算4.1 SetData 与 AddData脚本传数组和实时追加的设计SetData 属于“整包替换”语义适合历史曲线查询或页面初始化AddData 属于“滚动追加”语义适合实时趋势。SetData 里用 COleSafeArray 拆包void CCurveChartCtrl::SetData(const VARIANT va, double dMinValue, double dMaxValue) { COleSafeArray sa(va); long lLBound 0, lUBound 0; sa.GetLBound(lLBound); sa.GetUBound(lUBound); m_vecData.clear(); m_vecData.reserve(lUBound - lLBound 1); for (long i lLBound; i lUBound; i) { double v 0.0; sa.GetElement(i, v); m_vecData.push_back(v); } m_dMinValue dMinValue; m_dMaxValue dMaxValue; InvalidateControl(); // 只是请求重绘不立刻在采集线程里画 }AddData 则要注意滚动缓冲的效率。低频场景下 vector 头删尾插问题不大高频采集就要避免每次对新数据进行 O(n) 搬移void CCurveChartCtrl::AddData(double dValue) { if ((int)m_vecData.size() m_nBufferSize) { m_vecData.push_back(dValue); } else { std::rotate(m_vecData.begin(), m_vecData.begin() 1, m_vecData.end()); m_vecData.back() dValue; } InvalidateControl(); }参数说明m_nBufferSize 控制可见点数默认 1000 点在工控趋势里足够看完一个班次的关键片段量程 dMinValue/dMaxValue 不只是用于显示也决定坐标换算时数据落在像素矩形的哪个位置。4.2 OnDraw 双缓冲绘制流程先画到内存 DC再一次 BitBltActiveX 控件的绘制入口是 OnDraw。直接往 pdc 上画线数据量大时肉眼可见闪烁常见做法是先用内存 DC 把整幅图画好再一次性复制到目标 DCvoid CCurveChartCtrl::OnDraw(CDC* pdc, const CRect rcBounds, const CRect /*rcInvalid*/) { CDC memDC; memDC.CreateCompatibleDC(pdc); CBitmap bmp; bmp.CreateCompatibleBitmap(pdc, rcBounds.Width(), rcBounds.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 1. 背景 memDC.FillSolidRect(rcBounds, TranslateColor(m_clrBack)); // 2. 网格 DrawGrid(memDC, rcBounds); // 3. 按 ChartType 分发绘制 switch (m_nChartType) { case 1: DrawPolyline(memDC, rcBounds); break; // 折线 case 2: DrawBar(memDC, rcBounds); break; // 柱状图 default: DrawCurve(memDC, rcBounds); break; // 曲线 } // 4. 整幅回拷 pdc-BitBlt(rcBounds.left, rcBounds.top, rcBounds.Width(), rcBounds.Height(), memDC, 0, 0, SRCCOPY); // 5. 资源链还原位图和 DC 必须释放否则 GDI 句柄会慢慢耗尽 memDC.SelectObject(pOldBmp); bmp.DeleteObject(); memDC.DeleteDC(); }这里的逻辑顺序是固定的先建兼容位图再画背景和网格再画数据最后回拷。任何一步颠倒都会在重绘时出现残影或黑块。GDI 资源为什么必须恢复旧对象并删除位图因为 ActiveX 控件可能被页面反复创建销毁每漏一个 GDI 对象宿主进程的句柄数就涨一格最终表现为“运行几天后画面变白”。4.3 坐标换算值域到像素矩形的映射规则工控绘图的本质是把物理量温度、压力、转速映射到控件客户区的像素坐标。MFC 窗口坐标系里 y 轴向下所以值越大像素 y 越小int CCurveChartCtrl::MapValueToY(double v, const CRect rc) { double dSpan m_dMaxValue - m_dMinValue; if (dSpan 0.0) dSpan 1.0; // 防除零 if (v m_dMinValue) v m_dMinValue; if (v m_dMaxValue) v m_dMaxValue; int nHeight rc.Height() - 1; // 上下各留 5% 边距曲线不会顶破矩形边框 double t (v - m_dMinValue) / dSpan; return rc.bottom - 1 - (int)(t * nHeight * 0.9) - (int)(nHeight * 0.05); } int CCurveChartCtrl::MapIndexToX(int idx, const CRect rc) { int n max(2, (int)m_vecData.size()); double t (double)idx / (n - 1); // 首点在左边界末点在右边界 return rc.left (int)(t * (rc.Width() - 1)); }参数说明5% 边距防止峰值压线i / (n-1) 而不是 i / n是为了让最后一个数据点正好落在绘图区右边界不出现右侧留白。量程由脚本通过 MaxValue/MinValue 或 SetData 传入AutoZoom 模式则自行扫描 m_vecData 的 min/max曲线始终占满纵向空间。4.4 柱状图与三种图切换的绘制参数柱状图与曲线共用 MapValueToY只是绘制方式从连线变成填充矩形。工控柱状图多数以 0 值为基线数值有正有负时矩形从零线向上下两个方向延伸void CCurveChartCtrl::DrawBar(CDC* pdc, const CRect rc) { if (m_vecData.empty()) return; CBrush brush(TranslateColor(m_clrBar)); CBrush* pOldBrush pdc-SelectObject(brush); int n (int)m_vecData.size(); int nGap 5; // 柱间距 int nUsable rc.Width() - nGap * (n 1); int nBarW max(2, nUsable / n); // 柱宽均分 for (int i 0; i n; i) { int x1 rc.left nGap i * (nBarW nGap); int yBase MapValueToY(0.0, rc); // 零线位置 int yVal MapValueToY(m_vecData[i], rc); CRect rcBar(x1, min(yBase, yVal), x1 nBarW, max(yBase, yVal)); pdc-FillRect(rcBar, brush); } pdc-SelectObject(pOldBrush); }ChartType名称绘制方式典型工控场景0曲线相邻点连线可加贝塞尔平滑温度、压力趋势1折线直线连点不插值开关量、步进时序2柱状图从零线向值方向填充矩形多路负荷对比曲线和折线在代码上几乎一样差别只在是否对相邻点做插值。曲线图若用 Polyline 批量送点数百点以上时比逐条 MoveTo/LineTo 省不少绘制时间柱状图柱宽会随数据点数自动变窄点太多时应先做聚合或只显示末 N 根柱。5. 现场加载与稳定性验证位宽、GDI 句柄和刷新节拍5.1 控件拉不进页面时的三个排查方向现场最常碰到的三类问题一是 ocx 注册成功但页面找不到控件多半是 64 位 regsvr32 注册了 32 位组件改用C:\Windows\SysWOW64\regsvr32.exe重新注册二是加载时报 MFC 运行库缺失工程里应把“MFC 的使用”设为“在静态库中使用 MFC”单 DLL 交付最省事三是 XML 解析器或硬件加速相关的 IE 安全拦截先把页面加进受信任站点并开启“允许运行 ActiveX 控件”。5.2 用 GetGuiResources 实测 GDI 泄漏绘制控件最容易出暗病的就是 GDI 对象泄漏标准做法是在 Release 版里把每帧 GDI 句柄数打出来DWORD dwGdi GetGuiResources(::GetCurrentProcess(), GR_GDIOBJECTS); CString sMsg; sMsg.Format(_T(GDI Object Count %u), dwGdi); OutputDebugString(sMsg);测试步骤加载控件后反复缩放窗口、连续 SetData、切换 ChartType观察 GDI 对象数。正常状态应在几十个以内波动十几秒后回到基线如果数字单调上升且不回落说明代码里漏了 SelectObject 还原或 DeleteObject优先检查 OnDraw 和 DrawBar 里的画刷、画笔释放。把这段日志留在正式版本里也不丢人组态现场维护时它就是最重要的判据。5.3 采集线程不画图定时器把刷新节拍固定下来最后补一个高频数据现场的惯用做法采集线程只负责把数据写进临界区保护的环形缓冲绝不跨线程调用 InvalidateControl。控件内部用 SetTimer 起一个 50ms 的定时器到期后从缓冲快照数据再重绘。这样哪怕采集端是 10ms 一包的数据流界面刷新频率也恒定在 20 帧双缓冲才能真正发挥作用采集线程直接抢 UI 线程的绘制权再好的双缓冲也会被撕成花屏。把环形缓冲、定时器刷新和 GDI 计数三个点接在一起控件在整定页面连续跑 8 小时不用重启工控现场才算真的收工。本文还有配套的精品资源点击获取