如果你用 WPF 做工业上位机,想做出西门子 WinCC / TIA Portal 那种 HMI 画面——固定比例的工艺流程图、青绿色管道、带 AUT/MANUAL 标签的泵阀符号、会起伏的罐体液位——核心做法不是找一套第三方控件库,而是用组态软件的思路组织 WPF:固定分辨率 Canvas 绝对定位 + Viewbox 等比缩放 + 一套带依赖属性的自定义符号控件。
关键步骤只有四步:
Viewbox 包裹固定尺寸 Canvas,窗口缩放画面不变形;
- 管道、颜色、按钮风格全部收进
App.xaml 资源字典;
- 泵、阀、罐做成
UserControl,用依赖属性暴露状态;
- 液位用
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 的常规布局思路

做业务系统时,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();
}
}
}

为什么必须用依赖属性而不是普通 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",触摸屏上直接点泵就能切手自动。
罐体分两层:外壳用水平渐变模拟不锈钢圆柱(两边暗、中间偏左一道高光),液位是底部升起的蓝色矩形。
最直觉的做法是绑定液位矩形的 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