【C#】观察者模式 + UI 线程调度、委托讲解
“观察者模式 + UI 线程调度”的典型应用
A. 涉及的知识点(抽象)
-
观察者模式(Observer Pattern)
- 发布者:
DemoDeviceService.cs
内部生成一帧数据ScopeFrame
,通过OnScopeFrame?.Invoke(frame)
发布事件。 - 订阅者:UI 在
Form1
里订阅OnScopeFrame
,收到后处理。 - 作用:发布者和订阅者解耦,谁关心就订阅,不关心就不订阅。
- 发布者:
-
.NET 事件机制
event Action<ScopeFrame> OnScopeFrame;
是强类型事件。- 发布者只需要
Invoke(frame)
,订阅方自动收到。 - 支持多订阅者(UI 可以同时有多个模块监听)。
-
线程调度与 UI 安全
- 事件触发在后台任务线程里,但 WinForms 控件只能在 UI 线程更新。
- 订阅处理时用
BeginInvoke
切回 UI 线程,保证线程安全。
-
数据帧抽象
- 推送的数据被打包成
ScopeFrame
,而不是裸数组,这样一帧数据的元信息(通道数、采样率、时间戳)都随事件传递,方便上层统一处理。 - 这是“数据契约”的思想:上下游通过一个约定好的对象交互。
- 推送的数据被打包成
B. 可复用的“套路总结”
** 模板**:
👉 “后台产生数据 → 打包成帧/消息对象 → 事件广播 → UI 或其它订阅者收到 → 用 BeginInvoke/Dispatcher.Invoke
切回安全线程更新界面。”
步骤:
- 定义一个 数据承载类(类似
ScopeFrame
)。 - 在后台服务类里定义
public event Action<T>
。 - 产生数据时调用
OnEvent?.Invoke(new T(...))
。 - UI 层
service.OnEvent += e => BeginInvoke(() => Handle(e));
。
C. 举个复用的例子:心率监控仪 UI
假设我们采集心电信号,每秒推送一次平均心率。
-
数据帧定义
public class HeartRateFrame {public int BPM { get; }public DateTime Timestamp { get; }public HeartRateFrame(int bpm, DateTime ts) { BPM = bpm; Timestamp = ts; } }
-
后台服务
public class HeartRateService {public event Action<HeartRateFrame> OnHeartRate;public async Task StartAsync(CancellationToken ct) {var rand = new Random();while (!ct.IsCancellationRequested) {int bpm = rand.Next(60, 100);OnHeartRate?.Invoke(new HeartRateFrame(bpm, DateTime.Now));await Task.Delay(1000, ct);}} }
-
前台订阅
_hrService.OnHeartRate += f => BeginInvoke(new Action(() => lblHeartRate.Text = $"{f.BPM} BPM"));
D. 为什么要这样设计?
- 松耦合:后台服务不依赖 UI,UI 也不用知道后台如何采集。
- 可扩展:多个 UI 组件(实时曲线、报警灯、日志面板)都可以同时订阅
OnScopeFrame
或OnHeartRate
。 - 安全:通过
BeginInvoke
保证 UI 线程安全。 - 一致性:
ScopeFrame
/HeartRateFrame
封装上下文信息,接口清晰。
✅ 总结
这个“ScopeFrame
订阅机制”本质是 观察者模式 + UI 线程切换 + 数据帧契约。
复用时,你只要换掉帧里承载的数据类型(心率帧、温度帧、网络包帧……),其它套路一模一样。
补充
public event Action<T> SomethingHappened;
背后串起了 3 个核心知识点:委托(delegate)、事件(event)、观察者模式(发布/订阅)。下面用“教学版”把它讲透,并结合你现在的 OnScopeFrame
用法。
1) Action<T>
是什么?—— 委托(delegate)
- 委托是“方法的类型”。它描述“能被调用的方法长什么样”。
Action<T>
是 .NET 内置的泛型委托:表示接受一个T
参数、没有返回值的方法签名。
常见的还有Action
(无参)、Action<T1,T2>
、Func<TIn,TOut>
(有返回值)等。- 你项目里:
Action<ScopeFrame>
就代表“入参是ScopeFrame
、无返回值”的回调。
小练习:任何能写成
void Handle(T x)
的方法/lambda,都能赋给Action<T>
:
void HandleFrame(ScopeFrame f) { /* ... */ }
Action<ScopeFrame> handler = HandleFrame;
// 或者
Action<ScopeFrame> handler2 = f => Console.WriteLine(f.Length);
2) event
是什么?—— 事件(event)
-
event
基于委托,加了一层封装/访问控制:- 类外部只能
+=
订阅、-=
退订;不能直接调用、不能覆盖整个委托字段。 - 类内部(发布者)才可以触发(Invoke)。
- 类外部只能
-
目的:防止外部把你的回调列表清空/替换,或私自触发事件,保证类的不变式与封装性。
对比:
public Action<ScopeFrame> OnScopeFrame; // 裸委托(不安全)
public event Action<ScopeFrame> OnScopeFrame; // 事件(外部只能 += / -=)
3) 它实现了什么模式?—— 观察者(发布/订阅)
- 发布者(Subject):在合适时机
OnScopeFrame?.Invoke(frame)
- 订阅者(Observer):
_device.OnScopeFrame += f => BeginInvoke(() => PushFrameToDyn(f));
- 多播委托:一个事件可以挂多个处理器,触发时会逐个调用(调用链)。
这正是你现在用的套路:设备服务“发布帧”,前台多个模块(示波、记录、告警…)都可以各自订阅。
4) 触发与订阅:正确姿势
触发(发布者内部)
// C# 6+ 推荐写法:空条件调用,避免空引用
OnScopeFrame?.Invoke(frame);
订阅(UI/消费者)
_device.OnScopeFrame += f =>BeginInvoke(new Action(() => PushFrameToDyn(f))); // 切回 UI 线程
线程要点:事件经常在后台线程触发;WinForms/WPF 控件只能在UI 线程操作 → 用
BeginInvoke/Dispatcher.Invoke
切回。
5) 进阶要点(面试/实战都常用)
-
异常隔离:某个订阅者抛异常会中断后续订阅者的调用。稳妥做法:发布者遍历调用列表,逐个
try/catch
:var handlers = OnScopeFrame; // 拷贝引用 if (handlers != null)foreach (Action<ScopeFrame> h in handlers.GetInvocationList())try { h(frame); } catch (Exception ex) { Log(ex); }
-
内存泄漏:长生命周期发布者(单例/后台服务)↔ 短生命周期订阅者(窗体/控件)——要记得
-=
退订;或采用“弱事件”方案。 -
命名与约定:.NET 传统是
EventHandler<TEventArgs>
模式:
event EventHandler<MyEventArgs> Something;
,签名固定(object sender, TEventArgs e)
。
你现在用Action<T>
更简洁,团队内部统一即可。 -
泛型协变/逆变:
Action<in T>
对T
是逆变的(了解即可)。
6) “对照理解”
-
现在的定义:
public event Action<ScopeFrame> OnScopeFrame;
表示“有一件事发生了(新的一帧数据)”,订阅者拿到
ScopeFrame
自己处理。 -
触发点(设备服务里):
var frame = new ScopeFrame(ch1, ch2, fs, sw.ElapsedTicks); OnScopeFrame?.Invoke(frame);
——把“一帧数据的契约”一起广播出去(通道数组、采样率、时间戳),订阅方解耦。
-
订阅点(UI):
_device.OnScopeFrame += f => BeginInvoke(() => PushFrameToDyn(f));
——收到就切回 UI 线程,批量刷新 ScottPlot。
7) 可复用的模板
// ① 定义事件(发布者)
public class DataSource<T>
{public event Action<T> OnData;public void Produce(T value) => OnData?.Invoke(value);
}// ② 订阅(消费者,WinForms 中)
_dataSource.OnData += v => BeginInvoke(new Action(() => Handle(v)));// ③ 退订(释放时)
_dataSource.OnData -= Handle; // 如果是具名方法
替换 T
就能复用到:日志行、温度帧、相机图像帧、网络包、心率、GPS……任何“源源不断的数据”。
8) 一个复用示例:温度监控
public sealed class TemperatureFrame
{public double Value { get; }public DateTime Time { get; }public TemperatureFrame(double v, DateTime t) { Value = v; Time = t; }
}public class TempService
{public event Action<TemperatureFrame> OnTemperature;public async Task StartAsync(CancellationToken ct){var rand = new Random();while (!ct.IsCancellationRequested){var f = new TemperatureFrame(20 + rand.NextDouble()*5, DateTime.Now);OnTemperature?.Invoke(f);await Task.Delay(1000, ct);}}
}// UI
_tempService.OnTemperature += f =>BeginInvoke(new Action(() => lblTemp.Text = $"{f.Value:F1} ℃"));
小结
public event Action<T>
= 用强类型事件把“某类消息 T”广播出去;外部只能订阅/退订,不能乱触发;配合 UI 线程切换,天然实现“发布/订阅 + 解耦 + 线程安全”。