第七章:C#響應式程式設計System.Reactive
- 第七章:C#響應式程式設計System.Reactive
- 7.1 為什麼選擇響應式程式設計?
- 1. 事件流的重要性
- 2. Rx.NET 的優勢
- 3. Rx.NET 的適用場景
- 4. Rx.NET 的核心思想
- 小結
- 7.2 主要概念和型別
- 1.
IObservable<T>
- 2.
IObserver<T>
- 3. Push vs Pull(推 vs 拉)
- 4. 熱(Hot)和冷(Cold)Observable
- 5. 訂閱與取消訂閱
- 6. 常用運算子
- 7. 排程器(Schedulers)
- 8. 總結
- 1.
- 7.3 建立可觀察序列
- 1. 基本的 Observable 建立方法
- 示例:使用
Observable.Create
- 示例:使用
- 2. 使用現成的工廠方法
- 2.1
Observable.Return
- 2.2
Observable.Range
- 2.3
Observable.Empty
- 2.4
Observable.Never
- 2.5
Observable.Throw
- 2.1
- 3. 非同步序列
- 3.1
Observable.Timer
- 3.2
Observable.Interval
- 3.1
- 4. 將現有的資料來源轉換為 Observable
- 4.1 使用
Task
建立 Observable - 4.2 使用
FromEventPattern
將事件轉換為 Observable
- 4.1 使用
- 5. 總結
- 1. 基本的 Observable 建立方法
- 7.4 轉換 .NET 事件
- 問題背景
- 事件轉換的核心
- 示例 1:將事件轉換為 Observable
- 示例 2:處理帶有資料的事件
- 示例 3:處理自定義事件
- 異常處理
- 執行緒上下文問題
- 總結
- 7.5 向上下文傳送通知
- 問題背景
- 解決方案:使用
ObserveOn
- 示例 1:切換到 UI 執行緒
- 示例 2:從 UI 執行緒切換到後臺執行緒處理複雜計算
- 延遲與佇列問題
- 總結
- 7.6 使用視窗和緩衝來分組事件資料
- 問題背景
- 解決方案:
Buffer
和Window
運算子 - 示例 1:使用
Buffer
按數量分組事件 - 示例 2:使用
Window
按數量分組事件 - 示例 3:使用
Buffer
按時間分組事件 - 示例 4:使用
Window
按時間分組事件 Buffer
和Window
的區別- 總結
- 7.7 超時
- 問題背景
- 解決方案:
Timeout
運算子 - 示例 1:對 Web 請求應用超時
Timeout
的常見應用場景
- 示例 2:為滑鼠移動事件設定超時
- 使用
Timeout
的過載方法- 示例 3:在超時後切換到滑鼠點選事件流
- 總結
Timeout
的使用方式
- 討論
- 總結
- 7.1 為什麼選擇響應式程式設計?
響應式程式設計(Reactive Programming)是一種透過響應事件來編寫程式碼的程式設計方式,特別適合處理使用者介面或任何需要應對外部事件的程式。它避免了輪詢的開銷,允許系統在事件發生時自動觸發程式碼。
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
可以在超時發生後,切換到另一個事件流,而不是直接丟擲異常終止流。