完整示例:解耦的串口通信框架 + UI 更新流程(豆包整理)

完整示例:解耦的串口通信框架 + UI 更新流程

我们将实现一个 三层架构 的串口通信系统,包含:

  1. 串口核心层(SerialManager):完全脱离 UI,负责串口读写、数据解析,通过强类型事件对外暴露数据。
  2. 业务逻辑层(可选):处理数据转换、校验等业务规则。
  3. UI 层(以 WinForm 为例,WPF 同理):订阅事件并更新界面,不包含任何串口操作逻辑。

该框架可无缝迁移到 WPF/控制台程序,支持多监听者同时响应(例如:同时更新界面 + 保存数据库 + 日志记录)。


一、定义强类型事件参数

创建独立的参数类,封装需要传递的数据(如温湿度、设备状态等),提高语义清晰度。

using System;

// 强类型事件参数:温湿度数据
public class SensorDataEventArgs : EventArgs
{
    // 温度(单位:℃)
    public float Temperature { get; set; }
    // 湿度(单位:%RH)
    public int Humidity { get; set; }
    // 数据接收时间戳
    public DateTime Timestamp { get; set; }
    // 设备地址(可选,多设备场景)
    public byte DeviceAddress { get; set; }
}

// 强类型事件参数:串口状态变更
public class SerialStatusEventArgs : EventArgs
{
    public bool IsConnected { get; set; }
    public string Message { get; set; }
}

二、实现串口核心层 SerialManager

完全脱离 UI,负责串口初始化、数据收发、协议解析,通过事件对外传递数据。

using System;
using System.IO.Ports;
using System.Linq;

public class SerialManager : IDisposable
{
    #region 字段
    private SerialPort _serialPort;
    // 协议帧缓存(处理粘包/拆包)
    private readonly byte[] _frameBuffer = new byte[1024];
    private int _bufferIndex = 0;
    // 协议帧头/尾定义(示例:[0xAA,0x55]开头,0x0D结尾)
    private readonly byte[] _frameHeader = { 0xAA, 0x55 };
    private readonly byte _frameTail = 0x0D;
    #endregion

    #region 强类型事件定义
    // 温湿度数据接收事件
    public event EventHandler<SensorDataEventArgs> SensorDataReceived;
    // 串口状态变更事件(连接/断开)
    public event EventHandler<SerialStatusEventArgs> StatusChanged;
    #endregion

    #region 公共方法
    /// <summary>
    /// 打开串口
    /// </summary>
    public bool OpenPort(string portName, int baudRate = 115200)
    {
        try
        {
            if (_serialPort?.IsOpen == true)
                ClosePort();

            _serialPort = new SerialPort(portName, baudRate)
            {
                Parity = Parity.None,
                DataBits = 8,
                StopBits = StopBits.One,
                ReadTimeout = 500,
                WriteTimeout = 500,
                // 每收到1字节触发DataReceived事件
                ReceivedBytesThreshold = 1
            };

            // 订阅串口数据接收事件
            _serialPort.DataReceived += OnSerialDataReceived;
            _serialPort.Open();

            // 触发状态变更事件
            OnStatusChanged(true, $"串口 {portName} 已打开");
            return true;
        }
        catch (Exception ex)
        {
            OnStatusChanged(false, $"打开失败:{ex.Message}");
            return false;
        }
    }

    /// <summary>
    /// 关闭串口
    /// </summary>
    public void ClosePort()
    {
        if (_serialPort?.IsOpen == true)
        {
            _serialPort.DataReceived -= OnSerialDataReceived;
            _serialPort.Close();
            OnStatusChanged(false, "串口已关闭");
        }
    }

    /// <summary>
    /// 发送数据(二进制指令)
    /// </summary>
    public bool SendData(byte[] data)
    {
        try
        {
            if (_serialPort?.IsOpen != true)
                throw new InvalidOperationException("串口未打开");

            _serialPort.Write(data, 0, data.Length);
            return true;
        }
        catch (Exception ex)
        {
            OnStatusChanged(false, $"发送失败:{ex.Message}");
            return false;
        }
    }
    #endregion

    #region 私有方法:数据接收与解析
    /// <summary>
    /// 串口数据接收回调(运行在子线程)
    /// </summary>
    private void OnSerialDataReceived(object sender, SerialDataReceivedEventArgs e)
    {
        if (_serialPort?.IsOpen != true) return;

        try
        {
            int bytesToRead = _serialPort.BytesToRead;
            byte[] rawData = new byte[bytesToRead];
            _serialPort.Read(rawData, 0, bytesToRead);

            // 解析协议帧(处理粘包/拆包)
            ParseProtocolFrame(rawData);
        }
        catch (Exception ex)
        {
            OnStatusChanged(false, $"接收数据异常:{ex.Message}");
        }
    }

