自定义控件(一):三色灯、到位指示与状态指示器
这是「从零搭建半导体设备上位机」系列第7篇。上一篇讲了 XAML 的组织,这一篇进到最细的粒度:一个"就画个圈"的状态灯控件,为什么写了 74 行 XAML 加 124 行代码。
一个"就画个圈"的控件,写了 124 行
第一次看到 BreatheLight.xaml.cs 的时候,我的反应是:这玩意儿有 124 行?
XAML 那边 74 行,一共就一个 Grid、一个 TextBlock、一个 20×20 的 Ellipse。代码那边 124 行,全是依赖属性声明。
一个绿灯,至于吗?
后来在设备上跑了一周,我改口了:这个控件的复杂度不在"画圈",在"状态语义"。
它要回答的问题不是"这个点是什么颜色",而是:
- 值是 true 还是 false?
- 这个 true 是"稳定正常"还是"正在变化中"?
- 变化中的状态要不要闪?
- 闪烁的节奏是"急促"(报警)还是"平缓"(运行中)?
- 灭掉的时候是"关"(灰)还是"异常"(红)?
五个问题,五个依赖属性。 这就是那 124 行的来源。
一、状态灯不止一种
项目里的状态指示器有八个:
| BreatheLight | 通用点位 | 常亮 / 呼吸(可配颜色) |
| LightEAP | 与 MES 的通信状态 | 三色(灰/黄/绿) |
| LightHost | 上位机自身状态 | 三色 |
| CHStatus | 工艺腔状态 | 多状态 + 文字 |
| LoadlockStatus | 装载腔状态 | 多状态 |
| CollingStatus | 冷却水状态 | 双色 + 流量 |
| ValveCtrlStatus | 阀门开度 | 三态(开/关/中间) |
| WaferStatus | 晶圆位置 | 图标 + 颜色 |
看起来重复,其实不是。通用点位用 BreatheLight,有业务语义的各自实现。
区别在于:BreatheLight 只认"真/假"两个布尔,业务含义由外部传颜色进来。而 CHStatus 认的是"腔体状态枚举",颜色和文字都在控件内部映射。
判据:如果这个状态的颜色是固定的(比如 EAP 永远是灰黄绿),就写死在控件里;如果颜色要外部决定,就做成参数。
二、BreatheLight 拆开看

