第七章:C#响应式编程System.Reactive

第七章:C#响应式编程System.Reactive

目录


响应式编程(Reactive Programming)是一种通过响应事件来编写代码的编程方式,特别适合处理用户界面或任何需要应对外部事件的程序。它避免了轮询的开销,允许系统在事件发生时自动触发代码。

更多详细内容参考:Introduction to Rx.NET

7.1 为什么选择响应式编程?

在现代应用程序中,异步操作事件驱动的需求变得越来越普遍。无论是响应用户界面的交互、处理实时数据流,还是处理 I/O 操作,程序都需要能够高效地处理随时可能发生的事件。传统的编程方式有时显得笨重、复杂,难以处理这些情况。而 Rx.NET(Reactive Extensions for .NET) 提供了一种声明式、简洁的方式来处理异步事件流

1. 事件流的重要性

事件流(streams of events)在许多应用中是核心部分,例如:

  • 用户交互:按钮点击、输入变化等。
  • 实时数据:股票行情、传感器数据等。
  • 异步操作:网络请求、文件读写等。

传统上,这些操作通常通过回调事件处理异步编程模式来实现,但这些方式容易导致代码复杂、难以维护。

2. Rx.NET 的优势

Rx.NET 通过提供一个统一的方式来处理事件流,解决了传统方法的许多问题。它的优点包括:

  • 声明式的事件流处理:通过 Rx.NET,开发者可以像操作集合(如数组、列表)一样处理事件流。你可以用熟悉的 LINQ 风格的方法来过滤、转换和组合事件。

  • 简化异步编程:Rx.NET 内置了对异步事件的处理,避免了复杂的回调地狱。它提供了强大的工具来处理并发和异步任务。

  • 处理复杂的事件交互:Rx.NET 可以轻松处理多个事件源的组合、合并和分割。例如,你可以将两个不同的事件流合并,然后根据需要对它们进行处理。

  • 更好的错误处理:Rx.NET 通过其流式操作,提供了一个一致的错误处理机制,避免了散乱的 try-catch 块。

  • 可测试性:Rx.NET 提供了内置的工具,可以模拟事件流,帮助开发者轻松测试异步操作。

3. Rx.NET 的适用场景

Rx.NET 适用于处理异步事件流的场景,包括但不限于:

  • 用户界面编程:响应用户输入,处理复杂的界面交互。
  • 实时数据处理:处理实时的市场数据、传感器数据等。
  • 并发处理:协调多个异步操作,优化系统性能。
  • 响应式系统:例如聊天应用、社交媒体更新、流媒体播放等。

4. Rx.NET 的核心思想

Rx.NET 的核心思想是将一切看作一个流。不管是键盘按键、鼠标点击,还是复杂的异步操作,Rx.NET 都将其抽象为 IObservable<T>,允许开发者通过 IObserver<T> 订阅并响应这些事件。

这种序列化思维(thinking in sequences)是 Rx.NET 最强大的概念之一。它让开发者可以将传统的顺序操作模式,转变为对事件的流式操作,简化了复杂业务逻辑的表达。

小结

Rx.NET 通过其声明式的事件流处理方式,简化了异步编程,尤其适合需要应对异步事件并发操作的场景。它不仅减少了代码的复杂度,还提供了强大的工具来处理事件流的组合、转换和错误处理。通过 Rx.NET,你可以更轻松地构建高效、可扩展的响应式系统。


7.2 主要概念和类型

在深入了解如何将 .NET 事件转换为可观察的流之前,我们需要先掌握一些 Reactive Extensions (Rx) 的核心概念和类型。理解这些基础知识将帮助我们在后续章节中更轻松地使用 Rx.NET 处理事件流和异步数据流。

若要使用System.Reactive,需要在应用程序中安装用于System.Reactive的NuGet包。

Rx.NET和System.Reactive是同一个东西,只是不同的叫法而已。只不过Rx.NET更多是在上下文中用作简称,而System.Reactive则是具体的包名。

1. IObservable<T>

IObservable<T> 是 Rx 的核心接口,它代表一个推送(push)数据流的源。它与传统的 IEnumerable<T> 不同,IEnumerable<T>拉取(pull)数据的方式,而 IObservable<T>推送数据的方式。IObservable<T> 可以用来表示一个事件流、异步操作、或其他随时间变化的数据源。

public interface IObservable<out T>
{
    IDisposable Subscribe(IObserver<T> observer);
}

如何使用:如果你有一个 IObservable<T>,你可以通过调用 Subscribe 方法来订阅它,从而接收数据流中的事件。Subscribe 方法返回一个 IDisposable,允许你取消订阅以停止接收数据。

2. IObserver<T>

