1. 介紹
一個典型的新手可能會問:「事件在實務中在哪裡用得到?難道類別之間的互動都要靠事件,而不是直接呼叫方法嗎?」答案很簡單:事件和 delegates 不是萬能的魔法,不能解決所有架構問題。但沒有它們,現代應用很快就會變成「緊密耦合 (tightly coupled)」,一個元件改了就會牽動很多其他元件。使用事件和 delegates 可以讓系統更有彈性、容易擴充且好維護。
- UI 程式設計 (WinForms, WPF, Xamarin, MAUI):處理按鈕點擊、滑鼠移入、文字輸入等使用者互動。
- 非同步操作:檔案下載完成、從網路取得資料、計時器觸發等。
- 外掛與可擴充系統架構:允許新增模組而不用對主程式做強耦合整合。
- 訊號/廣播系統:通知多個關心的元件發生了某件事。
- 變更觀察(Observer):對新訊息、資料變更、介面更新等做出反應。
好了,開始實作吧!
2. 架構骨架
假設你要開一個小型的 console 應用 — 「迷你聊天室」,用來公司內部教學或練習技術。系統中有實體:使用者、聊天室,還可能有一個 機器人助理。當使用者傳訊息時,聊天室要通知所有連線的使用者和機器人,使他們在畫面上顯示訊息,或讓機器人產生自動回覆。這是典型場景:發布者觸發事件 (event),處理器訂閱 EventHandler/EventHandler<TEventArgs>,像 OnMessageReceived 這種方法會在通知到達時被呼叫。
// 使用者類別(訂閱者)
public class User
{
public string Name { get; }
public User(string name)
{
Name = name;
}
// 會當作事件處理器的方法
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
Console.WriteLine($"[{Name}] 看到新訊息:{e.MessageText}");
}
}
// 事件參數
public class MessageEventArgs : EventArgs
{
public string MessageText { get; }
public MessageEventArgs(string text) => MessageText = text;
}
// 聊天室類別(事件發布者)
public class ChatRoom
{
public event EventHandler<MessageEventArgs>? MessageReceived;
public void SendMessage(string text)
{
// 觸發事件(通知所有訂閱者)
MessageReceived?.Invoke(this, new MessageEventArgs(text));
}
}
使用範例:
var chat = new ChatRoom();
var user1 = new User("安東");
var user2 = new User("瑪麗亞");
chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;
chat.SendMessage("大家好! 😊");
在主控台會看到兩則訊息 — 兩位使用者都收到新訊息通知。
3. 動態訂閱與取消
在真實場景中,使用者可能會離開聊天室而不想再接收訊息。教使用者正確取消訂閱(使用運算子 -=):
// 在 User 類別可以新增方法「離開聊天室」
public void Unsubscribe(ChatRoom chat)
{
chat.MessageReceived -= OnMessageReceived;
}
延伸範例:
var chat = new ChatRoom();
var user1 = new User("安東");
var user2 = new User("瑪麗亞");
chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;
chat.SendMessage("第一則新聞");
user2.Unsubscribe(chat); // 瑪麗亞離開聊天室
chat.SendMessage("瑪麗亞將不會看到這則訊息");
動態訂閱/取消在各處都會遇到:視窗關閉、分頁關閉、臨時服務從全域事件來源取消訂閱。別忘了 — 沒有取消訂閱的訂閱者會變成殭屍訂閱者,應用程式也會出現記憶體洩漏!
4. 訊息處理與生成回覆
現在加入一個機器人,對每則訊息做出回應。假設機器人會自動說 "哈囉!",當訊息包含關鍵字 "機器人"。同時示範多重訂閱與多重委託的行為。
public class Bot
{
public string Name { get; }
public Bot(string name) => Name = name;
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
// 機器人對關鍵字做出反應
if (e.MessageText.Contains("機器人", StringComparison.OrdinalIgnoreCase))
{
if (sender is ChatRoom chatRoom)
{
Console.WriteLine($"[機器人 {Name}]: 你好!有什麼我可以幫忙的嗎?");
// 機器人發送回覆訊息
chatRoom.SendMessage($"機器人 {Name} 準備好幫助你。");
}
}
}
}
使用方式:
var chat = new ChatRoom();
var user = new User("葉夫根尼");
var bot = new Bot("助理");
chat.MessageReceived += user.OnMessageReceived;
chat.MessageReceived += bot.OnMessageReceived;
// 葉夫根尼 發送會觸發機器人的訊息
chat.SendMessage("嗨,機器人,最近好嗎?");
主控台輸出示意(呼叫順序不保證!):
[葉夫根尼] 看到新訊息:嗨,機器人,最近好嗎?
[機器人 助理]: 你好!有什麼我可以幫忙的嗎?
[葉夫根尼] 看到新訊息:機器人 助理 準備好幫助你。
[機器人 助理]: 你好!有什麼我可以幫忙的嗎?
[葉夫根尼] 看到新訊息:機器人 助理 準備好幫助你。
[機器人 助理]: 你好!有什麼我可以幫忙的嗎?
...
誰發現了潛在的 bug?沒錯,會有無限迴圈:機器人會回應自己的訊息(他仍然看到「機器人」關鍵字)。一種避免方式是加個簡單檢查:
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
// 機器人不會對自己的訊息回應
if (e.MessageText.Contains("機器人", StringComparison.OrdinalIgnoreCase) &&
!e.MessageText.Contains(Name))
{
if (sender is ChatRoom chatRoom)
{
Console.WriteLine($"[機器人 {Name}]: 你好!有什麼我可以幫忙的嗎?");
chatRoom.SendMessage($"機器人 {Name} 準備好幫助你。");
}
}
}
實務上,這類情況會促使你思考循環事件處理、lambda 表達式,或甚至引入取消後續處理器的機制(參見後面的章節)。
5. 非同步操作與回呼
事件常被用來通知非同步操作完成。例如從網路載入資料或長時間運算:事件 Completed 表示作業結束,而方法 RunLongOperationAsync 則執行耗時工作。
public class LongRunner
{
// 完成事件
public event EventHandler<EventArgs>? Completed;
public async Task RunLongOperationAsync()
{
Console.WriteLine("長時間操作開始...");
await Task.Delay(2000); // 這是模擬一個耗時工作
Console.WriteLine("操作完成,通知訂閱者。");
Completed?.Invoke(this, EventArgs.Empty);
}
}
客戶端程式:
var runner = new LongRunner();
// 訂閱完成事件
runner.Completed += (sender, e) =>
{
Console.WriteLine("收到通知:操作已完成!");
};
await runner.RunLongOperationAsync();
這是非同步互動的基礎 — 在 UI framework 中經常用事件來標示載入完成、動畫結束、使用者點擊等情況。
6. 訊號/通知系統
另一個常見場景:有個通知系統(例如電商的商品降價)。所有關心的「聽眾」會透過事件 SaleOccurred 得知:
public class SaleNotifier
{
public event EventHandler<SaleEventArgs>? SaleOccurred;
public void AnnounceSale(string product, decimal newPrice)
{
SaleOccurred?.Invoke(this, new SaleEventArgs(product, newPrice));
}
}
public class SaleEventArgs : EventArgs
{
public string Product { get; }
public decimal NewPrice { get; }
public SaleEventArgs(string product, decimal price)
{
Product = product; NewPrice = price;
}
}
public class Customer
{
public string Name { get; }
public Customer(string name) => Name = name;
public void OnSale(object? sender, SaleEventArgs e)
{
Console.WriteLine($"[{Name}] 收到通知:{e.Product} 現在要價 {e.NewPrice.ToString(\"F2\")} 單位!");
}
}
使用範例:
var notifier = new SaleNotifier();
var c1 = new Customer("安德烈");
var c2 = new Customer("奧爾加");
notifier.SaleOccurred += c1.OnSale;
notifier.SaleOccurred += c2.OnSale;
notifier.AnnounceSale("熱水壺", 999.99m);
如果其中一位顧客不再關心 — 就取消訂閱:
notifier.SaleOccurred -= c2.OnSale;
notifier.AnnounceSale("攪拌機", 1999.99m);
這個模式常用於發送通知(notification, pub/sub),在現代架構中非常重要。
7. 實作取消事件鏈的機制
有時候某個處理器需要「阻止」後續的通知。通常會使用繼承自 EventArgs 並帶有取消旗標的類別。下面範例手動透過 GetInvocationList() 遍歷訂閱者,當 Cancel = true 時停止執行。
public class CancelEventArgs : EventArgs
{
public bool Cancel { get; set; }
}
public class EventSource
{
public event EventHandler<CancelEventArgs>? SomethingHappened;
public void DoSomething()
{
var args = new CancelEventArgs();
// 經典的迴圈遍歷訂閱者(需要控制呼叫順序時才用)
var handlers = SomethingHappened?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
((EventHandler<CancelEventArgs>)handler)(this, args);
if (args.Cancel)
{
Console.WriteLine("訊息鏈已中斷。");
break;
}
}
}
}
}
使用範例:
var source = new EventSource();
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("第一個處理器");
};
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("第二個處理器取消事件。");
e.Cancel = true;
};
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("這個處理器不會被呼叫。");
};
source.DoSomething();
結果:
第一個處理器
第二個處理器取消事件。
訊息鏈已中斷。
這類機制常見於驗證、關閉視窗前的事件處理、權限檢查等,需要有能力說「停!後面不要再處理了。」的場景。
8. 執行緒安全與訂閱者管理
在多執行緒應用中,訂閱者可能會在事件被觸發的同時被新增或移除,這時要使用執行緒安全的模式(參見 官方文件)。可以手動實作事件的 add/remove 存取子並用 lock 保護:
public class CustomEvent
{
private EventHandler? _handlers;
public event EventHandler SomethingHappened
{
add
{
lock (this) // 執行緒安全
{
_handlers += value;
}
}
remove
{
lock (this)
{
_handlers -= value;
}
}
}
protected void RaiseEvent()
{
// 使用複本
EventHandler? handler;
lock (this)
{
handler = _handlers;
}
handler?.Invoke(this, EventArgs.Empty);
}
}
這種做法比較少見(通常內建機制就夠了),但在高負載系統有時很需要。
9. UI 中的事件 — 來自 WinForms/WPF/MAUI
WinForms:
private void button1_Click(object sender, EventArgs e)
{
MessageBox.Show("按鈕被按了!");
}
背後其實是訂閱機制:
button1.Click += button1_Click;
WPF/MAUI:像是屬性變更的事件 PropertyChanged(通知模型屬性改變):
public class MyViewModel : INotifyPropertyChanged
{
private string _value;
public string Value
{
get => _value;
set
{
if (_value != value)
{
_value = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value));
}
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
framework 廣泛使用事件來建立反應式介面(MVVM)。
10. 常見錯誤與踩坑
錯誤 №1:產生記憶體洩漏。
這是最常見的問題。如果短生命週期的物件(例如視窗或臨時服務)訂閱了長生命週期的物件(例如全域快取或靜態類別)的事件,且在被銷毀時沒有取消訂閱,該物件會永遠留在記憶體。務必在 Dispose 或合適的生命週期位置實作取消訂閱。
錯誤 №2:造成無限事件迴圈。
處理器可能因回應事件而觸發同樣的事件,導致無限遞迴並造成 StackOverflowException。務必檢查條件,避免處理器對自己或自己觸發的事件產生反應。
錯誤 №3:依賴訂閱者呼叫順序。
不要假設事件處理器會照訂閱的順序被呼叫。C# 規範和 CLR 實作沒有保證這點。如果順序很重要,可以使用 GetInvocationList() 並以你指定的順序手動呼叫訂閱者。
錯誤 №4:在多執行緒環境對事件的非安全操作。
如果訂閱者在一個執行緒新增或移除,而事件在另一個執行緒被觸發,可能會發生 race condition。經典的保護方式是先複製委託到本地變數再呼叫:
var handler = MyEvent;
handler?.Invoke(this, EventArgs.Empty);
或是在手動實作 add/remove 時使用 lock 保護存取。
GO TO FULL VERSION