面向刚接触 WPF、已经会写基本 XAML、绑定和事件处理,准备学习”怎样做工业风格控件和图表”的读者。 推荐环境:.NET 10 LTS + WPF。随附示例使用 net9.0-windows,文中涉及的 WPF 与 LiveCharts API 在 .NET 9 与 .NET 10 中没有变化。 最后更新:2026-10-22
目录
点动开关案例
指针仪表案例
更多工业组件开发说明
第三方UI控件库介绍
LiveCharts图表库的基本使用
LiveCharts图表更多配置
写在前面 这份笔记解决什么问题 刚开始写 WPF 时,工业界面常会写成这个样子:
1 2 3 4 5 6 一个按钮同时表示"按住输出"和"自锁保持" 仪表是一张背景图,指针位置靠美术切图 换一台设备就要复制一遍相似页面 需要图表时不知道数据应该放在哪里 图表要么空白,要么每次刷新都重新创建系列 第三方库没分清图表库和 UI 框架,越引越乱
这一篇按下面顺序推进:
1 2 3 4 5 先做点动开关:把"按住"和"自锁"分成两个状态 再做指针仪表:把数值映射成角度、刻度和警戒区 然后看工业组件体系:状态语义、规则和模板分层 再介绍第三方库:先分清图表库和整套 UI 框架 最后用 LiveCharts2:从 Series、Axis 到实时曲线配置
图 1:六章从”能显示”走到”能判断”。
推荐学习顺序
先运行随附示例,依次点击六个页签,每页只看操作结果,不急着读代码。
第 1 章按住开关不放,观察”点动输出”变化;单击一次,观察”自锁保持”变化。
第 2 章点击 25 MPa、65 MPa、92 MPa 三个按钮,观察指针、警戒区和读数。
第 3 章看六个工业组件的说明,重点理解”先定规则,再做外观”。
第 4 章只做一件事:分清哪些库负责画数据,哪些库负责整套界面。
第 5 章点击”刷新产量”,再点击”记录图表状态”,理解 Series 与 Axis 的关系。
第 6 章点击”启动实时”,观察曲线追加;勾选和取消显示选项,再点”应用显示配置”。
随附示例位置 1 C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfIndustrialChartsDemo
运行:
1 2 3 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfIndustrialChartsDemo" dotnet build dotnet run
第一次运行需要联网还原 NuGet 包,请耐心等待。还原完成后即可离线运行。
Visual Studio 中:
打开 WpfIndustrialChartsDemo.csproj。
确认启动项目是 WpfIndustrialChartsDemo。
按 F5。
依次操作六个页签。
创建自己的跟做项目 在任意练习目录执行:
1 2 3 dotnet new wpf -n WpfIndustrialPractice -o .\WpfIndustrialPractice cd .\WpfIndustrialPracticedotnet run
确认默认窗口能打开后,把项目文件中的目标框架改为:
1 <TargetFramework > net9.0-windows</TargetFramework >
如果本机使用 .NET 10,也可以写:
1 <TargetFramework > net10.0-windows</TargetFramework >
安装 LiveCharts2 第 5、6 章需要图表库,执行:
1 dotnet add package LiveChartsCore.SkiaSharpView.WPF --version 2.0 .5
安装后项目文件会多出:
1 2 3 <ItemGroup > <PackageReference Include ="LiveChartsCore.SkiaSharpView.WPF" Version ="2.0.5" /> </ItemGroup >
示例固定使用 2.0.5 正式版,避免教程跟随候选版 API 漂移。如果你看到的版本更新,可以先照本文写完,再按官方升级说明迁移。
关于构建警告 LiveCharts2 通过 SkiaSharp.Views.WPF 绘图,该包会间接引入面向旧版 .NET Framework 的 OpenTK 等依赖。因此在 .NET 9 项目上构建时,可能出现类似警告:
1 warning NU1701: 已使用 .NETFramework,Version=v4.6.1 ... 而不是项目目标框架 net9.0-windows7.0 还原包 OpenTK 3.3.1
需要正确理解它:
这是第三方依赖的目标框架兼容警告,不是本篇代码错误。
它不影响示例运行,图表能正常显示。
不要为了消警告随意添加 NoWarn,否则会掩盖真正的兼容问题。
正式项目应锁定版本,并在升级前查看图表库的发布说明。
跟做完成后的结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 WpfIndustrialPractice ├─ App.xaml ├─ App.xaml.cs ├─ IndustrialRules.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ Controls │ ├─ JogSwitch.cs │ └─ AnalogGauge.cs ├─ Models │ └─ AppOptions.cs ├─ Themes │ ├─ Generic.xaml │ └─ Themes.cs └─ ViewModels ├─ ObservableObject.cs └─ MainViewModel.cs
本文使用 WpfIndustrialChartsDemo 作为命名空间。你自己练习时可以把命名空间换成项目名,但同一文件内必须保持一致。
先准备公共规则 新建 IndustrialRules.cs,把仪表相关的纯计算集中起来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 namespace WpfIndustrialChartsDemo ;public static class IndustrialRules { public static double Clamp ( double value , double minimum, double maximum ) { return Math.Clamp(value , minimum, maximum); } public static double ToNeedleAngle ( double value , double minimum, double maximum ) { if (maximum <= minimum) { return 270 ; } var ratio = Clamp( (value - minimum) / (maximum - minimum), 0 , 1 ); return 135 + ratio * 270 ; } public static bool IsAlarm ( double value , double warning, double maximum ) { return value >= warning || value > maximum; } }
这段代码不依赖窗口,可以单独测试。第 2 章的角度换算、越界限制和报警判断都复用它。
再准备 ViewModel 基类 新建 ViewModels\ObservableObject.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 using System.ComponentModel;using System.Runtime.CompilerServices;namespace WpfIndustrialChartsDemo.ViewModels ;public abstract class ObservableObject : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; protected bool SetProperty <T >( ref T field, T value , [CallerMemberName] string ? propertyName = null ) { if (EqualityComparer<T>.Default.Equals(field, value )) { return false ; } field = value ; PropertyChanged?.Invoke( this , new PropertyChangedEventArgs(propertyName)); return true ; } }
后面所有界面状态都放在继承它的 MainViewModel 中,页面只负责显示和调用命令式方法。
1. 点动开关案例 1.1 本节目标 做一个真正能用于”启动设备”的按钮:
1 2 3 4 5 6 按住鼠标:输出接通 松开鼠标:点动输出立即停止 单击一次:切换自锁,松开后仍保持 再单击一次:取消自锁 离开按钮区域:点动输出复位 提供重置方法,让页面可以统一清状态
最终效果:
图 2:点动开关页签,界面右侧同时显示点动与自锁两个状态。
普通 Button 的 Click 只告诉你”点击完成了一次”,却没有直接表达:
1 2 3 4 鼠标现在是否还按着 松开后状态是否要保持 离开按钮时是否应该复位 程序设置状态时,界面如何同步
工业设备的点动按钮通常同时包含两种语义:
1 2 点动:按住才输出,松开立刻停止 自锁:按一下开始,再按一下停止
因此,我们不要让颜色和文字各自猜状态,而是先定义两个独立状态:
1 2 IsPressed:此刻是否按住 IsLatched:是否启用自锁保持
图 3:按压与自锁分开保存,颜色和文字都由状态派生。
1.3 跟做:创建 JogSwitch 类 在项目中新建文件夹:
新建 Controls\JogSwitch.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 using System.Windows;using System.Windows.Controls;using System.Windows.Input;namespace WpfIndustrialChartsDemo.Controls ;public sealed class JogSwitch : Control { public static readonly DependencyProperty IsPressedProperty = DependencyProperty.Register( nameof (IsPressed), typeof (bool ), typeof (JogSwitch), new FrameworkPropertyMetadata( false , FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsPressedChanged)); public static readonly DependencyProperty IsLatchedProperty = DependencyProperty.Register( nameof (IsLatched), typeof (bool ), typeof (JogSwitch), new FrameworkPropertyMetadata( false , FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsLatchedChanged)); public static readonly DependencyProperty LabelProperty = DependencyProperty.Register( nameof (Label), typeof (string ), typeof (JogSwitch), new PropertyMetadata("点动" )); public static readonly DependencyProperty StateTextProperty = DependencyProperty.Register( nameof (StateText), typeof (string ), typeof (JogSwitch), new PropertyMetadata("待命" )); private Button? _button; static JogSwitch () { DefaultStyleKeyProperty.OverrideMetadata( typeof (JogSwitch), new FrameworkPropertyMetadata(typeof (JogSwitch))); } public bool IsPressed { get => (bool )GetValue(IsPressedProperty); private set => SetValue(IsPressedProperty, value ); } public bool IsLatched { get => (bool )GetValue(IsLatchedProperty); set => SetValue(IsLatchedProperty, value ); } public string Label { get => (string )GetValue(LabelProperty); set => SetValue(LabelProperty, value ); } public string StateText { get => (string )GetValue(StateTextProperty); private set => SetValue(StateTextProperty, value ); } public event EventHandler<bool >? PressChanged; public event EventHandler<bool >? LatchedChanged; public void ResetVisualState () { IsPressed = false ; IsLatched = false ; StateText = "待命" ; } public override void OnApplyTemplate () { base .OnApplyTemplate(); if (_button is not null ) { _button.PreviewMouseLeftButtonDown -= OnButtonDown; _button.PreviewMouseLeftButtonUp -= OnButtonUp; _button.MouseLeave -= OnButtonLeave; _button.Click -= OnButtonClick; } _button = GetTemplateChild("PART_Button" ) as Button; if (_button is not null ) { _button.PreviewMouseLeftButtonDown += OnButtonDown; _button.PreviewMouseLeftButtonUp += OnButtonUp; _button.MouseLeave += OnButtonLeave; _button.Click += OnButtonClick; } } private void OnButtonDown ( object sender, MouseButtonEventArgs e ) { IsPressed = true ; } private void OnButtonUp ( object sender, MouseButtonEventArgs e ) { IsPressed = false ; } private void OnButtonLeave ( object sender, MouseEventArgs e ) { IsPressed = false ; } private void OnButtonClick ( object sender, RoutedEventArgs e ) { IsLatched = !IsLatched; } private static void OnIsPressedChanged ( DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e ) { var control = (JogSwitch)dependencyObject; control.StateText = (bool )e.NewValue ? "按住中" : "待命" ; control.PressChanged?.Invoke( control, (bool )e.NewValue); } private static void OnIsLatchedChanged ( DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e ) { var control = (JogSwitch)dependencyObject; control.LatchedChanged?.Invoke( control, (bool )e.NewValue); } }
先不要急着看效果。这里先记住三个关键点:
IsPressed 的 setter 是私有的,因为”是否按住”应该由鼠标事件决定,页面不应该随便伪造。
IsLatched 的 setter 是公开的,因为页面或 ViewModel 可以主动取消自锁。
OnApplyTemplate 中先取消旧事件,再订阅新事件,避免模板重新应用时重复触发。
1.4 跟做:写默认模板 新建 Themes\Generic.xaml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 <ResourceDictionary xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls ="clr-namespace:WpfIndustrialChartsDemo.Controls" > <Style TargetType ="{x:Type controls:JogSwitch}" > <Setter Property="Width " Value="118 " /> <Setter Property="Height " Value="118 " /> <Setter Property="Background " Value="#173B5E " /> <Setter Property="BorderBrush" Value="#7D9BB5 " /> <Setter Property="Foreground" Value="White" /> <Setter Property="Template"> <Setter.Value > <ControlTemplate TargetType="{x :Type controls:JogSwitch}"> <Grid > <Ellipse x :Name="OuterRing" Fill="#D9E2EC" Stroke="{TemplateBinding BorderBrush}" StrokeThickness="2" /> <Ellipse x:Name="InnerRing" Margin="9" Fill="{TemplateBinding Background}" Stroke="#3D617D" StrokeThickness="1" /> <Button x:Name="PART_Button" Width="76" Height="76" Background="#EAF2F8" BorderBrush="#92A9BC" BorderThickness="2" Cursor="Hand" HorizontalAlignment="Center" VerticalAlignment="Center" > <Button.Template> <ControlTemplate TargetType="Button" > <Grid> <Ellipse x:Name="Cap" Fill="{TemplateBinding Background}" Stroke="{TemplateBinding BorderBrush}" StrokeThickness="2" /> <Ellipse Margin="10" Fill="#C9D8E6" Opacity="0.45" /> <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" /> </Grid> <ControlTemplate.Triggers> <Trigger Property="IsPressed" Value="True" > <Setter TargetName="Cap" Property="Fill" Value="#8FB5D1" /> </Trigger> <Trigger Property="IsMouseOver" Value="True" > <Setter TargetName="Cap" Property="Stroke" Value="#2456A6" /> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Button.Template> <StackPanel> <TextBlock Text="{TemplateBinding Label}" Foreground="#173B5E" FontSize="17" FontWeight="Bold" HorizontalAlignment="Center" /> <TextBlock Margin="0,3,0,0" Text="{TemplateBinding StateText}" Foreground="#4D6578" FontSize="12" HorizontalAlignment="Center" /> </StackPanel> </Button> <TextBlock Margin="0,0,0,7" VerticalAlignment="Bottom" HorizontalAlignment="Center" Text="JOG" Foreground="#EAF2F8" FontSize="11" FontWeight="Bold" /> </Grid> <ControlTemplate.Triggers> <Trigger Property="IsPressed" Value="True" > <Setter TargetName="OuterRing" Property="Stroke" Value="#2456A6" /> <Setter TargetName="InnerRing" Property="Fill" Value="#2456A6" /> </Trigger> <Trigger Property="IsLatched" Value="True" > <Setter TargetName="OuterRing" Property="Stroke" Value="#F2B84B" /> <Setter TargetName="InnerRing" Property="Stroke" Value="#F2B84B" /> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style > </ResourceDictionary >
再新建 Themes\Themes.cs,告诉 WPF 到当前程序集查找默认主题:
1 2 3 4 5 6 7 8 9 10 11 using System.Windows;[assembly: ThemeInfo( ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly) ]namespace WpfIndustrialChartsDemo.Themes ;public static class Themes { }
1.5 跟做:在页面中使用 在 MainWindow.xaml 的根元素加入命名空间:
1 xmlns:controls="clr-namespace:WpfIndustrialChartsDemo.Controls"
放一个开关和状态区:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 <StackPanel VerticalAlignment ="Center" > <controls:JogSwitch x:Name ="Jog" Label ="启动" HorizontalAlignment ="Center" /> <TextBlock Margin ="0,18,0,0" HorizontalAlignment ="Center" Text ="{Binding SwitchState}" Foreground ="#617089" /> </StackPanel > <StackPanel Grid.Column ="1" Margin ="30,0,0,0" > <TextBlock Text ="输出状态" FontSize ="19" FontWeight ="Bold" Foreground ="#2456A6" /> <TextBlock Margin ="0,18,0,0" Text ="{Binding IsPressed, StringFormat=点动输出:{0}}" FontSize ="17" /> <TextBlock Margin ="0,10,0,0" Text ="{Binding IsLatched, StringFormat=自锁保持:{0}}" FontSize ="17" /> </StackPanel >
StringFormat 中文字符串前加 {} 的写法通常用于避免 XAML 把花括号误认为标记扩展。本例当前写法能正常显示;如果你的项目报 XAML 解析错误,可改为:
1 Text="{Binding IsPressed, StringFormat={}{0} 已接通}"
1.6 跟做:把状态同步到 ViewModel 在 MainViewModel 中增加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 private bool _isPressed;private bool _isLatched;private string _switchState = "待命" ;public bool IsPressed{ get => _isPressed; set { if (SetProperty(ref _isPressed, value )) { SwitchState = value ? "点动输出中" : "点动停止" ; } } } public bool IsLatched{ get => _isLatched; set { if (SetProperty(ref _isLatched, value )) { SwitchState = value ? "自锁保持" : "已释放" ; } } } public string SwitchState{ get => _switchState; set => SetProperty(ref _switchState, value ); }
在窗口构造函数中订阅控件事件:
1 2 Jog.PressChanged += OnJogPressChanged; Jog.LatchedChanged += OnJogLatchedChanged;
事件处理方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 private void OnJogPressChanged ( object ? sender, bool pressed ){ ViewModel.IsPressed = pressed; ViewModel.AddJog( pressed ? "点动按下:输出接通。" : "点动松开:输出停止。" ); } private void OnJogLatchedChanged ( object ? sender, bool latched ){ ViewModel.IsLatched = latched; ViewModel.AddJog( latched ? "自锁开启:松开后保持。" : "自锁关闭:松开即释放。" ); }
1.7 跟做:增加重置 页面按钮:
1 2 <Button Content ="重置开关" Click ="OnResetJogClick" />
代码后置:
1 2 3 4 5 6 7 8 9 private void OnResetJogClick ( object sender, RoutedEventArgs e ){ Jog.ResetVisualState(); ViewModel.IsPressed = false ; ViewModel.IsLatched = false ; ViewModel.SwitchState = "待命" ; }
这里故意同时处理控件与 ViewModel:
1 2 控件:清掉按住与自锁的视觉状态 ViewModel:清掉页面展示与业务状态
如果控件属性已经双向绑定到 ViewModel,可以只改 ViewModel;但在初学阶段,把两条路径都写清楚更容易观察。
1.8 运行检查 按 F5 运行,依次做:
按住开关不松手,右侧”点动输出”应变为 True。
松开鼠标,”点动输出”应立即变为 False。
单击一次,”自锁保持”应变为 True。
再单击一次,”自锁保持”应变为 False。
点击”重置开关”,两个状态都应回到 False。
图 4:初始待命状态。
1.9 常见错误 错误一:把按下和自锁写成一个 bool
结果会是”按一下就永久保持”,无法区分短时点动和自锁。
错误二:只处理 Click,不处理 MouseDown / MouseUp
Click 只在一次完整点击结束时触发,不能表示”按住期间一直输出”。
错误三:模板里没有 PART_Button
GetTemplateChild("PART_Button") 会返回 null,鼠标事件不会挂上,控件看起来有外观却没有交互。
错误四:OnApplyTemplate 中只加事件,不减事件
模板重新应用时会重复订阅,一次点击可能触发多次状态切换。
错误五:用颜色表示全部状态
工业界面要考虑色弱、反光和远距离观看,文字、形状、日志同样要表达状态。
1.10 小练习
给 JogSwitch 增加一个 IsEnabledVisual 属性,禁用时让外圈变灰。
在 ViewModel 中记录每次按下的持续时间,并在日志中显示毫秒数。
增加一个”脉冲模式”:按住超过 1 秒后,即使松开也保持 500 毫秒再停止。
把 IsLatched 的触发器颜色改成橙色,并保留文字”自锁保持”作为独立提示。
2. 指针仪表案例 2.1 本节目标 做一个量程 0 - 100 MPa 的指针仪表:
1 2 3 4 5 数值越大,指针从左下顺指针方向转向右下 刻度、主刻度标签和警戒区都由属性计算 读数超过 80 MPa 时进入警戒色 程序传入 120 时自动限制为 100 拖动滑块或点击快捷按钮,指针与读数同步变化
运行效果:
图 5:42.5 MPa 时指针位于左上方,警戒区从 80 MPa 开始。
2.2 仪表不是一张背景图 把仪表拆开看,至少有三层:
1 2 3 几何层:圆心、半径、刻度线、警戒弧、指针三角形 数据层:Value、Minimum、Maximum、Warning 状态层:NeedleAngle、IsAlarm、显示文本
三者关系:
图 6:先把数值归一化为比例,再把比例映射成 135° 到 405°。
这里使用屏幕坐标,所以角度方向与数学课上的单位圆略有不同:
1 2 3 135°:左下 270°:顶部 405°:右下(等价于 45°)
这就是为什么 ToNeedleAngle(42.5, 0, 100) 得到 249.75°,指针指向左上方。
2.3 跟做:定义仪表属性 在 Controls 新建 AnalogGauge.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 using System.Windows;using System.Windows.Controls;using System.Windows.Media;namespace WpfIndustrialChartsDemo.Controls ;public sealed class AnalogGauge : Control { private GaugeGeometry? _lastGeometry; public static readonly DependencyProperty ValueProperty = DependencyProperty.Register( nameof (Value), typeof (double ), typeof (AnalogGauge), new FrameworkPropertyMetadata( 0d , FrameworkPropertyMetadataOptions.AffectsRender, OnValueChanged, CoerceValue)); public static readonly DependencyProperty MinimumProperty = DependencyProperty.Register( nameof (Minimum), typeof (double ), typeof (AnalogGauge), new FrameworkPropertyMetadata( 0d , FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty MaximumProperty = DependencyProperty.Register( nameof (Maximum), typeof (double ), typeof (AnalogGauge), new FrameworkPropertyMetadata( 100d , FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty WarningProperty = DependencyProperty.Register( nameof (Warning), typeof (double ), typeof (AnalogGauge), new FrameworkPropertyMetadata( 80d , FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty UnitProperty = DependencyProperty.Register( nameof (Unit), typeof (string ), typeof (AnalogGauge), new FrameworkPropertyMetadata( "MPa" , FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty NeedleAngleProperty = DependencyProperty.Register( nameof (NeedleAngle), typeof (double ), typeof (AnalogGauge), new FrameworkPropertyMetadata( 135d , FrameworkPropertyMetadataOptions.AffectsRender)); public static readonly DependencyProperty IsAlarmProperty = DependencyProperty.Register( nameof (IsAlarm), typeof (bool ), typeof (AnalogGauge), new FrameworkPropertyMetadata( false , FrameworkPropertyMetadataOptions.AffectsRender)); public AnalogGauge () { SnapsToDevicePixels = true ; } public double Value { get => (double )GetValue(ValueProperty); set => SetValue(ValueProperty, value ); } public double Minimum { get => (double )GetValue(MinimumProperty); set => SetValue(MinimumProperty, value ); } public double Maximum { get => (double )GetValue(MaximumProperty); set => SetValue(MaximumProperty, value ); } public double Warning { get => (double )GetValue(WarningProperty); set => SetValue(WarningProperty, value ); } public string Unit { get => (string )GetValue(UnitProperty); set => SetValue(UnitProperty, value ); } public double NeedleAngle { get => (double )GetValue(NeedleAngleProperty); private set => SetValue(NeedleAngleProperty, value ); } public bool IsAlarm { get => (bool )GetValue(IsAlarmProperty); private set => SetValue(IsAlarmProperty, value ); } public event EventHandler<double >? ValueChanged;
Value 注册时有四个重要参数:
1 2 3 4 默认值:0d AffectsRender:值变了自动请求重绘 OnValueChanged:变化后通知外部 CoerceValue:写入前先限制范围
2.4 跟做:补上绘制入口 继续在 AnalogGauge 中加入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 protected override void OnRenderSizeChanged ( SizeChangedInfo sizeInfo ){ base .OnRenderSizeChanged(sizeInfo); RenderGauge(); } protected override void OnRender ( DrawingContext drawingContext ){ base .OnRender(drawingContext); DrawGauge(drawingContext); } protected override void OnPropertyChanged ( DependencyPropertyChangedEventArgs e ){ base .OnPropertyChanged(e); if (e.Property == ValueProperty || e.Property == MinimumProperty || e.Property == MaximumProperty || e.Property == WarningProperty || e.Property == UnitProperty) { if (e.Property == MinimumProperty || e.Property == MaximumProperty || e.Property == WarningProperty) { CoerceValue(ValueProperty); } RenderGauge(); } } private void RenderGauge (){ if (ActualWidth <= 0 || ActualHeight <= 0 ) { return ; } var center = new Point( ActualWidth / 2 , ActualHeight / 2 ); var radius = Math.Min( ActualWidth, ActualHeight) / 2 - 16 ; if (radius <= 20 ) { return ; } _lastGeometry = new GaugeGeometry( ActualWidth, ActualHeight, center, radius); InvalidateVisual(); } private void DrawGauge (DrawingContext dc ){ if (_lastGeometry is not { } geometry || geometry.Radius <= 20 ) { return ; } var center = geometry.Center; var radius = geometry.Radius; var outer = new SolidColorBrush( Color.FromRgb(23 , 59 , 94 )); var ring = new SolidColorBrush( Color.FromRgb(211 , 224 , 234 )); var muted = new SolidColorBrush( Color.FromRgb(97 , 112 , 137 )); var alarm = new SolidColorBrush( Color.FromRgb(185 , 57 , 57 )); var accent = IsAlarm ? alarm : new SolidColorBrush(Color.FromRgb(36 , 86 , 166 )); dc.DrawEllipse( new SolidColorBrush(Color.FromRgb(248 , 251 , 254 )), new Pen(ring, 3 ), center, radius + 8 , radius + 8 ); dc.DrawEllipse( outer, null , center, radius, radius); var innerRadius = radius - 24 ; dc.DrawEllipse( new SolidColorBrush(Color.FromRgb(236 , 243 , 248 )), new Pen(ring, 1 ), center, innerRadius, innerRadius); DrawTicks(dc, center, innerRadius, muted); DrawWarningArc(dc, center, innerRadius - 17 ); DrawNeedle(dc, center, innerRadius - 42 , accent); dc.DrawEllipse( accent, new Pen(Brushes.White, 2 ), center, 10 , 10 ); var valueText = new FormattedText( $"{Value:0.0 } " , System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Microsoft YaHei UI" ), 27 , outer, 1.0 ); dc.DrawText( valueText, new Point( center.X - valueText.Width / 2 , center.Y + radius * 0.45 )); var unitText = new FormattedText( Unit, System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Microsoft YaHei UI" ), 12 , muted, 1.0 ); dc.DrawText( unitText, new Point( center.X - unitText.Width / 2 , center.Y + radius * 0.45 + 36 )); }
注意:DrawGauge 里没有自己创建 DrawingVisual 和 VisualCollection。Control 的模板根已经是 UIElement,在 OnRender 中直接往传入的 DrawingContext 绘制更简单,也不会出现”把 DrawingVisual 当成 UIElement 来测量”的异常。
2.5 跟做:画刻度、警戒区和指针 继续补充:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 private void DrawTicks ( DrawingContext dc, Point center, double radius, Brush brush ){ for (var index = 0 ; index <= 18 ; index++) { var angle = 135 + index * 15 ; var radians = angle * Math.PI / 180 ; var isMajor = index % 3 == 0 ; var inner = radius - (isMajor ? 16 : 9 ); var p1 = PointOnCircle(center, inner, radians); var p2 = PointOnCircle(center, radius, radians); dc.DrawLine( new Pen(brush, isMajor ? 2.4 : 1.2 ), p1, p2); if (!isMajor) { continue ; } var labelValue = Minimum + (Maximum - Minimum) * index / 18 ; var label = new FormattedText( $"{labelValue:0 } " , System.Globalization.CultureInfo.CurrentCulture, FlowDirection.LeftToRight, new Typeface("Microsoft YaHei UI" ), 11 , brush, 1.0 ); var labelPoint = PointOnCircle( center, radius - 32 , radians); dc.DrawText( label, new Point( labelPoint.X - label.Width / 2 , labelPoint.Y - label.Height / 2 )); } } private void DrawWarningArc ( DrawingContext dc, Point center, double radius ){ if (Maximum <= Minimum) { return ; } var warningRatio = IndustrialRules.Clamp( (Warning - Minimum) / (Maximum - Minimum), 0 , 1 ); var start = 135 + warningRatio * 270 ; var geometry = new StreamGeometry(); using (var context = geometry.Open()) { context.BeginFigure( PointOnCircle( center, radius, start * Math.PI / 180 ), false , false ); context.ArcTo( PointOnCircle( center, radius, 405 * Math.PI / 180 ), new Size(radius, radius), 0 , false , SweepDirection.Clockwise, true , false ); } geometry.Freeze(); dc.DrawGeometry( null , new Pen( new SolidColorBrush( Color.FromRgb(226 , 174 , 72 )), 8 ), geometry); } private void DrawNeedle ( DrawingContext dc, Point center, double radius, Brush brush ){ var radians = NeedleAngle * Math.PI / 180 ; var tip = PointOnCircle(center, radius, radians); var left = PointOnCircle( center, 10 , radians - Math.PI / 2 ); var right = PointOnCircle( center, 10 , radians + Math.PI / 2 ); var geometry = new StreamGeometry(); using (var context = geometry.Open()) { context.BeginFigure(tip, true , true ); context.LineTo(left, true , false ); context.LineTo(right, true , false ); } geometry.Freeze(); dc.DrawGeometry(brush, null , geometry); } private static Point PointOnCircle ( Point center, double radius, double radians ){ return new Point( center.X + radius * Math.Cos(radians), center.Y + radius * Math.Sin(radians)); }
这里的 PointOnCircle 是理解仪表的关键:
1 2 x = 圆心X + 半径 × cos(角度) y = 圆心Y + 半径 × sin(角度)
屏幕坐标 y 轴向下,所以角度越大,指针越往下走。角度 135° 到 405° 正好覆盖左下、顶部、右下这一段 270° 圆弧。
2.6 跟做:写入前做范围强制 在类末尾加入:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 private static object CoerceValue ( DependencyObject dependencyObject, object baseValue ) { var gauge = (AnalogGauge)dependencyObject; var value = IndustrialRules.Clamp( (double )baseValue, gauge.Minimum, gauge.Maximum); gauge.NeedleAngle = IndustrialRules.ToNeedleAngle( value , gauge.Minimum, gauge.Maximum); gauge.IsAlarm = IndustrialRules.IsAlarm( value , gauge.Warning, gauge.Maximum); return value ; } private static void OnValueChanged ( DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e ) { var gauge = (AnalogGauge)dependencyObject; gauge.ValueChanged?.Invoke( gauge, (double )e.NewValue); } private sealed record GaugeGeometry ( double Width, double Height, Point Center, double Radius ) ;}
CoerceValue 发生在属性真正保存之前。这样即使程序写:
外部最终读到的 Gauge.Value 也是 100,不会出现”读数显示 100,但内部还偷偷保存 120”的问题。
2.7 跟做:在页面中绑定仪表 1 2 3 4 5 6 7 8 9 10 11 12 13 <controls:AnalogGauge x:Name ="Gauge" Width ="330" Height ="330" Minimum ="0" Maximum ="100" Warning ="80" Unit ="MPa" Value ="{Binding Pressure, Mode=TwoWay}" /> <Slider Minimum ="0" Maximum ="100" TickFrequency ="10" Value ="{Binding Pressure, Mode=TwoWay}" />
ViewModel 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 private double _pressure = 42.5 ;private string _gaugeStatus = "压力正常" ;public double Pressure{ get => _pressure; set { var next = IndustrialRules.Clamp(value , 0 , 100 ); if (SetProperty(ref _pressure, next)) { GaugeStatus = IndustrialRules.IsAlarm(next, 80 , 100 ) ? "压力偏高" : "压力正常" ; } } } public string GaugeStatus{ get => _gaugeStatus; set => SetProperty(ref _gaugeStatus, value ); }
快捷按钮:
1 2 3 4 5 6 <Button Content ="25 MPa" Click ="OnGaugeLowClick" /> <Button Content ="65 MPa" Click ="OnGaugeMidClick" /> <Button Content ="92 MPa" Click ="OnGaugeHighClick" />
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 private void OnGaugeLowClick ( object sender, RoutedEventArgs e ){ ViewModel.Pressure = 25 ; } private void OnGaugeMidClick ( object sender, RoutedEventArgs e ){ ViewModel.Pressure = 65 ; } private void OnGaugeHighClick ( object sender, RoutedEventArgs e ){ ViewModel.Pressure = 92 ; }
2.8 运行检查 依次点击 25 MPa、65 MPa、92 MPa:
1 2 3 25 MPa:指针在左下到顶部之间,正常区 65 MPa:指针继续向右侧,仍为正常 92 MPa:越过警戒弧,指针与中心点变成报警色
图 7:42.5 MPa 的仪表读数、刻度和警戒区。
在代码中临时写:
运行后读数应为 100.0。如果仍显示 120,说明强制回调没有接上。
2.9 常见错误 错误一:把 AffectsRender ** 当成 AffectsMeasure**
AffectsRender 只请求重绘,不重新测量布局。仪表尺寸通常由 Width、Height 或父容器决定,因此这里使用 AffectsRender 是合适的。
错误二:在 CoerceValue ** 中递归设置自身**
不要在里面再次写 gauge.Value = ...。强制回调返回修正值即可;如果需要更新派生属性,只更新 NeedleAngle 和 IsAlarm。
错误三:修改 Minimum ** 或 Maximum ** 后没有重新强制当前值
量程从 0 - 100 改成 0 - 50 时,原来的 80 已经越界。本例在属性变化时再次调用 CoerceValue(ValueProperty)。
错误四:在 OnRender ** 外直接操作 DrawingVisual**
如果你把 DrawingVisual 塞进 VisualCollection,必须同时正确实现 VisualChildrenCount 和 GetVisualChild,并确保测量逻辑一致。初学者直接用 OnRender 更稳妥。
错误五:角度直接用 0° 到 270°
0° 在右侧、90° 在下方,不符合”左下到右下”的仪表习惯。本例使用 135° 到 405°。
2.10 小练习
把量程改成 0 - 10 bar,观察 Warning="8" 时的刻度标签。
增加一个 MajorTickCount 属性,让主刻度数量可配置。
给报警状态增加一个闪烁边框,但保留文字”压力偏高”。
把仪表改成半圆表,只使用 180° 弧,思考 ToNeedleAngle 应该如何修改。
3. 更多工业组件开发说明 3.1 本节目标 这一节不再从零做十几个控件,而是先建立一套判断标准:
1 2 3 4 5 6 一个组件应该保存哪些状态 哪些规则属于业务,哪些属于显示 什么时候用普通布局组合 什么时候值得封装成 UserControl 什么时候需要 CustomControl 和默认模板 怎样让多个组件看起来像同一套产品
运行效果:
图 8:状态灯、数值输入、报警条、设备卡片、工序步骤和趋势缩略图。
3.2 组件不是“漂亮的小方块” 工业界面有一个很现实的要求:操作者要快速判断设备是否安全、参数是否异常、下一步该做什么。因此组件的第一职责是表达状态,而不是装饰。
可以把一个工业组件拆成三层:
图 9:规则层决定状态,行为层处理输入,显示层负责呈现。
以“状态灯”为例:
1 2 3 规则层:什么条件算运行、待机、故障 行为层:是否允许点击、点击后是否确认 显示层:颜色、圆点大小、是否闪烁、文字位置
如果一开始就把绿色写死在三个不同页面里,后面修改报警规范时一定会漏改。
3.3 跟做:先定义状态语义 在 Models 新建 MachineState.cs:
1 2 3 4 5 6 7 8 namespace WpfIndustrialChartsDemo.Models ;public enum MachineState{ Running, Standby, Fault }
再写一个只负责判断文字和颜色的规则类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 using System.Windows.Media;namespace WpfIndustrialChartsDemo.Models ;public static class MachineStateRules { public static string GetText (MachineState state ) { return state switch { MachineState.Running => "运行" , MachineState.Standby => "待机" , MachineState.Fault => "故障" , _ => "未知" }; } public static Brush GetBrush (MachineState state ) { return state switch { MachineState.Running => new SolidColorBrush( Color.FromRgb(47 , 163 , 107 )), MachineState.Standby => new SolidColorBrush( Color.FromRgb(226 , 174 , 72 )), MachineState.Fault => new SolidColorBrush( Color.FromRgb(185 , 57 , 57 )), _ => Brushes.Gray }; } }
真实项目还可以把颜色放进资源字典,由主题统一替换。这里把映射集中在规则类中,是为了让初学者先理解”语义集中管理”。
3.4 跟做:做一个状态灯 UserControl 在 Controls 新建 StateLamp.xaml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <UserControl x:Class ="WpfIndustrialChartsDemo.Controls.StateLamp" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" x:Name ="Root" > <Border Padding ="10" Background ="#F4F7FC" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="9" > <StackPanel Orientation ="Horizontal" > <Ellipse x:Name ="Lamp" Width ="26" Height ="26" Fill ="{Binding State, ElementName=Root, Converter={StaticResource StateBrushConverter}}" /> <StackPanel Margin ="10,0,0,0" VerticalAlignment ="Center" > <TextBlock Text ="设备状态" Foreground ="#617089" FontSize ="12" /> <TextBlock Margin ="0,2,0,0" Text ="{Binding State, ElementName=Root, Converter={StaticResource StateTextConverter}}" FontSize ="17" FontWeight ="Bold" Foreground ="#172033" /> </StackPanel > </StackPanel > </Border > </UserControl >
如果不想先写转换器,也可以直接把状态灯做成只读控件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 <UserControl x:Class ="WpfIndustrialChartsDemo.Controls.StateLamp" xmlns ="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x ="http://schemas.microsoft.com/winfx/2006/xaml" x:Name ="Root" > <Border Padding ="10" Background ="#F4F7FC" BorderBrush ="#DCE5F2" BorderThickness ="1" CornerRadius ="9" > <StackPanel Orientation ="Horizontal" > <Ellipse x:Name ="Lamp" Width ="26" Height ="26" /> <StackPanel Margin ="10,0,0,0" VerticalAlignment ="Center" > <TextBlock Text ="设备状态" Foreground ="#617089" FontSize ="12" /> <TextBlock x:Name ="StateText" Margin ="0,2,0,0" FontSize ="17" FontWeight ="Bold" Foreground ="#172033" /> </StackPanel > </StackPanel > </Border > </UserControl >
代码后置 StateLamp.xaml.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 using System.Windows;using System.Windows.Controls;using System.Windows.Media;using WpfIndustrialChartsDemo.Models;namespace WpfIndustrialChartsDemo.Controls ;public partial class StateLamp : UserControl { public static readonly DependencyProperty StateProperty = DependencyProperty.Register( nameof (State), typeof (MachineState), typeof (StateLamp), new PropertyMetadata( MachineState.Standby, OnStateChanged)); public StateLamp () { InitializeComponent(); ApplyState(State); } public MachineState State { get => (MachineState)GetValue(StateProperty); set => SetValue(StateProperty, value ); } private static void OnStateChanged ( DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e ) { var lamp = (StateLamp)dependencyObject; lamp.ApplyState((MachineState)e.NewValue); } private void ApplyState (MachineState state ) { Lamp.Fill = MachineStateRules.GetBrush(state); StateText.Text = MachineStateRules.GetText(state); } }
在页面中使用:
1 2 3 4 5 xmlns:models="clr-namespace:WpfIndustrialChartsDemo.Models" <controls:StateLamp State ="{x:Static models:MachineState.Running}" Width ="180" HorizontalAlignment ="Left" />
这个做法的优点是直接、好调试。缺点是“颜色和文字”写在控件代码后置中,换主题不如资源字典灵活。初学阶段先接受这个取舍。
3.5 组件开发顺序
图 10:属性、模板、模板部件、交互和主题五步。
建议按下面顺序做:
先写状态模型 :状态枚举、范围、阈值、单位和格式。
再做单个控件 :不要一开始做十个控件,先做最有代表性的一个。
然后抽公共规则 :把重复的越界修正、报警判断放进规则类。
最后统一主题 :颜色、字号、圆角和间距做成资源或样式。
每一步都留可运行检查点 :值能不能改、异常能不能恢复、换主题会不会坏。
3.6 更多工业组件方向 状态灯 必须同时提供颜色和文字。不要只靠红绿区分,尤其在色弱、强光或远距离环境下。
数值输入 至少要考虑:
1 2 3 4 5 6 最小值、最大值 步长 单位 非法输入恢复 提交时机 键盘上下键
上一篇的 NumericBox 已经覆盖了这些基础点,本篇不再重复写完整代码。
报警条 一个报警条至少要有:
如果只显示“压力高报”四个字,操作者无法追溯是哪台设备、什么时候发生、是否已经确认。
设备卡片 设备卡片适合放“名称 + 编号 + 状态 + 两个关键参数 + 操作入口”。不要把整页详细参数塞进卡片,否则总览会失去意义。
工序步骤 工序步骤的核心是“当前步骤、已完成步骤、异常步骤”的区别。颜色只是辅助,序号和文字必须能独立说明状态。
趋势缩略图 缩略图用于总览,不用于精确读数。完整图表可以再点击打开。缩略图可以省略坐标轴标签,但要保留趋势方向和异常点。
3.7 什么时候用 UserControl,什么时候用 CustomControl 简单判断:
1 2 把几个已有控件组合成一个区域,且外观基本固定:UserControl 需要默认模板、换皮肤、发布成通用控件:CustomControl
例如:
StateLamp:组合一个圆点和一个文本块,用 UserControl 很合适。
JogSwitch:需要 PART_Button 和默认模板,用 CustomControl。
AnalogGauge:绘制逻辑复杂、需要完全自绘,也可以继承 Control 并重写 OnRender。
3.8 运行检查
图 11:六个工业组件方向在页面中的排列。
检查点:
状态灯文字和颜色一致。
数值输入超出范围时能恢复。
报警条有编号、时间、等级等字段。
设备卡片和工序步骤不会挤在同一行互相遮挡。
趋势缩略图只表达趋势,不承担精确读数。
3.9 常见错误 错误一:只做视觉,不做状态
页面看起来像工业界面,但按钮点击后没有任何业务含义。
错误二:把所有组件都做成 CustomControl
组合控件用 UserControl 更快,只有在需要默认模板和换皮肤时才值得做 CustomControl。
错误三:颜色和文字各自维护
状态灯变红,但文字仍写“运行”,操作者会不知道该信哪个。
错误四:组件之间共享可变全局状态
两个设备卡片不应共用一个 IsRunning 字段。每个实例要有自己的数据上下文。
错误五:组件尺寸写死
工业窗口可能运行在 1366×768、1920×1080 或更高分辨率屏幕上。用 MinWidth、MinHeight 和 Grid 的比例布局,比到处写死像素更稳。
3.10 小练习
给 StateLamp 增加 BlinkWhenFault 属性,故障时闪烁,但保留文字。
做一个 AlarmBar UserControl,包含编号、时间、等级和确认按钮。
把 MachineStateRules 中的颜色迁移到 ResourceDictionary,比较两种做法的优缺点。
给设备卡片增加 DoubleClick 事件,双击后打开详细参数面板。
4. 第三方UI控件库介绍 4.1 本节目标 这一节解决三个问题:
1 2 3 图表库和整套 UI 框架有什么区别 LiveCharts2、OxyPlot、ScottPlot 各适合什么 MahApps.Metro、HandyControl、MaterialDesignInXaml 应该怎样选
运行效果:
图 12:六种常见第三方库的职责对比。
4.2 先分清两类库
图 13:图表库负责画数据,整套 UI 框架负责统一窗口与控件。
1 2 图表库:把数据画成折线、柱状、饼图、散点等 UI 框架:统一窗口、按钮、输入框、导航、主题和图标
两者不是替代关系,可以组合:
1 2 3 LiveCharts2 + MahApps.Metro LiveCharts2 + HandyControl OxyPlot + MaterialDesignInXaml
但如果项目只需要一个图表,就没有必要为了一个按钮引入整套 UI 框架。
4.3 LiveCharts2 LiveCharts2 是跨平台图表库,WPF 版本通过 SkiaSharp 渲染。
适合:
1 2 3 4 实时曲线 柱状图、折线图、饼图 与 WPF 绑定结合 动画和交互提示
本篇第 5、6 章使用它,因为它的 API 结构清晰:
1 2 3 Series:数据系列 Axis:坐标轴 Chart:承载控件
4.4 OxyPlot OxyPlot 是成熟的二维绘图库,常用于科研、工程和需要精细坐标轴控制的场景。
适合:
1 2 3 4 二维函数和实验数据 多坐标轴 导出图片 需要高度控制绘图细节
它不一定像 LiveCharts2 那样强调实时动画,但坐标轴和绘图模型非常完整。
4.5 ScottPlot ScottPlot 强调高性能绘图和大数据量。
适合:
1 2 3 4 几十万点以上的数据 实时更新 交互缩放和密集点绘制 工程数据分析
如果你的场景是“每秒几千个点、需要快速缩放”,不要只看界面是否漂亮,要先比较数据量和刷新频率。
4.6 MahApps.Metro MahApps.Metro 是整套 WPF 界面框架。
适合:
1 2 3 快速统一窗口外观 使用现成主题、图标和常用控件 管理类桌面应用
它解决的是“整套界面风格”,而不是“如何画一条实时曲线”。
4.7 HandyControl HandyControl 控件覆盖面广,中文文档相对友好。
适合:
1 2 3 需要大量现成输入框、对话框、导航和步骤控件 希望减少基础控件样式工作量 中文团队快速上手
引入前仍要确认版本、许可证、主题冲突和现有资源字典的加载顺序。
4.8 MaterialDesignInXaml MaterialDesignInXaml 提供 Material 风格的控件和主题体系。
适合:
1 2 3 接受 Material 视觉语言 希望统一按钮、卡片、阴影和颜色 需要一套完整主题资源
它不是图表库。做实时曲线时仍然要另外选择 LiveCharts2、OxyPlot 或 ScottPlot。
4.9 选型检查表 1 2 3 4 5 6 7 8 1. 我需要画数据,还是需要统一整套窗口和控件? 2. 我的数据量大概多少?每秒刷新多少次? 3. 我是否需要导出图片、打印或精确坐标轴? 4. 我是否需要中文文档和现成控件? 5. 许可证是否允许当前项目使用? 6. 版本是否支持当前的 .NET 与 WPF? 7. 引入后会不会和现有资源字典、主题冲突? 8. 出问题后能否锁定版本、查看发布说明和社区讨论?
许可证和版本会变化,本文不替你下结论。正式项目应查看库的官方网站、NuGet 页面和仓库许可证文件。
4.10 常见错误 错误一:把图表库当 UI 框架
LiveCharts2 不会帮你做导航栏、登录窗口和整套按钮主题。
错误二:为了一个控件引入整套框架
先评估样式冲突、包体积和升级成本。
错误三:只看截图,不看数据量
演示 100 个点很流畅,不代表 100 万个点也流畅。
错误四:不锁定版本
教程代码能跑,不代表未来任意新版本 API 不变。正式项目应在项目文件中固定版本。
错误五:忽略许可证
公司项目、商业项目和个人项目的要求可能不同,必须自行核实。
4.11 小练习
选一个你熟悉的业务界面,分别列出它需要的图表能力和 UI 框架能力。
用 LiveCharts2 画 1000 个点,再用 ScottPlot 画同样数据,比较启动和缩放体验。
在空白项目中只引入 MahApps.Metro,不改任何图表代码,观察原有样式是否被影响。
记录你最终选择的库、版本、许可证来源和升级策略。
5. LiveCharts图表库的基本使用 5.1 本节目标 用一个最小可用例子理解 LiveCharts2:
1 2 3 4 5 Series 是什么 Axis 是什么 CartesianChart 怎样承载柱状图 PieChart 怎样承载占比图 数据变化后图表怎样刷新
最终效果:
图 14:左侧柱状图显示一周产量,右侧饼图显示设备状态占比。
5.2 图表结构 LiveCharts2 可以用四层来理解:
图 15:数据、Series、Axis 和 Chart 控件分工。
1 2 3 4 业务数据:数组、List、ObservableCollection Series:决定图形是柱、线还是饼 Axis:决定坐标范围、标签和分隔线 Chart:WPF 控件,负责承载并显示
一张图表的最小关系:
1 2 3 4 CartesianChart ├─ Series ├─ XAxes └─ YAxes
如果没有 Series,图表没有内容;如果没有 Axis,数值型图表无法确定坐标范围。
5.3 跟做:准备 ViewModel 新建 ViewModels\MainViewModel.cs,先写基础骨架:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 using LiveChartsCore;using LiveChartsCore.SkiaSharpView;using LiveChartsCore.SkiaSharpView.Painting;using SkiaSharp;namespace WpfIndustrialChartsDemo.ViewModels ;public sealed class MainViewModel : ObservableObject { public MainViewModel () { WeeklySeries = new ISeries[] { new ColumnSeries<double > { Name = "产量" , Values = new double [] { 42 , 58 , 51 , 76 , 64 , 82 , 73 }, Fill = new SolidColorPaint( SKColor.Parse("#2456A6" )), MaxBarWidth = 30 } }; WeeklyXAxes = new [] { new Axis { Labels = new [] { "周一" , "周二" , "周三" , "周四" , "周五" , "周六" , "周日" } } }; WeeklyYAxes = new [] { new Axis { MinLimit = 0 , MaxLimit = 100 , MinStep = 20 } }; } public ISeries[] WeeklySeries { get ; } public Axis[] WeeklyXAxes { get ; } public Axis[] WeeklyYAxes { get ; } }
先只做柱状图。饼图稍后再加,避免一次引入太多概念。
5.4 跟做:加入柱状图控件 在 MainWindow.xaml 根元素增加:
1 xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF"
放入图表:
1 2 3 4 5 6 7 <lvc:CartesianChart x:Name ="WeeklyChart" Series ="{Binding WeeklySeries}" XAxes ="{Binding WeeklyXAxes}" YAxes ="{Binding WeeklyYAxes}" LegendPosition ="Hidden" TooltipPosition ="Top" AnimationsSpeed ="0:0:0.5" />
运行后应看到七根柱子,横轴是周一到周日,纵轴从 0 到 100。
5.5 Series、Axis 和 Chart 各管什么
图 16:Series 是内容,Axis 是坐标系,Legend 与 Tooltip 是辅助信息。
用一张表记住:
对象
负责什么
常见错误
Series
数据和图形类型
把值写进 Axis,或忘记设置 Values
Axis
范围、标签、分隔线
X 轴没有 Labels,分类对不上
CartesianChart
承载二维图表
Series 或 Axes 没有绑定
PieChart
承载占比图
给饼图硬套 CartesianChart
Legend
解释系列名称
名称重复或为空
Tooltip
悬停时显示详细值
数值格式不适合业务单位
5.6 跟做:加入饼图 在 ViewModel 中增加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 ShareSeries = new ISeries[] { new PieSeries<double > { Name = "运行" , Values = new [] { 68d }, Fill = new SolidColorPaint( SKColor.Parse("#2456A6" )), Stroke = null }, new PieSeries<double > { Name = "待机" , Values = new [] { 22d }, Fill = new SolidColorPaint( SKColor.Parse("#F2B84B" )), Stroke = null }, new PieSeries<double > { Name = "停机" , Values = new [] { 10d }, Fill = new SolidColorPaint( SKColor.Parse("#C9D8E6" )), Stroke = null } }; public ISeries[] ShareSeries { get ; }
XAML 中:
1 2 3 4 <lvc:PieChart Series ="{Binding ShareSeries}" LegendPosition ="Right" TooltipPosition ="Top" AnimationsSpeed ="0:0:0.5" />
运行后,饼图应显示 68、22、10 三块,并在右侧显示三个图例。
5.7 跟做:刷新数据 在 ViewModel 增加:
1 2 3 4 5 6 7 8 public void UpdateWeekly ( IReadOnlyList<double > values ){ if (WeeklySeries[0 ] is ColumnSeries<double > series) { series.Values = values; } }
按钮事件:
1 2 3 4 5 6 7 8 9 10 11 12 13 private void OnRefreshWeeklyClick ( object sender, RoutedEventArgs e ){ var random = new Random(2026 ); var values = Enumerable.Range(0 , 7 ) .Select(_ => (double )random.Next(35 , 95 )) .ToArray(); ViewModel.UpdateWeekly(values); ViewModel.AddBasicChart( "刷新一周产量:生成 7 个新数据点。" ); }
点击按钮后,柱子应更新,而不是整个窗口重新创建。
5.8 数据集合怎样选择 1 2 3 固定不变的 7 天数据:数组或 List 即可 会增删的数据点:ObservableCollection 需要高性能实时刷新:优先复用集合,控制点数
不要在每次刷新时都重建:
1 2 3 新的 MainViewModel 新的 CartesianChart 新的 Series
这样会把动画重新启动,导致界面抖动,也让内存持续上涨。
5.9 运行检查
图 17:柱状图和饼图同时显示。
检查点:
七根柱子对应周一到周日。
纵轴从 0 到 100,步长为 20。
点击”刷新产量”后柱子发生变化。
饼图显示运行、待机、停机三块。
图例名称不是空字符串。
图表区域不是空白,说明渲染层已经正常工作。
如果图表区域一片空白,先按这个顺序排查:
1 2 3 4 5 1. Series 是否有 Values 2. Series 或 Axes 是否绑定成功 3. 控件是否真的有宽高 4. 项目是否安装了 WPF 版 LiveCharts 包 5. 是否在错误线程上更新了 UI 数据
5.10 常见错误 错误一:只安装 LiveChartsCore
WPF 需要 LiveChartsCore.SkiaSharpView.WPF。只安装核心包时,XAML 中找不到 CartesianChart。
错误二:在饼图上绑定 XAxes 和 YAxes
饼图没有二维坐标轴,Series 和 Legend 才是主要配置。
错误三:X 轴 Labels 数量与 Values 数量不一致
柱子数量和标签数量不对应时,会出现错位或部分标签缺失。
错误四:用 UI 线程之外的代码直接改 ObservableCollection
WPF 集合更新通常需要回到 UI 线程。耗时计算放到后台,结果再通过调度器回到 UI 线程。
错误五:把图表空白都归因于库
多数空白来自数据为空、绑定失败、宽高为 0 或渲染线程问题,不要一开始就更换库。
5.11 小练习
把一周产量改成 10 天,并让 X 轴标签数量一致。
给柱状图增加 Rx 和 Ry 圆角。
把饼图改成四个状态,增加”维修”。
用 List<double> 替换数组,观察图表是否仍能刷新。
6. LiveCharts图表更多配置 6.1 本节目标 在能显示图表之后,把它调到”好用”:
1 2 3 4 5 6 7 8 实时曲线追加数据 DateTimeAxis 显示时间 滚动窗口限制点数 显示或隐藏数据点 显示或隐藏图例 显示或隐藏提示框 平滑曲线与线宽 坐标轴范围和分隔线
运行效果:
图 18:实时曲线、图例、数据点和时间轴。
6.2 配置分成三类
图 19:数据与坐标、线与标记、交互与信息三类配置。
不要一次改十几项。推荐顺序:
1 2 3 4 5 1. 数据正确 2. 坐标范围正确 3. 趋势可读 4. 图例与提示框可用 5. 最后再调动画和细节
6.3 跟做:准备实时数据集合 在 ViewModel 中增加:
1 2 3 4 5 6 7 using System.Collections.ObjectModel;using System.Windows.Threading;using LiveChartsCore.Defaults;private readonly ObservableCollection<DateTimePoint> _livePoints = new ();private readonly DispatcherTimer _telemetryTimer;private readonly Random _random = new (2026 );
构造函数中先生成 25 个初始点:
1 2 3 4 5 6 7 8 9 var now = DateTime.Now;for (var index = 24 ; index >= 0 ; index--){ _livePoints.Add(new DateTimePoint( now.AddSeconds(-index * 2 ), 38 + Math.Sin(index * 0.55 ) * 8 + _random.NextDouble() * 3 )); }
创建实时折线:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 LiveSeries = new ISeries[] { new LineSeries<DateTimePoint> { Name = "出口压力" , Values = _livePoints, GeometrySize = 7 , GeometryFill = new SolidColorPaint( SKColor.Parse("#2456A6" )), GeometryStroke = new SolidColorPaint( SKColor.Parse("#FFFFFF" ), 2 ), Stroke = new SolidColorPaint( SKColor.Parse("#2456A6" ), 3 ), Fill = new SolidColorPaint( SKColor.Parse("#2456A6" ).WithAlpha(35 )), LineSmoothness = 0.45 } };
6.4 跟做:配置时间轴和数值轴 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 LiveXAxes = new [] { new DateTimeAxis( TimeSpan.FromSeconds(2 ), date => date.ToString("HH:mm:ss" )) { Name = "时间" , TextSize = 12 , LabelsPaint = new SolidColorPaint( SKColor.Parse("#617089" )), SeparatorsPaint = new SolidColorPaint( SKColor.Parse("#DCE5F2" ), 1 ) } }; LiveYAxes = new [] { new Axis { Name = "MPa" , MinLimit = 0 , MaxLimit = 70 , MinStep = 10 , TextSize = 12 , LabelsPaint = new SolidColorPaint( SKColor.Parse("#617089" )), SeparatorsPaint = new SolidColorPaint( SKColor.Parse("#DCE5F2" ), 1 ) } };
关键参数:
1 2 3 4 DateTimeAxis:把时间点显示成时间标签 TimeSpan:标签间隔 MinLimit / MaxLimit:固定纵轴范围 MinStep:最小刻度间隔
6.5 跟做:追加数据 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public void AddTelemetryPoint (double value ){ var point = new DateTimePoint( DateTime.Now, IndustrialRules.Clamp(value , 0 , 70 )); _livePoints.Add(point); while (_livePoints.Count > 34 ) { _livePoints.RemoveAt(0 ); } TelemetryCount++; }
while 循环就是滚动窗口:只保留最近 34 个点,避免集合无限增长。
图 20:定时器产生值,集合保存点,Series 负责刷新。
6.6 跟做:启动和暂停 1 2 3 4 5 6 7 8 9 10 11 public void StartLive (){ _telemetryTimer.Start(); LiveStatus = "实时曲线运行中" ; } public void StopLive (){ _telemetryTimer.Stop(); LiveStatus = "实时曲线已暂停" ; }
创建定时器:
1 2 3 4 5 _telemetryTimer = new DispatcherTimer( TimeSpan.FromMilliseconds(900 ), DispatcherPriority.Background, OnTelemetryTick, Dispatcher.CurrentDispatcher);
1 2 3 4 5 6 7 8 9 10 private void OnTelemetryTick ( object ? sender, EventArgs e ){ var next = 38 + Math.Sin(TelemetryCount * 0.65 ) * 9 + _random.NextDouble() * 4 ; AddTelemetryPoint(next); }
按钮:
1 2 3 4 5 6 <Button Content ="启动实时" Click ="OnStartLiveClick" /> <Button Content ="暂停实时" Click ="OnStopLiveClick" /> <Button Content ="追加一个点" Click ="OnAddPointClick" />
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 private void OnStartLiveClick ( object sender, RoutedEventArgs e ){ ViewModel.StartLive(); } private void OnStopLiveClick ( object sender, RoutedEventArgs e ){ ViewModel.StopLive(); } private void OnAddPointClick ( object sender, RoutedEventArgs e ){ ViewModel.AddTelemetryPoint( 38 + Math.Sin( ViewModel.TelemetryCount * 0.5 ) * 12 ); }
6.7 跟做:显示选项 XAML:
1 2 3 4 5 6 7 8 9 10 11 <CheckBox Content ="显示数据点" IsChecked ="{Binding ShowMarkers}" /> <CheckBox Content ="显示图例" IsChecked ="{Binding ShowLegend}" /> <CheckBox Content ="显示提示框" IsChecked ="{Binding ShowTooltip}" /> <CheckBox Content ="平滑曲线" IsChecked ="{Binding SmoothLine}" /> <Button Content ="应用显示配置" Click ="OnApplyChartConfigClick" />
ViewModel:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 private bool _showMarkers = true ;private bool _showLegend = true ;private bool _showTooltip = true ;private bool _smoothLine = true ;public bool ShowMarkers{ get => _showMarkers; set => SetProperty(ref _showMarkers, value ); } public bool ShowLegend{ get => _showLegend; set => SetProperty(ref _showLegend, value ); } public bool ShowTooltip{ get => _showTooltip; set => SetProperty(ref _showTooltip, value ); } public bool SmoothLine{ get => _smoothLine; set => SetProperty(ref _smoothLine, value ); }
6.8 跟做:把选项应用到图表 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 private void OnApplyChartConfigClick ( object sender, RoutedEventArgs e ){ var series = (LineSeries<DateTimePoint>) ViewModel.LiveSeries[0 ]; series.GeometrySize = ViewModel.ShowMarkers ? 7 : 0 ; series.LineSmoothness = ViewModel.SmoothLine ? 0.45 : 0 ; series.Stroke = new SolidColorPaint( new SKColor(36 , 86 , 166 ), 3 ); series.GeometryFill = new SolidColorPaint( new SKColor(36 , 86 , 166 )); series.Fill = new SolidColorPaint( new SKColor(36 , 86 , 166 ).WithAlpha(35 )); LiveChart.LegendPosition = ViewModel.ShowLegend ? LegendPosition.Right : LegendPosition.Hidden; LiveChart.TooltipPosition = ViewModel.ShowTooltip ? TooltipPosition.Top : TooltipPosition.Hidden; ViewModel.AddConfigChart( $"应用配置:标记 {ViewModel.ShowMarkers} ," + $"图例 {ViewModel.ShowLegend} ," + $"提示框 {ViewModel.ShowTooltip} ," + $"平滑 {ViewModel.SmoothLine} 。" ); }
注意:GeometrySize = 0 比直接删除系列更简单。数据仍然保留,只是不画点。
6.9 坐标轴与性能 实时图的坐标轴要尽量稳定:
1 2 3 纵轴范围固定:避免每次刷新都重新计算 横轴时间间隔固定:避免标签跳动 点数有上限:避免内存和绘制成本持续增长
如果数据量明显变大,再考虑:
1 2 3 4 5 降低刷新频率 减少数据点 关闭动画 关闭数据点标记 把耗时计算放到后台线程
不要在数据量还没测出来时就关掉所有功能。先测量,再优化。
6.10 运行检查
图 21:时间轴、数据点、图例和实时曲线。
依次操作:
点击”启动实时”,观察曲线每 900 毫秒追加一个点。
点击”暂停实时”,确认曲线停止追加但历史点保留。
点击”追加一个点”,确认只增加一个点。
取消”显示数据点”,再点”应用显示配置”,圆点消失。
取消”显示图例”和”显示提示框”,确认图表右侧和悬停提示消失。
取消”平滑曲线”,确认折线变成直线段。
6.11 常见错误 错误一:每次 Tick 都重建 Series
会导致动画反复启动、图表抖动和性能下降。应该复用集合和 Series。
错误二:集合无限增长
实时运行几小时后,几万个点会让内存和绘制成本越来越高。必须设置滚动窗口。
错误三:在后台线程直接改 ObservableCollection
WPF 控件绑定集合时通常要求 UI 线程更新。需要回到 Dispatcher。
错误四:纵轴范围随数据自动变化
曲线看起来一直在跳,操作者无法比较前后趋势。实时图应优先固定范围。
错误五:用动画掩盖数据问题
动画只影响观感。数据错、坐标错、线程错,都不是动画能解决的。
6.12 小练习
把刷新间隔改成 500 毫秒,观察曲线速度和 CPU 占用。
把滚动窗口从 34 个点改成 60 个点。
增加一个”纵轴范围”下拉框,在 0 - 70 和 0 - 100 之间切换。
给曲线增加一条警戒水平线,标注 60 MPa。
暂停实时后,尝试把集合清空,观察图表和日志的变化。
完整项目结构 随附示例的完整结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 WpfIndustrialChartsDemo ├─ App.xaml ├─ App.xaml.cs ├─ IndustrialRules.cs ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ SelfTestRunner.cs ├─ UiProbe.cs ├─ WpfIndustrialChartsDemo.csproj ├─ Controls │ ├─ AnalogGauge.cs │ └─ JogSwitch.cs ├─ Models │ └─ AppOptions.cs ├─ Themes │ ├─ Generic.xaml │ └─ Themes.cs └─ ViewModels ├─ MainViewModel.cs └─ ObservableObject.cs
自动测试 示例提供 --self-test 参数,不依赖人工点击即可检查关键逻辑:
1 2 3 4 5 6 cd "C:\Users\North\Desktop\WPF学习笔记\示例代码\WpfIndustrialChartsDemo" dotnet build $exe = (Resolve-Path ".\bin\Debug\net9.0-windows\WpfIndustrialChartsDemo.exe" ).Path$p = Start-Process -FilePath $exe -ArgumentList "--self-test" -PassThru -Wait "ExitCode=$ ($p .ExitCode)" Get-Content ".\bin\Debug\net9.0-windows\self-test-results.txt" -Encoding UTF8
自测覆盖:
1 2 3 4 5 6 7 8 9 仪表 0、50、100 的角度映射 越界值被限制到量程 警戒值判断 JogSwitch 模板与 PART_Button JogSwitch 自锁与复位 AnalogGauge 报警和范围强制 柱状图、饼图、折线图系列数量 实时点追加 周产量刷新
本次验证结果:16 项全部通过。
常见错误速查
现象
先检查什么
常见原因
控件只有空白区域
模板和 DefaultStyleKey
没找到 Generic.xaml,或模板没有内容
点动按钮没有反应
PART_Button
模板名称不一致,GetTemplateChild 返回空
仪表指针不动
NeedleAngle 和 CoerceValue
只改了 Value,没有更新派生属性
仪表越界仍显示原值
强制回调
CoerceValueCallback 没有注册或返回值不对
图表空白
Series 与 Axes
没有绑定、数据为空、控件宽高为 0
饼图没有内容
Series 类型
用了 CartesianChart 或没设置 Values
实时曲线越跑越卡
集合大小
没有滚动窗口,点数无限增长
点击图表后程序崩溃
事件签名
第三方事件签名与当前版本不一致
截图里图表空白
截图方式
SkiaSharp 图层需要真实窗口截图
构建出现 NU1701
依赖目标框架
第三方包间接引用旧框架依赖,先确认是否影响运行
综合练习 把本篇内容组合成一个小型设备监控页:
左上角放 JogSwitch,控制”启动/停止”。
中间放 AnalogGauge,显示出口压力。
右侧放状态灯,显示运行、待机、故障。
下方放实时曲线,纵轴固定为 0 - 100 MPa。
点击启动时,曲线开始追加;点击停止时,曲线暂停。
压力超过 80 MPa 时,仪表和状态灯同时进入报警语义。
页面保留日志,记录每次启动、停止和报警。
完成标准:
1 2 3 4 5 不重建整个窗口也能更新状态 不重建 Series 也能追加曲线 颜色、文字、日志三者一致 范围越界能自动修正 暂停后历史数据仍保留
结语 这一篇的重点不是记住所有 LiveCharts 属性,而是建立三个习惯:
1 2 3 先把状态和规则写清楚,再考虑颜色和动画 先确认数据、Series、Axis,再排查图表空白 先测量数据量和刷新频率,再决定是否引入更大的库
当你能独立完成点动开关、指针仪表和一条稳定的实时曲线后,再学习更复杂的工业组件、主题系统和图表交互,会轻松很多。
后续可以继续练习:
自定义报警列表与确认流程
多设备切换与数据上下文隔离
实时数据的后台采集与 UI 线程调度
图表导出、打印和报表
第三方 UI 框架与自定义控件的主题整合