IObserver<T> 是与 IObservable<T> 相对应的接口,它定义了如何处理来自 IObservable<T> 的事件流。IObserver<T> 有三个方法:

  • OnNext(T value):当有新数据推送时调用。
  • OnError(Exception error):当发生错误时调用。
  • OnCompleted():当数据流完成时调用。
public interface IObserver<in T>
{
    void OnNext(T value);
    void OnError(Exception error);
    void OnCompleted();
}

如何使用:当你订阅一个 IObservable<T> 时,你通常会提供一个 IObserver<T>,该 IObserver<T> 定义了如何处理每一个数据事件、错误以及完成通知。

3. Push vs Pull(推 vs 拉)

  • Pull 模式:在传统的 IEnumerable<T> 中,消费者通过 foreach 从集合中拉取数据,数据的产生是由消费者控制的。

  • Push 模式:在 IObservable<T> 中,数据是主动推送给消费者的,消费者只需要订阅,不需要主动请求数据。数据的产生是由 IObservable<T> 控制的,消费者被动接收。

4. 热(Hot)和冷(Cold)Observable

IObservable<T> 可以分为两种类型:

  • 冷 Observable:数据流在订阅时才开始产生事件。每个订阅者会从头开始接收数据。例如,读取文件或从集合中推送数据。

  • 热 Observable:数据流在创建时就开始产生事件。订阅者只能接收到在它订阅之后产生的事件,订阅之前发生的事件将无法捕获。例如,鼠标点击事件、传感器数据等实时事件。

5. 订阅与取消订阅

当我们订阅一个 IObservable<T> 时,实际上是注册了一个 IObserver<T>,以便接收事件。这个订阅可以通过 IDisposable 接口来取消,以终止继续接收事件。

var subscription = observable.Subscribe(
    onNext: value => Console.WriteLine($"Received: {value}"),
    onError: error => Console.WriteLine($"Error: {error.Message}"),
    onCompleted: () => Console.WriteLine("Completed")
);

// 取消订阅
subscription.Dispose();

6. 常用操作符

Rx.NET 提供了大量操作符,用于转换、过滤和组合事件流。这些操作符类似于 LINQ,允许我们以声明式的方式处理数据流。常用的操作符包括:

  • Select:类似于 LINQ 的 Select 操作符,用于映射数据流中的每个元素。
  • Where:用于过滤数据流,只保留符合条件的元素。
  • Merge:合并多个 IObservable<T> 数据流。
  • Throttle:对事件流进行节流,忽略短时间内重复的事件。
observable
    .Where(value => value > 10)
    .Select(value => value * 2)
    .Subscribe(value => Console.WriteLine($"Processed value: {value}"));

7. 调度器(Schedulers)

Rx.NET 中的调度器用于控制代码在特定线程或上下文中执行。常见的调度器有:

  • Scheduler.CurrentThread:在当前线程上执行。
  • Scheduler.NewThread:在新线程上执行。
  • Scheduler.Default:在线程池中执行。
  • DispatcherScheduler:用于 WPF 或 WinForms 应用程序的 UI 线程调度。

你可以使用调度器来控制数据流的订阅和观察行为:

observable
    .ObserveOn(Scheduler.NewThread)  // 在新线程上观察数据
    .SubscribeOn(Scheduler.CurrentThread)  // 在当前线程上订阅
    .Subscribe(value => Console.WriteLine($"Received on new thread: {value}"));

8. 总结

在 Rx.NET 中,IObservable<T>IObserver<T> 是最基本的构建块。IObservable<T> 用于表示事件流,而 IObserver<T> 则用于处理这些事件。通过订阅一个 IObservable<T>,我们可以获得事件的推送,并使用各种 LINQ 风格的操作符来对事件流进行处理。理解这些基础概念有助于我们更好地在后续章节中处理 .NET 事件并将其转换为响应式的 IObservable<T> 数据流。


7.3 创建可观察序列

在 Rx.NET 中,可观察序列(Observable Sequence) 是事件流的核心。一个可观察序列可以是任何类型的异步数据源,例如按钮点击、传感器数据、或者网络请求的结果。在这一小节中,我们将讨论几种常用的创建可观察序列的方法。

1. 基本的 Observable 创建方法

Rx.NET 提供了多种创建可观察序列的方式。最常用的方式之一是使用 Observable.Create,它允许我们手动定义事件流的行为。

示例:使用 Observable.Create

IObservable<int> observable = Observable.Create<int>(observer =>
{
    // 模拟数据流
    observer.OnNext(1);
    observer.OnNext(2);
    observer.OnNext(3);
    
    // 完成流
    observer.OnCompleted();

    // 返回 IDisposable,用于取消订阅
    return Disposable.Empty;
});

在这个例子中,我们创建了一个简单的 IObservable<int>,它推送了三个整数并调用了 OnCompleted 来结束数据流。我们还返回了 Disposable.Empty,表示没有特定的取消订阅逻辑。

