找回密码
立即注册
搜索
发回帖 发新帖

5345

积分

0

好友

685

主题
发表于 2 小时前 | 查看: 3| 回复: 0

如果你用 WPF 做工业上位机,想做出西门子 WinCC / TIA Portal 那种 HMI 画面——固定比例的工艺流程图、青绿色管道、带 AUT/MANUAL 标签的泵阀符号、会起伏的罐体液位——核心做法不是找一套第三方控件库,而是用组态软件的思路组织 WPF:固定分辨率 Canvas 绝对定位 + Viewbox 等比缩放 + 一套带依赖属性的自定义符号控件。

关键步骤只有四步:

  1. Viewbox 包裹固定尺寸 Canvas,窗口缩放画面不变形;
  2. 管道、颜色、按钮风格全部收进 App.xaml 资源字典;
  3. 泵、阀、罐做成 UserControl,用依赖属性暴露状态;
  4. 液位用 ScaleTransform 实现,状态变色用 DataTrigger。

最小骨架长这样:

<Window Background="#D5DBE0">
  <Viewbox Stretch="Uniform">
    <Canvas Width="1080" Height="608">
      <Rectangle Canvas.Left="115" Canvas.Top="85" Width="444"
                 Style="{StaticResource PipeH}"/>
      <c:PumpSymbol Canvas.Left="52" Canvas.Top="12"
                    Percent="48" Mode="AUT" Direction="Up"/>
      <c:TankVertical Canvas.Left="34" Canvas.Top="70" Level="0.70"/>
    </Canvas>
  </Viewbox>
</Window>

下面用一个完整的水处理厂演示项目(.NET 8 / WPF,20 个自定义控件,可直接编译运行)把每一步讲清楚。最容易踩的坑在液位实现上:改 Height 和用 ScaleTransform,看起来效果一样,渲染管线的开销完全不同。

一、为什么不能用 WPF 的常规布局思路

西门子水处理厂HMI工艺流程界面

做业务系统时,WPF 的标准做法是 Grid 分行分列、控件自适应拉伸。这套思路搬到 HMI 画面上会直接翻车。

HMI 画面本质上是一张"图",不是一个"表单"。 泵必须画在管道的正上方,阀门必须卡在管段中间,罐体和液位要对齐到像素。一旦用 Grid 自适应,现场显示器分辨率从 1920×1080 变成 1366×768,管道和符号就错位了。

组态软件(WinCC、InTouch、组态王)的做法是反过来的:先定一个设计分辨率,所有元素绝对坐标摆放,运行时整体等比缩放。 WPF 里正好有两个原生机制对应:

  • Canvas:用 Canvas.Left / Canvas.Top 绝对定位,不参与自适应布局;
  • Viewbox:把固定尺寸的内容等比缩放到可用空间,Stretch="Uniform" 保证不变形。

