HMIs 中的嵌入式控件
为 WonderWare InTouch 和 WinCC 创建嵌入式控件
背景
我目前签约的公司销售加热钢炉。随炉销售的 Level 2 系统包含一个复杂的数学模型,能够计算炉内钢材的内部热量和温度。尽管 PLC 有一个带有所有屏幕的 Level 1 HMI,但此系统的 Level 2 通常有一个专用的 PC,仅用于运行 Level 2 来计算这些温度,并以最少的燃料和最少的结垢来实现所需输出温度的最有效燃料利用。
客户要求在同一台计算机终端上运行这两个 HMI 并不罕见,我们经常会在 Level 1 HMI 上创建一个能够启动 Level 2 HMI 的按钮。在极少数情况下,客户会要求将 Level 2 HMI 的某些部分(例如等温线模拟图像)嵌入到 Level 1 屏幕中;但最近,我们发现越来越多的客户坚持要求将 Level 2 HMI 完全嵌入到 Level 1 HMI 中。我们一直在客户的 Level 1 SCADA 系统中创建定制的、一次性的实现,为此借用或创建了定制的、一次性的控件,但在完成了两个完全混合的系统后,似乎是时候研究我们 HMI 的一种新设计理念了。
这是我们正在进行的一项工作。第一步是检查可行性。由于我们的客户要求这种实现的两个最常见的 SCADA 系统是 WinCC 和 WonderWare 的 InTouch,我们的研究(以及本文)将主要集中在这两个系统上,但此处讨论的问题很可能适用于任何 SCADA 系统中此类设计的通用问题。同样,此处发现的任何建议或反馈都可能有助于我们最终的设计。
定义
- HMI
- 人机界面(Human Machine Interface,HMI)是工厂控制某些工业设备的图形界面。世界上的其他人会称之为图形用户界面(Graphical User Interface,GUI)或 Gooey,但人机界面听起来更像是“直奔主题”的。
- PLC
- 可编程逻辑控制器(Programmable Logic Controller,PLC)是一种特殊的、坚固的计算机,它读取工业传感器并控制工业设备。
- Level 1、2 及其他
- 自动化级别基于 ANSI/ISA 批处理标准 (S88)。Level 0 通常指人工控制(Man In The Loop,MITL)。这是指某人手动操作阀门并按下泵的启动按钮,观察液位填充,然后按下停止按钮。Level 1 是指该人员的主管厌倦了支付工资让某人整天站着观察罐体液位,于是购买了 PLC 来监控电子液位计,并对 PLC 进行编程,使其能够自动向泵发送启动和停止信号。Level 2 是指该主管的经理意识到,在早上 6 点到下午 5 点之间,罐体中的水成本更高,因此决定在下午 5 点后将罐体从半满填充至全满,但在早上 6 点到下午 5 点之间,仅将从四分之一填充至半满。
- SCADA
- 监督控制与数据采集系统(Supervisory Control and Data Acquisition,SCADA)是一种应用程序开发环境,旨在让非程序员更容易创建 HMI。
- WonderWare,InTouch
- WonderWare 的 InTouch 是一个 SCADA 系统。有关此系统的更多信息,请查看 Invensys 的 WonderWare InTouch 产品页面。
- WinCC
- WinCC 是一个 SCADA 系统。有关此系统的更多信息,请查看 西门子 Simatic WinCC 产品页面。在此查找有关 WinCC 的信息时要小心,因为西门子还有一个功能相似但无关的 SCADA 系统,称为 WinCC Flexible。(如果您在 Google 上搜索有关 WinCC 的信息,我建议在查询末尾附加 -Flex -Flexible 过滤器。)
- 设定值
- 设定值最容易理解,可以类比家庭的恒温器。您通常不会手动打开和关闭加热器;而是将温度设置为特定的设定值,例如 72 华氏度,当温度过低时,恒温器会打开加热器,当温度足够温暖时,会关闭加热器。
- PID
- 比例-积分-微分(Proportional-Integral-Derivative,PID)控制器的工作原理类似于一些现代汽车的巡航控制系统。它是一种复杂的输出计算(向发动机供给多少燃油),基于您的设定值(您期望的当前速度,以 mph 或 kph 为单位)、加速或减速的速率以及加速或减速所需的时间。
- 标签
- 标签(Tag)是 PLC 和 SCADA 共有的特殊变量。外部标签是对应于某些物理设备的控制点,例如压力指示器或阀门的开启百分比。有些,例如压力指示器,是只读的。有些只有在特定情况下才能写入;例如,阀门的开启百分比仅在阀门处于手动模式时才能写入。如果 PLC 通过 PID 控制器控制阀门,则设定值是可写值,而开启百分比由 PID 计算出的输出控制。内部标签或内存标签模仿外部标签,但未绑定到特定的物理设备。相反,内存标签更像 SCADA 系统的变量,其值由 SCADA 系统的编程逻辑设置。
- Microsoft 的 Interop Forms Toolkit
- “Microsoft 的 Interop Forms Toolkit”是一个免费的 Visual Studio 插件,它简化了在 Visual Basic 6 应用程序中显示 .NET 窗体和控件的过程。” [Microsoft, 2010] 该工具主要用于让客户延长现有 Visual Basic 6 应用程序的生命周期,并允许他们将新的 VB.NET 控件和窗体集成到此类现有应用程序中。然而,它已成功应用于使用相同技术的其他应用程序。
- SOA
- 面向服务架构(Service-Oriented Architecture,SOA)是一种设计理念,用于将一台计算机上的数据提供给一台或多台计算机使用,其目的可能在创建数据服务时并未可知。理解 SOA 服务最简单的方法是类比网页。最初,HTTP 请求返回的 HTML 语言仅用于显示网页,但越来越多地用于提供原始数据,这些数据可以由各种设备以多种方式显示。播客(Pod Casts)、RSS 提要和 Google Maps 等服务的地理空间数据是 SOA 数据服务的示例。
- VMS
- 虚拟内存系统(Virtual Memory System,VMS)曾经是计算机操作系统的“凯迪拉克”。然而,它消耗了过多的资源,在计算机转向全电动时很快被 Windows NT 取代。有关 VMS 的更多信息,请查看 惠普 OpenVMS 产品页面。
- OPC
- 面向过程控制的对象的链接与嵌入(Object-linking and embedding, OLE for Process Control, OPC)最初是作为 PLC 的 Windows 设备驱动程序创建的。当前名称有些误导,因为更现代的 OPC 标准规范不依赖于对象链接与嵌入来实现。事实上,有证据表明在当前标准中有一些动向,旨在向非 Windows 操作系统开放驱动程序。有关 OPC 的更多信息,请查看 OPC 基金会的网站。
设计考虑因素
第一步是规划我们理想的实现方式。对我们来说,最好的解决方案仍然是 HMI 的独立 PC 实现。从我们的角度来看,SCADA 系统代表的不是一项长期投资,而是托管我们 HMI 的另一种方法。在大多数情况下,这些系统代表了一种相当陌生的托管环境。它们非常陌生,以至于我们不得不从头开始重写我们的大部分 HMI,并且我们希望尽可能多地重用现有代码以降低成本和缩短进度。如果我们能将时间和金钱花在创新而非实现上,这将使我们的公司和客户双方受益。
可消费的组件
我怀疑,即使是对于经验丰富的程序员来说,开发也始于将控件拖放到窗体上,然后双击这些控件以添加标准事件逻辑。自定义控件,如果创建的话,也只会在重构期间创建。将窗体的代码转换为在 SCADA 中运行的难度促使我们重新考虑我们的设计理念。转向自定义控件将使我们能够将控件托管在任何支持嵌入式控件的系统中。在这种情况下,Windows 窗体只是另一种托管环境。除非您打算在多个不同的主机上运行相同的代码,否则我不能说开发嵌入式控件是一个引人注目的设计原因,但我怀疑这种情况并不常见。对于大多数读者来说,最可能的理由是增强现有 SCADA 系统的原生功能。无论您的理由是什么,我希望我能在这里提供一些见解。
数据访问和屏幕更新
接下来的考虑是如何执行屏幕更新,包括时间和数据,到我们的嵌入式控件。我们希望让托管环境来协调更新和控件之间的通信:我们希望避免在每个嵌入式控件中添加计时器,并避免使用数千个内部标签来向我们的控件提供数据。
理想情况下,数据收集应该能够作为系统服务运行,以便能够自动启动,而不会增加最终用户的复杂性。然而,数据收集架构应保持灵活性。自从我在这家公司工作以来,即使是我们的标准 Windows Forms HMI 也已经适应使用 SQL Server、Oracle、OPC 和套接字(Sockets)进行 Level 2 和 HMI 之间的通信。如果可能,我们数据收集系统的设计应使我们能够更换通信机制。
在 SCADA 系统中托管控件会给套接字通信带来一个独特的问题。很容易忘记,一旦 HMI 分发到每个客户端,每个客户端都会运行该代码的实例。换句话说,如果您为控件创建了一个套接字连接,而该控件由三个客户端显示,这将在另一端创建三个套接字连接。此外,如果控件无法共享数据收集,那么每个控件都必须支持自己的套接字连接。总之,3 个客户端上的 10 个控件意味着 30 个套接字连接。
这不是一个技术问题,但相当令人烦恼。特别是我们肯定会多次将相同的信息发送到同一客户端上运行的不同控件。更有效利用网络和计算资源的方法是让一个数据访问层(DAL)在后台管理所有控件的所有数据,然后让控件查询 DAL 以获取所需信息。
托管限制
最后,我们需要根据最严格的限制来编码。我们已经非常熟悉 Windows Forms,但是 WinCC 和 WonderWare 的功能仍在完善中。我们还没有确定所有这些限制,所以我预计这篇文章会有几次未来的编辑。我们确实取得了一些重要的发现,希望会对您有所帮助。
定义可行性
我最喜欢的故事之一是,世界上最复杂的技术成就之一——航天飞机——的设计竟然是基于马屁股的大小。长话短说,火箭助推器必须通过铁路运送到目的地,而铁路轨道的间距与古罗马战车的间距相同——也就是两匹马屁股的宽度。然后,航天飞机必须考虑到其助推器火箭的大小是特定的。
同样,我们设想创建一个大型的、独立的(monolithic)数据对象来管理数据,该对象将由所有对象共享,并由一个正在运行的服务更新。但您无法直接访问应用程序边界之间的数据,尤其是系统服务。所以那立即被架构击垮了。我们需要找出是否存在任何机制可以收集可供多个控件共享的数据。
我们从供应商文档和其他工程师那里得知,WinCC 完全支持 .NET 控件,并且通过 InterOp,可以将它们嵌入到 WonderWare 中。这为我们留下了很多空间来填写确切的支持内容。由于一个通用对象不能在单独的应用程序之间传递,那么当它在嵌入式控件之间传递时,它将如何工作?
WinCC 对 .NET 控件的支持
我们发现的结果令人沮丧,但绝对值得分享。根据供应商的文档,我们预计 WinCC 中的嵌入式控件不会遇到太多困难。我们预计在同一画面或复合窗口的不同画面之间共享数据访问层对象时可能会出现一些问题。我们甚至预料到支持 WonderWare 所需的 InterOp 命令和属性可能会有一些干扰。结果证明,供应商声称支持 .NET 控件有些夸大。到目前为止,我们已确认 public
读写属性得到完全支持(不支持只读和只写属性),但 public
方法不支持。
此外,声明一个 public
事件可以使程序员在 WinCC 开发环境中看到该事件,甚至编写脚本代码。但是,将事件提高到 WinCC 会导致 OutOfMemory
异常。(我推测这是因为 WinCC 托管环境无法访问分配 .NET 控件和应用程序内存的垃圾回收器。我仍在等待西门子的回复以确认这一点。)
我们可以创建一个包含数十个其他控件的控件,其中任何一个包含的控件都可以触发由容器处理的事件,但容器本身不能触发事件,也不能允许事件冒泡到 WinCC。这意味着 WinCC 中的任何嵌入式控件都必须是独立的,并且 WinCC 托管环境无法像我们希望的那样控制或有效地管理嵌入式控件。例如,嵌入独立的(monolithic)控件,例如整个 HMI,是可以工作的。将 HMI 分割成单独的控件并将其托管在 WinCC 中将更加困难。
WinCC v7 的 Service Pack 2 已经发布供下载,并且应该可以解决对嵌入式 .NET 控件支持不足的问题,但我还没有机会测试这个新版本。当有机会验证更改时,我会更新此部分以反映结果。在此期间,如果您知道我上面评论中的错误,请在下方留言分享您的经验。
WonderWare 对 .NET 控件的支持
奇怪的是,尽管 WonderWare 在其产品中并未声明支持 .NET 控件,但我们发现 InterOp 属性很烦人,但功能齐全。我在此项目中的 WonderWare 编程伙伴尚未尝试运行多个来自公共库的控件,因此我无法验证通用的数据访问层对象共享是否仍按预期工作。到目前为止,所有属性(我们尚未尝试只读或只写)、所有方法和所有事件都按预期执行。(如果您发现任何限制,请告诉我,我将编辑此部分以包含。)
整合
我们对 WinCC 的限制感到惊讶和失望,但我们能够仅使用属性想出一些解决方案。属性可以接收参数,并且属性的 getter 和 setter 方法可以被赋值或读取以调用一个方法。对于事件,可以在 WinCC 中创建周期性脚本,该脚本可以读取 public
属性来触发操作。甚至可以将内部标签链接到属性并使用该属性来触发事件。然而,代码的响应速度会变慢,并且难以理解。
特别是,我们设想使用事件来传达一个控件的状态变化到其他托管控件。例如,在炉膛概览控件中选择一块钢材会触发一个带有零件 ID 的事件。然后,WinCC 将设置等温线控件的 PieceID
属性,等温线控件将以图形方式显示该零件的温度分布。但是,等待脚本的下一个周期会引起控件之间不可接受的延迟。因此,我们需要谨慎选择控件的边界,并且可能会导致比我们期望的更大的边界。
说实话,我们从未考虑过独立的(monolithic)控件,并且很感兴趣地发现我们的意大利合作伙伴公司在以前的工作中成功地将整个 HMI 嵌入为一个控件。由于我们还没有仔细检查 HMI 以将其分割成独立的控件,我们很可能会至少考虑这个选项。
坦率地说,我更兴奋于有机会重构整个应用程序,并将一些面向对象架构引入过程工程应用程序,而不管 HMI 如何划分。在接下来的部分中,我们将探讨 WonderWare 和 WinCC 嵌入式控件的边界,届时我们将深入研究其中的一些内容。
简单的等温线控件
InterOp Toolkit 差异
对于那些跟随我的人,如果您正在使用 WonderWare,我建议您下载并安装 Microsoft InterOp Forms Toolkit(请参阅上面的定义以获取链接)。如果您仅打算针对 WinCC,则可以跳过此步骤。此工具包提供了 VB6 Interop UserControl 和 VB6 InteropForm Library 项目,我们的演练将使用 VB6 Interop UserControl。如果您不使用该工具包,Windows 控件库将是非 InterOp 等效项。在将您的代码与此示例进行比较时,请注意此处显示的任何代码都将包含自动插入的额外属性。此外,该工具包会插入两个辅助类和至少两个有用的方法,用于注册和注销控件。
最后,该工具包还创建了几个有用的公共事件,如果您正在运行 WinCC,应该删除它们。在控件内部触发这些事件会在此处停止执行,并引发 OutOfMemory
异常。另外,虽然这些事件仍然是 public
的,但它们在 WinCC 的开发环境中可见,这会鼓励未来的程序员为它们编写脚本。
语言选择
请注意,嵌入式控件可以用任何语言编写,而不管托管环境对该语言的支持如何:编译后的控件作为对象嵌入,而不是原始语言结构。我个人更喜欢 C# 语言而不是 VB,因为我是在 C 语言中学会编程的,而且在我所知道的所有语言中,有一大部分——Java、JavaScript、C#、C 和 C++——都具有 VB 所没有的共同结构。然而,尽管我们所有的 C# 程序员都能编程 VB,反之则不然。所以我们正在使用 VB 语言。我还想指出,上面的工具包仅适用于 VB,这可能会影响您选择的语言。(如果您需要任何帮助将本文的任何部分翻译成 C#,请在评论中提出,我很乐意为您翻译。)
入门
我们将创建一个简单的控件,用于显示我们的模拟等温线图像。由于其简单性和缺乏交互性,这就是我们开始努力的地方(而且,我们当时在这个特定代码上遇到了麻烦)。然而,由于此控件缺乏任何“点击”功能,因此它不适合尝试事件。因此,我们很快切换到了更人为的示例,但等温线图像控件为本次讨论提供了更好的工作示例。
创建新项目
首先,创建一个新项目;如上所述,我使用的是 VB6 Interop UserControl,并将其命名为 InteropIsothermControl
。展开所有内容,如果您正在使用 Interop,请找到并注释掉(或删除)Click
和 DblClick
这两个事件。
#Region "VB6 Events"
'This section shows some examples of exposing a UserControl's events to VB6.
'Typically, you just
'1) Declare the event as you want it to be shown in VB6
'2) Raise the event in the appropriate UserControl event.
'Public Shadows Event Click() 'Event must be marked as Shadows since
'.NET UserControls have the same name.
'Public Event DblClick()
'Private Sub InteropUserControl_Click(ByVal sender As Object,
'ByVal e As System.EventArgs) Handles MyBase.Click
' RaiseEvent Click()
'End Sub
'Private Sub InteropUserControl_DoubleClick(ByVal sender As Object,
'ByVal e As System.EventArgs) Handles Me.DoubleClick
' RaiseEvent DblClick()
'End Sub
#End Region
添加命名空间和成员变量
接下来,转到文件顶部并添加一个命名空间声明。(此处未显示,End Namespace
应添加到文件底部。)在类的顶部,我们需要声明我们的位图。我们暂时不想实例化它,因为我们不知道位图的大小。我们也想能够获取我们将用于位图的温度数组,这需要来自某个数据提供者。为了方便使用各种实现来编程这个数据提供者,我们使用了 Interface
对象。不过,现在我们只需要声明接口。我们将使用反射来处理我们数据源返回的数组,以提供位图的尺寸,但这将在稍后完成,当我们能够选择要绘制的位图时(也就是说,当我们能够提供一个 PieceID
string
时)。
Namespace MyCorp
<ComClass(InteropIsothermControl.ClassId, _
InteropIsothermControl.InterfaceId, InteropIsothermControl.EventsId)> _
Public Class InteropIsothermControl
Public Interface IGetIsothermTemperatures
Function GetIsothermTemperatures(ByVal PieceID As String) As Double(,)
End Interface
Private myBitmap As System.Drawing.Bitmap = Nothing
一旦您将类定义移入命名空间,它将失去对 Partial Class 定义中所有 Designer 生成的成员属性和方法的访问权限。在您的解决方案资源管理器中,单击显示所有文件的按钮。然后打开 InteropIsothermControl.Designer.vb 文件。在 Partial Class 声明的上方,添加与之前相同的命名空间声明。(再次记住,在文件底部结束命名空间。)
接口模式
这种代码模式,非常恰当地,被称为接口模式(Interface Pattern)。其思想是将显示等温线的类与收集数据的方法解耦。Interface
关键字在 VB.NET 中用于绕过单继承的限制,但我不需要继承此接口。事实上,我甚至不想在此类中实现此接口,我只想让此类拥有一个继承此接口的对象。但是,如果您使用 abstract
类在此处实现,当您决定从一个控件扩展到一系列控件时,您会遇到问题。
使用 Interface
关键字实现这一点,我们可以为每个控件定义一个单独的接口对象类型。请记住,我们希望能够实现一个数据访问层对象,该对象可以处理我们所有的数据收集需求;一个可以被所有控件共享的对象。如果我们使用接口,我们可以让一个类实现多个接口,但它只能继承自一个父类。理解这一点,我们需要包含此接口类型的成员变量(将在我们的构造函数中初始化)。
Public Class InteropIsothermControl
Public Interface IGetIsothermTemperatures
Function GetIsothermTemperatures(ByVal PieceID As String) As Double(,)
End Interface
Private myBitmap As System.Drawing.Bitmap = Nothing
Private IsothermDataProvider As IGetIsothermTemperatures = Nothing
使用 Interface
关键字实现这一点,类似于 Windows Communication Foundation (WCF) 实现 SOA 的方式。C++ 不支持 WCF,只要我们的公司继续支持 VMS 客户端——好吧,我们暂时还无法使用该选项。然而,WCF 并不是实现 SOA 服务的唯一方法。事实上,WCF 服务提供者可以在 C++ 中手动实现。但接口模式真正的美妙之处在于,您无需了解数据服务提供者的任何细节,只需知道它提供了一个名为 GetIsothermTemperatures
的函数。我传入一个包含 PieceID
的 string
,并收到一个二维数组的温度,我可以用它来构建我的等温线位图。
下一个任务是添加我们的构造函数代码来初始化我们的成员变量。我们还没有一个 Piece ID 可以传递给接口,所以我们仍然不知道我们的位图的大小。相反,我们将在 paint
方法中实例化位图。这是一个小位图,所以在 paint 事件处理程序中分配位图不会有问题。由于位图在不同零件之间大小不会改变,我可以在声明时硬编码大小,然后在 paint
方法中对其进行着色,但当它确实改变时,我们还需要在工作之间对其进行编辑。由于我从未见过这个位图图像大于 7x11,我决定承受这一影响。如果您打算将此代码用于自己的目的,您需要自己做出判断并相应地修改代码。
构造函数内部:工厂方法
如果您正在使用 Interop Toolkit,您已经在 VB6 Methods 区域中拥有一个隐藏的构造函数。将其添加是为了调用 OnCreateControl
来为 VB6 类型的主机触发 Load 事件。(据我所知,WonderWare 不需要此事件。但它不会与 WinCC 产生任何问题,所以我保留了它。)如果您不使用该工具包,则需要创建构造函数。在 VB.NET 中,这是一个名为 New
的 public
子例程。当您创建一个时,Intellisense 应该会出现,但请确保构造函数包含 InitializeComponents
调用。如果 Visual Studio 没有将此函数调用添加到继承自控件或组件的类中,则表明您已在别处定义了默认构造函数。
无论哪种情况,我们都需要分配实际的成员变量。理解接口模式的关键在于认识到成员变量 IsothermDataProvider
的类型是 IGetIsothermTemperatures
,但它被分配给了 DataAccessLayer
对象类型。我们可以这样做,因为 VB.NET 中的 Implements
关键字是一种特殊的继承类型。换句话说,DataAccessLayer
对象是 IGetIsothermTemperatures
的子实现。只要我在我的 Isotherm Control 中仅使用父声明,我就可以用任何其他实现替换整个 DataAccessLayer.vb 文件,而 Isotherm Control 将无需任何更改即可工作。这将允许我们创建一个 SqlDataAccessLayer
对象、一个 OracleDataAccessLayer
对象、一个 TcpDataAccessLayer
对象,以及在 2025 年 RFC 计划获得批准时,我们甚至可以编写一个 AITelepathicProtocolDataAccessLayer
对象。通过我们的新模块快速重新编译,我们的 Isotherm Control 将无需任何代码更改即可工作。
抱歉,我一定是做梦了。现在我清醒过来,我看到了一个直接的问题。我即将把我的 IGetIsothermTemperatures
成员变量 IsothermDataProvider
分配给一个 DataAccessLayer
对象。我们需要一种方法在此处将该成员变量分配给一个子实现,而无需引用该子实现。这个巧妙的小技巧是通过工厂模式(Factory Pattern)(或者更准确地说,是工厂方法,Factory Method)来实现的。这可以通过任何返回 IGetIsothermTemperatures
对象类型的 public
函数来实现。
工厂模式旨在用于获取多个同级对象之一,其中返回的具体子对象取决于运行时条件。然而,在实现数据访问层时,它总是一个设计时的决定,并且是静态分配的。如果您更喜欢直接在构造函数中进行分配,这并不会导致您的键盘立即自燃。但是,如果我们能以某种方式颠倒分配,使其发生在我们知道要替换的同一个文件中,从而省去编辑包含控件的文件,那就太好了,事实证明我们可以直接在 DataAccessLayer.vb 类文件中声明一个 Module。
我们将这个函数命名为 GetIsothermDataProvider
,它将返回一个 IGetIsothermTemperatures
对象,但这个函数要到我们创建实现具体 IGetIsothermTemperatures
接口类的文件时才会被定义。在那个文件的底部,我们将把这个函数定义放在一个新的 FactoryProvider
模块中。(习惯看到这里出现的小波浪线。这会持续一段时间,但我们会解决它。)目前,我们的构造函数看起来是这样的。
Public Sub New()
' This call is required by the Windows Form Designer.
InitializeComponent()
' Add any initialization after the InitializeComponent() call.
IsothermDataProvider = GetIsothermDataProvider()
'Raise Load event
Me.OnCreateControl()
End Sub
绘制等温线
在处理完架构细节后,我们就可以开始实际绘制位图了。在左侧下拉列表中选择 "(<Control Name> Events)",然后浏览右侧下拉列表直到找到 Paint。这将填充一个骨架 OnPaint
事件处理程序。首先,我们需要获取包含我们数据的数组。在这里将一个双精度数组声明为一个二维数组,并将其分配给从我们的数据供应商返回的值。由于我知道位图在零件之间不会改变,我将在这里创建一个 if
语句来检查我的成员位图变量是否为 Nothing
,这仅在第一次通过时为 true
。一旦我们知道二维温度数组的两个维度的尺寸,我们就可以用这些尺寸实例化一个位图对象。
我们的等温线图像并非旨在显示热量,而是为了突出钢材在炉内加热过程中的冷点。我们有七种不同的颜色区间,这些区间在数组的最高温度和最低温度之间平均分配。所有温度都是根据最接近的两种颜色进行线性插值计算的。此算法没有科学或教育价值,因此我不打算展示该算法。就我们而言,我们可以通过简单地将温度划分为固定的颜色区间并将温度分配给一个 Select
-Case
语句来显著减少代码量。对于好奇的读者来说,基于黑体辐射的算法可能是一个更有趣的算法,无论是科学上还是编程上,您都可以从 维基百科关于色温的文章 开始研究。我使用了一个 select
语句,它大致模拟了黑体辐射,但缩小了六倍。
在将温度转换为颜色时,我们将逐像素绘制位图。SetPixel
方法是一个慢方法,因为它必须先锁定像素的位图数组。对于我们这样大小的位图来说,这是微不足道的,但如果您要创建更大的位图,您需要先冻结位图数组,然后着色所有像素,然后解冻位图。您可以在我的代码中看到我尝试过这种方法,但当我在 WinCC 中托管此控件时,它会崩溃并声称位图已冻结,因此我将其删除。如果您需要构建更大的图像,我建议您进一步研究。如果您知道我哪里做错了,请分享,以便我能转达。
最后,由于 7x11(或更小)的图形价值不大,我们将使用 GDI+ 来执行插值和抗锯齿,以便将图像扩展到填充我们整个控件区域。像素插值在缩小图像时很重要,因为您希望找到一个颜色,该颜色是所有被压缩成单个像素的颜色的加权表示。当您拉伸图像时,这一点至关重要。当图像拉伸时,您必须猜测新像素的颜色会是什么。GDI+ 在这里提供了多种选择,最佳选择取决于您的具体情况。一旦您看到了图像显示,就可以轻松地尝试所有 GDI+ 插值方法。不幸的是,一些最适合摄影的选项并未作为内置选项提供,但 CodeProject 上有许多文章详细介绍了更高级的照片图像处理方法。在我们的案例中,高质量的双线性插值提供了最佳结果,并且在科学上是合理的。也许是因为我正在使用 ARGB 并且不知道如何关闭它,但我的图像与背景图像进行了 alpha 混合。请记住这一点来选择您的背景。
如果您曾使用 GDI+ 例程进行位图插值,您可能知道它如何偏移图像半个像素。当您使用最近邻插值(复制最近的像素)时,这一点最为明显。当您处理极小的位图区域时,这种偏移最为明显。要纠正这种行为,您需要将图像偏移半个像素。幸运的是,有一个属性可以为您做到这一点。这种偏移的原因本身就可以写一篇文章,我个人也不完全理解。我确实知道这个将它移回以进行极端缩放的属性是一个隐藏很深的“宝石”。
'Please enter any new code here, below the Interop code
Private Function BlackBodyRadiance(ByVal Temperature As Double) As Color
Select Case Temperature
Case Is < 200
BlackBodyRadiance = Color.DarkGray
Case Is < 525
BlackBodyRadiance = Color.OrangeRed
Case Is < 1123
BlackBodyRadiance = Color.DarkOrange
Case Is < 1425
BlackBodyRadiance = Color.Orange
Case Is < 1725
BlackBodyRadiance = Color.Wheat
Case Is < 2025
BlackBodyRadiance = Color.PapayaWhip
Case Is < 2325
BlackBodyRadiance = Color.AliceBlue
Case Is < 2625
BlackBodyRadiance = Color.Lavender
Case Else
BlackBodyRadiance = Color.LightSteelBlue
End Select
End Function
Private Sub InteropIsothermControl_Paint(ByVal sender As Object, _
ByVal e As System.Windows.Forms.PaintEventArgs) Handles Me.Paint
'Retrieve our array of temperatures from our data access layer.
Dim IsothermColors As Double(,) = _
IsothermDataProvider.GetIsothermTemperatures("TestPiece")
'Instantiate a bitmap, but only if it's not already instantiated.
If myBitmap Is Nothing Then
myBitmap = New Bitmap(IsothermColors.GetUpperBound(0) + 1, _
IsothermColors.GetUpperBound(1) + 1)
End If
Debug.Assert(myBitmap.Width = IsothermColors.GetUpperBound(0) + 1, _
"You're isotherm bitmap changed sizes.")
Debug.Assert(myBitmap.Height = IsothermColors.GetUpperBound(1) + 1, _
"You're isotherm bitmap changed sizes.")
'Pin our bitmap in memory to increase speed.
'Dim myBitmapData As System.Drawing.Imaging.BitmapData = _
myBitmap.LockBits(New Rectangle(New Point(0, 0), myBitmap.Size), _
Imaging.ImageLockMode.WriteOnly, myBitmap.PixelFormat)
For x As Integer = 0 To myBitmap.Width - 1
For y As Integer = 0 To myBitmap.Height - 1
myBitmap.SetPixel(x, y, BlackBodyRadiance(IsothermColors(x, y)))
Next
Next
'Remember to unpin the bitmap array so the GC can do it's magic.
'myBitmap.UnlockBits(myBitmapData)
'You should play with the interpolation mode, smoothing mode and pixel
'offset just for fun.
e.Graphics.InterpolationMode = _
System.Drawing.Drawing2D.InterpolationMode.HighQualityBilinear
e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias
e.Graphics.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.Half
'Allow GDI+ to scale your image for you.
e.Graphics.DrawImage_
(myBitmap, 0, 0, Me.ClientSize.Width, Me.ClientSize.Height)
End Sub
End Class
End Namespace
正如您在代码中看到的,我们传递了一个 static string
作为 Piece ID。通常,这会来自一个 private
成员变量,该变量通过一个 public
属性设置。public
属性会设置 private
变量并调用刷新。我在这里进行了一些修改以简化设计,以便我们可以专注于本次讨论的主题。
数据访问层
现在我们已经完成了控件,我们需要创建一个新类来为我们的控件提供数据。从您的解决方案资源管理器中,右键单击解决方案并选择“添加新项”。选择 Class,并将类名更改为一个有意义的名称,例如 DataAccessLayer
。在新文件的顶部添加您的命名空间。在文件底部结束该命名空间。在类声明下方,添加 Implements InteropIsothermControl.IGetIsothermTemperatures
。Intellisense 将在此处插入 GetIsothermTemperatures()
函数的骨架。在这种情况下,我们忽略 PieceID
字符串,并将返回一个 static
的 3x3 数组。我们可以稍后重写这部分代码,以使用套接字、OPC、SQL 查询或其他方法从持续模拟钢坯通过炉膛的 Level 2 炉膛模型中获取此数据。
我应该指出,即使 DataAccessLayer
类不在同一个命名空间中,这也可以工作。无论命名空间如何,我们也希望在此文件中实现 GetDataAccessLayerObject()
函数。在您的 End Class
之后,声明 Module FactoryMethod
。一旦您在 DataAccessLayer
类中实现了接口,该类将被识别为 IGetIsothermTemperatures
类型。在您的函数中,您将创建一个新的并将其返回。
Namespace MyCorp
Public Class DataAccessLayer
Implements InteropIsothermControl.IGetIsothermTemperatures
Public Function GetIsothermTemperatures(ByVal PieceID As String) As Double(,) _
Implements MyCorp.InteropIsothermControl.IGetIsothermTemperatures._
GetIsothermTemperatures
Dim TemperatureArray As Double(,) = {{159, 240, 136}, _
{240, 1150, 229}, {133, 239, 160}}
Return TemperatureArray
End Function
End Class
Module FactoryMethod
Public Function GetIsothermDataProvider() As _
InteropIsothermControl.IGetIsothermTemperatures
Return New DataAccessLayer
End Function
End Module
End Namespace
单例模式
因为这个库中只有一个控件,并且数据不是通过有限资源(如套接字)获取的,所以这实际上应该可以工作。然而,如果您向此库添加另一个控件并尝试在两个控件之间共享数据,您会很快意识到,每个获取 DataAccessLayer
对象(无论什么类型)的新控件都会拥有自己的副本。为了防止这种情况,我们需要使用单例模式(Singleton Pattern)。
谁还记得 DefInstance
?好了,安静点!我知道,我知道,相信我,我知道。我不好意思承认,但我们代码中仍然有它们。我们办公室使用一种相当粗暴的配置管理机制——我们随同每个项目一起提供整个开发环境,包括 Visual Studio 和源代码。因此,我们不会快速为开发人员购买 Visual Studio。如果有人被派往现场(或者更常见的是,远程连接到站点),他们所需的一切都已在那里预装好了。粗暴,但有效,我不能说它不便宜,尤其是开发工具都是由客户支付的。(在我以前的工作中,我们会非常喜欢这些人!我们可以将一套完整的开发工具作为硬件采购费用报销!)
关键在于,在 .NET 推出的时候,我们正经历钢铁行业的严重繁荣,没有时间对代码进行深思熟虑的重新解释。我们那时发现了许多人正在痛苦地通过 Visual Studio 的每个新版本学习的东西——如果买不到旧版本的开发环境,向后不兼容性就很糟糕。微软的政策是,一旦新版本发货,就从货架上撤下过剩产品。我们被迫将代码从 VB6 升级到当时最新的 VB.NET 版本,并且必须在正常的项目周期内完成整个转换。喜欢它与否是可选的。因此,我们从自动 VB.NET 升级工具中获得的 DefInstances
仍然存在于我们的代码中。
尽管这很有趣,但我之所以提到 DefInstance
,是因为它提供了一个单例模式(Singleton Pattern)的好例子。无论您请求多少次 DefInstance
的副本,它都会给您一个指向已实例化对象的引用。只有当它不存在时,您才会获得一个新的。为了实现这种魔法,我们需要一个共享的 private
成员变量,我们称之为 m_Singleton
,以及一个共享的、只读的属性,我们称之为——抱歉,我忍不住——DefInstance
。这些被添加到 DataAccessLayer
类中。在 GetDataAccessLayerObject()
函数中,我们将返回 DataAccessLayer
类的 DefInstance
,而不是返回一个新对象。重写如下。
Namespace MyCorp
Public Class DataAccessLayer
Implements InteropIsothermControl.IGetIsothermTemperatures
Private Shared m_Singleton As DataAccessLayer = Nothing
Public Shared ReadOnly Property DefInstance()
Get
If m_Singleton Is Nothing Then
m_Singleton = New DataAccessLayer
End If
Return m_Singleton
End Get
End Property
Public Function GetIsothermTemperatures(ByVal PieceID As String) As Double(,) _
Implements MyCorp.InteropIsothermControl.IGetIsothermTemperatures._
GetIsothermTemperatures
Dim TemperatureArray As Double(,) = {{159, 240, 136}, _
{240, 1150, 229}, {133, 239, 160}}
Return TemperatureArray
End Function
End Class
Module FactoryProvider
Public Function GetIsothermDataProvider() _
As InteropIsothermControl.IGetIsothermTemperatures
Return DataAccessLayer.DefInstance
End Function
End Module
End Namespace
现在,一个纯粹主义者会争辩说这个实现是不完整的,因为我没有将默认(无参数)构造函数设为 private
。因此,下一个冒失鬼可以实例化十几个这样的对象,并彻底搞乱我们的代码。因为这是真实的可能性,所以您应该在此代码中添加两件事:一个 private
的默认构造函数,以及一个注释,解释为什么删除它或将其更改为 public
是一个非常、非常糟糕的主意。请注意,intellisense 不会添加对 InitializeComponents
的调用,因为此类不继承自控件或组件。
总结
首先在 Visual Studio 中托管
您应该做的第一件事是将此控件添加到标准的 Windows 窗体中。如果有什么不对劲,比如我的 LockBits,您将从 Visual Studio 获得比从 WinCC 或 WonderWare 获得的更多调试信息。要做到这一点,创建一个新的 Windows 应用程序。打开图形设计器,右键单击工具箱并选择“选择项”。浏览到您在构建控件时获得的 DLL 的位置。一个新控件将被添加到您的工具箱中,您可以将其拖放到窗体上。如果您需要单步执行控件代码,您可以在一个 Visual Studio 实例中打开控件,然后附加到您托管控件的 Visual Studio 的 devenv.exe。希望您永远不需要这样做,但如果控件仅仅因为将其拖放到窗体上就崩溃了,那么这是一个很棒的技巧。
我希望我能有机会写至少一篇关于这个主题的后续文章。由于库中只有一个控件,您无法验证数据访问层类是否支持控件之间的数据共享。在我们完成之前,我的搭档被派往现场。因此,这种方法仅在 WinCC 中进行了测试,并且如预期那样工作。(这就是为什么本文缺少更多关于 WonderWare 的信息。)希望我不仅能提供更详细的信息,还能添加至少一个额外的控件,以便读者能够跟随。
在 WinCC 中托管
服务器
一旦在 Visual Studio 中完成,您应该在将此控件插入 WinCC picture 之前编译发布版本。当您将其插入 WinCC picture 时,您应该使用 Smart Objects 的 .NET Control,而不是尝试将控件库添加到 .NET Controls 列表中。当您添加控件库时,您只会获得库中的第一个控件。如果您使用 Smart Object 浏览,最后一步将显示库中所有控件的列表,您可以进行选择。
客户端
我现在无法访问运行 Client WinCC 软件的计算机,但文档 Working with WinCC 确实包含了有关将自定义嵌入式控件复制到客户端计算机的信息。我相信主要问题是确保包含已编译控件的文件在所有计算机上的位置相同。
在 WonderWare 的 InTouch 中托管
我知道此工作产生的控件可以在 WonderWare 的 InTouch 中托管。但是,我不确定是否必须通过 regsvr32、regasm、将此控件添加到全局程序集缓存 (GAC) 或其他方式进行注册。如果您有关于在 WonderWare 的 InTouch 的服务器或客户端上托管此控件的信息,请联系我并提供详细信息,以便我在此添加这些信息。特别是,请确保包含您可以发布的确切说明,并说明安装是针对自定义控件的服务器还是客户端安装。
避免远程桌面
我将分享一个我看到越来越多的人抱怨的最后一个奇怪的经历——GDI+ 知道它何时在远程模式下运行。因此,微软完全不顾用户偏好,为了提高性能(他们声称),擅自禁用了除最近邻以外的所有混合模式。显然,微软的网络工程师甚至无法让光纤网络连接足够快,以在远程桌面连接上渲染一个混合的 3x3 位图。如果这很重要,请使用 VNC。(我们曾考虑过将位图旋转后,再将其旋转到正确的位置,但很高兴地发现 VNC 在这方面确实会遵守用户偏好。)
祝您好运!
您可以在 这里 阅读本文的续篇。
历史
- 更新的 Working with WinCC 链接。2017 年 11 月 10 日
- 将“VPN”远程桌面更正为“VNC”。2011 年 2 月 14 日
- 删除公司引用。2010 年 10 月 8 日
- 更改调用方式以使用新的侧边栏。2010 年 10 月 1 日
- WinCC v7 的 Service Pack 2 发布,解决了 .NET 支持的不足。2010 年 9 月 22 日
- 通过调用来增加视觉趣味。修正了几处拼写错误。2010 年 9 月 14 日
- 解决了工厂方法调用问题。修正了语法错误。改进了几段的清晰度。2010 年 9 月 11 日
- 首次发布于 2010 年 9 月 9 日