云计算百科
云计算领域专业知识百科平台

从零搭建半导体设备上位机(七):自定义控件(一),三色灯、到位指示与状态指示器

自定义控件(一):三色灯、到位指示与状态指示器

这是「从零搭建半导体设备上位机」系列第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 拆开看

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("外观")]。


    本篇小结

  • 状态灯复杂在语义不在绘制:一个布尔值不够,要区分"稳定正常"和"变化中",所以有七个依赖属性。
  • 默认值用 SetCurrentValue,它会保留外部绑定;SetValue 会冲掉。
  • 动画用 Storyboard 不用 Timer,前者在渲染线程推进,不占 UI 线程。
  • Forever 动画必须有停止条件,否则窗口最小化后仍在烧 CPU。
  • 下一篇讲阀门控件家族:气体阀、水阀、MFC 阀,三个长得像但状态机完全不同的控件,以及它们演进过程中那次"重命名引发的连锁修改"。


    下期预告

    第8篇:自定义控件(二):阀门控件家族——气体阀、水阀、MFC 阀

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 从零搭建半导体设备上位机(七):自定义控件(一),三色灯、到位指示与状态指示器
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!