第七章: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}"));
这个序列会发出从 1
到 5
的整数,并在最后调用 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
,也不会触发 OnCompleted
或 OnError
。
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 提供了多种简单的工厂方法,如
Return
、Range
、Empty
等,帮助我们快速创建各种数据流。 - 我们还可以使用
Observable.Timer
和Observable.Interval
来处理定时事件流。 - 最后,Rx.NET 可以将
Task
和事件等异步源轻松转换为 Observable。
在理解了如何创建可观察序列之后,接下来我们将讨论如何将 .NET 事件转换为可观察序列,在下一小节 7.4 转换 .NET 事件 中会详细介绍。
7.4 转换 .NET 事件
问题背景
在 .NET 中,事件是处理异步操作的常见方式,而在 Reactive Extensions (Rx) 中,我们使用 IObservable<T>
来处理数据流。为了让传统的 .NET 事件能与 Rx 的响应式编程模型兼容,我们需要将事件转换为 IObservable<T>
。这个过程可以通过 Observable.FromEvent
或 Observable.FromEventPattern
来实现。
事件转换的核心
FromEvent
:适用于不符合标准事件模式的事件。FromEventPattern
:适用于标准的 .NET 事件,特别是使用EventHandler<T>
的事件。例如,ProgressChanged
和Elapsed
事件。
示例 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>
,并在每次温度变化时输出新的温度值。
异常处理
有些事件可能会在执行中抛出异常。例如,WebClient
的 DownloadStringCompleted
事件可能会因为网络问题而在 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 提供的一个运算符,它可以将事件通知(如 OnNext
、OnCompleted
、OnError
)切换到指定的调度器或线程上下文中。通过 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}"));
}
过程分析:
- 我们从
MouseMove
事件创建了一个Observable
,每次鼠标移动时都将捕获鼠标位置。 - 使用
ObserveOn(Scheduler.Default)
将事件流切换到后台线程,进行耗时的计算(模拟了 100 毫秒的延迟)。 - 计算完成后,使用
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 提供了两个强大的运算符:Buffer
和 Window
。
- Buffer(缓冲):收集一组事件,并在该组完成后,将这些事件作为一个集合发出。
- Window(窗口):按逻辑分组事件,但在事件到达时就直接传递出去。
Window
会返回一个IObservable<IObservable<T>>
,即“事件流的事件流”。
解决方案:Buffer
和 Window
运算符
Buffer
和 Window
可以根据事件的数量或时间来对事件进行分组。下面,我们将使用一些具体的示例来说明它们的工作方式。
示例 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
,关闭当前窗口。
Buffer
和 Window
的区别
-
Buffer
:将事件收集到一个集合中,直到分组条件满足(如达到指定数量或时间窗口结束),然后一次性发出整个集合。返回类型是IObservable<IList<T>>
,即事件集合的可观察流。 -
Window
:按分组条件(如数量或时间)创建一个新的窗口(即IObservable<T>
),并在事件到达时立即发出。返回类型是IObservable<IObservable<T>>
,即“事件流的事件流”。
总结
Buffer
和Window
是 Rx.NET 中常用的运算符,用于对事件流进行分组处理。Buffer
会等待事件组完成后再发出整个集合,而Window
会立即发出新的窗口并在其中逐个发出事件。- 这两个运算符都支持按事件数量或时间分组,适用于不同的场景。
通过使用 Buffer
和 Window
,我们可以更高效地处理批量事件或时间敏感的事件流,尤其是在需要对事件进行分组、批处理或窗口化时。
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
可以在超时发生后,切换到另一个事件流,而不是直接抛出异常终止流。