工业上位机的专业性,不体现在界面上画了多少仪表盘,而体现在通信层抖动时数据是否完整、UI 卡顿时采集是否仍在继续、断线重连后历史曲线是否连续。WPF 的单线程 UI 模型与现场设备的不稳定通信,是一对天然矛盾:一旦把“读设备”写在按钮事件里,界面就会在超时那几秒彻底假死。专业做法的核心只有一句:采集、缓冲、展示三段彻底解耦,采集永不等待 UI。下面按这条链路拆解,并给出可运行代码。
上位机对接的设备五花八门:Modbus TCP、OPC UA、串口、私有 TCP。上层不应感知差异,所有协议收敛到统一的读写契约。
public interface IDeviceClient : IAsyncDisposable
{
string Name { get; }
bool IsConnected { get; }
Task<short[]> ReadHoldingAsync(ushort start, ushort count, CancellationToken ct);
Task WriteSingleAsync(ushort addr, short value, CancellationToken ct);
event EventHandler<bool>? ConnectionChanged;
}重连必须是独立后台任务 + 指数退避,而不是在读取失败的 catch 里原地重试——那样会把超时层层叠加到采集周期上。
public sealed class ResilientConnection
{
private readonly Func<CancellationToken, Task<IDeviceClient>> _factory;
private readonly ILogger _log;
private int _backoffMs = 500;
public async Task RunAsync(Action<IDeviceClient> onReady, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await using var client = await _factory(ct);
_backoffMs = 500; // 连上就重置退避
onReady(client);
await Task.Delay(Timeout.Infinite, ct); // 由外部取消触发重连
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_log.LogWarning(ex, "连接失败,{Ms}ms 后重试", _backoffMs);
await Task.Delay(_backoffMs, ct);
_backoffMs = Math.Min(_backoffMs * 2, 30_000); // 封顶 30s
}
}
}
}要点:退避上限必须有(30s),否则设备长时间离线后重连风暴会打垮交换机;每次读写的 CancellationToken 要绑定统一超时(如 1000ms),绝不允许无超时的同步读。
这是上位机最关键的设计点。采集线程只做一件事:把采样值写入无界 Channel,然后立刻读下一轮;UI 用定时器按固定频率(如 200ms)批量取出并刷新,绝不逐条刷新。
public sealed record Sample(DateTime Ts, string Tag, double Value);
public sealed class AcquisitionService
{
private readonly Channel<Sample> _channel =
Channel.CreateUnbounded<Sample>(new UnboundedChannelOptions { SingleReader = true });
public ChannelReader<Sample> Reader => _channel.Reader;
public async Task PollAsync(IDeviceClient dev, CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
while (await timer.WaitForNextTickAsync(ct))
{
try
{
var raw = await dev.ReadHoldingAsync(0, 16, ct);
for (int i = 0; i < raw.Length; i++)
_channel.Writer.TryWrite(new Sample(DateTime.Now, $"D{i}", raw[i] * 0.1));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// 单次失败只记录,不停采集;断线由 ResilientConnection 处理
_log.LogWarning(ex, "采集失败,跳过本轮");
}
}
}
}UI 侧消费:
private DispatcherTimer _uiTimer = new() { Interval = TimeSpan.FromMilliseconds(200) };
private void DrainUi()
{
var latest = new Dictionary<string, double>();
while (_acq.Reader.TryRead(out var s)) latest[s.Tag] = s.Value; // 只保留最新值
foreach (var (tag, v) in latest) _vm.UpdateTag(tag, v); // 批量刷新
}要点:TryRead 循环把积压样本一次性吸干,界面只呈现最新值——工业画面关注的是“现在是多少”,不是每一条历史样本;历史数据另走落盘通道,二者互不阻塞。
朴素写法 if (v > limit) alarm() 在现场会被噪声打爆。必须引入回差(hysteresis)与确认状态。
public sealed class AlarmPoint
{
public string Tag { get; init; } = "";
public double Hi { get; init; }
public double Deadband { get; init; } = 2.0;
public bool Active { get; private set; }
public bool Acknowledged { get; private set; }
public event Action<AlarmPoint>? Changed;
public void Evaluate(double v)
{
if (!Active && v > Hi) { Active = true; Acknowledged = false; Changed?.Invoke(this); }
else if (Active && v < Hi - Deadband) { Active = false; Changed?.Invoke(this); } // 回差
}
public void Ack() => Acknowledged = true;
}要点:回差宽度应大于正常工况波动幅度的 2 倍;报警产生与消除都应立即落盘(含时间戳),否则事故回溯时无法证明“当时有没有报”。
趋势曲线与事后分析依赖历史库。写法上先用内存环形缓冲承接,再批量写 SQLite,避免高频单条 INSERT 拖慢采集。
public sealed class HistoryWriter
{
private readonly Channel<Sample[]> _batch = Channel.CreateBounded<Sample[]>(
new BoundedChannelOptions(64) { FullMode = BoundedChannelFullMode.DropOldest });
public void Enqueue(Sample[] samples) => _batch.Writer.TryWrite(samples);
public async Task RunAsync(SqliteConnection conn, CancellationToken ct)
{
await foreach (var batch in _batch.Reader.ReadAllAsync(ct))
{
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.CommandText = "INSERT INTO samples(ts,tag,val) VALUES(@t,@g,@v)";
foreach (var s in batch)
{
cmd.Parameters.Clear();
cmd.Parameters.AddWithValue("@t", s.Ts.ToString("O"));
cmd.Parameters.AddWithValue("@g", s.Tag);
cmd.Parameters.AddWithValue("@v", s.Value);
cmd.ExecuteNonQuery();
}
tx.Commit();
}
}
}要点:队列有界且丢弃最旧——磁盘写不动时宁可丢历史,也绝不能反压采集线程导致现场数据中断;写入用事务批量提交,单条 INSERT 在机械盘上会成为瓶颈。
C# + WPF 上位机开发的专业性,可以用一句话检验:当把网线拔掉 30 秒再插回,界面是否卡死、报警是否丢、趋势曲线是否出现断点。不卡死,说明通信与 UI 已解耦;报警不丢,说明判定独立于刷新;曲线连续,说明缓冲与落盘分工正确。WPF 只是呈现工具,真正让上位机“能上现场”的,是这条采集—缓冲—展示—落盘的流水线纪律。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。