☰
C# 上位机开发(二):为什么你的 WPF 程序一改 UI 就崩?——线程亲和性与 Dispatcher
2026/10/6 8:05:27 网站建设 项目流程

上一篇我们解决了串口数据"粘包/半包"的问题。这一篇接着讲:数据切出来之后,怎么解析?怎么通知 UI?为什么一更新 UI 就崩溃?——涉及两个必懂的概念:事件和线程。

这里以我实现的项目为例,我使用ESP32-S3(一种芯片)和DHT11(一种温湿度传感器)配合使用来传输数据给上位机(我的电脑)。

一.Analysis分析数据

你可能要问了:数据不是已经拿到了吗?为什么还要"分析"?

因为字符串只能"看",不能"用":

(1)想做比较(温度 > 30 报警)→ 需要double类型

(2)想存数据库(字段拆开存)→ 需要独立的Temp/Humidity

(3)想画曲线(取数值)→ 需要double类型

所以要把"25.0,60.0"这种原始字符串,结构化成SensorData { Temp=25.0, Humidity=60.0 }。

如下图:25.0,60.0是原始数据,t:25 h:60是处理后的两条数据。

原理:

因为我们知道前面的数据是温度 而后面的数据是湿度

而Split函数可以将一条数据切成两半

方案:

使用Split函数分别得到温度和湿度数据,并返回给UI以显示更新

代码:

