У попередніх статтях ми створили ViewModel, Commands та автоматизували boilerplate через MVVM Toolkit. Але залишилася одна проблема: як ViewModel'и спілкуються між собою?
Уявіть ситуацію: у вашому додатку є кілька ViewModel:
MainViewModel — головне вікно з списком елементівSettingsViewModel — налаштування (тема, мова, розмір шрифту)EditorViewModel — редактор елементаСценарій: Користувач змінює тему у SettingsViewModel з Light на Dark. MainViewModel має оновити UI відповідно до нової теми.
Питання: Як MainViewModel дізнається про зміну теми?
Спроба 1: Пряме посилання
public class MainViewModel : ObservableObject
{
private SettingsViewModel _settingsViewModel;
public MainViewModel(SettingsViewModel settingsViewModel)
{
_settingsViewModel = settingsViewModel;
// Підписка на зміни
_settingsViewModel.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(SettingsViewModel.Theme))
{
UpdateTheme(_settingsViewModel.Theme);
}
};
}
}
Проблеми:
MainViewModel залежить від SettingsViewModelSettingsViewModel для тестування MainViewModelРішення: Messenger Pattern — центральний посередник для комунікації між ViewModel без прямих посилань.
Messenger Pattern базується на Mediator Pattern — один з класичних патернів проектування (Gang of Four).
Ідея: Замість прямої комунікації між об'єктами, всі об'єкти спілкуються через центрального посередника (Mediator).
Переваги:
Процес комунікації:
Ключові моменти:
CommunityToolkit.Mvvm надає готову реалізацію Messenger — WeakReferenceMessenger.
Проблема сильних посилань:
// Сильне посилання
List<ViewModel> subscribers = new List<ViewModel>();
subscribers.Add(mainViewModel);
// mainViewModel не може бути видалений Garbage Collector,
// навіть якщо більше не використовується
Рішення — слабкі посилання:
// Слабке посилання
List<WeakReference<ViewModel>> subscribers = new List<WeakReference<ViewModel>>();
subscribers.Add(new WeakReference<ViewModel>(mainViewModel));
// mainViewModel може бути видалений Garbage Collector,
// якщо більше немає сильних посилань
Переваги WeakReferenceMessenger:
Недоліки:
Крок 1: Створити повідомлення
// Просте повідомлення
public class ThemeChangedMessage
{
public string Theme { get; }
public ThemeChangedMessage(string theme)
{
Theme = theme;
}
}
Крок 2: Надіслати повідомлення
public partial class SettingsViewModel : ObservableObject
{
[ObservableProperty]
private string _theme = "Light";
partial void OnThemeChanged(string value)
{
// Надіслати повідомлення через Messenger
WeakReferenceMessenger.Default.Send(new ThemeChangedMessage(value));
}
}
Крок 3: Підписатися на повідомлення
public partial class MainViewModel : ObservableObject
{
public MainViewModel()
{
// Підписатися на повідомлення
WeakReferenceMessenger.Default.Register<ThemeChangedMessage>(this, (recipient, message) =>
{
// Обробити повідомлення
UpdateTheme(message.Theme);
});
}
private void UpdateTheme(string theme)
{
// Логіка оновлення теми
Console.WriteLine($"Тема змінена на: {theme}");
}
}
Ключові моменти:
WeakReferenceMessenger.Default — singleton екземпляр MessengerRegister<TMessage> — підписка на повідомлення типу TMessagethis — recipient (хто отримує повідомлення)(recipient, message) => { ... }Чому важливо відписуватися?
Хоча WeakReferenceMessenger використовує слабкі посилання, краща практика — явно відписуватися, коли ViewModel більше не потрібен.
Коли відписуватися:
public partial class MainViewModel : ObservableObject, IDisposable
{
public MainViewModel()
{
WeakReferenceMessenger.Default.Register<ThemeChangedMessage>(this, OnThemeChanged);
}
private void OnThemeChanged(object recipient, ThemeChangedMessage message)
{
UpdateTheme(message.Theme);
}
public void Dispose()
{
// Відписатися від всіх повідомлень
WeakReferenceMessenger.Default.UnregisterAll(this);
// Або від конкретного типу
// WeakReferenceMessenger.Default.Unregister<ThemeChangedMessage>(this);
}
}
Методи відписки:
| Метод | Опис |
|---|---|
Unregister<TMessage>(recipient) | Відписатися від повідомлень типу TMessage |
UnregisterAll(recipient) | Відписатися від всіх повідомлень |
Reset() | Очистити всіх підписників (рідко використовується) |
CommunityToolkit.Mvvm надає кілька готових типів повідомлень для типових сценаріїв.
Використання:
// Надіслати
WeakReferenceMessenger.Default.Send(new ValueChangedMessage<string>("Dark"));
// Отримати
WeakReferenceMessenger.Default.Register<ValueChangedMessage<string>>(this, (r, msg) =>
{
string newTheme = msg.Value;
UpdateTheme(newTheme);
});
Переваги:
Недоліки:
ValueChangedMessage<string>ValueChangedMessage<string> з різним значеннямВикористання:
// Надіслати
WeakReferenceMessenger.Default.Send(new PropertyChangedMessage<string>(
sender: this,
propertyName: nameof(Theme),
oldValue: "Light",
newValue: "Dark"
));
// Отримати
WeakReferenceMessenger.Default.Register<PropertyChangedMessage<string>>(this, (r, msg) =>
{
if (msg.PropertyName == nameof(SettingsViewModel.Theme))
{
Console.WriteLine($"Тема змінена з '{msg.OldValue}' на '{msg.NewValue}'");
}
});
Переваги:
Сценарій: EditorViewModel потребує дані з MainViewModel, але не має прямого посилання.
Використання:
// Повідомлення-запит
public class GetCurrentUserRequest : RequestMessage<User>
{
}
// MainViewModel — надає дані
public class MainViewModel : ObservableObject
{
private User _currentUser = new User { Name = "Іван" };
public MainViewModel()
{
WeakReferenceMessenger.Default.Register<GetCurrentUserRequest>(this, (r, msg) =>
{
// Відповісти на запит
msg.Reply(_currentUser);
});
}
}
// EditorViewModel — запитує дані
public class EditorViewModel : ObservableObject
{
public void LoadUser()
{
var request = new GetCurrentUserRequest();
// Надіслати запит
WeakReferenceMessenger.Default.Send(request);
// Отримати відповідь
if (request.HasReceivedResponse)
{
User user = request.Response;
Console.WriteLine($"Отримано користувача: {user.Name}");
}
}
}
Ключові моменти:
RequestMessage<T> — базовий клас для запитівReply(value) — надати відповідьHasReceivedResponse — чи отримано відповідьResponse — значення відповідіПереваги:
Недоліки:
HasReceivedResponseСценарій: Зібрати дані з кількох ViewModel.
Використання:
// Запит
public class GetAllOpenEditorsRequest : CollectionRequestMessage<string>
{
}
// Кілька EditorViewModel відповідають
public class EditorViewModel : ObservableObject
{
public string FileName { get; set; }
public EditorViewModel()
{
WeakReferenceMessenger.Default.Register<GetAllOpenEditorsRequest>(this, (r, msg) =>
{
msg.Reply(FileName);
});
}
}
// MainViewModel — збирає відповіді
public class MainViewModel : ObservableObject
{
public void ShowOpenEditors()
{
var request = new GetAllOpenEditorsRequest();
WeakReferenceMessenger.Default.Send(request);
// Отримати всі відповіді
foreach (string fileName in request.Responses)
{
Console.WriteLine($"Відкритий файл: {fileName}");
}
}
}
Переваги:
CommunityToolkit.Mvvm також надає StrongReferenceMessenger — версію з сильними посиланнями.
| Аспект | WeakReferenceMessenger | StrongReferenceMessenger |
|---|---|---|
| Посилання | Слабкі (WeakReference) | Сильні (звичайні) |
| Memory leaks | Немає (GC видаляє) | Можливі (якщо забули Unregister) |
| Продуктивність | Трохи повільніше | Швидше |
| Unregister | Опціонально | Обов'язково |
| Використання | За замовчуванням | Для high-performance сценаріїв |
Сценарії:
Використання:
// Створити екземпляр
var messenger = new StrongReferenceMessenger();
// Використовувати як WeakReferenceMessenger
messenger.Register<ThemeChangedMessage>(this, OnThemeChanged);
messenger.Send(new ThemeChangedMessage("Dark"));
// ⚠️ ОБОВ'ЯЗКОВО відписатися
messenger.UnregisterAll(this);
StrongReferenceMessenger обов'язково викликайте Unregister або UnregisterAll, інакше виникнуть memory leaks.Розберемо типові сценарії використання Messenger у реальних додатках.
Проблема: Користувач змінює тему у налаштуваннях. Всі вікна мають оновити UI.
Рішення:
// Повідомлення
public class ThemeChangedMessage
{
public string Theme { get; }
public ThemeChangedMessage(string theme) => Theme = theme;
}
// SettingsViewModel — надсилає
public partial class SettingsViewModel : ObservableObject
{
[ObservableProperty]
private string _theme = "Light";
partial void OnThemeChanged(string value)
{
WeakReferenceMessenger.Default.Send(new ThemeChangedMessage(value));
}
}
// MainViewModel — отримує
public partial class MainViewModel : ObservableObject
{
public MainViewModel()
{
WeakReferenceMessenger.Default.Register<ThemeChangedMessage>(this, (r, msg) =>
{
ApplyTheme(msg.Theme);
});
}
private void ApplyTheme(string theme)
{
// Логіка застосування теми
Application.Current.Resources.MergedDictionaries.Clear();
Application.Current.Resources.MergedDictionaries.Add(
new ResourceDictionary { Source = new Uri($"Themes/{theme}.xaml", UriKind.Relative) }
);
}
}
Проблема: MainViewModel потребує відкрити EditorView, але не має прямого доступу до View.
Рішення:
// Повідомлення
public class NavigateMessage
{
public string ViewName { get; }
public object Parameter { get; }
public NavigateMessage(string viewName, object parameter = null)
{
ViewName = viewName;
Parameter = parameter;
}
}
// MainViewModel — надсилає
public partial class MainViewModel : ObservableObject
{
[RelayCommand]
private void OpenEditor(int itemId)
{
WeakReferenceMessenger.Default.Send(new NavigateMessage("EditorView", itemId));
}
}
// MainWindow (code-behind) — отримує
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
WeakReferenceMessenger.Default.Register<NavigateMessage>(this, (r, msg) =>
{
NavigateToView(msg.ViewName, msg.Parameter);
});
}
private void NavigateToView(string viewName, object parameter)
{
switch (viewName)
{
case "EditorView":
var editorWindow = new EditorWindow();
editorWindow.DataContext = new EditorViewModel((int)parameter);
editorWindow.Show();
break;
}
}
}
Проблема: EditorViewModel потребує показати діалог "Зберегти зміни?", але не має доступу до UI.
Рішення:
// Повідомлення-запит
public class ConfirmationRequest : RequestMessage<bool>
{
public string Title { get; }
public string Message { get; }
public ConfirmationRequest(string title, string message)
{
Title = title;
Message = message;
}
}
// EditorViewModel — запитує
public partial class EditorViewModel : ObservableObject
{
[RelayCommand]
private void Close()
{
if (HasUnsavedChanges)
{
var request = new ConfirmationRequest(
"Зберегти зміни?",
"У вас є незбережені зміни. Зберегти перед закриттям?"
);
WeakReferenceMessenger.Default.Send(request);
if (request.HasReceivedResponse && request.Response)
{
Save();
}
}
}
}
// MainWindow (code-behind) — відповідає
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
WeakReferenceMessenger.Default.Register<ConfirmationRequest>(this, (r, msg) =>
{
var result = MessageBox.Show(
msg.Message,
msg.Title,
MessageBoxButton.YesNo,
MessageBoxImage.Question
);
msg.Reply(result == MessageBoxResult.Yes);
});
}
}
Проблема: EditorViewModel зберіг зміни. MainViewModel має оновити список.
Рішення:
// Повідомлення
public class ItemUpdatedMessage
{
public int ItemId { get; }
public ItemUpdatedMessage(int itemId) => ItemId = itemId;
}
// EditorViewModel — надсилає
public partial class EditorViewModel : ObservableObject
{
[RelayCommand]
private async Task SaveAsync()
{
await _repository.UpdateAsync(Item);
// Сповістити про оновлення
WeakReferenceMessenger.Default.Send(new ItemUpdatedMessage(Item.Id));
}
}
// MainViewModel — отримує та оновлює список
public partial class MainViewModel : ObservableObject
{
public ObservableCollection<Item> Items { get; set; }
public MainViewModel()
{
WeakReferenceMessenger.Default.Register<ItemUpdatedMessage>(this, async (r, msg) =>
{
// Перезавантажити оновлений елемент
var updatedItem = await _repository.GetByIdAsync(msg.ItemId);
var existingItem = Items.FirstOrDefault(i => i.Id == msg.ItemId);
if (existingItem != null)
{
int index = Items.IndexOf(existingItem);
Items[index] = updatedItem;
}
});
}
}
📝 Виразні назви повідомлень
ThemeChangedMessage, а не ValueChangedMessage<string>. Код стає читабельнішим.🎯 Один тип — одна мета
🧹 Завжди Unregister
🔒 Immutable повідомлення
1. Забули Unregister → Memory leak (для StrongReferenceMessenger)
// ❌ Погано
public class MyViewModel : ObservableObject
{
public MyViewModel()
{
StrongReferenceMessenger.Default.Register<MyMessage>(this, OnMessage);
// Забули Unregister — memory leak!
}
}
// ✅ Добре
public class MyViewModel : ObservableObject, IDisposable
{
public MyViewModel()
{
StrongReferenceMessenger.Default.Register<MyMessage>(this, OnMessage);
}
public void Dispose()
{
StrongReferenceMessenger.Default.UnregisterAll(this);
}
}
2. Циклічні повідомлення → Stack overflow
// ❌ Погано — безкінечний цикл
public class ViewModelA : ObservableObject
{
public ViewModelA()
{
WeakReferenceMessenger.Default.Register<MessageA>(this, (r, msg) =>
{
WeakReferenceMessenger.Default.Send(new MessageB()); // Надсилає B
});
}
}
public class ViewModelB : ObservableObject
{
public ViewModelB()
{
WeakReferenceMessenger.Default.Register<MessageB>(this, (r, msg) =>
{
WeakReferenceMessenger.Default.Send(new MessageA()); // Надсилає A → цикл!
});
}
}
Рішення: Додати прапорець для запобігання повторній обробці:
public class MessageA
{
public bool IsProcessed { get; set; }
}
WeakReferenceMessenger.Default.Register<MessageA>(this, (r, msg) =>
{
if (!msg.IsProcessed)
{
msg.IsProcessed = true;
// Обробка
}
});
3. Надто багато повідомлень → Складність
Якщо у додатку 50+ типів повідомлень — це сигнал, що архітектура занадто складна. Розгляньте альтернативи:
4. Синхронна обробка → UI зависає
// ❌ Погано — довга операція у обробнику
WeakReferenceMessenger.Default.Register<DataLoadedMessage>(this, (r, msg) =>
{
Thread.Sleep(5000); // UI зависає!
ProcessData(msg.Data);
});
// ✅ Добре — асинхронна обробка
WeakReferenceMessenger.Default.Register<DataLoadedMessage>(this, async (r, msg) =>
{
await Task.Delay(5000);
await ProcessDataAsync(msg.Data);
});
Мета: Навчитися використовувати Messenger для простої комунікації.
Завдання:
Створіть два ViewModel:
Theme (Light/Dark)ThemeChangedMessageThemeChangedMessageКритерії успіху:
Theme у SettingsViewModel → MainViewModel отримує повідомленняWeakReferenceMessenger.DefaultПідказка:
// Повідомлення
public class ThemeChangedMessage
{
public string Theme { get; }
public ThemeChangedMessage(string theme) => Theme = theme;
}
// SettingsViewModel
public partial class SettingsViewModel : ObservableObject
{
[ObservableProperty]
private string _theme = "Light";
partial void OnThemeChanged(string value)
{
// TODO: Надіслати повідомлення
WeakReferenceMessenger.Default.Send(new ThemeChangedMessage(value));
}
}
// MainViewModel
public partial class MainViewModel : ObservableObject
{
public MainViewModel()
{
// TODO: Підписатися на повідомлення
WeakReferenceMessenger.Default.Register<ThemeChangedMessage>(this, (r, msg) =>
{
Console.WriteLine($"Тема змінена на: {msg.Theme}");
});
}
}
Тести:
[Test]
public void ThemeChanged_ShouldSendMessage()
{
var settingsVm = new SettingsViewModel();
var mainVm = new MainViewModel();
bool messageReceived = false;
string receivedTheme = null;
WeakReferenceMessenger.Default.Register<ThemeChangedMessage>(this, (r, msg) =>
{
messageReceived = true;
receivedTheme = msg.Theme;
});
settingsVm.Theme = "Dark";
Assert.IsTrue(messageReceived);
Assert.AreEqual("Dark", receivedTheme);
WeakReferenceMessenger.Default.UnregisterAll(this);
}
Мета: Реалізувати навігацію без прямих посилань на View.
Завдання:
Створіть систему навігації:
ViewName (string) — назва ViewParameter (object) — параметр для передачіOpenEditorCommand — надсилає NavigateMessage("EditorView", itemId)OpenSettingsCommand — надсилає NavigateMessage("SettingsView")NavigateMessageКритерії успіху:
Підказка:
// Повідомлення
public class NavigateMessage
{
public string ViewName { get; }
public object Parameter { get; }
public NavigateMessage(string viewName, object parameter = null)
{
ViewName = viewName;
Parameter = parameter;
}
}
// MainViewModel
public partial class MainViewModel : ObservableObject
{
[RelayCommand]
private void OpenEditor(int itemId)
{
WeakReferenceMessenger.Default.Send(new NavigateMessage("EditorView", itemId));
}
[RelayCommand]
private void OpenSettings()
{
WeakReferenceMessenger.Default.Send(new NavigateMessage("SettingsView"));
}
}
// NavigationService (у MainWindow code-behind)
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
WeakReferenceMessenger.Default.Register<NavigateMessage>(this, (r, msg) =>
{
NavigateToView(msg.ViewName, msg.Parameter);
});
}
private void NavigateToView(string viewName, object parameter)
{
// TODO: Відкрити відповідне вікно
switch (viewName)
{
case "EditorView":
var editorWindow = new EditorWindow();
editorWindow.DataContext = new EditorViewModel((int)parameter);
editorWindow.Show();
break;
case "SettingsView":
var settingsWindow = new SettingsWindow();
settingsWindow.DataContext = new SettingsViewModel();
settingsWindow.Show();
break;
}
}
}
Тести:
[Test]
public void OpenEditor_ShouldSendNavigateMessage()
{
var vm = new MainViewModel();
bool messageReceived = false;
string receivedViewName = null;
int receivedItemId = 0;
WeakReferenceMessenger.Default.Register<NavigateMessage>(this, (r, msg) =>
{
messageReceived = true;
receivedViewName = msg.ViewName;
receivedItemId = (int)msg.Parameter;
});
vm.OpenEditorCommand.Execute(42);
Assert.IsTrue(messageReceived);
Assert.AreEqual("EditorView", receivedViewName);
Assert.AreEqual(42, receivedItemId);
WeakReferenceMessenger.Default.UnregisterAll(this);
}
Мета: Реалізувати запит-відповідь через Messenger.
Завдання:
Створіть систему діалогів підтвердження:
Title — заголовок діалогуMessage — текст повідомленняCloseCommand — перевіряє незбережені зміниConfirmationRequestConfirmationRequestMessageBox.Showmsg.Reply(result)Критерії успіху:
Підказка:
// Повідомлення-запит
public class ConfirmationRequest : RequestMessage<bool>
{
public string Title { get; }
public string Message { get; }
public ConfirmationRequest(string title, string message)
{
Title = title;
Message = message;
}
}
// EditorViewModel
public partial class EditorViewModel : ObservableObject
{
[ObservableProperty]
private bool _hasUnsavedChanges;
[RelayCommand]
private void Close()
{
if (HasUnsavedChanges)
{
var request = new ConfirmationRequest(
"Зберегти зміни?",
"У вас є незбережені зміни. Зберегти перед закриттям?"
);
WeakReferenceMessenger.Default.Send(request);
if (request.HasReceivedResponse && request.Response)
{
Save();
}
}
// Закрити вікно (через інше повідомлення або подію)
}
[RelayCommand]
private void Save()
{
// Логіка збереження
HasUnsavedChanges = false;
}
}
// DialogService (у MainWindow code-behind)
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
WeakReferenceMessenger.Default.Register<ConfirmationRequest>(this, (r, msg) =>
{
var result = MessageBox.Show(
msg.Message,
msg.Title,
MessageBoxButton.YesNo,
MessageBoxImage.Question
);
msg.Reply(result == MessageBoxResult.Yes);
});
}
}
Тести:
[Test]
public void Close_ShouldRequestConfirmation_WhenHasUnsavedChanges()
{
var vm = new EditorViewModel { HasUnsavedChanges = true };
bool requestReceived = false;
WeakReferenceMessenger.Default.Register<ConfirmationRequest>(this, (r, msg) =>
{
requestReceived = true;
Assert.AreEqual("Зберегти зміни?", msg.Title);
msg.Reply(true); // Симулюємо "Так"
});
vm.CloseCommand.Execute(null);
Assert.IsTrue(requestReceived);
Assert.IsFalse(vm.HasUnsavedChanges); // Має зберегти
WeakReferenceMessenger.Default.UnregisterAll(this);
}
[Test]
public void Close_ShouldNotSave_WhenUserDeclines()
{
var vm = new EditorViewModel { HasUnsavedChanges = true };
WeakReferenceMessenger.Default.Register<ConfirmationRequest>(this, (r, msg) =>
{
msg.Reply(false); // Симулюємо "Ні"
});
vm.CloseCommand.Execute(null);
Assert.IsTrue(vm.HasUnsavedChanges); // Не має зберігати
WeakReferenceMessenger.Default.UnregisterAll(this);
}
Messenger Pattern забезпечує loose coupling між ViewModel через центрального посередника.
Ключові висновки:
🎯 Mediator Pattern
🔗 WeakReferenceMessenger
📨 Типи повідомлень
⚡ StrongReferenceMessenger
🎨 Практичні сценарії
⚠️ Best Practices
Переваги Messenger:
Недоліки:
Що далі?
Ви завершили Block 7: MVVM! Наступний блок — Стилізація та шаблони:
MVVM Toolkit — MVVM без boilerplate через Source Generators
CommunityToolkit.Mvvm для автоматизації MVVM — [ObservableProperty], [RelayCommand], [NotifyPropertyChangedFor], ObservableValidator та Source Generators
Стилі WPF — CSS для десктопу
Глибоке занурення в систему стилів WPF. Implicit та Explicit стилі, BasedOn успадкування, область дії та пріоритети. Від аналогії з CSS до повної стилізації форми.