1. 實作情境下的事件使用
事件不是書本上的漂亮理論。實務上幾乎每第二個應用都會用到事件,若你在做 UI 或網路服務,幾乎大部分情況都需要。
我們來看幾個典型的真實開發場景。順便看看標準做法和最佳實務如何幫你避免常見錯誤。
回應使用者操作(像在 UI)
可能最常見的情境是:使用者做了某件事(按按鈕、在列表選取項目),應用要回應。這就是 Windows Forms、WPF、UWP、Avalonia、MAUI 等 UI framework 的運作方式。
迷你範例(無 UI — 模擬按鈕):
public class Button
{
public event EventHandler? Click; // 標準 delegate
public void SimulateClick()
{
Click?.Invoke(this, EventArgs.Empty); // "按鈕" 被 "按下"
}
}
class Program
{
static void Main()
{
var button = new Button();
button.Click += (sender, e) => Console.WriteLine("按鈕被按下!");
button.SimulateClick(); // > 按鈕被按下!
}
}
在真實的 UI framework 裡,事件 Click 在使用者用滑鼠點擊或用手指點擊螢幕時觸發。所有訂閱了這事件的都會收到通知——這就是應用邏輯運作的方式。
進度、完成與錯誤的通報
想像:檔案下載、資料處理、長時間運算。需要告知進度或錯誤。通常會設計一個「進度更新」事件,和一個「完成」或「失敗」的事件。
檔案下載器示例:
public class FileDownloader
{
public event EventHandler<int>? ProgressChanged;
public event EventHandler? DownloadCompleted;
public event EventHandler<string>? DownloadFailed;
public void Download()
{
for (int i = 1; i <= 100; i += 10)
{
Thread.Sleep(50); // 模擬延遲
ProgressChanged?.Invoke(this, i);
}
DownloadCompleted?.Invoke(this, EventArgs.Empty);
}
}
var downloader = new FileDownloader();
downloader.ProgressChanged += (s, progress) => Console.WriteLine($"下載:{progress}%");
downloader.DownloadCompleted += (s, e) => Console.WriteLine("下載完成。");
downloader.Download();
這裡有好幾個事件:你可以同時訂閱全部或只訂閱你需要的。很多 .NET 的標準類別(像是處理執行緒、檔案下載、HTTP 等)就是這樣工作的。
對物件生命週期的反應(例如「已儲存」、「已刪除」)
假如你有一個領域模型。當使用者儲存或刪除物件時,希望有些流程能回應:更新 cache、寫 log、發送訊息等等。
public class UserRepository
{
public event EventHandler<UserEventArgs>? UserSaved;
public event EventHandler<UserEventArgs>? UserDeleted;
public void Save(User user)
{
//... 儲存使用者
UserSaved?.Invoke(this, new UserEventArgs(user));
}
public void Delete(User user)
{
//... 刪除使用者
UserDeleted?.Invoke(this, new UserEventArgs(user));
}
}
public class UserEventArgs : EventArgs
{
public User User { get; }
public UserEventArgs(User user) => User = user;
}
現在不同模組可以訂閱,並且——在沒有直接耦合的情況下——接收使用者變更的通知。而 repository 本身不知道誰訂閱了它。
非同步事件與多執行緒
有時事件是從非主要(UI)執行緒產生的,例如背景工作、計時器或非同步操作。這時要注意,處理器的程式會在「外來」執行緒執行。如果處理器直接從其他執行緒更新 UI 元件,會產生錯誤。
什麼是 marshaling? Marshaling 是把執行從一個執行緒傳到另一個執行緒,通常是把背景執行緒的結果傳回 UI 執行緒以安全更新介面。在 UI 應用(WinForms、WPF)會用像 SynchronizationContext 或 Dispatcher 的機制,讓你把處理器的呼叫「導回」到正確執行緒。
不要這樣做:
// 事件從背景執行緒呼叫,而處理器直接更新 UI — 會得到例外!
建議: 檢查目前執行緒,必要時把呼叫 marshal 回 UI 執行緒(透過 SynchronizationContext 或 Dispatcher)。在主控台或伺服器應用通常沒有這種限制。
Event Aggregator / Messaging
大型應用常用集中式的事件聚合器(Event Aggregator)。它降低耦合度:訂閱者和發布者互不相識,透過中介交換訊息。
public class EventAggregator
{
public event EventHandler<SomeEventArgs>? SomeEvent;
public void Publish(SomeEventArgs args)
{
SomeEvent?.Invoke(this, args);
}
}
2. 最佳範式與實務
對錯誤使用 [CallerMemberName]
如果你在做基礎設施程式(像 logger、tracer),用 CallerMemberName 記錄事件來源方法名稱,能讓診斷更簡單。
若有需要,讓事件 thread-safe
事件是 multicast 的,且不同執行緒存取時要小心:在呼叫前把 delegate 複製到本地變數。
var handler = MyEvent;
if (handler != null) handler(this, args);
在 C# 6+ 可以用安全呼叫: MyEvent?.Invoke(this, args)。
把自訂的 EventArgs 做成 immutable
定義你的 EventArgs 類型時,用唯讀屬性,避免訂閱者不小心改變事件狀態。
public class WorkCompletedEventArgs : EventArgs
{
public string Message { get; }
public WorkCompletedEventArgs(string message) => Message = message;
}
public 或 private event
把事件聲明為 public event,並把觸發事件的邏輯封裝在一個 protected virtual 方法(像 OnEventName)中。這樣可擴充(繼承者可覆寫),外部使用者也不能直接觸發事件。
不要破壞生命週期的順序
若你啟動一連串的處理者,記得有人可能會在處理過程中訂閱或改變內部邏輯。把何時何地會觸發事件寫清楚,並說明事件是否可能被觸發多次。
多個事件與組合
當一個物件監聽多個來源時,把訂閱與取消訂閱放在同一地方(例如 Subscribe/Unsubscribe 方法)。必要時保存私有的 delegate 欄位以方便取消訂閱。
3. 不該如何使用事件:常見錯誤
錯誤 №1:忽略標準的事件 pattern。
如果每個類別都為每個事件宣告自己的 delegate,很快會變成一團亂。盡量使用標準的 delegate EventHandler 或 EventHandler<T>,其中 T 繼承自 EventArgs。這讓程式碼對 .NET 開發者更直覺與容易理解。
錯誤 №2:外部程式不正確地呼叫事件。
絕不要在 publisher 類別外直接呼叫事件。事件應該只在類別內部被觸發,通常透過受保護的方法 OnEventName。訂閱者只能做訂閱(+=)與取消訂閱(-=)。
糟糕做法:
foo.MyEvent(); // 錯誤!不能在外部直接呼叫事件
正確做法:
// 只有在 foo 內部:
// protected virtual void OnMyEvent()
// {
// MyEvent?.Invoke(this, EventArgs.Empty);
// }
錯誤 №3:在呼叫事件前沒檢查是否有訂閱者 (null)。
若嘗試呼叫沒有訂閱者的事件,會得到 NullReferenceException。在 C# 6.0+ 使用 ?.Invoke(...) — 只有在有訂閱者時才會呼叫事件。
錯誤 №4:忘記取消訂閱事件(記憶體洩漏)。
若物件訂閱了事件但在被銷毀前未取消訂閱,publisher 會持有對訂閱者的參考。GC 不能釋放這些記憶體,這在長生命期的 publisher 和短生命期的 subscriber(像 ViewModel、表單)時尤其致命。最好的做法是明確取消訂閱,或在適當時候使用弱參考(WeakReference)。
錯誤 №5:不透過受保護的 virtual 方法 OnEvent 來觸發事件。
直接觸發事件會剝奪繼承者覆寫行為的機會。正確的 pattern 是宣告一個 protected virtual 方法,該方法負責呼叫事件。
標準範例:
protected virtual void OnSomething(EventArgs e)
{
Something?.Invoke(this, e);
}
GO TO FULL VERSION