第二个问题是风格一致性。西门子 HMI 的辨识度来自一套严格的视觉规范:青绿(#2FA79C)管道、灰白金属罐体、统一的标签字号。如果每个控件里写死颜色,改一次主题色要翻几十个文件。所以颜色、画刷、按钮模板必须集中在资源字典。

第三个问题是符号复用。一个水厂画面里有 7 台泵、5 个阀、6 个罐,每个泵都有运行百分比、AUT/MANUAL 模式、手动高亮、流向箭头四种状态。复制粘贴 XAML 不可维护,必须做成带依赖属性的控件,让状态驱动外观。

二、布局骨架:Viewbox + 固定 Canvas

主窗口的全部布局只有一层:

<Window Title="Water Treatment Plant" Width="1150" Height="690"
        WindowStartupLocation="CenterScreen" Background="#D5DBE0">
  <Viewbox Stretch="Uniform">
    <Canvas Width="1080" Height="608">
      <!-- 顶栏、标题、设备单元、管道、导航栏 -->
    </Canvas>
  </Viewbox>
</Window>

三个要点:

  • 设计分辨率定为 1080×608,所有坐标都在这个坐标系里量出来,和最终窗口大小无关;
  • Viewbox 负责缩放,窗口拉到 4K 或缩到一半,画面等比变化,不会错位、不变形;
  • 设备之间的连接管道就是普通 Rectangle,用 Canvas.Left/Top 卡在两个设备中间。

这和 WinCC 画面编辑器的行为一致:你在设计器里看到什么,现场就看到什么。

三、全局风格:把西门子色系收进资源字典

App.xaml 里集中定义三类资源:管道画刷、文字样式、按钮模板。

管道是 HMI 画面里出现次数最多的元素,用渐变模拟金属圆管的高光:

<LinearGradientBrush x:Key="PipeGradH" StartPoint="0,0" EndPoint="0,1">
  <GradientStop Color="#4AC2B8" Offset="0"/>
  <GradientStop Color="#2AA398" Offset="0.5"/>
  <GradientStop Color="#1B7F76" Offset="1"/>
</LinearGradientBrush>

<Style x:Key="PipeH" TargetType="Rectangle">
  <Setter Property="Height" Value="6"/>
  <Setter Property="Fill" Value="{StaticResource PipeGradH}"/>
</Style>

<Style x:Key="PipeV" TargetType="Rectangle">
  <Setter Property="Width" Value="6"/>
  <Setter Property="Fill" Value="{StaticResource PipeGradV}"/>
</Style>

水平管固定高度 6px、垂直管固定宽度 6px,中间亮两边暗,视觉上就是一根圆管。项目里另有一套藏青色 PipeNavyH/V 用于出水段,通过 BasedOn 继承只改画刷。

按钮模板同理。西门子 HMI 的按钮是直角小圆角、青底白字,用 ControlTemplate 重画一次,全局复用:

<Style x:Key="GrpBtn" TargetType="Button">
  <Setter Property="FocusVisualStyle" Value="{x:Null}"/>
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="Button">
        <Border Background="#2FA79C" BorderBrush="#1F8A80"
                BorderThickness="1" CornerRadius="3">
          <ContentPresenter HorizontalAlignment="Center"
                            VerticalAlignment="Center"
                            TextElement.FontSize="10"
                            TextElement.FontWeight="Bold"
                            TextElement.Foreground="White"/>
        </Border>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

注意 FocusVisualStyle 设为 {x:Null}——HMI 是触摸屏场景,按钮获得焦点时的虚线框会非常出戏。

四、符号控件:依赖属性 + DataTrigger 驱动状态

泵符号是整套界面里状态最多的控件:百分比框、圆形泵体、四向箭头、AUT/MANUAL 标签、Sim 仿真标记、手动高亮。外观全部用 XAML 画,状态全部用依赖属性暴露。

依赖属性的标准注册方式:

public static readonly DependencyProperty ManualProperty =
    DependencyProperty.Register("Manual", typeof(bool), typeof(PumpSymbol),
    new FrameworkPropertyMetadata(false, OnManualChanged));

public bool Manual
{
    get { return (bool)GetValue(ManualProperty); }
    set { SetValue(ManualProperty, value); }
}

private static void OnManualChanged(DependencyObject d,
    DependencyPropertyChangedEventArgs e)
{
    var p = (PumpSymbol)d;
    if ((bool)e.NewValue && p.Mode == "AUT") p.Mode = "MANUAL";
    else if (!(bool)e.NewValue && p.Mode == "MANUAL") p.Mode = "AUT";
}
using System.Windows;
using System.Windows.Controls;

namespace WaterTreatmentPlant.Controls
{
    public partial class PumpUnit : UserControl
    {
        public static readonly DependencyProperty PercentProperty = DependencyProperty.Register(
            "Percent", typeof(double), typeof(PumpUnit),
            new FrameworkPropertyMetadata(0.0, (d, e) => ((PumpUnit)d).pump.Percent = (double)e.NewValue));

        public static readonly DependencyProperty ModeProperty = DependencyProperty.Register(
            "Mode", typeof(string), typeof(PumpUnit),
            new FrameworkPropertyMetadata("AUT", (d, e) => ((PumpUnit)d).pump.Mode = (string)e.NewValue));

        public static readonly DependencyProperty ManualProperty = DependencyProperty.Register(
            "Manual", typeof(bool), typeof(PumpUnit),
            new FrameworkPropertyMetadata(false, (d, e) => ((PumpUnit)d).pump.Manual = (bool)e.NewValue));

        public static readonly DependencyProperty DirectionProperty = DependencyProperty.Register(
            "Direction", typeof(string), typeof(PumpUnit),
            new FrameworkPropertyMetadata("Right", (d, e) => ((PumpUnit)d).pump.Direction = (string)e.NewValue));

        public static readonly DependencyProperty HighlightProperty = DependencyProperty.Register(
            "Highlight", typeof(bool), typeof(PumpUnit),
            new FrameworkPropertyMetadata(false, (d, e) => ((PumpUnit)d).pump.Highlight = (bool)e.NewValue));

        public static readonly DependencyProperty ShowSimProperty = DependencyProperty.Register(
            "ShowSim", typeof(bool), typeof(PumpUnit),
            new FrameworkPropertyMetadata(true, (d, e) => ((PumpUnit)d).pump.ShowSim = (bool)e.NewValue));

        public double Percent
        {
            get { return (double)GetValue(PercentProperty); }
            set { SetValue(PercentProperty, value); }
        }

        public string Mode
        {
            get { return (string)GetValue(ModeProperty); }
            set { SetValue(ModeProperty, value); }
        }

        public bool Manual
        {
            get { return (bool)GetValue(ManualProperty); }
            set { SetValue(ManualProperty, value); }
        }

        public string Direction
        {
            get { return (string)GetValue(DirectionProperty); }
            set { SetValue(DirectionProperty, value); }
        }

        public bool Highlight
        {
            get { return (bool)GetValue(HighlightProperty); }
            set { SetValue(HighlightProperty, value); }
        }

        public bool ShowSim
        {
            get { return (bool)GetValue(ShowSimProperty); }
            set { SetValue(ShowSimProperty, value); }
        }

        public PumpSymbol Pump
        {
            get { return pump; }
        }

        public PumpUnit()
        {
            InitializeComponent();
        }
    }
}

WPF中PumpUnit自定义控件的设计视图与XAML代码

为什么必须用依赖属性而不是普通 CLR 属性? 只有依赖属性才能被 XAML 里的 DataTrigger 监听、被绑定驱动、参与属性值继承。普通属性赋值不会触发任何 UI 更新。

外观切换完全写在 XAML 里,C# 一行都不用碰。手动模式时泵体变绿、描边变黄:

<Ellipse>
  <Ellipse.Style>
    <Style TargetType="Ellipse">
      <Setter Property="Fill" Value="#F4FCFA"/>
      <Setter Property="Stroke" Value="#2FA79C"/>
      <Style.Triggers>
        <DataTrigger Value="True"
                     Binding="{Binding Manual, RelativeSource={RelativeSource AncestorType=UserControl}}">
          <Setter Property="Fill" Value="#7CB342"/>
          <Setter Property="Stroke" Value="#F2C200"/>
        </DataTrigger>
      </Style.Triggers>
    </Style>
  </Ellipse.Style>
</Ellipse>

流向箭头是四个预画的 Path(上下左右四个三角形),用 DataTrigger 按 Direction 属性控制显隐,只显示一个方向。状态逻辑在 XAML 触发器里,数据在依赖属性里,C# 只负责转发——这是 WPF 符号控件和标准组态符号最接近的写法。

还有两个细节值得抄:

  • 可选元素用"哨兵值"控制显隐:Percent 默认值是 double.NaN,配一个 NanToVisibleConverter,NaN 时百分比框整体 Collapsed;Mode 为空字符串时模式标签隐藏。不需要显隐开关属性。
  • 点击切换手动:MouseLeftButtonDown 里 Manual = !Manual,配合 Cursor="Hand",触摸屏上直接点泵就能切手自动。

五、罐体液位:用 ScaleTransform,不要改 Height

罐体分两层:外壳用水平渐变模拟不锈钢圆柱(两边暗、中间偏左一道高光),液位是底部升起的蓝色矩形。

最直觉的做法是绑定液位矩形的 Height。不要这么做——Height 是布局属性,每次液位变化都触发一次 Measure/Arrange 布局重算,画面上十几个罐同时刷新时,UI 线程会做大量无效布局工作。

正确的做法是把液位画满,再用 ScaleTransform 从底部压缩:

<Grid Canvas.Left="5" Canvas.Top="18" Width="78" Height="135"
      ClipToBounds="True">
  <StackPanel VerticalAlignment="Bottom" RenderTransformOrigin="0.5,1">
    <StackPanel.RenderTransform>
      <ScaleTransform ScaleY="{Binding Level,
                  RelativeSource={RelativeSource AncestorType=UserControl}}"/>
    </StackPanel.RenderTransform>
    <Rectangle Height="2" Fill="#BFEAF6" Opacity="0.67"/>
    <Rectangle Height="133">
      <Rectangle.Fill>
        <LinearGradientBrush StartPoint="0,0" EndPoint="0,1">
          <GradientStop Color="#6FCDE9" Offset="0"/>
          <GradientStop Color="#116EA4" Offset="1"/>
        </LinearGradientBrush>
      </Rectangle.Fill>
    </Rectangle>
  </StackPanel>