    /// <summary>
    /// 自定义协议解析(示例:[0xAA,0x55][Addr][TempH][TempL][Humi][Check][0x0D])
    /// </summary>
    private void ParseProtocolFrame(byte[] rawData)
    {
        foreach (byte b in rawData)
        {
            // 1. 缓存数据
            if (_bufferIndex < _frameBuffer.Length)
                _frameBuffer[_bufferIndex++] = b;

            // 2. 检测帧尾:找到完整帧
            if (b == _frameTail && _bufferIndex >= _frameHeader.Length + 1)
            {
                // 3. 检测帧头
                if (_frameBuffer[0] == _frameHeader[0] && _frameBuffer[1] == _frameHeader[1])
                {
                    // 4. 解析数据(根据自定义协议拆解字节)
                    byte deviceAddr = _frameBuffer[2];
                    byte tempH = _frameBuffer[3];
                    byte tempL = _frameBuffer[4];
                    byte humidity = _frameBuffer[5];
                    byte checksum = _frameBuffer[6];

                    // 5. 校验(示例:前6字节异或)
                    byte calcCheck = _frameBuffer.Take(6).Aggregate((a, b) => (byte)(a ^ b));
                    if (calcCheck == checksum)
                    {
                        // 6. 转换数据格式
                        float temperature = (tempH << 8 | tempL) / 10.0f;

                        // 7. 触发强类型事件,传递解析后的数据
                        OnSensorDataReceived(new SensorDataEventArgs
                        {
                            DeviceAddress = deviceAddr,
                            Temperature = temperature,
                            Humidity = humidity,
                            Timestamp = DateTime.Now
                        });
                    }
                }

                // 8. 清空缓存,准备接收下一帧
                Array.Clear(_frameBuffer, 0, _bufferIndex);
                _bufferIndex = 0;
            }
        }
    }
    #endregion

    #region 触发事件的保护方法
    /// <summary>
    /// 触发温湿度数据事件(线程安全)
    /// </summary>
    protected virtual void OnSensorDataReceived(SensorDataEventArgs e)
    {
        SensorDataReceived?.Invoke(this, e);
    }

    /// <summary>
    /// 触发状态变更事件(线程安全)
    /// </summary>
    protected virtual void OnStatusChanged(bool isConnected, string message)
    {
        StatusChanged?.Invoke(this, new SerialStatusEventArgs
        {
            IsConnected = isConnected,
            Message = message
        });
    }
    #endregion

    #region 资源释放
    public void Dispose()
    {
        ClosePort();
        _serialPort?.Dispose();
    }
    #endregion
}

三、实现 UI 层(WinForm 示例)

UI 层不包含任何串口逻辑,仅通过订阅事件更新界面,支持 多监听者。

3.1 WinForm 界面设计

控件类型 控件名称 功能
ComboBox cmbPorts 串口列表
ComboBox cmbBaudRate 波特率选择
Button btnConnect 连接/断开
Label lblTemp 显示温度
Label lblHumi 显示湿度
TextBox txtLog 状态日志(只读)
DataGridView dgvHistory 历史数据记录

3.2 UI 层代码实现

using System;
using System.Windows.Forms;

public partial class MainForm : Form
{
    // 串口核心管理器(完全独立于UI)
    private readonly SerialManager _serialManager = new SerialManager();

    public MainForm()
    {
        InitializeComponent();
        InitUI();
        SubscribeEvents();
    }

    #region 初始化UI
    private void InitUI()
    {
        // 加载可用串口
        cmbPorts.Items.AddRange(SerialPort.GetPortNames());
        if (cmbPorts.Items.Count > 0)
            cmbPorts.SelectedIndex = 0;

        // 加载常用波特率
        cmbBaudRate.Items.AddRange(new[] { "9600", "19200", "115200" });
        cmbBaudRate.SelectedItem = "115200";

        // 初始化DataGridView列
        dgvHistory.Columns.AddRange(new[]
        {
            new DataGridViewTextBoxColumn { Name = "Time", HeaderText = "时间" },
            new DataGridViewTextBoxColumn { Name = "Device", HeaderText = "设备地址" },
            new DataGridViewTextBoxColumn { Name = "Temp", HeaderText = "温度(℃)" },
            new DataGridViewTextBoxColumn { Name = "Humi", HeaderText = "湿度(%)" }
        });

        txtLog.ReadOnly = true;
        btnConnect.Text = "连接串口";
    }
    #endregion

    #region 订阅SerialManager的事件
    private void SubscribeEvents()
    {
        // 订阅1:温湿度数据事件(更新界面)
        _serialManager.SensorDataReceived += OnSensorDataReceived;

        // 订阅2:串口状态事件(更新日志)
        _serialManager.StatusChanged += OnSerialStatusChanged;

        // 支持多监听者示例:同时保存到数据库
        // _serialManager.SensorDataReceived += SaveToDatabase;
    }

    // 取消订阅(防止内存泄漏)
    private void UnsubscribeEvents()
    {
        _serialManager.SensorDataReceived -= OnSensorDataReceived;
        _serialManager.StatusChanged -= OnSerialStatusChanged;
    }
    #endregion

