Перейти к основному содержимому

Практикум WPF — основы MVVM

Разработчику Архитектору

Практикум, шаг 2 из 6. Разделяем данные, логику представления и XAML. Теория паттернов в 112.md; локальный пример — 119.md.


Три роли MVVM

СлойTaskDeskЗапрещено
ModelTaskItem, статусы, валидация заголовкаСсылки на Button, MessageBox
ViewModelTaskListViewModel, списки, AddTaskCommand, вызов ITaskApiПрямой new HttpClient() в каждой команде без DI
ViewTaskListView.xaml, списки, поляРасчёт бизнес-правил в Click

View не знает о Model напрямую — только о свойствах ViewModel, удобных для отображения (DisplayStatus, IsBusy).


Model — данные и правила

namespace TaskDesk.Core.Models;

public enum TaskStatus { Todo, InProgress, Done }

public sealed class TaskItem
{
public Guid Id { get; init; } = Guid.NewGuid();
public required string Title { get; set; }
public TaskStatus Status { get; set; } = TaskStatus.Todo;
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;

public bool IsValid() =>
!string.IsNullOrWhiteSpace(Title) && Title.Length <= 200;
}

Model живёт в TaskDesk.Core — его подключают и API, и клиент (DTO можно маппить отдельно).


ViewModel — INotifyPropertyChanged

UI обновляется, когда ViewModel сообщает об изменении свойства:

public class TaskListViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;

private string _newTitle = "";
public string NewTitle
{
get => _newTitle;
set
{
if (_newTitle == value) return;
_newTitle = value;
PropertyChanged?.Invoke(this, new(nameof(NewTitle)));
AddTaskCommand.RaiseCanExecuteChanged();
}
}

public ObservableCollection<TaskItemViewModel> Tasks { get; } = new();
public RelayCommand AddTaskCommand { get; }

public TaskListViewModel(ITaskRepository repository)
{
_repository = repository;
AddTaskCommand = new RelayCommand(ExecuteAdd, CanAdd);
}
}

ObservableCollection<T> сам уведомляет список об добавлении и удалении — ListBox перерисовывается без ручного Items.Refresh().


Команды — ICommand вместо Click

public sealed class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;

public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute;
_canExecute = canExecute;
}

public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
public event EventHandler? CanExecuteChanged;

public void RaiseCanExecuteChanged() =>
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

В XAML:

<Button Content="Добавить задачу" Command="{Binding AddTaskCommand}"/>

Кнопка автоматически неактивна, когда CanExecute возвращает false (пустой заголовок).


CommunityToolkit.Mvvm — меньше boilerplate

Пакет CommunityToolkit.Mvvm (официальный, MIT) генерирует код через source generators:

dotnet add package CommunityToolkit.Mvvm
public partial class TaskListViewModel : ObservableObject
{
private readonly ITaskRepository _repository;

[ObservableProperty]
private string _newTitle = "";

public ObservableCollection<TaskItemViewModel> Tasks { get; } = new();

[RelayCommand(CanExecute = nameof(CanAddTask))]
private async Task AddTaskAsync()
{
var item = new TaskItem { Title = NewTitle.Trim() };
await _repository.CreateAsync(item);
Tasks.Add(TaskItemViewModel.FromModel(item));
NewTitle = "";
}

private bool CanAddTask() => !string.IsNullOrWhiteSpace(NewTitle);
}

[ObservableProperty] создаёт свойство с уведомлениями; [RelayCommand] — асинхронную команду с CanExecute.


Асинхронность и IsBusy

Сетевые вызовы в TaskDesk не блокируют UI:

[ObservableProperty]
private bool _isBusy;

[RelayCommand]
private async Task LoadTasksAsync()
{
if (IsBusy) return;
IsBusy = true;
try
{
var items = await _repository.GetAllAsync();
Tasks.Clear();
foreach (var t in items)
Tasks.Add(TaskItemViewModel.FromModel(t));
}
finally
{
IsBusy = false;
}
}

В View индикатор:

<ProgressBar IsIndeterminate="True" Height="4"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVis}}"/>

Сервисный слой — ITaskRepository

ViewModel не вызывает HTTP напрямую в финальной архитектуре — только интерфейс:

public interface ITaskRepository
{
Task<IReadOnlyList<TaskItem>> GetAllAsync(CancellationToken ct = default);
Task<TaskItem> CreateAsync(TaskItem task, CancellationToken ct = default);
Task UpdateAsync(TaskItem task, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}

Реализации:

  • InMemoryTaskRepository — для unit-тестов и офлайн-прототипа;
  • ApiTaskRepositoryHttpClient к TaskDesk.Api (шаг 4).

Так ViewModel тестируется с моком репозитория без окна и без сервера.


View — только привязки

<UserControl x:Class="TaskDesk.Client.Views.TaskListView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<TextBox Width="280" Text="{Binding NewTitle, UpdateSourceTrigger=PropertyChanged}"/>
<Button Margin="8,0,0,0" Content="Добавить" Command="{Binding AddTaskCommand}"/>
</StackPanel>
<ListBox Grid.Row="1" ItemsSource="{Binding Tasks}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Title}" FontWeight="SemiBold"/>
<TextBlock Text="{Binding StatusLabel}" Opacity="0.7"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</UserControl>

DataContext задаёт Prism при навигации (шаг 4) или вручную в MainWindow.


Типичные ошибки MVVM

СимптомПричинаРешение
UI не обновляетсяНет INotifyPropertyChangedToolkit или ручные уведомления
Кнопка всегда сераяCanExecute не пересчитываетсяRaiseCanExecuteChanged при изменении полей
Дубликаты в спискеДобавление до ответа APIЖдать CreateAsync, один источник правды
Краш при стартеDataContext nullРегистрация VM в DI, проверка привязок в Output

Чек-лист шага 2

  • Model без ссылок на WPF-сборки.
  • ViewModel с командами и ObservableCollection.
  • View без обработчиков Click с бизнес-логикой.
  • Интерфейс репозитория для будущего API.

Дальше: Сервер на ASP.NET Core Web API.


См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").