2. 使用现成的工厂方法

Rx.NET 提供了许多工厂方法来快捷创建常见的可观察序列。这些方法可以帮助我们快速创建序列,而不需要手动实现 IObservable 接口。

2.1 Observable.Return

Observable.Return 用于创建一个只发出单个值的简单序列。

IObservable<int> singleValue = Observable.Return(42);
singleValue.Subscribe(value => Console.WriteLine($"Received: {value}"));

这个序列只会发出一个值 42,然后立即结束。

2.2 Observable.Range

Observable.Range 用于创建一个发出整数序列的可观察流。

IObservable<int> range = Observable.Range(1, 5);
range.Subscribe(value => Console.WriteLine($"Received: {value}"));

这个序列会发出从 15 的整数,并在最后调用 OnCompleted

2.3 Observable.Empty

Observable.Empty 创建一个立即完成的空序列。

IObservable<int> empty = Observable.Empty<int>();
empty.Subscribe(
    onNext: value => Console.WriteLine($"Received: {value}"),
    onCompleted: () => Console.WriteLine("Completed")
);

此序列不会发出任何值,它只会调用 OnCompleted

2.4 Observable.Never

Observable.Never 创建一个永不发出任何事件的序列。它既不会触发 OnNext,也不会触发 OnCompletedOnError

IObservable<int> never = Observable.Never<int>();
never.Subscribe(
    onNext: value => Console.WriteLine($"Received: {value}"),
    onCompleted: () => Console.WriteLine("Completed")
);

这个序列永远不会结束,也不会发出任何事件。

2.5 Observable.Throw

Observable.Throw 创建一个立即发出错误的序列。

IObservable<int> error = Observable.Throw<int>(new Exception("An error occurred"));
error.Subscribe(
    onNext: value => Console.WriteLine($"Received: {value}"),
    onError: ex => Console.WriteLine($"Error: {ex.Message}")
);

此序列不会发出任何值,而是直接调用 OnError 传递异常。

3. 异步序列

我们还可以创建异步的可观察序列,它们可以用于处理异步操作或定时事件。

3.1 Observable.Timer

Observable.Timer 创建一个在指定时间后触发的序列,它可以用于延迟事件。

IObservable<long> timer = Observable.Timer(TimeSpan.FromSeconds(2));
timer.Subscribe(value => Console.WriteLine($"Timer fired: {value}"));

在这个例子中,2 秒之后序列会发出一个值,并调用 OnCompleted

3.2 Observable.Interval

Observable.Interval 创建一个定时触发的序列,按照指定的时间间隔重复发出值。

IObservable<long> interval = Observable.Interval(TimeSpan.FromSeconds(1));
interval.Subscribe(value => Console.WriteLine($"Tick: {value}"));

这个例子中,Observable.Interval 每 1 秒发出一个值(从 0 开始递增)。

4. 将现有的数据源转换为 Observable

除了手动创建 Observable 之外,Rx.NET 还提供了一些工具,用来将现有的数据源(如任务、事件等)转换为 Observable。

4.1 使用 Task 创建 Observable

Rx.NET 提供了将 Task 转换为 IObservable 的方法。

Task<int> task = Task.FromResult(42);
IObservable<int> taskObservable = task.ToObservable();

taskObservable.Subscribe(value => Console.WriteLine($"Task result: {value}"));

这个例子展示了如何将一个 Task 转换为 Observable,并在任务完成时发出结果。

4.2 使用 FromEventPattern 将事件转换为 Observable

我们可以使用 FromEventPattern 将标准的 .NET 事件转换为 Observable。这个部分将在下一小节详细讨论。

5. 总结

  • 创建可观察序列 是使用 Rx.NET 的第一步。我们可以通过手动创建、使用现成的工厂方法、或将现有的数据源转换来创建 IObservable<T>
  • Rx 提供了多种简单的工厂方法,如 ReturnRangeEmpty 等,帮助我们快速创建各种数据流。
  • 我们还可以使用 Observable.TimerObservable.Interval 来处理定时事件流。
  • 最后,Rx.NET 可以将 Task 和事件等异步源轻松转换为 Observable。

在理解了如何创建可观察序列之后,接下来我们将讨论如何将 .NET 事件转换为可观察序列,在下一小节 7.4 转换 .NET 事件 中会详细介绍。


7.4 转换 .NET 事件

问题背景

在 .NET 中,事件是处理异步操作的常见方式,而在 Reactive Extensions (Rx) 中,我们使用 IObservable<T> 来处理数据流。为了让传统的 .NET 事件能与 Rx 的响应式编程模型兼容,我们需要将事件转换为 IObservable<T>。这个过程可以通过 Observable.FromEventObservable.FromEventPattern 来实现。