</Grid>

三个关键点:

  • RenderTransformOrigin="0.5,1" 让缩放以底部中心为原点,液面从罐底升起;
  • ScaleY 绑定 Level(0~1),液位 0.7 就是压缩到 70%;
  • 外层 Grid 设 ClipToBounds="True",超出罐体的部分裁掉。

RenderTransform 只影响渲染,不触发布局,液位刷新的开销就是一次重绘。顶部再放一个 2px 的浅色矩形当"液面线",视觉上就是真实的水面。

罐体外形本身全是渐变画出来的:顶部椭圆当罐口封头、底部椭圆当罐底、中间矩形用 #B4BCC3 → #F3F6F8 → #969FA7 三段渐变模拟圆柱受光。不需要任何图片资源,缩放不失真,这也是矢量 HMI 相对位图组态的优势。

六、组合单元:父控件转发属性,主窗口只认"设备"

单个符号还不够用——现场的一台"喷泉单元"是罐 + 泵 + 两个数显框 + 标题的组合。做法是再包一层 UserControl,父控件的依赖属性在回调里转发给内部子控件:

public static readonly DependencyProperty LevelProperty =
    DependencyProperty.Register("Level", typeof(double), typeof(FountainUnit),
    new FrameworkPropertyMetadata(0.7,
            (d, e) => ((FountainUnit)d).tank.Level = (double)e.NewValue));

