"观察者模式 + 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
假设我们采集心电信号,每秒推送一次平均心率。
-
数据帧定义
csharppublic class HeartRateFrame { public int BPM { get; } public DateTime Timestamp { get; } public HeartRateFrame(int bpm, DateTime ts) { BPM = bpm; Timestamp = ts; } }
-
后台服务
csharppublic 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); } } }
-
前台订阅
csharp_hrService.OnHeartRate += f => BeginInvoke(new Action(() => lblHeartRate.Text = $"{f.BPM} BPM"));
D. 为什么要这样设计?
- 松耦合:后台服务不依赖 UI,UI 也不用知道后台如何采集。
- 可扩展 :多个 UI 组件(实时曲线、报警灯、日志面板)都可以同时订阅
OnScopeFrame
或OnHeartRate
。 - 安全 :通过
BeginInvoke
保证 UI 线程安全。 - 一致性 :
ScopeFrame
/HeartRateFrame
封装上下文信息,接口清晰。
✅ 总结
这个"ScopeFrame
订阅机制"本质是 观察者模式 + UI 线程切换 + 数据帧契约 。
复用时,你只要换掉帧里承载的数据类型(心率帧、温度帧、网络包帧......),其它套路一模一样。
补充
csharp
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>
:
csharp
void HandleFrame(ScopeFrame f) { /* ... */ }
Action<ScopeFrame> handler = HandleFrame;
// 或者
Action<ScopeFrame> handler2 = f => Console.WriteLine(f.Length);
2) event
是什么?------ 事件(event)
-
event
基于委托,加了一层封装/访问控制:- 类外部 只能
+=
订阅、-=
退订;不能直接调用、不能覆盖整个委托字段。 - 类内部(发布者)才可以触发(Invoke)。
- 类外部 只能
-
目的:防止外部把你的回调列表清空/替换,或私自触发事件,保证类的不变式与封装性。
对比:
csharp
public Action<ScopeFrame> OnScopeFrame; // 裸委托(不安全)
public event Action<ScopeFrame> OnScopeFrame; // 事件(外部只能 += / -=)
3) 它实现了什么模式?------ 观察者(发布/订阅)
- 发布者(Subject) :在合适时机
OnScopeFrame?.Invoke(frame)
- 订阅者(Observer) :
_device.OnScopeFrame += f => BeginInvoke(() => PushFrameToDyn(f));
- 多播委托 :一个事件可以挂多个处理器,触发时会逐个调用(调用链)。
这正是你现在用的套路:设备服务"发布帧",前台多个模块(示波、记录、告警...)都可以各自订阅。
4) 触发与订阅:正确姿势
触发(发布者内部)
csharp
// C# 6+ 推荐写法:空条件调用,避免空引用
OnScopeFrame?.Invoke(frame);
订阅(UI/消费者)
csharp
_device.OnScopeFrame += f =>
BeginInvoke(new Action(() => PushFrameToDyn(f))); // 切回 UI 线程
线程要点:事件经常在后台线程 触发;WinForms/WPF 控件只能在UI 线程 操作 → 用
BeginInvoke/Dispatcher.Invoke
切回。
5) 进阶要点(面试/实战都常用)
-
异常隔离 :某个订阅者抛异常会中断后续订阅者的调用。稳妥做法:发布者遍历调用列表,逐个
try/catch
:csharpvar 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) "对照理解"
-
现在的定义:
csharppublic event Action<ScopeFrame> OnScopeFrame;
表示"有一件事发生了(新的一帧数据) ",订阅者拿到
ScopeFrame
自己处理。 -
触发点(设备服务里):
csharpvar frame = new ScopeFrame(ch1, ch2, fs, sw.ElapsedTicks); OnScopeFrame?.Invoke(frame);
------把"一帧数据的契约"一起广播出去(通道数组、采样率、时间戳),订阅方解耦。
-
订阅点(UI):
csharp_device.OnScopeFrame += f => BeginInvoke(() => PushFrameToDyn(f));
------收到就切回 UI 线程,批量刷新 ScottPlot。
7) 可复用的模板
csharp
// ① 定义事件(发布者)
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) 一个复用示例:温度监控
csharp
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 线程切换,天然实现"发布/订阅 + 解耦 + 线程安全"。