事件转换的核心

  • FromEvent:适用于不符合标准事件模式的事件。
  • FromEventPattern:适用于标准的 .NET 事件,特别是使用 EventHandler<T> 的事件。例如,ProgressChangedElapsed 事件。

示例 1:将事件转换为 Observable

假设我们有一个按钮点击的事件 Click,我们想将它转换成一个 IObservable,并在每次点击时执行对应的响应动作。

var button = new Button();

// 将 Click 事件转换为 Observable
IObservable<EventPattern<EventArgs>> clicks =
    Observable.FromEventPattern<EventHandler, EventArgs>(
        handler => button.Click += handler,
        handler => button.Click -= handler
    );

// 订阅事件,处理点击行为
clicks.Subscribe(click => Console.WriteLine("Button clicked!"));

在这个例子中,FromEventPattern 将按钮的 Click 事件转换为 IObservable<EventPattern<EventArgs>>。每当按钮被点击时,OnNext 会被触发,输出 "Button clicked!"。

示例 2:处理带有数据的事件

假设我们有一个进度条,每次进度更新时会触发 ProgressChanged 事件。我们可以使用 FromEventPattern 将该事件转换成 Observable,并在每次进度变化时处理数据。

var progress = new Progress<int>();

// 将 ProgressChanged 事件转换为 Observable
IObservable<EventPattern<int>> progressReports =
    Observable.FromEventPattern<EventHandler<int>, int>(
        handler => progress.ProgressChanged += handler,
        handler => progress.ProgressChanged -= handler
    );

// 打印每次进度变化的值
progressReports.Subscribe(report => Console.WriteLine("Progress: " + report.EventArgs));

在这个例子中,ProgressChanged 是一个标准的 EventHandler<T> 类型事件,因此我们可以简单地使用 FromEventPattern 来包装它。每当进度更新时,OnNext 会被触发,并打印当前的进度值。

示例 3:处理自定义事件

如果我们遇到自定义的事件类型,它可能不符合 EventHandler<T> 的标准模式。在这种情况下,我们可以使用 FromEvent。假设有一个自定义的事件 OnTemperatureChanged,我们可以这样处理:

public class Thermometer
{
    public event Action<double> OnTemperatureChanged;

    public void SimulateTemperatureChange(double newTemp)
    {
        OnTemperatureChanged?.Invoke(newTemp);
    }
}

var thermometer = new Thermometer();

// 将自定义事件转换为 Observable
IObservable<double> temperatureChanges =
    Observable.FromEvent<double>(
        handler => thermometer.OnTemperatureChanged += handler,
        handler => thermometer.OnTemperatureChanged -= handler
    );

// 订阅温度变化事件
temperatureChanges.Subscribe(temp => Console.WriteLine($"Temperature changed to: {temp}°C"));

// 模拟温度变化
thermometer.SimulateTemperatureChange(23.5);
thermometer.SimulateTemperatureChange(24.0);

在这个例子中,OnTemperatureChanged 是一个自定义的 Action<double> 委托。我们使用 FromEvent 将其转化为 IObservable<double>,并在每次温度变化时输出新的温度值。

异常处理

有些事件可能会在执行中抛出异常。例如,WebClientDownloadStringCompleted 事件可能会因为网络问题而在 EventArgs 中包含错误。Rx 默认将这些错误视为数据,而不是异常。这时,我们需要手动处理这些错误。

var client = new WebClient();

// 将 DownloadStringCompleted 事件转换为 Observable
IObservable<EventPattern<DownloadStringCompletedEventArgs>> downloadedStrings =
    Observable.FromEventPattern<DownloadStringCompletedEventArgs>(
        handler => client.DownloadStringCompleted += handler,
        handler => client.DownloadStringCompleted -= handler
    );

// 处理下载结果或错误
downloadedStrings.Subscribe(
    data =>
    {
        if (data.EventArgs.Error != null)
            Console.WriteLine("Download failed: " + data.EventArgs.Error.Message);
        else
            Console.WriteLine("Downloaded: " + data.EventArgs.Result);
    }
);

// 发起异步下载
client.DownloadStringAsync(new Uri("http://example.com"));

在这个例子中,DownloadStringCompletedEventArgs 包含了下载结果或错误信息。我们通过检查 eventArgs.Error 来判断是否发生了错误,并在控制台中输出相应的信息。

线程上下文问题

在某些情况下,事件的订阅和取消订阅必须在特定的上下文中执行。例如,UI 事件必须在 UI 线程上订阅。System.Reactive 提供了 SubscribeOn 操作符来控制订阅的线程上下文:

IObservable<EventPattern<EventArgs>> clicks =
    Observable.FromEventPattern<EventHandler, EventArgs>(
        handler => button.Click += handler,
        handler => button.Click -= handler
    );

