SCADA 仪表盘开发
大约 10 分钟约 2870 字
SCADA 仪表盘开发
简介
SCADA(Supervisory Control And Data Acquisition)仪表盘是工业监控系统的核心界面。WPF 凭借强大的数据绑定、动画和自定义绘制能力,非常适合构建实时监控仪表盘。本文介绍使用 WPF 开发工业仪表盘的关键技术和实践。
特点
自定义仪表盘控件
圆形仪表盘
/// <summary>
/// 圆形仪表盘控件 — 模拟指针式仪表
/// </summary>
public class GaugeControl : Control
{
static GaugeControl()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(GaugeControl),
new FrameworkPropertyMetadata(typeof(GaugeControl)));
}
public static readonly DependencyProperty ValueProperty =
DependencyProperty.Register("Value", typeof(double), typeof(GaugeControl),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsRender, OnValueChanged));
public static readonly DependencyProperty MinimumProperty =
DependencyProperty.Register("Minimum", typeof(double), typeof(GaugeControl),
new FrameworkPropertyMetadata(0.0));
public static readonly DependencyProperty MaximumProperty =
DependencyProperty.Register("Maximum", typeof(double), typeof(GaugeControl),
new FrameworkPropertyMetadata(100.0));
public static readonly DependencyProperty UnitProperty =
DependencyProperty.Register("Unit", typeof(string), typeof(GaugeControl),
new PropertyMetadata(""));
public static readonly DependencyProperty WarningValueProperty =
DependencyProperty.Register("WarningValue", typeof(double), typeof(GaugeControl),
new PropertyMetadata(70.0));
public static readonly DependencyProperty DangerValueProperty =
DependencyProperty.Register("DangerValue", typeof(double), typeof(GaugeControl),
new PropertyMetadata(90.0));
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 string Unit { get => (string)GetValue(UnitProperty); set => SetValue(UnitProperty, value); }
public double WarningValue { get => (double)GetValue(WarningValueProperty); set => SetValue(WarningValueProperty, value); }
public double DangerValue { get => (double)GetValue(DangerValueProperty); set => SetValue(DangerValueProperty, value); }
private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
// 值变化时可以触发动画
}
}<!-- GaugeControl 样式 -->
<Style TargetType="local:GaugeControl">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:GaugeControl">
<Grid>
<Viewbox>
<Canvas Width="200" Height="200">
<!-- 背景圆 -->
<Ellipse Canvas.Left="10" Canvas.Top="10" Width="180" Height="180"
Stroke="#333" StrokeThickness="2" Fill="#1a1a2e"/>
<!-- 刻度弧线 -->
<Path Data="M 30,100 A 70,70 0 1,1 170,100"
Stroke="#444" StrokeThickness="15" Fill="Transparent"/>
<!-- 正常范围(绿色) -->
<Path Data="M 30,100 A 70,70 0 0,1 65,40"
Stroke="#4CAF50" StrokeThickness="15" Fill="Transparent"/>
<!-- 警告范围(黄色) -->
<Path Data="M 65,40 A 70,70 0 0,1 145,35"
Stroke="#FFC107" StrokeThickness="15" Fill="Transparent"/>
<!-- 危险范围(红色) -->
<Path Data="M 145,35 A 70,70 0 0,1 170,100"
Stroke="#F44336" StrokeThickness="15" Fill="Transparent"/>
<!-- 数值显示 -->
<TextBlock Canvas.Left="60" Canvas.Top="90"
Text="{Binding Value, StringFormat={}{0:F1}, RelativeSource={RelativeSource TemplatedParent}}"
Foreground="White" FontSize="28" FontWeight="Bold"/>
<TextBlock Canvas.Left="75" Canvas.Top="120"
Text="{TemplateBinding Unit}"
Foreground="Gray" FontSize="14"/>
</Canvas>
</Viewbox>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 使用 -->
<local:GaugeControl Value="{Binding Temperature}" Minimum="0" Maximum="120"
Unit="°C" WarningValue="80" DangerValue="100"
Width="200" Height="200"/>状态指示灯
/// <summary>
/// 状态指示灯控件
/// </summary>
public class StatusIndicator : Control
{
static StatusIndicator()
{
DefaultStyleKeyProperty.OverrideMetadata(
typeof(StatusIndicator),
new FrameworkPropertyMetadata(typeof(StatusIndicator)));
}
public static readonly DependencyProperty StatusProperty =
DependencyProperty.Register("Status", typeof(DeviceStatus), typeof(StatusIndicator),
new PropertyMetadata(DeviceStatus.Offline));
public static readonly DependencyProperty LabelProperty =
DependencyProperty.Register("Label", typeof(string), typeof(StatusIndicator),
new PropertyMetadata(""));
public DeviceStatus Status { get => (DeviceStatus)GetValue(StatusProperty); set => SetValue(StatusProperty, value); }
public string Label { get => (string)GetValue(LabelProperty); set => SetValue(LabelProperty, value); }
}
public enum DeviceStatus
{
Running, // 运行(绿色)
Warning, // 警告(黄色)
Error, // 故障(红色)
Offline // 离线(灰色)
}<!-- StatusIndicator 样式 -->
<Style TargetType="local:StatusIndicator">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:StatusIndicator">
<StackPanel Orientation="Horizontal">
<Ellipse Width="16" Height="16" Margin="0,0,8,0">
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Fill" Value="Gray"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Status, RelativeSource={RelativeSource TemplatedParent}}" Value="Running">
<Setter Property="Fill" Value="#4CAF50"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status, RelativeSource={RelativeSource TemplatedParent}}" Value="Warning">
<Setter Property="Fill" Value="#FFC107"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status, RelativeSource={RelativeSource TemplatedParent}}" Value="Error">
<Setter Property="Fill" Value="#F44336"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
</Ellipse>
<TextBlock Text="{TemplateBinding Label}" VerticalAlignment="Center" Foreground="White"/>
</StackPanel>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>实时数据 ViewModel
监控仪表盘 ViewModel
/// <summary>
/// SCADA 仪表盘 ViewModel — 实时数据绑定
/// </summary>
public class DashboardViewModel : ObservableObject, IDisposable
{
private readonly IDeviceService _deviceService;
private CancellationTokenSource _cts = new();
// 设备状态
private double _temperature;
public double Temperature
{
get => _temperature;
set => SetProperty(ref _temperature, value);
}
private double _pressure;
public double Pressure
{
get => _pressure;
set => SetProperty(ref _pressure, value);
}
private double _flowRate;
public double FlowRate
{
get => _flowRate;
set => SetProperty(ref _flowRate, value);
}
private DeviceStatus _pumpStatus;
public DeviceStatus PumpStatus
{
get => _pumpStatus;
set => SetProperty(ref _pumpStatus, value);
}
private DeviceStatus _valveStatus;
public DeviceStatus ValveStatus
{
get => _valveStatus;
set => SetProperty(ref _valveStatus, value);
}
// 历史数据
public ObservableCollection<DataPoint> TemperatureHistory { get; } = new();
// 告警列表
public ObservableCollection<AlarmRecord> ActiveAlarms { get; } = new();
public ICommand StartCommand { get; }
public ICommand StopCommand { get; }
public ICommand AcknowledgeAlarmCommand { get; }
public DashboardViewModel(IDeviceService deviceService)
{
_deviceService = deviceService;
StartCommand = new RelayCommand(StartMonitoring);
StopCommand = new RelayCommand(StopMonitoring);
AcknowledgeAlarmCommand = new RelayCommand<AlarmRecord>(AcknowledgeAlarm);
}
private void StartMonitoring()
{
_cts = new CancellationTokenSource();
Task.Run(async () =>
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
var data = _deviceService.ReadAll();
Application.Current.Dispatcher.BeginInvoke(() =>
{
Temperature = data.Temperature;
Pressure = data.Pressure;
FlowRate = data.FlowRate;
PumpStatus = data.PumpRunning ? DeviceStatus.Running : DeviceStatus.Offline;
ValveStatus = data.ValveOpen ? DeviceStatus.Running : DeviceStatus.Offline;
// 添加历史数据
TemperatureHistory.Add(new DataPoint(DateTime.Now, data.Temperature));
if (TemperatureHistory.Count > 100)
TemperatureHistory.RemoveAt(0);
// 检查告警
CheckAlarms(data);
});
await Task.Delay(500, _cts.Token);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
Console.WriteLine($"数据采集异常:{ex.Message}");
}
}
}, _cts.Token);
}
private void StopMonitoring()
{
_cts.Cancel();
}
private void CheckAlarms(DeviceData data)
{
if (data.Temperature > 100)
{
ActiveAlarms.Insert(0, new AlarmRecord(
DateTime.Now, "高温告警", $"温度 {data.Temperature:F1}°C 超过阈值", AlarmLevel.Critical));
}
if (data.Pressure > 8.0)
{
ActiveAlarms.Insert(0, new AlarmRecord(
DateTime.Now, "高压告警", $"压力 {data.Pressure:F2}MPa 超过阈值", AlarmLevel.Warning));
}
}
private void AcknowledgeAlarm(AlarmRecord? alarm)
{
if (alarm != null) ActiveAlarms.Remove(alarm);
}
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}
public record DataPoint(DateTime Time, double Value);
public record AlarmRecord(DateTime Time, string Title, string Message, AlarmLevel Level);
public enum AlarmLevel
{
Info, Warning, Critical
}趋势图
简易实时折线图
/// <summary>
/// 简易实时折线图 — 使用 Canvas 绘制
/// </summary>
public class TrendChart : Control
{
public static readonly DependencyProperty DataPointsProperty =
DependencyProperty.Register("DataPoints", typeof(ObservableCollection<DataPoint>), typeof(TrendChart),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty TitleProperty =
DependencyProperty.Register("Title", typeof(string), typeof(TrendChart),
new PropertyMetadata(""));
public static readonly DependencyProperty LineColorProperty =
DependencyProperty.Register("LineColor", typeof(Brush), typeof(TrendChart),
new PropertyMetadata(Brushes.Green));
public ObservableCollection<DataPoint>? DataPoints
{
get => (ObservableCollection<DataPoint>?)GetValue(DataPointsProperty);
set => SetValue(DataPointsProperty, value);
}
public string Title { get => (string)GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
public Brush LineColor { get => (Brush)GetValue(LineColorProperty); set => SetValue(LineColorProperty, value); }
protected override void OnRender(DrawingContext dc)
{
base.OnRender(dc);
var width = ActualWidth;
var height = ActualHeight;
var padding = 40;
// 背景
dc.DrawRectangle(Brushes.Black, null, new Rect(0, 0, width, height));
// 标题
var titleText = new FormattedText(Title, CultureInfo.CurrentCulture, FlowDirection.LeftToRight,
new Typeface("Microsoft YaHei"), 14, Brushes.White, 1.0);
dc.DrawText(titleText, new Point(10, 5));
if (DataPoints == null || DataPoints.Count < 2) return;
var values = DataPoints.Select(p => p.Value).ToList();
var min = values.Min();
var max = values.Max();
var range = max - min;
if (range == 0) range = 1;
var chartWidth = width - padding * 2;
var chartHeight = height - padding * 2;
// 绘制网格线
var gridPen = new Pen(Brushes.Gray, 0.5);
gridPen.Freeze();
for (int i = 0; i <= 4; i++)
{
var y = padding + chartHeight * i / 4;
dc.DrawLine(gridPen, new Point(padding, y), new Point(width - padding, y));
}
// 绘制数据线
var points = new PointCollection();
for (int i = 0; i < values.Count; i++)
{
var x = padding + chartWidth * i / (values.Count - 1);
var y = padding + chartHeight - chartHeight * (values[i] - min) / range;
points.Add(new Point(x, y));
}
var linePen = new Pen(LineColor, 2);
linePen.Freeze();
for (int i = 0; i < points.Count - 1; i++)
{
dc.DrawLine(linePen, points[i], points[i + 1]);
}
}
}优点
缺点
性能优化
高频数据更新优化
/// <summary>
/// 高频数据更新优化策略
/// SCADA 系统中设备数据可能每 100-500ms 更新一次
/// 直接更新所有 UI 元素会导致界面卡顿
/// </summary>
public class DataUpdateOptimizer
{
private readonly DispatcherTimer _batchTimer;
private readonly Dictionary<string, double> _pendingUpdates = new();
private readonly Action<Dictionary<string, double>> _applyUpdates;
private readonly object _lock = new();
public DataUpdateOptimizer(Action<Dictionary<string, double>> applyUpdates, int intervalMs = 100)
{
_applyUpdates = applyUpdates;
_batchTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(intervalMs)
};
_batchTimer.Tick += BatchApply;
}
// 后台线程调用 — 收集待更新的数据
public void EnqueueUpdate(string key, double value)
{
lock (_lock)
{
_pendingUpdates[key] = value;
}
}
// UI 线程定时批量应用
private void BatchApply(object? sender, EventArgs e)
{
Dictionary<string, double> updates;
lock (_lock)
{
updates = new Dictionary<string, double>(_pendingUpdates);
_pendingUpdates.Clear();
}
if (updates.Count > 0)
{
_applyUpdates(updates);
}
}
public void Start() => _batchTimer.Start();
public void Stop() => _batchTimer.Stop();
}
// 使用
public class OptimizedDashboardViewModel : ObservableObject, IDisposable
{
private readonly DataUpdateOptimizer _optimizer;
private double _temperature;
public double Temperature { get => _temperature; set => SetProperty(ref _temperature, value); }
private double _pressure;
public double Pressure { get => _pressure; set => SetProperty(ref _pressure, value); }
private double _flowRate;
public double FlowRate { get => _flowRate; set => SetProperty(ref _flowRate, value); }
public OptimizedDashboardViewModel()
{
// 每 200ms 批量更新一次 UI(而非每次数据到达都更新)
_optimizer = new DataUpdateOptimizer(updates =>
{
if (updates.TryGetValue("Temperature", out var temp))
Temperature = temp;
if (updates.TryGetValue("Pressure", out var pressure))
Pressure = pressure;
if (updates.TryGetValue("FlowRate", out var flow))
FlowRate = flow;
}, intervalMs: 200);
_optimizer.Start();
}
public void OnDeviceDataReceived(string key, double value)
{
// 后台线程调用,不会触发 PropertyChanged
_optimizer.EnqueueUpdate(key, value);
}
public void Dispose() => _optimizer.Stop();
}使用 OxyPlot 绑定实时曲线
/// <summary>
/// 使用 OxyPlot 实现高性能实时趋势图
/// NuGet: OxyPlot.Wpf
/// </summary>
public class RealtimeTrendViewModel : ObservableObject, IDisposable
{
private CancellationTokenSource _cts = new();
// OxyPlot 模型
public PlotModel TrendModel { get; }
private LineSeries _tempSeries;
private LineSeries _pressureSeries;
private DateTimeAxis _timeAxis;
private int _maxPoints = 200; // 最大数据点数
public ICommand StartCommand { get; }
public ICommand StopCommand { get; }
public RealtimeTrendViewModel()
{
TrendModel = new PlotModel
{
Title = "实时趋势",
Background = OxyColors.Black,
PlotAreaBorderColor = OxyColors.Gray,
};
// 时间轴
_timeAxis = new DateTimeAxis
{
Position = AxisPosition.Bottom,
StringFormat = "HH:mm:ss",
MajorGridlineStyle = LineStyle.Dash,
MajorGridlineColor = OxyColor.FromRGB(50, 50, 50),
TextColor = OxyColors.LightGray,
};
TrendModel.Axes.Add(_timeAxis);
// 数值轴
TrendModel.Axes.Add(new LinearAxis
{
Position = AxisPosition.Left,
MajorGridlineStyle = LineStyle.Dash,
MajorGridlineColor = OxyColor.FromRGB(50, 50, 50),
TextColor = OxyColors.LightGray,
});
// 温度曲线
_tempSeries = new LineSeries
{
Title = "温度",
Color = OxyColors.Green,
StrokeThickness = 1.5,
};
TrendModel.Series.Add(_tempSeries);
// 压力曲线
_pressureSeries = new LineSeries
{
Title = "压力",
Color = OxyColors.Orange,
StrokeThickness = 1.5,
};
TrendModel.Series.Add(_pressureSeries);
StartCommand = new RelayCommand(Start);
StopCommand = new RelayCommand(Stop);
}
private void Start()
{
_cts = new CancellationTokenSource();
Task.Run(async () =>
{
var random = new Random();
while (!_cts.Token.IsCancellationRequested)
{
var now = DateTime.Now;
Application.Current.Dispatcher.BeginInvoke(() =>
{
// 添加数据点
_tempSeries.Points.Add(new DataPoint(
DateTimeAxis.ToDouble(now), 20 + random.NextDouble() * 10));
_pressureSeries.Points.Add(new DataPoint(
DateTimeAxis.ToDouble(now), 2 + random.NextDouble() * 3));
// 保持固定窗口
if (_tempSeries.Points.Count > _maxPoints)
_tempSeries.Points.RemoveAt(0);
if (_pressureSeries.Points.Count > _maxPoints)
_pressureSeries.Points.RemoveAt(0);
// 刷新图表
TrendModel.InvalidatePlot(true);
});
await Task.Delay(500, _cts.Token);
}
}, _cts.Token);
}
private void Stop() => _cts.Cancel();
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
}<!-- XAML 中使用 OxyPlot -->
<!-- xmlns:oxy="http://oxyplot.org/wpf" -->
<oxy:PlotView Model="{Binding TrendModel}" Width="600" Height="300"/>总结
WPF 非常适合开发 SCADA 仪表盘。核心是自定义控件(仪表盘、指示灯、趋势图)和实时数据绑定。推荐使用 OxyPlot 或 LiveCharts 做复杂图表,自定义控件做特殊工业元素。数据采集用后台线程,通过 Dispatcher 更新 UI。
关键知识点
- 先分清主题属于界面层、ViewModel 层、线程模型还是设备接入层。
- WPF 文章真正的价值在于把 UI、数据、命令、线程和资源关系讲清楚。
- 上位机场景里,稳定性和异常恢复常常比界面花哨更重要。
项目落地视角
- 优先明确 DataContext、绑定路径、命令源和线程切换位置。
- 涉及设备时,补齐超时、重连、日志、告警和资源释放策略。
- 复杂控件最好提供最小可运行页面,便于后续复用和排障。
常见误区
- 把大量逻辑堆进 code-behind,导致页面难测难维护。
- 在后台线程直接操作 UI,或忽略 Dispatcher 切换。
- 只验证正常路径,不验证设备掉线、权限缺失和资源泄漏。
进阶路线
- 继续向 MVVM 工具链、控件可复用性、性能优化和视觉树诊断深入。
- 把主题放回真实设备流程,思考启动、连接、采集、显示、告警和恢复。
- 沉淀成控件库、通信层和诊断工具,提高整套客户端的复用度。
适用场景
- 当你准备把《SCADA 仪表盘开发》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合桌面业务系统、监控看板、工控上位机和设备交互界面。
- 当系统需要同时处理复杂 UI、后台任务和硬件通信时,这类主题尤为关键。
落地建议
- 优先明确 View、ViewModel、服务层和设备层边界,避免代码隐藏过重。
- 涉及实时刷新时,提前设计 UI 线程切换、节流和资源释放。
- 对硬件通信、日志、告警和异常恢复建立标准流程。
排错清单
- 先检查 DataContext、绑定路径、INotifyPropertyChanged 和命令状态是否正常。
- 排查 Dispatcher 调用、死锁、后台线程直接操作 UI 的问题。
- 出现内存增长时,优先检查事件订阅、图像资源和窗口生命周期。
复盘问题
- 如果把《SCADA 仪表盘开发》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《SCADA 仪表盘开发》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《SCADA 仪表盘开发》最大的收益和代价分别是什么?
