【C#】观察者模式 + UI 线程调度、委托讲解

"观察者模式 + UI 线程调度"的典型应用


A. 涉及的知识点(抽象)

  1. 观察者模式(Observer Pattern)

    • 发布者DemoDeviceService.cs 内部生成一帧数据 ScopeFrame,通过 OnScopeFrame?.Invoke(frame) 发布事件。
    • 订阅者 :UI 在 Form1 里订阅 OnScopeFrame,收到后处理。
    • 作用:发布者和订阅者解耦,谁关心就订阅,不关心就不订阅。
  2. .NET 事件机制

    • event Action<ScopeFrame> OnScopeFrame; 是强类型事件。
    • 发布者只需要 Invoke(frame),订阅方自动收到。
    • 支持多订阅者(UI 可以同时有多个模块监听)。
  3. 线程调度与 UI 安全

    • 事件触发在后台任务线程里,但 WinForms 控件只能在 UI 线程更新。
    • 订阅处理时用 BeginInvoke 切回 UI 线程,保证线程安全。
  4. 数据帧抽象

    • 推送的数据被打包成 ScopeFrame,而不是裸数组,这样一帧数据的元信息(通道数、采样率、时间戳)都随事件传递,方便上层统一处理。
    • 这是"数据契约"的思想:上下游通过一个约定好的对象交互。

B. 可复用的"套路总结"

** 模板**:

👉 "后台产生数据 → 打包成帧/消息对象 → 事件广播 → UI 或其它订阅者收到 → 用 BeginInvoke/Dispatcher.Invoke 切回安全线程更新界面。"

步骤:

  1. 定义一个 数据承载类 (类似 ScopeFrame)。
  2. 在后台服务类里定义 public event Action<T>
  3. 产生数据时调用 OnEvent?.Invoke(new T(...))
  4. UI 层 service.OnEvent += e => BeginInvoke(() => Handle(e));

C. 举个复用的例子:心率监控仪 UI

假设我们采集心电信号,每秒推送一次平均心率。

  1. 数据帧定义

    csharp 复制代码
    public class HeartRateFrame {
        public int BPM { get; }
        public DateTime Timestamp { get; }
        public HeartRateFrame(int bpm, DateTime ts) { BPM = bpm; Timestamp = ts; }
    }
  2. 后台服务

    csharp 复制代码
    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);
            }
        }
    }
  3. 前台订阅

    csharp 复制代码
    _hrService.OnHeartRate += f => 
        BeginInvoke(new Action(() => lblHeartRate.Text = $"{f.BPM} BPM"));

D. 为什么要这样设计?

  • 松耦合:后台服务不依赖 UI,UI 也不用知道后台如何采集。
  • 可扩展 :多个 UI 组件(实时曲线、报警灯、日志面板)都可以同时订阅 OnScopeFrameOnHeartRate
  • 安全 :通过 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

    csharp 复制代码
    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) "对照理解"

  • 现在的定义:

    csharp 复制代码
    public event Action<ScopeFrame> OnScopeFrame;

    表示"有一件事发生了(新的一帧数据) ",订阅者拿到 ScopeFrame 自己处理。

  • 触发点(设备服务里):

    csharp 复制代码
    var 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 线程切换,天然实现"发布/订阅 + 解耦 + 线程安全"。

相关推荐
pythonqiang94 小时前
Axure下载安装教程(附安装包)Axure RP 11 超详细下载安装教程
其他·ui·axure·photoshop
燃尽了,可无4 小时前
C#基础编程核心知识点总结
开发语言·c#
专注VB编程开发20年6 小时前
c# .net支持 NativeAOT 或 Trimming 的库是什么原理
前端·javascript·c#·.net
钢铁男儿6 小时前
C# 简单工厂模式(简单工厂模式如何工作)
前端·c#·简单工厂模式
Kingsdesigner9 小时前
我用Photoshop Firefly+Blender,拯救被环境毁掉的人像大片
图像处理·ui·adobe·社交电子·blender·传媒·photoshop
isyoungboy9 小时前
c#实现鼠标mousemove事件抽稀,避免大数据阻塞网络
c#·计算机外设·远程桌面·deskflow
一枚小小程序员哈12 小时前
基于asp.net 的在线餐饮订餐系统的设计与实现/基于c#的网上订餐系统/餐厅管理系统
后端·c#·asp.net
好望角雾眠12 小时前
第三阶段数据库-7:sql中函数,运算符,常用关键字
数据库·笔记·sql·学习·sqlserver·c#
海绵宝宝汉堡包17 小时前
c# 项目 文件夹
开发语言·c#