Уявіть ситуацію: ви створили WPF додаток. Спочатку все було просто — кілька кнопок, кілька TextBox-ів, трохи логіки у code-behind. Але проєкт ріс:
// MainWindow.xaml.cs — 500 рядків коду
public partial class MainWindow : Window
{
private List<Person> _people = new List<Person>();
private Person _selectedPerson;
private void LoadData_Click(object sender, RoutedEventArgs e)
{
// Завантаження даних з БД
_people = Database.GetPeople();
lstPeople.ItemsSource = _people;
}
private void Save_Click(object sender, RoutedEventArgs e)
{
// Валідація
if (string.IsNullOrWhiteSpace(txtFirstName.Text))
{
MessageBox.Show("Введіть ім'я!");
return;
}
// Збереження
var person = new Person
{
FirstName = txtFirstName.Text,
LastName = txtLastName.Text,
Age = int.Parse(txtAge.Text)
};
Database.SavePerson(person);
LoadData_Click(null, null); // Перезавантаження списку
}
private void Delete_Click(object sender, RoutedEventArgs e)
{
if (_selectedPerson != null)
{
Database.DeletePerson(_selectedPerson.Id);
LoadData_Click(null, null);
}
}
private void Search_TextChanged(object sender, TextChangedEventArgs e)
{
var query = txtSearch.Text.ToLower();
lstPeople.ItemsSource = _people.Where(p =>
p.FirstName.ToLower().Contains(query) ||
p.LastName.ToLower().Contains(query)
).ToList();
}
// ... ще 400 рядків подібного коду
}
Проблеми:
Save_Click? Потрібен весь UIПитання:
Рішення: MVVM (Model-View-ViewModel) — архітектурний патерн, що розділяє відповідальність між компонентами.
Розберемо детально, чому code-behind — це антипатерн для складних додатків.
Проблема: UI залежить від логіки, логіка залежить від UI.
private void Save_Click(object sender, RoutedEventArgs e)
{
// Читання з UI
var firstName = txtFirstName.Text;
var lastName = txtLastName.Text;
// Бізнес-логіка
var person = new Person { FirstName = firstName, LastName = lastName };
Database.SavePerson(person);
// Оновлення UI
lstPeople.ItemsSource = Database.GetPeople();
txtFirstName.Clear();
txtLastName.Clear();
}
Що не так?
txtFirstName) прямо використовуються у логіціlstPeople.ItemsSource)Проблема: Як написати unit-test для Save_Click?
[Test]
public void Save_ShouldSavePersonToDatabase()
{
// ❌ Як створити MainWindow без запуску всього UI?
var window = new MainWindow();
// ❌ Як встановити текст у TextBox?
window.txtFirstName.Text = "Іван";
// ❌ Як викликати Save_Click без реального кліку?
window.Save_Click(null, null);
// ❌ Як перевірити, що дані збережені?
// Потрібен доступ до приватних полів
}
Проблеми:
Проблема: Та сама логіка у двох вікнах.
// MainWindow.xaml.cs
private void Save_Click(object sender, RoutedEventArgs e)
{
if (string.IsNullOrWhiteSpace(txtFirstName.Text))
{
MessageBox.Show("Введіть ім'я!");
return;
}
var person = new Person { FirstName = txtFirstName.Text };
Database.SavePerson(person);
}
// EditWindow.xaml.cs
private void Update_Click(object sender, RoutedEventArgs e)
{
// ❌ Копіювання тієї самої валідації
if (string.IsNullOrWhiteSpace(txtFirstName.Text))
{
MessageBox.Show("Введіть ім'я!");
return;
}
var person = new Person { FirstName = txtFirstName.Text };
Database.UpdatePerson(person);
}
Проблеми:
Проблема: Зміна однієї речі ламає три інші.
// Додаємо нове поле "Email"
private void Save_Click(object sender, RoutedEventArgs e)
{
var person = new Person
{
FirstName = txtFirstName.Text,
LastName = txtLastName.Text,
Email = txtEmail.Text // Нове поле
};
Database.SavePerson(person);
// ❌ Забули оновити Search_TextChanged
// ❌ Забули оновити Delete_Click
// ❌ Забули оновити EditWindow
}
MVVM — це не перший патерн для розділення UI та логіки. Розберемо історію.
Походження: Smalltalk (Trygve Reenskaug, Xerox PARC).
Структура:
Проблема для WPF: Controller тісно зв'язаний з View. Не використовує Data Binding.
Походження: WinForms era (Microsoft).
Структура:
IViewПроблема для WPF: Presenter знає View (через інтерфейс). Не використовує Data Binding повною мірою.
Походження: John Gossman (Microsoft) для WPF.
Чому MVVM ідеальний для XAML-платформ?
🔗 Data Binding як клей
📢 INotifyPropertyChanged
⚡ Commands
🎨 Дизайнер + Розробник
Структура:
Ключова відмінність: ViewModel не знає View. Зв'язок тільки через Data Binding.
Розберемо детально кожен компонент та його відповідальність.
Відповідальність:
Що НЕ робить Model:
INotifyPropertyChanged (це відповідальність ViewModel)Приклади Model:
1. Entity (доменний об'єкт):
public class Person
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime BirthDate { get; set; }
public string Email { get; set; }
// Бізнес-логіка
public int Age => DateTime.Now.Year - BirthDate.Year;
public bool IsAdult => Age >= 18;
}
2. Service (бізнес-логіка):
public class PersonService
{
private readonly IPersonRepository _repository;
public PersonService(IPersonRepository repository)
{
_repository = repository;
}
public async Task<List<Person>> GetAllPeopleAsync()
{
return await _repository.GetAllAsync();
}
public async Task SavePersonAsync(Person person)
{
// Валідація
if (string.IsNullOrWhiteSpace(person.FirstName))
throw new ValidationException("Ім'я обов'язкове");
if (person.Age < 0)
throw new ValidationException("Вік не може бути від'ємним");
// Збереження
await _repository.SaveAsync(person);
}
}
3. Repository (доступ до даних):
public interface IPersonRepository
{
Task<List<Person>> GetAllAsync();
Task<Person> GetByIdAsync(int id);
Task SaveAsync(Person person);
Task DeleteAsync(int id);
}
public class PersonRepository : IPersonRepository
{
private readonly DbContext _context;
public PersonRepository(DbContext context)
{
_context = context;
}
public async Task<List<Person>> GetAllAsync()
{
return await _context.People.ToListAsync();
}
// ... інші методи
}
Ключова ідея: Model — це чистий C# без залежності від UI-фреймворку. Можна використовувати у консольному додатку, Web API, мобільному додатку.
Відповідальність:
Що НЕ робить View:
Приклад View:
XAML:
<Window x:Class="MyApp.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:MyApp.ViewModels"
Title="Контакти" Width="800" Height="600">
<!-- DataContext встановлюється у code-behind або через DI -->
<Grid Margin="20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<!-- Список контактів -->
<StackPanel Grid.Column="0">
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,10"/>
<ListBox ItemsSource="{Binding People}"
SelectedItem="{Binding SelectedPerson}"
Height="400"/>
<Button Content="Додати"
Command="{Binding AddCommand}"
Margin="0,10,0,0"/>
</StackPanel>
<!-- Деталі вибраного контакту -->
<Border Grid.Column="1"
Background="LightGray"
Padding="20"
Margin="10,0,0,0">
<StackPanel>
<TextBlock Text="Ім'я:"/>
<TextBox Text="{Binding SelectedPerson.FirstName}"/>
<TextBlock Text="Прізвище:" Margin="0,10,0,0"/>
<TextBox Text="{Binding SelectedPerson.LastName}"/>
<Button Content="Зберегти"
Command="{Binding SaveCommand}"
Margin="0,20,0,0"/>
</StackPanel>
</Border>
</Grid>
</Window>
Code-Behind (мінімальний):
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
// Встановлення DataContext
DataContext = new MainViewModel();
}
}
Що дозволено у code-behind:
✅ InitializeComponent()
✅ Встановлення DataContext
✅ UI-специфічна логіка (анімації, фокус):
private void TextBox_GotFocus(object sender, RoutedEventArgs e)
{
// Виділити весь текст при фокусі
((TextBox)sender).SelectAll();
}
✅ Складні UI-операції (прокрутка, діалоги):
private void ScrollToBottom_Click(object sender, RoutedEventArgs e)
{
scrollViewer.ScrollToBottom();
}
Що НЕ дозволено:
❌ Бізнес-логіка (валідація, обчислення) ❌ Доступ до БД/API ❌ Складна логіка обробки даних
Відповідальність:
INotifyPropertyChangedЩо НЕ робить ViewModel:
Приклад ViewModel:
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
public class MainViewModel : INotifyPropertyChanged
{
private readonly PersonService _personService;
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
// Властивості для Data Binding
private ObservableCollection<Person> _people;
public ObservableCollection<Person> People
{
get => _people;
set
{
_people = value;
OnPropertyChanged();
}
}
private Person _selectedPerson;
public Person SelectedPerson
{
get => _selectedPerson;
set
{
_selectedPerson = value;
OnPropertyChanged();
}
}
private string _searchQuery;
public string SearchQuery
{
get => _searchQuery;
set
{
_searchQuery = value;
OnPropertyChanged();
ApplyFilter();
}
}
// Commands
public ICommand AddCommand { get; }
public ICommand SaveCommand { get; }
public ICommand DeleteCommand { get; }
// Конструктор
public MainViewModel()
{
_personService = new PersonService(new PersonRepository());
// Ініціалізація команд
AddCommand = new RelayCommand(Add);
SaveCommand = new RelayCommand(Save, CanSave);
DeleteCommand = new RelayCommand(Delete, CanDelete);
// Завантаження даних
LoadData();
}
// Методи
private async void LoadData()
{
var people = await _personService.GetAllPeopleAsync();
People = new ObservableCollection<Person>(people);
}
private void Add()
{
var newPerson = new Person { FirstName = "Новий", LastName = "Користувач" };
People.Add(newPerson);
SelectedPerson = newPerson;
}
private async void Save()
{
if (SelectedPerson != null)
{
await _personService.SavePersonAsync(SelectedPerson);
}
}
private bool CanSave()
{
return SelectedPerson != null &&
!string.IsNullOrWhiteSpace(SelectedPerson.FirstName);
}
private void Delete()
{
if (SelectedPerson != null)
{
People.Remove(SelectedPerson);
}
}
private bool CanDelete()
{
return SelectedPerson != null;
}
private void ApplyFilter()
{
// Фільтрація через ICollectionView (детальніше у наступних статтях)
}
}
Ключові моменти:
PersonServiceРозберемо, як дані рухаються між компонентами.
View → ViewModel:
ViewModel → View:
ViewModel → Model:
Model → ViewModel:
// ✅ Правильно
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
}
}
<!-- ✅ Правильно -->
<TextBox Text="{Binding FirstName}"/>
<Button Command="{Binding SaveCommand}"/>
// ❌ НЕПРАВИЛЬНО
public class MainViewModel
{
private MainWindow _view; // ❌ Посилання на View
public void Save()
{
_view.txtFirstName.Text = "..."; // ❌ Маніпуляція UI
}
}
// ✅ Правильно
public class MainViewModel
{
private string _firstName;
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
OnPropertyChanged();
}
}
public void Save()
{
// Логіка без посилань на UI
}
}
// ❌ НЕПРАВИЛЬНО
public class PersonService
{
private MainViewModel _viewModel; // ❌ Залежність від ViewModel
public void SavePerson(Person person)
{
// ...
_viewModel.UpdateUI(); // ❌
}
}
// ✅ Правильно
public class PersonService
{
public async Task SavePersonAsync(Person person)
{
// Чистий C# без залежностей від UI
await _repository.SaveAsync(person);
}
}
// ❌ НЕПРАВИЛЬНО — бізнес-логіка у ViewModel
public class MainViewModel
{
public void Save()
{
if (string.IsNullOrWhiteSpace(FirstName))
throw new Exception("Ім'я обов'язкове");
if (Age < 0 || Age > 150)
throw new Exception("Некоректний вік");
// ... складна валідація
}
}
// ✅ Правильно — бізнес-логіка у Model
public class PersonService
{
public void ValidatePerson(Person person)
{
if (string.IsNullOrWhiteSpace(person.FirstName))
throw new ValidationException("Ім'я обов'язкове");
if (person.Age < 0 || person.Age > 150)
throw new ValidationException("Некоректний вік");
}
}
public class MainViewModel
{
public async void Save()
{
try
{
await _personService.SavePersonAsync(SelectedPerson);
}
catch (ValidationException ex)
{
ErrorMessage = ex.Message; // UI-логіка
}
}
}
Для студентів зі слабким розумінням ООП — коротке нагадування ключових концепцій, що використовуються у MVVM.
Що таке інтерфейс?
Інтерфейс — це контракт, який клас зобов'язується виконати. Інтерфейс визначає що клас має робити, але не як.
// Інтерфейс — контракт
public interface INotifyPropertyChanged
{
// Клас має мати цю подію
event PropertyChangedEventHandler PropertyChanged;
}
// Клас, що виконує контракт
public class MainViewModel : INotifyPropertyChanged
{
// Реалізація контракту
public event PropertyChangedEventHandler PropertyChanged;
// Тепер клас "обіцяє" повідомляти про зміни
}
Чому це важливо для MVVM?
INotifyPropertyChanged — контракт: "Я обіцяю повідомити UI про зміни властивостей"INotifyPropertyChanged, може бути ViewModelАналогія: Інтерфейс — це як договір. INotifyPropertyChanged — це договір між ViewModel та WPF: "Я обіцяю повідомити тебе, коли мої дані зміняться".
Що таке поліморфізм?
Поліморфізм — це можливість використовувати об'єкти різних типів через спільний інтерфейс.
// Базовий інтерфейс
public interface INotifyPropertyChanged
{
event PropertyChangedEventHandler PropertyChanged;
}
// Різні реалізації
public class MainViewModel : INotifyPropertyChanged { /* ... */ }
public class SettingsViewModel : INotifyPropertyChanged { /* ... */ }
public class LoginViewModel : INotifyPropertyChanged { /* ... */ }
// WPF не знає конкретний тип — працює через інтерфейс
public void SetDataContext(INotifyPropertyChanged viewModel)
{
DataContext = viewModel; // Може бути будь-який ViewModel
}
Чому це важливо для MVVM?
INotifyPropertyChangedЩо таке подія (Event)?
Подія — це механізм сповіщення у C#. Об'єкт може "викликати" подію, а інші об'єкти можуть "підписатися" на неї.
// Клас з подією
public class MainViewModel
{
// Подія — список підписників
public event PropertyChangedEventHandler PropertyChanged;
public string FirstName
{
get => _firstName;
set
{
_firstName = value;
// Викликаємо подію — повідомляємо всіх підписників
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FirstName)));
}
}
}
// Підписник (WPF Binding Engine)
var viewModel = new MainViewModel();
viewModel.PropertyChanged += (sender, e) =>
{
// Обробка зміни — оновлення UI
Console.WriteLine($"Властивість {e.PropertyName} змінилася!");
};
Чому це важливо для MVVM?
PropertyChangedАналогія: Подія — це як дзвінок. ViewModel дзвонить (викликає подію), а WPF відповідає (оновлює UI).
Мета: Навчитися розділяти відповідальність між компонентами MVVM.
Завдання:
Для простого калькулятора визначте, що належить до Model, View та ViewModel:
Функціональність калькулятора:
Питання:
Критерії успіху:
Підказка:
Model:
public class Calculator
{
public double Add(double a, double b) => a + b;
public double Subtract(double a, double b) => a - b;
public double Multiply(double a, double b) => a * b;
public double Divide(double a, double b) => b != 0 ? a / b : throw new DivideByZeroException();
}
public class CalculationHistory
{
private List<string> _history = new List<string>();
public void AddEntry(string entry)
{
_history.Add(entry);
}
public List<string> GetHistory() => _history;
}
Мета: Практично застосувати MVVM до реального додатку.
Завдання:
У вас є Todo-додаток з code-behind (500 рядків). Перенесіть його на MVVM:
Поточний code-behind (антипатерн):
public partial class MainWindow : Window
{
private List<TodoItem> _todos = new List<TodoItem>();
private void Add_Click(object sender, RoutedEventArgs e)
{
var todo = new TodoItem { Title = txtTitle.Text };
_todos.Add(todo);
lstTodos.ItemsSource = null;
lstTodos.ItemsSource = _todos;
}
private void Delete_Click(object sender, RoutedEventArgs e)
{
var selected = (TodoItem)lstTodos.SelectedItem;
_todos.Remove(selected);
lstTodos.ItemsSource = null;
lstTodos.ItemsSource = _todos;
}
// ... ще 400 рядків
}
Вимоги до MVVM версії:
TodoItem (Title, IsCompleted, DueDate)TodoService (валідація, збереження)MainViewModel з INotifyPropertyChangedObservableCollection<TodoItem> для спискуInitializeComponent())Критерії успіху:
Підказка для ViewModel:
public class MainViewModel : INotifyPropertyChanged
{
private readonly TodoService _todoService;
public ObservableCollection<TodoItem> Todos { get; set; }
private string _newTodoTitle;
public string NewTodoTitle
{
get => _newTodoTitle;
set
{
_newTodoTitle = value;
OnPropertyChanged();
}
}
public ICommand AddCommand { get; }
public ICommand DeleteCommand { get; }
public MainViewModel()
{
_todoService = new TodoService();
Todos = new ObservableCollection<TodoItem>();
AddCommand = new RelayCommand(Add, CanAdd);
DeleteCommand = new RelayCommand<TodoItem>(Delete);
}
private void Add()
{
var todo = new TodoItem { Title = NewTodoTitle };
Todos.Add(todo);
NewTodoTitle = string.Empty;
}
private bool CanAdd()
{
return !string.IsNullOrWhiteSpace(NewTodoTitle);
}
private void Delete(TodoItem todo)
{
Todos.Remove(todo);
}
// ... INotifyPropertyChanged implementation
}
Мета: Зрозуміти переваги MVVM для тестування.
Завдання:
Напишіть unit-тести для двох версій Todo-додатку:
1. Code-behind версія (неможливо протестувати):
Спробуйте написати тест:
[Test]
public void Add_ShouldAddTodoToList()
{
// ❌ Як створити MainWindow без UI?
var window = new MainWindow();
// ❌ Як встановити текст у TextBox?
window.txtTitle.Text = "Нове завдання";
// ❌ Як викликати Add_Click без реального кліку?
window.Add_Click(null, null);
// ❌ Як перевірити, що завдання додано?
// Потрібен доступ до приватного поля _todos
}
2. MVVM версія (легко тестувати):
Напишіть тести:
[Test]
public void Add_ShouldAddTodoToCollection()
{
// ✅ Створюємо ViewModel без UI
var viewModel = new MainViewModel();
// ✅ Встановлюємо властивість
viewModel.NewTodoTitle = "Нове завдання";
// ✅ Викликаємо команду
viewModel.AddCommand.Execute(null);
// ✅ Перевіряємо результат
Assert.AreEqual(1, viewModel.Todos.Count);
Assert.AreEqual("Нове завдання", viewModel.Todos[0].Title);
}
[Test]
public void Add_ShouldClearNewTodoTitle()
{
var viewModel = new MainViewModel();
viewModel.NewTodoTitle = "Нове завдання";
viewModel.AddCommand.Execute(null);
Assert.IsEmpty(viewModel.NewTodoTitle);
}
[Test]
public void AddCommand_ShouldBeDisabled_WhenTitleIsEmpty()
{
var viewModel = new MainViewModel();
viewModel.NewTodoTitle = "";
Assert.IsFalse(viewModel.AddCommand.CanExecute(null));
}
[Test]
public void Delete_ShouldRemoveTodoFromCollection()
{
var viewModel = new MainViewModel();
var todo = new TodoItem { Title = "Завдання" };
viewModel.Todos.Add(todo);
viewModel.DeleteCommand.Execute(todo);
Assert.AreEqual(0, viewModel.Todos.Count);
}
Критерії успіху:
Висновок: Порівняйте складність тестування code-behind vs MVVM. Зробіть висновок про переваги MVVM для testability.
MVVM — це архітектурний патерн, що розділяє відповідальність між компонентами та робить код тестованим, підтримуваним та масштабованим.
Ключові висновки:
🏗️ Три компоненти
🔗 Data Binding як клей
✅ Testability
♻️ Reusability
👥 Team collaboration
📏 Золоті правила
Переваги MVVM:
Недоліки MVVM:
Що далі?
.xaml.cs. У MVVM має бути мінімальним.Data Binding — механізм зв'язування UI та даних. Ключовий елемент MVVM.Collections Binding Part 2 — ICollectionView, Filtering, Sorting та Virtualization
Сортування, фільтрація та групування колекцій без зміни оригінальних даних через ICollectionView та оптимізація продуктивності через віртуалізацію
ViewModel Implementation — Від BaseViewModel до валідації
Практична реалізація ViewModel — створення BaseViewModel, SetProperty<T>, обчислювані властивості, валідація через INotifyDataErrorInfo та DesignTime дані