简介:基于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 ActiveX | VS 向导一键生成 OCX | 自动生成 .idl/.tlb | COleControl 自带 OnDraw 和消息链 | 可静态链接 MFC,单 DLL 交付 | 资料最多,出问题好查 |
| ATL ActiveX | ATL 向导生成 | 需手工维护 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 = 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 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 小时不用重启,工控现场才算真的收工。
本文还有配套的精品资源,点击获取