// 使用 SubscribeOn 指定事件处理需要在 UI 线程上执行
clicks
    .SubscribeOn(Scheduler.CurrentThread)
    .Subscribe(click => Console.WriteLine("Button clicked on UI thread"));

在这个例子中,我们使用 SubscribeOn 来确保事件的订阅在 UI 线程上执行。

总结

  • FromEventPattern 适用于标准的 EventHandler<T> 事件。
  • FromEvent 适用于自定义的或不符合标准的事件。
  • 当事件被转换为 IObservable<T> 之后,Rx 的各种操作符(如过滤、转换、合并等)就可以轻松应用到事件流上,帮助我们简化异步编程。

7.5 向上下文发送通知

问题背景

在 Rx.NET 中,事件通知(如 OnNext)可以从任何线程发出,特别是当你使用像 Observable.Interval 这类基于定时器的操作符时,通知可能来自不同的线程池线程。这种行为通常对后台处理没有问题,但在某些场景下,尤其是涉及 UI 的场景时,线程问题就变得至关重要。例如,许多 UI 框架要求 UI 更新必须在主线程(UI 线程)上进行。如果通知来自后台线程而你试图更新 UI 元素,就会抛出异常。因此,我们需要确保所有相关的通知能在正确的上下文中处理。

解决方案:使用 ObserveOn

ObserveOn 是 Rx.NET 提供的一个运算符,它可以将事件通知(如 OnNextOnCompletedOnError)切换到指定的调度器或线程上下文中。通过 ObserveOn,我们可以将通知从后台线程切换到 UI 线程,确保 UI 更新在正确的线程中进行。

注意ObserveOn 控制的是可观察通知的执行上下文。不要将它与 SubscribeOn 混淆,后者控制的是订阅(即添加/移除事件处理程序)的代码所在的上下文。简而言之:

  • ObserveOn:决定通知在哪个上下文或线程上发出。
  • SubscribeOn:决定订阅逻辑在哪个上下文或线程上执行。

示例 1:切换到 UI 线程

假设我们有一个按钮点击事件,每次点击后我们启动一个 Observable.Interval,每秒发出一次 OnNext 通知。由于 Interval 默认使用线程池线程,我们需要将这些通知切换到 UI 线程来处理,确保 UI 更新在正确的线程上进行。

private void Button_Click(object sender, RoutedEventArgs e)
{
    // 获取当前的 UI 线程上下文
    SynchronizationContext uiContext = SynchronizationContext.Current;

    Trace.WriteLine($"UI thread is {Environment.CurrentManagedThreadId}");

    // 创建基于时间间隔的 Observable
    Observable.Interval(TimeSpan.FromSeconds(1))
        // 切换到 UI 线程上下文处理通知
        .ObserveOn(uiContext)
        .Subscribe(x => Trace.WriteLine(
            $"Interval {x} on thread {Environment.CurrentManagedThreadId}"));
}

输出示例:

UI thread is 9
Interval 0 on thread 9
Interval 1 on thread 9
Interval 2 on thread 9

在这个例子中,Observable.Interval 每秒发出一个值。由于我们使用了 ObserveOn 运算符,并传递了当前的 SynchronizationContext,所有通知都会在 UI 线程(线程 9)上执行。即使 Interval 默认使用后台线程,ObserveOn 也确保了通知会切换到 UI 线程处理。

示例 2:从 UI 线程切换到后台线程处理复杂计算

在某些情况下,你可能希望从 UI 线程切换到后台线程来处理一些耗时的计算任务。例如,当鼠标移动时,我们可能需要进行 CPU 密集型的计算。我们可以使用 ObserveOn 将计算任务移到后台线程上进行处理,避免阻塞 UI 线程,然后再将结果切换回 UI 线程显示。

private void SetupMouseMoveProcessing()
{
    SynchronizationContext uiContext = SynchronizationContext.Current;

    Trace.WriteLine($"UI thread is {Environment.CurrentManagedThreadId}");

    Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
            handler => (s, a) => handler(s, a),
            handler => this.MouseMove += handler,
            handler => this.MouseMove -= handler)
        .Select(evt => evt.EventArgs.GetPosition(this))
        // 切换到后台线程进行计算
        .ObserveOn(Scheduler.Default)
        .Select(position =>
        {
            // 模拟复杂计算
            Thread.Sleep(100);  // 假设计算需要花费一些时间
            var result = position.X + position.Y;
            var thread = Environment.CurrentManagedThreadId;
            Trace.WriteLine($"Calculated result {result} on thread {thread}");
            return result;
        })
        // 将结果切换回 UI 线程
        .ObserveOn(uiContext)
        .Subscribe(result => Trace.WriteLine(
            $"Result {result} on thread {Environment.CurrentManagedThreadId}"));
}

