news 2026/9/12 13:02:32

基于MFC ActiveX的工控绘图控件:双缓冲与GDI资源管理实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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,让页面能保存 TagName

3.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 = 2Ctrl.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_I2VT_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 id="trend1" classid="clsid:你的控件CLSID" width="640" height="320"></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,再一次 BitBlt

ActiveX 控件的绘制入口是 OnDraw。直接往 pdc 上画线,数据量大时肉眼可见闪烁,常见做法是先用内存 DC 把整幅图画好,再一次性复制到目标 DC:

void 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 小时不用重启,工控现场才算真的收工。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 13:01:11

GPT系列模型演进:从GPT-1到GPT-5的技术突破与应用

1. GPT系列模型的演进历程从2018年GPT-1的诞生到2025年GPT-5的发布&#xff0c;OpenAI的语言模型经历了令人瞩目的技术跃迁。作为一名长期跟踪AI发展的技术观察者&#xff0c;我完整见证了这场革命。让我们从技术角度剖析每个关键版本的突破点。1.1 GPT-1&#xff1a;Transform…

作者头像 李华
网站建设 2026/9/12 13:00:23

MATLAB遗传算法求解VRP:路径编码与约束处理实战

简介&#xff1a;本资源是一套基于MATLAB实现的遗传算法求解车辆路径问题&#xff08;VRP&#xff09;的完整代码实践包&#xff0c;面向物流优化、智能算法学习及运筹学课程设计的本科生、研究生与工程实践者。资源聚焦VRP这一经典组合优化难题&#xff0c;通过遗传算法模拟自…

作者头像 李华
网站建设 2026/9/12 13:00:05

Docker 深入理解:从容器原理到生产环境最佳实践

作者&#xff1a;王仕宇&#xff08;JavaPub&#xff09;前言 很多开发者学习 Docker&#xff0c;只停留在&#xff1a; docker run nginx然后认为 Docker 就是一个启动程序的工具。 但真正进入企业开发之后&#xff0c;你会发现&#xff1a; 为什么 Docker 启动速度这么快&…

作者头像 李华
网站建设 2026/9/12 12:59:45

Android车载串口开发实战:UART/RS232/RS485通信与稳定性优化

1. 这个项目要解决什么问题&#xff1a;车载屏与ECU之间的最后一公里 做了多年车载应用开发&#xff0c;大家应该都有同感&#xff1a;Android层能玩的花活再多&#xff0c;到了和整车ECU通信这一步&#xff0c;绕不开的就是串口。我接手这个项目时&#xff0c;硬件端已经固定了…

作者头像 李华
网站建设 2026/9/12 12:59:45

LunaTranslator上手指南:5分钟让视觉小说跑出实时翻译

LunaTranslator上手指南&#xff1a;5分钟让视觉小说跑出实时翻译 【免费下载链接】LunaTranslator 视觉小说翻译器 / Visual Novel Translator 项目地址: https://gitcode.com/GitHub_Trending/lu/LunaTranslator LunaTranslator是一款免费开源的视觉小说实时翻译工具&…

作者头像 李华
网站建设 2026/9/12 12:59:38

Proteus仿真51单片机电流检测与过流保护设计详解

简介&#xff1a;这份Protues仿真实例围绕51单片机电流检测设计&#xff0c;适合自动化、电子类学生及初学者用来理解电流采样与单片机控制结合的完整流程。资源内含完整的DSN仿真电路、KEIL工程及C51源程序&#xff0c;并附带编译后的HEX文件&#xff0c;可直接加载到Protues中…

作者头像 李华