首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >C# + WPF 上位机开发:通信与界面解耦,数据才不丢

C# + WPF 上位机开发:通信与界面解耦,数据才不丢

原创
作者头像
IT大佬 jzit-top
发布于 2026-10-24 18:32:51
发布于 2026-10-24 18:32:51
50
举报

工业上位机的专业性,不体现在界面上画了多少仪表盘,而体现在通信层抖动时数据是否完整、UI 卡顿时采集是否仍在继续、断线重连后历史曲线是否连续。WPF 的单线程 UI 模型与现场设备的不稳定通信,是一对天然矛盾:一旦把“读设备”写在按钮事件里,界面就会在超时那几秒彻底假死。专业做法的核心只有一句:采集、缓冲、展示三段彻底解耦,采集永不等待 UI。下面按这条链路拆解,并给出可运行代码。

一、通信层:接口隔离 + 超时 + 断线重连

上位机对接的设备五花八门:Modbus TCP、OPC UA、串口、私有 TCP。上层不应感知差异,所有协议收敛到统一的读写契约。

代码语言:javascript
复制
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 里原地重试——那样会把超时层层叠加到采集周期上。

代码语言:javascript
复制
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 节流

这是上位机最关键的设计点。采集线程只做一件事:把采样值写入无界 Channel,然后立刻读下一轮;UI 用定时器按固定频率(如 200ms)批量取出并刷新,绝不逐条刷新。

代码语言:javascript
复制
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 侧消费:

代码语言:javascript
复制
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)与确认状态。

代码语言:javascript
复制
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 拖慢采集。

代码语言:javascript
复制
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 删除。

目录
  • 一、通信层:接口隔离 + 超时 + 断线重连
  • 二、采集与展示解耦:Channel 缓冲 + UI 节流
  • 三、报警:死区 + 回差,避免抖动刷屏
  • 四、历史数据:环形缓冲 + 异步落盘
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档