过程分析:

  1. 我们从 MouseMove 事件创建了一个 Observable,每次鼠标移动时都将捕获鼠标位置。
  2. 使用 ObserveOn(Scheduler.Default) 将事件流切换到后台线程,进行耗时的计算(模拟了 100 毫秒的延迟)。
  3. 计算完成后,使用 ObserveOn(uiContext) 将结果切换回 UI 线程,以便安全地更新 UI 或其他需要在 UI 线程执行的操作。

输出示例:

UI thread is 9
Calculated result 150 on thread 10
Result 150 on thread 9
Calculated result 200 on thread 10
Result 200 on thread 9

解释:

  • 鼠标移动事件最初在 UI 线程上触发,Observable.FromEventPattern 捕获这些事件。
  • 计算任务被切换到后台线程(线程 10),避免阻塞 UI 线程。
  • 计算完成后,结果被切换回 UI 线程(线程 9)进行处理。

延迟与队列问题

在这个例子中,由于鼠标移动频率比计算速度快(每次计算需要 100 毫秒),计算和结果会出现延迟,因为事件会排队等待处理。这意味着鼠标移动事件在后台线程中会被排队处理,而不是实时计算最新的鼠标位置。

为了解决这种延迟问题,Rx.NET 提供了很多运算符,比如节流(Throttle),来减少事件的频率。你可以在高频繁的事件流中使用这些运算符来减轻负载。

总结

  • Rx.NET 默认不区分线程:事件通知可以来自任何线程,特别是像 Observable.Interval 等操作符使用的线程池线程。
  • ObserveOn 运算符:允许我们将事件流切换到指定的线程或调度器上。对于 UI 操作,通常需要从后台线程切换回 UI 线程。
  • SubscribeOn 运算符:与 ObserveOn 不同,SubscribeOn 决定的是订阅逻辑(即添加和移除事件处理程序)在哪个线程上执行。
  • 处理复杂计算的场景:我们可以使用 ObserveOn(Scheduler.Default) 将计算任务移到后台线程,避免阻塞 UI 线程,然后通过 ObserveOn 切换回 UI 线程处理结果。

通过 ObserveOn,我们可以在不同的线程或上下文间灵活切换,确保所有操作都在合适的线程中完成。


7.6 使用窗口和缓冲来分组事件数据

问题背景

在处理事件流时,经常会遇到这样一种需求:我们需要对事件进行分组处理。比如,你需要每两个事件成对处理,或者在特定的时间窗口内处理收到的所有事件。为了解决这些问题,Rx.NET 提供了两个强大的运算符:BufferWindow

  • Buffer(缓冲):收集一组事件,并在该组完成后,将这些事件作为一个集合发出。
  • Window(窗口):按逻辑分组事件,但在事件到达时就直接传递出去。Window 会返回一个 IObservable<IObservable<T>>,即“事件流的事件流”。

解决方案:BufferWindow 运算符

BufferWindow 可以根据事件的数量或时间来对事件进行分组。下面,我们将使用一些具体的示例来说明它们的工作方式。

示例 1:使用 Buffer 按数量分组事件

Buffer 会累积一定数量的事件,当达到指定的数量后,将这些事件作为一个集合发出。

例如,使用 Observable.Interval 每秒发出一个 OnNext 通知,我们可以使用 Buffer(2) 每次将两个事件组成一个集合。

Observable.Interval(TimeSpan.FromSeconds(1))
    .Buffer(2)   // 每两个事件分组
    .Subscribe(bufferedItems => 
    {
        Trace.WriteLine($"{DateTime.Now.Second}: Got {bufferedItems[0]} and {bufferedItems[1]}");
    });

输出示例:

13: Got 0 and 1
15: Got 2 and 3
17: Got 4 and 5
19: Got 6 and 7
21: Got 8 and 9

在这个例子中,Buffer(2) 将每次收到的两个事件组成一个 IList<T> 集合,并一起发出。每秒一个事件,因此每两秒我们可以看到一对事件被处理。

示例 2:使用 Window 按数量分组事件

Window 的工作方式与 Buffer 类似,但它不会等待所有事件都到达后再发出集合,而是立即发出一个 IObservable<T>(即一个新的“事件流”),并且在这个新的事件流中逐一发出事件。

下面是一个类似的示例,使用 Window(2) 每次分组两个事件:

Observable.Interval(TimeSpan.FromSeconds(1))
    .Window(2)   // 每两个事件分组
    .Subscribe(window =>
    {
        Trace.WriteLine($"{DateTime.Now.Second}: Starting new group");
        window.Subscribe(
            x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x}"),
            () => Trace.WriteLine($"{DateTime.Now.Second}: Ending group"));
    });

输出示例:

17: Starting new group
18: Saw 0
19: Saw 1
19: Ending group
19: Starting new group
20: Saw 2
21: Saw 3
21: Ending group
21: Starting new group
22: Saw 4
23: Saw 5
23: Ending group

