完整示例:解耦的串口通信框架 + UI 更新流程(豆包整理)
完整示例:解耦的串口通信框架 + UI 更新流程
我们将实现一个 三层架构 的串口通信系统,包含:
- 串口核心层(
SerialManager):完全脱离 UI,负责串口读写、数据解析,通过强类型事件对外暴露数据。 - 业务逻辑层(可选):处理数据转换、校验等业务规则。
- 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
}
四、关键调用流程(完整链路)
-
串口数据接收
单片机发送协议帧 → 串口硬件接收 →SerialPort.DataReceived触发 →SerialManager.OnSerialDataReceived解析数据 → 触发SensorDataReceived强类型事件。 -
跨线程UI更新
SensorDataReceived事件运行在 子线程,直接更新UI会报错。- 通过
InvokeRequired判断线程 → 调用Invoke将操作封送回UI主线程 → 更新标签、表格等控件。
-
多监听者扩展
如需同时保存数据到数据库,只需新增一个事件订阅: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();
}
}
核心优势总结
- 完全解耦:
SerialManager不依赖任何 UI 组件,可复用在 WinForm/WPF/控制台。 - 强类型安全:事件参数语义明确,避免字符串解析错误。
- 多监听者支持:一个事件可被多个订阅者处理(界面更新 + 数据库 + 日志)。
- 线程安全:通过
Invoke/Dispatcher处理跨线程 UI 更新。
浙公网安备 33010602011771号