public SensorData Analysis(string str) { string[] strarr = str.Split(","); //以逗号为分隔的标志 将数据一分为二 且抛弃逗号 if (double.TryParse(strarr[0], out double temp) && double.TryParse(strarr[1], out double humidity)) //转换成功,返回数据 { Debug.WriteLine($"t:{temp} h:{humidity}"); return new SensorData(temp, humidity); //返回温湿度数据 } return null; //将string类型转换成double 转换失败则返回null。防止脏数据流入 public class SensorData //创建一个新数据类型 用来存储温湿度变量 { public double Temp { set; get; } public double Humidity { set; get; } public SensorData() { } public SensorData(double temp, double humidity) { Temp = temp; Humidity = humidity; } }

二.event 事件 绑定

说到这里大家可能就好奇了,虽然我们现在可以接收数据也可以解析数据

得到直接可以使用的数据了, 但是这个获取数据的过程难道要一直while?

通过不停的循环来实现?

实际上确实可以通过while循环这一过程来实现。不过我们有更好的办法!

接下来我们会用到两个重要的方法:

(1)event(事件)

(2)DataReceived(官方api的特定事件)

事件:

主体分为两部分 分别是发布者和订阅者

发布者:负责当达成某种条件时,通知订阅者运行

订阅者:当收到通知的时候 ,运行本身代码

DataReceived:

官方写的事件

条件:当接口收到数据的时候 通知订阅者

因此我们可以通过事件实现如下效果(知道大概流程即可,下面有具体代码):

第一次事件:ComPortService的DataArrived→ 触发DataPond.DataHandle

第二次事件:DataPond的DisplaySensorData→ 触发MainWindow.DataUpdate(这个就是更新UI的函数) 和SqliteRepository.QueueAdd

看到这里,想必聪明的你肯定已经发现了 。如果我们使用 事件和DataReceived ,只需要静静等待原始数据道到来,原始数据就会像流水线上的产品一样自动变成我们想要的结构化数据。

①第一级事件:定义 + 触发 + 订阅:

// ========== 发布者:ComPortService ========== public event Action<string> DataArrived; // 定义事件:谁能收到?"接收 string 的回调" private void UpData(object sender, SerialDataReceivedEventArgs e) { DataArrived?.Invoke(ReadData()); // 串口收到数据 → 触发事件(自动通知订阅者) } // 在构造函数里登记:串口一有数据就调 UpData _serialPort.DataReceived += UpData; // ========== 订阅者:DataPond ========== public DataPond(ICommunicationService icomPortService) { icom = icomPortService; icom.DataArrived += DataHandle; // 订阅:串口来数据了,就调 DataHandle }

② 第二级事件:定义 + 触发:

// ========== 发布者:DataPond ========== public event Action<SensorData> DisplaySensorData; // 定义事件:通知"拿到 SensorData 了" public void DataHandle(string tempData) { sb.Append(tempData); while (true) { int position = sb.ToString().IndexOf("\n"); if (position < 0) break; if (position == 0) { sb.Remove(0, 1); continue; } string frame = sb.ToString(0, position).Trim(); sb.Remove(0, position + 1); var result = tempHumidityAnalysis.Analysis(frame); if (result != null) DisplaySensorData?.Invoke(result); // 解析成功 → 触发事件(通知下游) } }

③ 第二级事件的订阅者(两个,互不知情)

// ========== 订阅者 1:UI 层 ========== // MainWindow.xaml.cs dataPond.DisplaySensorData += DataUpdate; // 订阅:有数据了就调 DataUpdate // ========== 订阅者 2:持久层 ========== // SqliteRepository.cs public SqliteRepository(DataPond dp) { dataPond = dp; dataPond.DisplaySensorData += QueueAdd; // 订阅:有数据了就入队,后续写数据库 }

三.在event事件里面更新ui导致的线程问题

你可能已经猜到了 只要把更新ui也放到事件里面

这样连带更新ui都变成自动化的流水线了。

但如果真的这么做,代码会报错?这是为什么呢?

报错原因:只有创造UI的线程才能修改它的属性

所以我们只有了解线程,才能理解这个问题

线程是什么呢?

线程 = 操作系统调度的最小执行单元。一个线程 = 一条独立的"执行流"。

  • 进程= 一家工厂

  • 线程= 工厂的一条流水线(共享资源 ,但各自独立干活)

一个进程可以有多个线程,它们共享内存(变量、对象),但各自有独立的"执行位置"(当前执行到哪一行代码)。

这次的UI代码为例,

主要涉及的两条线程分别是 UI线程 和 串口回调线程

(1)串口回调线程:

当串口接收到数据时,DataReceived事件被触发 → 调用UpData→UpData触发DataArrived→ 调用DataHandle→DataHandle触发DisplaySensorData→ 调用DataUpdate。整条链都在这一条线程上执行。

(2)UI线程:

创建UI控件的地方。

显而易见,串口线程和UI 线程不是同一条。

由于WPF 控件内部状态不是线程安全的——如果允许多线程同时修改,会出现数据竞争、渲染错乱。所以 WPF 强制规定:只有创建控件的线程(通常是 UI 线程)才能修改它。

修改方案:

其实非常简单,只需要使用Dispatcher.BeginInvoke()即可。

Dispatcher.BeginInvoke():会把函数回调给UI线程运行。

修改UI代码:

public void DataUpdate(SensorData data) { Dispatcher.BeginInvoke(() => { TempUI.Content ="温度"+ data.Temp.ToString()+"℃"; HumidityUI.Content ="湿度"+ data.Humidity.ToString()+"%RH"; DateTime nowtime = DateTime.Now; _tempValues.Add(new DateTimePoint(nowtime, data.Temp)); _humidityValues.Add(new DateTimePoint(nowtime, data.Humidity)); if (_tempValues.Count > 100) { _tempValues.RemoveAt(0); _humidityValues.RemoveAt(0); } }); }

四.完整代码

反抗者z/SimpleHMI-TempHumidityhttps://gitee.com/rebel-z/simple-hmi

五.总结

这一篇讲了三个东西:

解析:字符串 → 结构化。用Split(',')+double.TryParse,失败返回 null 防脏数据。

事件:不用 while 轮询。发布者定义事件 + 触发,订阅者+=登记。你项目里两层事件链:串口→切帧层,切帧层→UI/持久层。

跨线程 UI:控件有线程亲和性,非 UI 线程改控件会崩。用Dispatcher.BeginInvoke把动作切回 UI 线程。

一个容易忽略的点:我用的是BeginInvoke不是Invoke。区别是:Invoke会阻塞调用线程(这里是串口线程),等 UI 更新完才返回;BeginInvoke只是把动作塞进 UI 线程的消息队列,立刻返回,不阻塞串口线程。串口线程不卡,才能及时处理下一帧数据。

下一篇讲:串口数据怎么不丢地落盘到数据库——引入BlockingCollection队列,做生产者-消费者解耦。


我是反抗者Z,一个正在往 C# 上位机方向走的开发者。如果这篇对你有帮助,欢迎在 Gitee 点个 Star。
系列文章:C# 上位机开发(一):串口数据总是「半截」或「粘一坨」?——粘包/半包彻底解决-CSDN博客https://blog.csdn.net/YYDS1683/article/details/167038364?spm=1001.2014.3001.5501

C# 上位机开发(二):为什么你的 WPF 程序一改 UI 就崩?——线程亲和性与 Dispatcher-CSDN博客https://blog.csdn.net/YYDS1683/article/details/167076907?spm=1001.2014.3001.5501

C# 上位机开发(三):串口丢帧、CPU 冒烟、连接竞态——一个队列全搞定-CSDN博客https://blog.csdn.net/YYDS1683/article/details/167084164?sharetype=blogdetail&sharerId=167084164&sharerefer=PC&sharesource=YYDS1683&spm=1011.2480.3001.8118

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询