在这个例子中,Window(2) 每两个事件分配一个新的窗口(即 IObservable<T>),并在该窗口内逐个发出事件。当窗口的事件接收完毕后,窗口会触发 OnCompleted,并结束当前的分组。

示例 3:使用 Buffer 按时间分组事件

除了按事件数量分组,Buffer 也可以按照时间窗口来分组。比如,我们可以在每 1 秒内收集所有事件,并将它们作为一个集合发出。这在处理高频率的事件流时非常有用,比如鼠标移动事件。

private void Button_Click(object sender, RoutedEventArgs e)
{
    Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
            handler => (s, a) => handler(s, a),
            handler => this.MouseMove += handler,
            handler => this.MouseMove -= handler)
        .Buffer(TimeSpan.FromSeconds(1))  // 每1秒缓冲一次
        .Subscribe(events =>
        {
            Trace.WriteLine($"{DateTime.Now.Second}: Saw {events.Count} items.");
        });
}

输出示例:

10: Saw 5 items.
11: Saw 3 items.
12: Saw 7 items.

在这个例子中,Buffer(TimeSpan.FromSeconds(1)) 会每隔一秒收集该秒内的所有鼠标移动事件,并将这些事件作为一个集合发出。输出的事件数量取决于用户在该秒内移动鼠标的频率。

示例 4:使用 Window 按时间分组事件

类似地,Window 也可以按照时间窗口来分组,但与 Buffer 不同,它会立即发出窗口(即 IObservable<T>),并在该窗口内逐个发出事件。

private void Button_Click(object sender, RoutedEventArgs e)
{
    Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
            handler => (s, a) => handler(s, a),
            handler => this.MouseMove += handler,
            handler => this.MouseMove -= handler)
        .Window(TimeSpan.FromSeconds(1))  // 每1秒创建一个新窗口
        .Subscribe(window =>
        {
            Trace.WriteLine($"{DateTime.Now.Second}: New window started");
            window.Subscribe(
                evt => Trace.WriteLine($"{DateTime.Now.Second}: Mouse moved"),
                () => Trace.WriteLine($"{DateTime.Now.Second}: Window closed"));
        });
}

输出示例:

10: New window started
10: Mouse moved
10: Mouse moved
11: Window closed
11: New window started
11: Mouse moved
12: Window closed

在这个例子中,Window(TimeSpan.FromSeconds(1)) 每秒开启一个新的窗口,每次鼠标移动时,事件会被立即发出,同时每秒的窗口结束时会触发 OnCompleted,关闭当前窗口。

BufferWindow 的区别

  • Buffer:将事件收集到一个集合中,直到分组条件满足(如达到指定数量或时间窗口结束),然后一次性发出整个集合。返回类型是 IObservable<IList<T>>,即事件集合的可观察流。

  • Window:按分组条件(如数量或时间)创建一个新的窗口(即IObservable<T>),并在事件到达时立即发出。返回类型是 IObservable<IObservable<T>>,即“事件流的事件流”。

总结

  • BufferWindow 是 Rx.NET 中常用的运算符,用于对事件流进行分组处理。
  • Buffer 会等待事件组完成后再发出整个集合,而 Window 会立即发出新的窗口并在其中逐个发出事件。
  • 这两个运算符都支持按事件数量或时间分组,适用于不同的场景。

通过使用 BufferWindow,我们可以更高效地处理批量事件或时间敏感的事件流,尤其是在需要对事件进行分组、批处理或窗口化时。


7.7 超时

问题背景

在某些情况下,你可能希望事件在一定的时间内到达。如果事件未能在规定的时间内到达,程序仍需要能够及时响应。这种需求在处理异步操作时非常常见,比如等待来自 Web 服务的响应。如果响应过慢,程序应该超时并采取相应的措施,而不是无限期地等待。

解决方案:Timeout 运算符

Timeout 运算符为事件流创建了一个滑动的超时窗口。每当有新事件到来时,超时窗口会被重置。如果超时窗口内没有收到新事件,则超时窗口过期,并且 Timeout 运算符会通过 OnError 通知,发出一个包含 TimeoutException 的终止信号。

示例 1:对 Web 请求应用超时

以下示例向一个示例域名发起 Web 请求,并为该请求设置了 1 秒的超时时间。如果超过 1 秒还没有得到响应,则会抛出 TimeoutException

void GetWithTimeout(HttpClient client)
{
    client.GetStringAsync("http://exampleurl").ToObservable()
        .Timeout(TimeSpan.FromSeconds(1))  // 设置1秒超时
        .Subscribe(
            x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x.Length}"),
            ex => Trace.WriteLine(ex));  // 当超时发生时,输出异常信息
}