2.1 XAML 那一层
核心是一个 Ellipse,样式里挂了两组触发器:
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="{Binding ElementName=self, Path=OffColor}" />
<Style.Triggers>
<!– 值 = true 时换成 OnColor –>
<DataTrigger Binding="{Binding ElementName=self, Path=IsValueTrue}" Value="True">
<Setter Property="Fill" Value="{Binding ElementName=self, Path=OnColor}" />
</DataTrigger>
<!– 值 = true 且 需要呼吸时,启动动画 –>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding ElementName=self, Path=IsValueTrue}" Value="True" />
<Condition Binding="{Binding ElementName=self, Path=IsBreathing}" Value="True" />
</MultiDataTrigger.Conditions>
<MultiDataTrigger.EnterActions>
<BeginStoryboard Name="Breath">
<Storyboard>
<DoubleAnimationUsingKeyFrames
RepeatBehavior="Forever"
Storyboard.TargetProperty="Opacity"
Duration="00:00:05">
<LinearDoubleKeyFrame KeyTime="0%" Value="1" />
<LinearDoubleKeyFrame KeyTime="25%" Value="0.1" />
<LinearDoubleKeyFrame KeyTime="50%" Value="0.2" />
<LinearDoubleKeyFrame KeyTime="70%" Value="0.5" />
<LinearDoubleKeyFrame KeyTime="100%" Value="1" />
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</BeginStoryboard>
</MultiDataTrigger.EnterActions>
<MultiDataTrigger.ExitActions>
<StopStoryboard BeginStoryboardName="Breath" />
</MultiDataTrigger.ExitActions>
</MultiDataTrigger>
</Style.Triggers>
</Style>
几个细节值得说:
关键帧不是均匀的。 1 → 0.1 → 0.2 → 0.5 → 1,中间那个"回一点又降一点"的形状,是调出来的——均匀的正弦看着像机器在闪,不均匀的才像"呼吸"。
ExitActions 里必须 StopStoryboard。 少了这一句,动画会一直跑(RepeatBehavior="Forever"),控件颜色变了但透明度还在被动画控制,表现是"灯灭了但还是半透明在闪"。
Fill 绑的是 OffColor / OnColor 两个依赖属性,不是写死的颜色。 这是这个控件能复用的关键——同一个控件,报警时传红/暗红,正常时传绿/暗绿。
2.2 代码那一层
124 行里,七个依赖属性:
Title、IsValueTrue、IsBreathing、OnColor、OffColor、Margin、BlurEffectRadius。
BlurEffectRadius 控制的是 Ellipse 上那个 BlurEffect——给灯加一圈光晕,值越大越"发光"。
七个属性里,真正参与逻辑的只有两个(IsValueTrue、IsBreathing),剩下五个都是外观参数。
三、构造函数里的 SetCurrentValue
这是这段代码里最值得学的一处:
public BreatheLight()
{
InitializeComponent();
SetCurrentValue(TitleProperty, "标题:");
SetCurrentValue(OnColorProperty, Brushes.LawnGreen);
SetCurrentValue(OffColorProperty, Brushes.Red);
SetCurrentValue(MarginProperty, new Thickness(5));
}
为什么用 SetCurrentValue 而不是直接 SetValue?
因为 SetValue 会覆盖外部绑定。
假设界面上这么用:
<uc:BreatheLight Title="{Binding PointName}" IsValueTrue="{Binding IsRunning}" />
如果构造函数里写的是 SetValue(TitleProperty, "标题:"),执行顺序是:XAML 解析 → 构造函数跑(设成"标题:")→ 绑定建立(覆盖成 PointName)。这个顺序下没问题。
但如果是代码里 new BreatheLight() 之后再设绑定:
var light = new BreatheLight(); // 构造里 SetValue 设了默认值
light.SetBinding(BreatheLight.TitleProperty, new Binding("PointName"));
也没问题——绑定后设,绑定赢。
问题出在两者顺序不确定的时候,比如控件在 DataTemplate 里被复用、绑定和构造交错执行。这时 SetValue 会把绑定的值冲掉,界面显示"标题:"而不是真实点位名。
SetCurrentValue 的语义是"设置当前值,但不改变绑定源"——有绑定的时候它只改当前生效值,不破坏绑定。
这条规则适用于所有"控件自带默认值"的场景:默认值一律用 SetCurrentValue,不要用 SetValue。
四、为什么用 Storyboard 而不是 Timer
早期版本是用 DispatcherTimer 做的:
timer.Tick += (s, e) => ellipse.Opacity = Math.Sin(...);
问题是:DispatcherTimer 的回调跑在 UI 线程上,每 50ms 一次,几十个灯同时闪的时候,UI 线程被这些回调切得很碎,界面滚动开始卡。
换成 Storyboard 之后好了——WPF 的动画时钟在渲染线程上推进,不占 UI 线程。同样的视觉频率,UI 线程完全空闲。
这条在工控软件里尤其重要:界面卡一下,操作员可能就错过一个报警闪变。
五、三色灯只有 39 行:LightEAP 和 LightHost
这两个控件都是 39 行,结构完全一样:
<Viewbox>
<Canvas Width="20" Height="20">
<Canvas Cursor="Hand" ToolTip="{Binding ElementName=self, Path=CurLightToolTip}">
<!– 外圈:固定深色,永远不变 –>
<Path Data="F1 M 10,0 C 15.523,0 …" Fill="#ff071015" />
<!– 内圈:颜色由外部决定 –>
<Path Data="F1 M 10,4 C 13.314,4 …"
Fill="{Binding ElementName=self, Path=CurLightBackground}" />
</Canvas>
</Canvas>
</Viewbox>
两个 Path 叠在一起:外圈固定近黑色,内圈绑 CurLightBackground。换状态只换内圈颜色,外圈那圈深色让灯在任何背景上都有边界。
这不是 WPF 的 Border,是 Illustrator 导出时就在图形里画好的描边。 用 Border 也能做,但圆角半径和描边粗细要和设计稿对齐,改起来比直接改 Path 麻烦。
还有两个小细节:
- ToolTip="{Binding ElementName=self, Path=CurLightToolTip}" —— 悬停显示人话说明(“与 MES 通信正常” / “连接中断”)。
- Cursor="Hand" —— 提示这里可以点。
问题:两个文件一模一样
把 LightEAP.xaml 和 LightHost.xaml 并排打开,除了 x:Class 那一行,一个字都不差。
这是复制粘贴的产物:先做 EAP 状态灯,做 Host 状态灯的时候直接复制一份改名。
后果很实际:
正确做法是一个控件 + 两个实例:
<uc:TriLight CurLightBackground="{Binding EapColor}"
CurLightToolTip="{Binding EapText}" />
<uc:TriLight CurLightBackground="{Binding HostColor}"
CurLightToolTip="{Binding HostText}" />
颜色和文字都由外部传,控件本身不需要知道"EAP"和"Host"的区别——它只认"给我一个颜色和一个说明"。这其实就是 BreatheLight 已经做对的事。
八个状态指示器,该不该统一
把那八个控件重新看一遍,其实可以归成两类:
| 参数化 | 颜色/文字由外部传入 | BreatheLight、LightEAP、LightHost |
| 语义化 | 颜色由内部枚举映射 | CHStatus、LoadlockStatus、CollingStatus、WaferStatus |
参数化那三个本该是同一个控件(BreatheLight 是完整版,LightEAP / LightHost 是它的退化版)。语义化那几个不适合合并,因为"腔体的哪个状态算红"属于业务知识,不该塞进通用控件。
判断标准:如果两个控件的区别只是"颜色从哪来",就该合并;如果区别是"状态怎么解释",就别合并。
这条标准是做完八个控件之后才想明白的。早一点想清楚,能省下两个 39 行的副本。
踩坑记录
坑一:窗口最小化,呼吸灯还在跑
RepeatBehavior="Forever" 的动画,窗口最小化之后不会自动停。
结果是:设备挂在现场,操作员把界面最小化去干别的,CPU 一直有个 3-5% 的占用在跑动画。
一天两天看不出来,设备连续跑三个月,这个问题会被现场工程师当成"软件越跑越卡"报上来。
修法是监听窗口状态:
if (Window.GetWindow(this) is { } w)
w.StateChanged += (_, _) =>
{
if (w.WindowState == WindowState.Minimized)
StopAllAnimations();
};
或者更省事的办法:给动画设 Timeline.DesiredFrameRate="15",降低推进频率。
原则:任何 Forever 的动画都要有停止条件。
坑二:AffectsMeasure | AffectsRender 用得太随意
七个依赖属性里,Title、IsValueTrue、Margin 都标了:
new FrameworkPropertyMetadata(default(string),
FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender)
AffectsMeasure 的意思是"这个属性变了,整个控件要重新测量布局"。
对 Margin 是对的。对 Title 就过头了——文字变了只需要重绘,不需要重新测量。
一屏几十个状态灯,每次刷新数据都触发一轮 Measure,布局开销就上来了。
正确做法:只影响绘制(颜色、文字)的用 AffectsRender;只影响尺寸的用 AffectsMeasure;两个都不确定的才两个都加。
坑三:[Category("Wu")]
依赖属性上有个特性:
[Category("Wu")]
[Description("是否呼吸")]
public bool IsBreathing { ... }
Category 是给 PropertyGrid 用的分组名。这里写了个 "Wu"——大概是当时随手写的缩写。
后果是:如果在设计器里选中这个控件,属性面板会出现一个叫 “Wu” 的分组,里面孤零零一个"是否呼吸"。
不影响功能,但每次新人看到都会问一句"Wu 是什么"。这种小地方的不讲究,会一点点消耗团队的注意力。
后来统一改成了 [Category("行为")]、[Category("外观")]。
本篇小结
下一篇讲阀门控件家族:气体阀、水阀、MFC 阀,三个长得像但状态机完全不同的控件,以及它们演进过程中那次"重命名引发的连锁修改"。
下期预告
第8篇:自定义控件(二):阀门控件家族——气体阀、水阀、MFC 阀
网硕互联帮助中心




评论前必须登录!
注册