public static readonly DependencyProperty FlowTextProperty =
    DependencyProperty.Register("FlowText", typeof(string), typeof(FountainUnit),
    new FrameworkPropertyMetadata("0.00",
            (d, e) => ((FountainUnit)d).flowBox.ValueText = (string)e.NewValue));

这样主窗口面对的不再是几十个零散控件,而是"Fountain 1""Filter 2""Clean Water Tank 1"这些有业务含义的设备单元:

<c:FountainUnit x:Name="fountain1" Canvas.Left="38" Canvas.Top="88"
                Title="Fountain 1" Percent="22" Highlight="True"
                FlowText="25.76" LevelText="70.44" Level="0.70"/>

数显框 ValueBox 支持 Accent="Teal/Red/Yellow" 三种配色,红色用于报警值、黄色用于需要关注的量——正常青绿、异常变红,这是 HMI 的通用语言。画刷在静态构造时 Freeze() 掉,避免每次刷新重复创建。

数据刷新用 DispatcherTimer 每 400ms 走一步随机游走模拟量,直接给单元属性赋值:

private readonly DispatcherTimer _sim =
    new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) };

private void SimStep()
{
    _f1Flow = Walk(_f1Flow, 0.10, 24.50, 27.00);
    fountain1.FlowText = _f1Flow.ToString("0.00", Inv);
    fountain1.Level = Walk(_lvlF1, 0.01, 0.66, 0.74);
    // ...其余单元同理
}

Walk 是一个带上下限钳制的随机游走,让数值在合理区间内小幅波动。接真实 PLC 时,把 SimStep 换成 S7/OPC UA 读取循环即可,界面层一行不用改——这正是属性转发分层的好处。

顶栏的西门子 Logo、时钟、图标按钮,底部导航栏的选中态切换,都是同样的套路:控件内部处理外观,通过路由事件(PowerClicked)或普通事件(NavSelected)通知主窗口。

七、边界:这套方案什么时候不适用

这套"Canvas 组态布局 + 依赖属性符号库"适合画面元素固定、以状态监视为主的 HMI/SCADA 概览画面:水处理、泵站、暖通、产线看板都合适。Viewbox 等比缩放对触摸屏一体机、监控大屏尤其友好。

但有几个明确的边界:

  • 不适合数据密集交互界面。 如果画面主要是表格、表单、曲线配置,老老实实回 Grid + DataGrid,绝对定位只会碍事。
  • Viewbox 缩放会连字号一起缩。 设计分辨率 1080 宽、现场屏幕 4K 时文字等比放大,通常没问题;但如果现场屏幕比设计分辨率小很多,小字号(项目里最小 7.5px)会糊。设计分辨率要按现场最小屏幕定。
  • 依赖属性回调转发是单向的。 子控件内部状态变化(比如用户点泵切了 Manual)不会自动回写到父单元属性,需要父控件主动暴露子控件(项目里的 Pump 属性)或补一层回传事件。接真实 PLC 时,手自动状态必须以 PLC 侧为准,界面点击只是下发请求。
  • 400ms 定时器直接刷 UI 只适合演示。 生产环境采集线程和 UI 线程要分开:后台线程读 PLC,UI 按自己的节奏取最新值,高频采集场景还要加节流,否则 UI 线程会被刷新占满。
  • 元素极多时 Canvas 绝对定位维护成本高。 几百个 IO 点的大型画面,应考虑按区域拆成多个 UserControl 画布,或者干脆评估成熟的组态软件——自己画符号库是有维护代价的。

结尾

WPF 做西门子 HMI 风格界面,关键不在某个具体控件,而在三个决定:用 Canvas + Viewbox 把画面当"图"处理,用资源字典把风格收敛到一处,用依赖属性 + DataTrigger 让状态驱动外观。 液位记得用 ScaleTransform 而不是 Height,这是渲染开销上最实在的一个细节。

如果你的项目正要起步,建议先把泵、阀、罐、数显框这四个出现频率最高的符号做扎实,其余设备单元都是它们的组合。你在工控界面里做得最多的符号是哪一种?是泵阀这类状态符号,还是趋势曲线和报警列表?


END




上一篇:TVS二极管摆放位置有多关键?同一颗TVS,芯片引脚电压从393V降到47V
下一篇:Winform 多线程实战:Invoke、BackgroundWorker 与 async/await 三种 UI 更新方案
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-6 03:11 , Processed in 0.089651 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表