在这个例子中,如果 Web 请求在 1 秒内没有完成,Timeout 运算符会自动终止流,并通过 OnError 发出 TimeoutException。这使得程序可以对超时情况做出响应,而不是无限期等待。

Timeout 的常见应用场景

  • Web 请求:在等待 Web 服务响应时,防止请求长时间挂起。
  • 异步任务:限制异步操作的执行时间,确保程序能够及时超时并采取相应行动。

示例 2:为鼠标移动事件设置超时

Timeout 可以应用于任何事件流,除了异步操作,它也可以用于用户输入、传感器数据等事件流。以下示例为鼠标移动事件设置了 1 秒的超时时间。如果 1 秒内没有收到鼠标移动事件,程序会抛出 TimeoutException

private void Button_Click(object sender, RoutedEventArgs e)
{
    Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
            handler => (s, a) => handler(s, a),
            handler => MouseMove += handler,
            handler => MouseMove -= handler)
        .Select(x => x.EventArgs.GetPosition(this))
        .Timeout(TimeSpan.FromSeconds(1))  // 设置1秒超时
        .Subscribe(
            x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x.X + x.Y}"),
            ex => Trace.WriteLine(ex));  // 超时后输出异常信息
}

输出示例:

16: Saw 180
16: Saw 178
16: Saw 177
16: Saw 176
System.TimeoutException: The operation has timed out.

在这个例子中,鼠标移动了几次后停止,1 秒内没有新的鼠标移动事件,因此 Timeout 运算符触发了 TimeoutException,并终止了事件流。

使用 Timeout 的重载方法

有时,你可能不希望在超时发生时立即终止事件流。Timeout 运算符提供了一个重载版本,允许你在超时发生时切换到另一个事件流,而不是通过异常终止当前流。

示例 3:在超时后切换到鼠标点击事件流

以下示例在超时之前监听鼠标移动事件。如果超时发生后,还没有新的鼠标移动事件,则切换到监听鼠标点击事件:

private void Button_Click(object sender, RoutedEventArgs e)
{
    // 鼠标点击事件流
    IObservable<Point> clicks =
        Observable.FromEventPattern<MouseButtonEventHandler, MouseButtonEventArgs>(
            handler => (s, a) => handler(s, a),
            handler => MouseDown += handler,
            handler => MouseDown -= handler)
        .Select(x => x.EventArgs.GetPosition(this));

    // 鼠标移动事件流,超时后切换到点击事件流
    Observable.FromEventPattern<MouseEventHandler, MouseEventArgs>(
            handler => (s, a) => handler(s, a),
            handler => MouseMove += handler,
            handler => MouseMove -= handler)
        .Select(x => x.EventArgs.GetPosition(this))
        .Timeout(TimeSpan.FromSeconds(1), clicks)  // 超时后切换到 clicks 流
        .Subscribe(
            x => Trace.WriteLine($"{DateTime.Now.Second}: Saw {x.X},{x.Y}"),
            ex => Trace.WriteLine(ex));  // 输出异常信息
}

输出示例:

49: Saw 95,39
49: Saw 94,39
49: Saw 94,38
49: Saw 94,37
53: Saw 130,141
55: Saw 469,4

在这个例子中,程序开始时监听鼠标移动事件。当鼠标静止超过 1 秒后,程序切换到监听鼠标点击事件。鼠标移动事件流超时后,程序捕获了两次鼠标点击事件。

总结 Timeout 的使用方式

  • 默认行为:当事件流中没有在指定的时间内收到事件,Timeout 运算符会发出 TimeoutException,并通过 OnError 终止流。
  • 可选行为:通过 Timeout 的重载方法,可以在超时发生时切换到另一个事件流,而不是终止当前流。

讨论

对于一些关键应用来说,Timeout 是一个必不可少的运算符,因为它确保了应用程序在任何情况下都能及时响应。对于异步操作,尤其是 Web 请求,Timeout 可以防止系统长时间等待,进而导致资源浪费。而在用户输入流中,Timeout 也可以用于处理用户长时间不活动的情况。

需要注意的是,使用 Timeout 并不会取消底层的操作(例如 HTTP 请求)。当 Timeout 触发时,底层操作仍然会继续执行,直到成功或失败。这意味着,程序可能会启动一些不再关心的异步操作,开发者需要考虑如何处理这些操作的结果。

总结

  • Timeout 运算符:用于确保事件流能够在指定时间内响应。如果事件流在超时窗口内没有事件到达,Timeout 会触发 TimeoutException,终止流。
  • 适用场景Timeout 常用于异步操作(如 Web 请求)和用户输入流,确保程序不会长时间等待。
  • 重载行为Timeout 可以在超时发生后,切换到另一个事件流,而不是直接抛出异常终止流。
posted @ 2024-12-09 16:04  平元兄  阅读(116)  评论(0编辑  收藏  举报