    #region 事件处理:更新UI(关键:跨线程调用)
    /// <summary>
    /// 处理温湿度数据事件(运行在子线程,需Invoke到UI线程)
    /// </summary>
    private void OnSensorDataReceived(object sender, SensorDataEventArgs e)
    {
        // 跨线程更新UI:使用Invoke确保在主线程执行
        if (InvokeRequired)
        {
            Invoke(new Action<object, SensorDataEventArgs>(OnSensorDataReceived), sender, e);
            return;
        }

        // 1. 更新实时显示标签
        lblTemp.Text = $"温度:{e.Temperature:F1} ℃";
        lblHumi.Text = $"湿度:{e.Humidity} %RH";

        // 2. 添加到历史数据表格
        dgvHistory.Rows.Add(
            e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss"),
            $"0x{e.DeviceAddress:X2}",
            e.Temperature.ToString("F1"),
            e.Humidity.ToString()
        );

        // 3. 滚动到最新行
        if (dgvHistory.Rows.Count > 0)
            dgvHistory.FirstDisplayedScrollingRowIndex = dgvHistory.Rows.Count - 1;
    }

    /// <summary>
    /// 处理串口状态事件(运行在子线程,需Invoke到UI线程)
    /// </summary>
    private void OnSerialStatusChanged(object sender, SerialStatusEventArgs e)
    {
        if (InvokeRequired)
        {
            Invoke(new Action<object, SerialStatusEventArgs>(OnSerialStatusChanged), sender, e);
            return;
        }

        // 更新按钮文本和状态日志
        btnConnect.Text = e.IsConnected ? "断开串口" : "连接串口";
        txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {e.Message}\r\n");
        txtLog.ScrollToCaret();
    }
    #endregion

    #region 按钮点击事件
    private void btnConnect_Click(object sender, EventArgs e)
    {
        if (btnConnect.Text == "连接串口")
        {
            string portName = cmbPorts.SelectedItem.ToString();
            int baudRate = int.Parse(cmbBaudRate.SelectedItem.ToString());
            _serialManager.OpenPort(portName, baudRate);
        }
        else
        {
            _serialManager.ClosePort();
        }
    }
    #endregion

    #region 窗体关闭:释放资源
    protected override void OnFormClosing(FormClosingEventArgs e)
    {
        base.OnFormClosing(e);
        UnsubscribeEvents();
        _serialManager.Dispose();
    }
    #endregion
}

四、关键调用流程(完整链路)

  1. 串口数据接收
    单片机发送协议帧 → 串口硬件接收 → SerialPort.DataReceived 触发 → SerialManager.OnSerialDataReceived 解析数据 → 触发 SensorDataReceived 强类型事件。

  2. 跨线程UI更新

    • SensorDataReceived 事件运行在 子线程,直接更新UI会报错。
    • 通过 InvokeRequired 判断线程 → 调用 Invoke 将操作封送回UI主线程 → 更新标签、表格等控件。
  3. 多监听者扩展
    如需同时保存数据到数据库,只需新增一个事件订阅:

    private void SaveToDatabase(object sender, SensorDataEventArgs e)
    {
        // 写入数据库逻辑(建议异步执行,避免阻塞事件)
        // dbContext.SensorRecords.Add(new SensorRecord { ... });
        // dbContext.SaveChangesAsync();
    }
    

五、WPF 迁移说明

WPF 中跨线程更新 UI 的方式略有不同,只需修改事件处理方法中的线程切换逻辑:

// WPF 跨线程更新UI
private void OnSensorDataReceived(object sender, SensorDataEventArgs e)
{
    if (!Dispatcher.CheckAccess())
    {
        Dispatcher.Invoke(() => OnSensorDataReceived(sender, e));
        return;
    }

    // 更新WPF控件(如TextBlock、DataGrid)
    txtTemp.Text = $"温度:{e.Temperature:F1} ℃";
    dataGridHistory.Items.Add(new { ... });
}

六、控制台程序迁移说明

控制台程序无需跨线程,直接订阅事件即可:

class Program
{
    static void Main(string[] args)
    {
        var serialManager = new SerialManager();
        serialManager.SensorDataReceived += (s, e) => 
        {
            Console.WriteLine($"[{e.Timestamp}] 设备0x{e.DeviceAddress:X2} - 温度:{e.Temperature:F1}℃ 湿度:{e.Humidity}%");
        };

        serialManager.OpenPort("COM3", 115200);
        Console.WriteLine("按任意键退出...");
        Console.ReadKey();
        serialManager.Dispose();
    }
}

核心优势总结

  1. 完全解耦:SerialManager 不依赖任何 UI 组件,可复用在 WinForm/WPF/控制台。
  2. 强类型安全:事件参数语义明确,避免字符串解析错误。
  3. 多监听者支持:一个事件可被多个订阅者处理(界面更新 + 数据库 + 日志)。
  4. 线程安全:通过 Invoke/Dispatcher 处理跨线程 UI 更新。
posted @ 2025-12-03 23:31  焦涛  阅读(85)  评论(0)    收藏  举报