:自定义控件设计——封装工业UI组件)
自定义控件设计封装工业UI组件这是「从零搭建工业控制系统」系列第17篇。上一篇讲了Dock面板布局这篇讲布局里放的东西——自定义控件。工业系统的UI元素跟普通软件不一样WPF自带的控件不够用得自己封装。为什么要自定义控件WPF自带 CheckBox、Button、Slider 这些控件。但在工业场景里这些控件不够用。举个例子一个电磁阀线圈在UI上要显示什么线圈名称比如进气阀当前状态开/关高低电平逻辑有的线圈高电平开有的高电平关是否受保护受保护的线圈不能手动操作点击后要发写入命令还要弹确认对话框用 CheckBox 做的话名称得单独放个 TextBlock电平逻辑得在 ViewModel 里处理确认对话框得在 Command 里处理。每个线圈都这么搞一遍XAML 里一堆重复代码。做成自定义控件后一行XAML搞定controls:CoilToggleControlCoilName{Binding Name}CoilValue{Binding CoilValue}IsProtected{Binding IsProtected}WriteCommand{Binding WriteCommand}LogicType{Binding LogicType}/封装的目的是减少重复、统一风格、隔离变化。控件一线圈开关CoilToggleControl这是项目里用得最多的控件。工业设备有大量数字IO线圈每个都需要在UI上显示和操作。依赖属性自定义控件的核心是依赖属性DependencyProperty。控件需要从外部接收数据这些数据通过依赖属性暴露publicclassCoilToggleControl:Control{publicstaticreadonlyDependencyPropertyCoilNamePropertyDependencyProperty.Register(nameof(CoilName),typeof(string),typeof(CoilToggleControl));publicstaticreadonlyDependencyPropertyCoilValuePropertyDependencyProperty.Register(nameof(CoilValue),typeof(bool),typeof(CoilToggleControl),newFrameworkPropertyMetadata(false,FrameworkPropertyMetadataOptions.BindsDirectlyToSource));publicstaticreadonlyDependencyPropertyIsProtectedPropertyDependencyProperty.Register(nameof(IsProtected),typeof(bool),typeof(CoilToggleControl));publicstaticreadonlyDependencyPropertyLogicTypePropertyDependencyProperty.Register(nameof(LogicType),typeof(LogicType),typeof(CoilToggleControl));publicstringCoilName{get(string)GetValue(CoilNameProperty);setSetValue(CoilNameProperty,value);}publicboolCoilValue{get(bool)GetValue(CoilValueProperty);setSetValue(CoilValueProperty,value);}publicboolIsProtected{get(bool)GetValue(IsProtectedProperty);setSetValue(IsProtectedProperty,value);}publicLogicTypeLogicType{get(LogicType)GetValue(LogicTypeProperty);setSetValue(LogicTypeProperty,value);}}控件模板控件的外观用 ControlTemplate 定义。WPF 的好处是逻辑和外观分离——控件类只管逻辑外观在 XAML 里定义可以随时换样式StyleTargetTypecontrols:CoilToggleControlSetter PropertyTemplate Setter.Value ControlTemplate TargetTypecontrols:CoilToggleControl Border CornerRadius4Padding8,4Background{TemplateBinding Background}BorderBrush{TemplateBinding BorderBrush}BorderThickness1 StackPanel OrientationHorizontal Ellipse Width10Height10Fill{Binding CoilValue, Converter{StaticResource BoolToColorConverter}, RelativeSource{RelativeSource TemplatedParent}} / TextBlock Text{TemplateBinding CoilName}Margin8,0,0,0/ /StackPanel /Border /ControlTemplate /Setter.Value /Setter/StyleTemplateBinding把模板内的绑定连到控件的依赖属性上。这是自定义控件的关键——模板里用的数据来自控件本身不是来自 DataContext。高低电平逻辑工业设备有个讨厌的特性不是所有线圈都是高电平开。有的线圈是 active_high高电平开有的是 active_low低电平开。如果不处理这个UI上显示的状态可能跟实际相反——操作员以为阀门关了实际还开着。在控件里处理publicboolDisplayValue{get{returnLogicTypeLogicType.ActiveLow?!CoilValue:CoilValue;}}显示用 DisplayValue逻辑值通信用 CoilValue物理值。操作员看到的是开/关不用关心底层电平。控件二模拟仪表AnalogGaugeControl模拟量压力、温度、流量需要显示数值单位范围。用 TextBox 显示一个数字太简陋操作员看不出这个值是高是低。自定义仪表控件包含数值显示大字体单位小字体范围指示最小值~最大值颜色预警接近上限变黄超限变红controls:AnalogGaugeControlValue{Binding PressureValue}UnitTorrMinValue0MaxValue760WarningThreshold700DangerThreshold750/颜色预警的逻辑publicBrushValueColor{get{if(ValueDangerThreshold)returnBrushes.Red;if(ValueWarningThreshold)returnBrushes.Orange;returnBrushes.Green;}}这样操作员一眼就能看出哪个值在安全范围、哪个快超限了。比纯数字直观得多。DataTemplate选择器IO列表里有不同类型的设备DI、DO、AI、AO每种类型的展示方式不同。DI/DO 用开关控件AI/AO 用仪表控件。用DataTemplateSelector根据数据类型选择模板publicclassIOTemplateSelector:DataTemplateSelector{publicDataTemplateDigitalInputTemplate{get;set;}publicDataTemplateDigitalOutputTemplate{get;set;}publicDataTemplateAnalogInputTemplate{get;set;}publicDataTemplateAnalogOutputTemplate{get;set;}publicoverrideDataTemplateSelectTemplate(objectitem,DependencyObjectcontainer){if(itemisModbusCoilcoil){returncoil.RegisterTypeswitch{RegisterType.DIDigitalInputTemplate,RegisterType.DODigitalOutputTemplate,RegisterType.AIAnalogInputTemplate,RegisterType.AOAnalogOutputTemplate,_base.SelectTemplate(item,container)};}returnbase.SelectTemplate(item,container);}}XAML里定义多套模板Grid.ResourcesDataTemplatex:KeyDigitalInputTemplatecontrols:CoilToggleControlCoilName{Binding Name}...//DataTemplateDataTemplatex:KeyAnalogInputTemplatecontrols:AnalogGaugeControlValue{Binding AnalogValue}...//DataTemplatelocal:IOTemplateSelectorx:KeyIOSelectorDigitalInputTemplate{StaticResource DigitalInputTemplate}AnalogInputTemplate{StaticResource AnalogInputTemplate}//Grid.ResourcesItemsControlItemTemplateSelector{StaticResource IOSelector}/一个 ItemsControl 就能自动按数据类型选模板渲染。新增IO类型时只需要加一个 DataTemplate不用改选择器逻辑。控件三进度条EllipticalProgressBehaviorWPF自带的进度条是直线的。工业系统有时候需要环形进度条——比如加热器升温进度用环形更直观。用 Behavior 而不是 Control 实现因为进度条只是外观变化不需要额外的依赖属性publicclassEllipticalProgressBehavior:BehaviorShape{publicstaticreadonlyDependencyPropertyProgressPropertyDependencyProperty.Register(nameof(Progress),typeof(double),typeof(EllipticalProgressBehavior),newPropertyMetadata(0.0,OnProgressChanged));publicdoubleProgress{get;set;}privatestaticvoidOnProgressChanged(DependencyObjectd,DependencyPropertyChangedEventArgse){varbehavior(EllipticalProgressBehavior)d;varangle(double)e.NewValue*3.6;// 0-100 → 0-360度// 更新Shape的StrokeDashArray来实现环形进度}}Behavior 的好处是可以附加到任何 Shape 上——Ellipse、Path、Arc 都行。不绑定到特定控件类型。控件复用与组合自定义控件之间可以组合。比如腔体状态控件由多个子控件组成腔体状态控件 ├── 压力仪表AnalogGaugeControl ├── 温度仪表AnalogGaugeControl ├── 进气阀开关CoilToggleControl ├── 出气阀开关CoilToggleControl └── 状态指示灯Ellipse ColorBinding组合控件对外暴露聚合属性controls:ChamberStatusControlPressure{Binding ChamberPressure}Temperature{Binding ChamberTemperature}InletValve{Binding InletValveState}OutletValve{Binding OutletValveState}Status{Binding ChamberStatus}/外部不需要知道内部用了哪些子控件。改内部实现不影响外部使用。混淆注意事项自定义控件如果用了{x:Static}或{Binding}引用属性名混淆后属性名会变绑定失效。必须加[Obfuscation(Exclude true)][Obfuscation(Excludetrue,ApplyToMemberstrue)]publicclassCoilToggleControl:Control{// 依赖属性名在XAML里用字符串引用不能被混淆}这个问题在 Debug 版不会出现只有 Release 版混淆后才暴露。曾经有一次发版后界面全是空白排查了半天才发现是控件属性被混淆了。踩坑记录坑1TemplateBinding vs RelativeSource一开始在模板里用{Binding CoilValue, RelativeSource{RelativeSource TemplatedParent}}后来发现{TemplateBinding CoilValue}更简洁效果一样。但 TemplateBinding 只支持 OneWay 绑定需要 TwoWay 时还得用 RelativeSource。坑2控件样式被默认样式覆盖自定义控件放在 ItemsControl 里时ItemContainerStyle 会覆盖控件的默认样式。解决办法是把控件样式定义在 ItemsControl.Resources 里优先级更高。坑3依赖属性默认值不生效定义依赖属性时设了默认值new PropertyMetadata(false)但控件初始化时还是拿到了default(bool)即 false。看起来没问题但如果属性是引用类型比如 string默认值是 null 不是空字符串。需要new PropertyMetadata()。本篇小结概念关键做法依赖属性控件对外暴露数据的接口ControlTemplate外观与逻辑分离可换样式TemplateBinding模板内绑定到控件属性高低电平显示用逻辑值通信用物理值DataTemplateSelector按数据类型选模板Behavior附加行为不绑定控件类型组合控件多个子控件聚合成一个复合控件混淆排除XAML引用的属性名必须Exclude自定义控件的核心减少重复、统一风格、隔离变化。工业UI的复用粒度比普通软件高——一个线圈控件可能用上百次。下期预告第18篇数据持久化 — SqlSugar MySQLUI讲完了下篇讲数据存储——怎么用SqlSugar ORM操作MySQL怎么保证线程安全怎么自动建表。