第七章:C#響應式程式設計System.Reactive

平元兄發表於2024-12-09

第七章: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. 總結
    • 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
      • 3. 非同步序列
        • 3.1 Observable.Timer
        • 3.2 Observable.Interval
      • 4. 將現有的資料來源轉換為 Observable
        • 4.1 使用 Task 建立 Observable
        • 4.2 使用 FromEventPattern 將事件轉換為 Observable
      • 5. 總結
    • 7.4 轉換 .NET 事件
      • 問題背景
      • 事件轉換的核心
      • 示例 1:將事件轉換為 Observable
      • 示例 2:處理帶有資料的事件
      • 示例 3:處理自定義事件
      • 異常處理
      • 執行緒上下文問題
      • 總結
    • 7.5 向上下文傳送通知
      • 問題背景
      • 解決方案:使用 ObserveOn
      • 示例 1:切換到 UI 執行緒
      • 示例 2:從 UI 執行緒切換到後臺執行緒處理複雜計算
      • 延遲與佇列問題
      • 總結
    • 7.6 使用視窗和緩衝來分組事件資料
      • 問題背景
      • 解決方案:BufferWindow 運算子
      • 示例 1:使用 Buffer 按數量分組事件
      • 示例 2:使用 Window 按數量分組事件
      • 示例 3:使用 Buffer 按時間分組事件
      • 示例 4:使用 Window 按時間分組事件
      • BufferWindow 的區別
      • 總結
    • 7.7 超時
      • 問題背景
      • 解決方案:Timeout 運算子
      • 示例 1:對 Web 請求應用超時
        • Timeout 的常見應用場景
      • 示例 2:為滑鼠移動事件設定超時
      • 使用 Timeout 的過載方法
        • 示例 3:在超時後切換到滑鼠點選事件流
        • 總結 Timeout 的使用方式
      • 討論
      • 總結


響應式程式設計(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}"));

這個序列會發出從 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 可以在超時發生後,切換到另一個事件流,而不是直接丟擲異常終止流。

相關文章