1. Wprowadzenie
Początkujący może zapytać: „Gdzie w praktyce używa się zdarzeń? Czy na serio wszystkie interakcje między klasami opierają się na zdarzeniach, a nie na bezpośrednich wywołaniach metod?” Odpowiedź jest prosta: zdarzenia i delegaty to nie magia rozwiązująca wszystkie problemy architektury. Ale bez nich nowoczesna aplikacja szybko zamienia się w mocno sprzężony system (tightly coupled), gdzie zmiana jednego komponentu wymusza modyfikacje wielu innych. Użycie zdarzeń i delegatów pomaga tworzyć elastyczne, rozszerzalne i łatwe w utrzymaniu systemy.
- Programowanie UI (WinForms, WPF, Xamarin, MAUI): obsługa kliknięć, najechań, wpisywania tekstu i innych akcji użytkownika.
- Operacje asynchroniczne: zakończenie pobierania pliku, otrzymanie danych z sieci, uruchomienie timerów.
- Architektura wtyczek i systemów rozszerzalnych: pozwala podłączać nowe moduły bez twardej integracji z głównym kodem.
- Systemy sygnalizacyjne/rozesyłające: powiadamianie wielu zainteresowanych komponentów o zaszłych zdarzeniach.
- Obserwacja zmian stanu („obserwator”): reagowanie na pojawienie się nowej wiadomości, zmianę danych, odświeżenie interfejsu.
A teraz do rzeczy!
2. Szkielet architektoniczny
Wyobraźmy sobie, że piszecie małą konsolową aplikację — "Mini-czat" do wewnętrznych szkoleń w firmie (albo po prostu do ćwiczeń). Mamy byty: Użytkownik, Czat i ewentualnie Bot-pomocnik. Gdy użytkownik wysyła wiadomość, czat musi powiadomić wszystkich podłączonych użytkowników i boty — żeby wyświetlili wiadomość na ekranie albo (w przypadku bota) wygenerowali automatyczną odpowiedź. To klasyczny scenariusz: publisher generuje zdarzenie (event), handlery są przypięte do delegata EventHandler/EventHandler<TEventArgs>, a metoda-handler typu OnMessageReceived reaguje na powiadomienie.
// Klasa User (subskrybent)
public class User
{
public string Name { get; }
public User(string name)
{
Name = name;
}
// Metoda, która będzie handlerem zdarzenia
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
Console.WriteLine($"[{Name}] zobaczył nową wiadomość: {e.MessageText}");
}
}
// Argumenty zdarzenia
public class MessageEventArgs : EventArgs
{
public string MessageText { get; }
public MessageEventArgs(string text) => MessageText = text;
}
// Klasa ChatRoom (wydawca zdarzenia)
public class ChatRoom
{
public event EventHandler<MessageEventArgs>? MessageReceived;
public void SendMessage(string text)
{
// Generujemy zdarzenie (powiadamiamy wszystkich subskrybentów)
MessageReceived?.Invoke(this, new MessageEventArgs(text));
}
}
Przykład użycia:
var chat = new ChatRoom();
var user1 = new User("Antoni");
var user2 = new User("Maria");
chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;
chat.SendMessage("Cześć wszystkim! 😊");
W konsoli zobaczymy dwie wiadomości — obaj użytkownicy dowiedzieli się o nowej wiadomości.
3. Dynamiczna subskrypcja i odsubskrypcja
W praktyce użytkownik może opuścić czat i nie chcieć więcej otrzymywać wiadomości. Nauczmy użytkownika prawidłowo się odsubskrybować (operator -=):
// W klasie User można dodać metodę "Opuść czat"
public void Unsubscribe(ChatRoom chat)
{
chat.MessageReceived -= OnMessageReceived;
}
Rozwijamy przykład:
var chat = new ChatRoom();
var user1 = new User("Antoni");
var user2 = new User("Maria");
chat.MessageReceived += user1.OnMessageReceived;
chat.MessageReceived += user2.OnMessageReceived;
chat.SendMessage("Pierwsza wiadomość");
user2.Unsubscribe(chat); // Maria opuszcza czat
chat.SendMessage("Maria nie zobaczy już tej wiadomości");
Dynamiczna subskrypcja/odsubskrypcja występuje wszędzie: okna się zamykają, zakładki się zamykają, tymczasowe serwisy odsubskrybowują się od globalnych źródeł zdarzeń. Pamiętajcie — jeśli nie odsubskrybujesz, subskrybent może zostać „zombie”, a aplikacja stanie się źródłem wycieku pamięci!
4. Obsługa wiadomości i generowanie odpowiedzi
Dodajmy teraz bota, który reaguje na każdą wiadomość. Niech bot mówi "Cześć!" automatycznie, jeśli w wiadomości pojawi się słowo "bot". Przy okazji omówimy wielokrotną subskrypcję i demonstrujemy multicast delegates.
public class Bot
{
public string Name { get; }
public Bot(string name) => Name = name;
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
// Bot reaguje na słowo kluczowe
if (e.MessageText.Contains("bot", StringComparison.OrdinalIgnoreCase))
{
if (sender is ChatRoom chatRoom)
{
Console.WriteLine($"[Bot {Name}]: Witaj! W czym mogę pomóc?");
// Bot wysyła wiadomość w odpowiedzi
chatRoom.SendMessage($"Bot {Name} jest gotów pomóc.");
}
}
}
}
Użycie:
var chat = new ChatRoom();
var user = new User("Eugeniusz");
var bot = new Bot("Pomocnik");
chat.MessageReceived += user.OnMessageReceived;
chat.MessageReceived += bot.OnMessageReceived;
// Eugeniusz wysyła wiadomość, która wywołuje bota
chat.SendMessage("Cześć, bot, co słychać?");
Konsola może dać (pokazujemy kolejność wywołań — nie jest ona gwarantowana!):
[Eugeniusz] zobaczył nową wiadomość: Cześć, bot, co słychać?
[Bot Pomocnik]: Witaj! W czym mogę pomóc?
[Eugeniusz] zobaczył nową wiadomość: Bot Pomocnik jest gotów pomóc.
[Bot Pomocnik]: Witaj! W czym mogę pomóc?
[Eugeniusz] zobaczył nową wiadomość: Bot Pomocnik jest gotów pomóc.
[Bot Pomocnik]: Witaj! W czym mogę pomóc?
...
Kto zauważył potencjalny bug? Tak — nieskończona pętla: bot reaguje na własne wiadomości (znowu widzi słowo "bot"). Jednym ze sposobów zapobiegania temu jest proste sprawdzenie:
public void OnMessageReceived(object? sender, MessageEventArgs e)
{
// Bot nie reaguje na swoje własne wiadomości
if (e.MessageText.Contains("bot", StringComparison.OrdinalIgnoreCase) &&
!e.MessageText.Contains(Name))
{
if (sender is ChatRoom chatRoom)
{
Console.WriteLine($"[Bot {Name}]: Witaj! W czym mogę pomóc?");
chatRoom.SendMessage($"Bot {Name} jest gotów pomóc.");
}
}
}
W praktyce takie sytuacje to dobry moment, żeby pomyśleć o obsłudze zdarzeń cyklicznych, użyciu wyrażeń lambda albo nawet mechanizmie anulowania dalszych handlerów (patrz wcześniejsze lekcje).
5. Operacje asynchroniczne i callbacki
Bardzo często zdarzenia służą do powiadamiania o zakończeniu operacji asynchronicznej. Na przykład pobieranie danych z internetu lub długotrwałe obliczenia: zdarzenie Completed informuje o końcu pracy, a metoda RunLongOperationAsync wykonuje długą operację.
public class LongRunner
{
// Zdarzenie o zakończeniu pracy
public event EventHandler<EventArgs>? Completed;
public async Task RunLongOperationAsync()
{
Console.WriteLine("Rozpoczęła się długa operacja...");
await Task.Delay(2000); // To symulacja długiej pracy
Console.WriteLine("Operacja zakończona, powiadamiamy subskrybentów.");
Completed?.Invoke(this, EventArgs.Empty);
}
}
Kod klienta:
var runner = new LongRunner();
// Subskrypcja zdarzenia zakończenia
runner.Completed += (sender, e) =>
{
Console.WriteLine("Otrzymano powiadomienie: operacja zakończona!");
};
await runner.RunLongOperationAsync();
To fundament interakcji asynchronicznych — zdarzenia często używane są w frameworkach UI, żeby sygnalizować zakończenie ładowania, koniec animacji, kliknięcia użytkownika itp.
6. Systemy powiadomień
Rozważmy klasyczne zadanie: mamy system powiadomień (np. w sklepie internetowym, gdy na produkt jest promocja). Wszyscy zainteresowani "słuchacze" dowiadują się o tym przez zdarzenie 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}] otrzymał powiadomienie: {e.Product} teraz kosztuje {e.NewPrice.ToString(\"F2\")} jednostek!");
}
}
Użycie:
var notifier = new SaleNotifier();
var c1 = new Customer("Andrzej");
var c2 = new Customer("Olga");
notifier.SaleOccurred += c1.OnSale;
notifier.SaleOccurred += c2.OnSale;
notifier.AnnounceSale("Czajnik", 999.99m);
Jeśli jeden z klientów nie jest już zainteresowany — odsubskrybowujemy:
notifier.SaleOccurred -= c2.OnSale;
notifier.AnnounceSale("Mikser", 1999.99m);
Taki wzorzec stosuje się do rozsyłania sygnałów (notification, pub/sub), co jest bardzo istotne we współczesnych architekturach.
7. Implementacja mechanizmu anulowania łańcucha zdarzeń
Czasem jeden z handlerów powinien "zatrzymać" dalsze powiadamianie. Zwykle używa się do tego pochodnej EventArgs z flagą anulowania. W przykładzie poniżej ręcznie iterujemy GetInvocationList() i przerywamy wykonanie, gdy 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();
// Klasyczna pętla do iteracji po subskrybentach (jeśli potrzebna kontrola nad kolejnością)
var handlers = SomethingHappened?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
((EventHandler<CancelEventArgs>)handler)(this, args);
if (args.Cancel)
{
Console.WriteLine("Łańcuch powiadomień przerwany.");
break;
}
}
}
}
}
Użycie:
var source = new EventSource();
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("Pierwszy obsługujący");
};
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("Drugi obsługujący anuluje zdarzenie.");
e.Cancel = true;
};
source.SomethingHappened += (s, e) =>
{
Console.WriteLine("Ten obsługujący nie zostanie wywołany.");
};
source.DoSomething();
Wynik:
Pierwszy obsługujący
Drugi obsługujący anuluje zdarzenie.
Łańcuch powiadomień przerwany.
Taki mechanizm przydaje się do walidacji, obsługi zdarzeń przed zamknięciem okna, sprawdzania uprawnień i innych zadań, gdzie ważna jest możliwość powiedzenia "Stop! Nie przetwarzamy dalej."
8. Bezpieczeństwo wątków i zarządzanie subskrybentami
W aplikacjach wielowątkowych, gdzie subskrybenci mogą być dodawani i usuwani jednocześnie z generowaniem zdarzenia, warto używać wzorców zapewniających bezpieczeństwo wątkowe (patrz oficjalna dokumentacja). Dla kontroli można zaimplementować zdarzenie ręcznie z akcesorami add i remove i chronić dostęp za pomocą lock:
public class CustomEvent
{
private EventHandler? _handlers;
public event EventHandler SomethingHappened
{
add
{
lock (this) // Bezpieczne w kontekście wątków
{
_handlers += value;
}
}
remove
{
lock (this)
{
_handlers -= value;
}
}
}
protected void RaiseEvent()
{
// Używamy kopii
EventHandler? handler;
lock (this)
{
handler = _handlers;
}
handler?.Invoke(this, EventArgs.Empty);
}
}
To podejście rzadko się pojawia (zwykle wbudowany mechanizm wystarcza), ale czasami jest potrzebne w systemach o dużym obciążeniu.
9. Zdarzenia w UI — WinForms/WPF/MAUI
WinForms:
private void button1_Click(object sender, EventArgs e)
{
MessageBox.Show("Przycisk został kliknięty!");
}
Za tą metodą stoi mechanizm subskrypcji:
button1.Click += button1_Click;
WPF/MAUI: zdarzenia typu PropertyChanged (powiadomienia o zmianie właściwości modelu):
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;
}
Frameworki intensywnie używają zdarzeń do budowania reaktywnych interfejsów (MVVM).
10. Typowe błędy i pułapki przy pracy ze zdarzeniami
Błąd #1: tworzenie wycieków pamięci.
To najczęstszy problem. Jeśli obiekt krótkotrwały (np. okno lub tymczasowy serwis) subskrybuje zdarzenie długotrwałego obiektu (np. globalnego cache'a lub klasy statycznej) i nie odsubskrybuje się przy swoim zniszczeniu, pozostanie na zawsze w pamięci. Zawsze implementuj odsubskrybowanie w metodzie Dispose lub w innym odpowiednim miejscu cyklu życia obiektu.
Błąd #2: tworzenie nieskończonych cykli zdarzeń.
Subskrybent może zareagować na zdarzenie, wywołując akcję, która z kolei znowu generuje to samo zdarzenie. To prowadzi do nieskończonej rekurencji i awarii aplikacji z StackOverflowException. Zawsze sprawdzaj warunki, aby handler nie reagował na zdarzenia, które sam wywołał.
Błąd #3: zależność od kolejności wywołań subskrybentów.
Nigdy nie polegaj na tym, że handlery będą wywoływane w tej samej kolejności, w jakiej zostały subskrybowane. Specyfikacja C# i implementacja CLR tego nie gwarantują. Jeśli kolejność ma znaczenie, wywołuj subskrybentów ręcznie przez GetInvocationList() w pożądanej kolejności.
Błąd #4: niebezpieczna praca ze zdarzeniami w środowisku wielowątkowym.
Jeśli subskrybenci są dodawani lub usuwani z jednego wątku, a zdarzenie jest wywoływane z innego, może wystąpić race condition. Klasyczna ochrona — skopiować delegata do lokalnej zmiennej przed wywołaniem:
var handler = MyEvent;
handler?.Invoke(this, EventArgs.Empty);
Albo użyć lock przy ręcznej implementacji zdarzeń przez add/remove.
GO TO FULL VERSION