CodeGym /課程 /C# SELF /事件驅動程式設計範例 ( event)

事件驅動程式設計範例 ( event)

C# SELF
等級 54 , 課堂 4
開放

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 保護存取。

1
問卷/小測驗
常見 delegate 錯誤,等級 54,課堂 4
未開放
常見 delegate 錯誤
事件導向程式設計最佳實踐
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION