MVVM en WPF : Guide pratique avec exemples

Le pattern MVVM est la clé d'applications WPF maintenables. Voici un guide terrain avec les patterns que j'utilise quotidiennement.

Pourquoi MVVM ?

MVVM (Model-View-ViewModel) sépare votre application en trois couches :

Avantages : testabilité, maintenabilité, séparation claire des responsabilités.

Structure de projet recommandée

MonApp/
├── Models/
│   └── Product.cs
├── ViewModels/
│   ├── BaseViewModel.cs
│   ├── MainViewModel.cs
│   └── ProductViewModel.cs
├── Views/
│   ├── MainWindow.xaml
│   └── ProductView.xaml
├── Services/
│   └── IProductService.cs
├── Commands/
│   └── RelayCommand.cs
└── App.xaml

BaseViewModel

La classe de base pour tous vos ViewModels :

public abstract class BaseViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
            return false;

        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }
}

RelayCommand

Pour gérer les actions utilisateur :

public class RelayCommand : ICommand
{
    private readonly Action<object?> _execute;
    private readonly Predicate<object?>? _canExecute;

    public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
    {
        _execute = execute ?? throw new ArgumentNullException(nameof(execute));
        _canExecute = canExecute;
    }

    public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;

    public void Execute(object? parameter) => _execute(parameter);

    public event EventHandler? CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }
}

Exemple complet : ProductViewModel

public class ProductViewModel : BaseViewModel
{
    private readonly IProductService _productService;

    private string _name = string.Empty;
    private decimal _price;
    private bool _isLoading;

    public ProductViewModel(IProductService productService)
    {
        _productService = productService;
        SaveCommand = new RelayCommand(ExecuteSave, CanSave);
        LoadCommand = new RelayCommand(async _ => await LoadAsync());
    }

    public string Name
    {
        get => _name;
        set => SetProperty(ref _name, value);
    }

    public decimal Price
    {
        get => _price;
        set => SetProperty(ref _price, value);
    }

    public bool IsLoading
    {
        get => _isLoading;
        set => SetProperty(ref _isLoading, value);
    }

    public ICommand SaveCommand { get; }
    public ICommand LoadCommand { get; }

    private bool CanSave(object? _) => !string.IsNullOrEmpty(Name) && Price > 0;

    private async void ExecuteSave(object? _)
    {
        IsLoading = true;
        try
        {
            await _productService.SaveAsync(new Product { Name = Name, Price = Price });
        }
        finally
        {
            IsLoading = false;
        }
    }

    private async Task LoadAsync()
    {
        IsLoading = true;
        try
        {
            var product = await _productService.GetAsync();
            Name = product.Name;
            Price = product.Price;
        }
        finally
        {
            IsLoading = false;
        }
    }
}

La View (XAML)

<Window x:Class="MonApp.Views.ProductView"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Grid Margin="20">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
            <RowDefinition Height="Auto"/>
        </Grid.RowDefinitions>

        <TextBox Grid.Row="0"
                 Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
                 Margin="0,0,0,10"/>

        <TextBox Grid.Row="1"
                 Text="{Binding Price, StringFormat=N2}"
                 Margin="0,0,0,10"/>

        <Button Grid.Row="2"
                Content="Sauvegarder"
                Command="{Binding SaveCommand}"
                IsEnabled="{Binding IsLoading, Converter={StaticResource InverseBoolConverter}}"/>
    </Grid>
</Window>

Dependency Injection

Avec Microsoft.Extensions.DependencyInjection :

public partial class App : Application
{
    private readonly IServiceProvider _serviceProvider;

    public App()
    {
        var services = new ServiceCollection();
        ConfigureServices(services);
        _serviceProvider = services.BuildServiceProvider();
    }

    private void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<IProductService, ProductService>();
        services.AddTransient<ProductViewModel>();
        services.AddTransient<MainWindow>();
    }

    protected override void OnStartup(StartupEventArgs e)
    {
        var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
        mainWindow.Show();
    }
}
Conseil : Utilisez CommunityToolkit.Mvvm pour éviter le boilerplate. Il génère automatiquement les notifications et commandes avec des attributs.

Conclusion

MVVM demande un investissement initial mais paie sur le long terme. Applications testables, maintenables, évolutives. C'est le standard pour le développement WPF professionnel.

Besoin d'aide sur votre projet WPF ? Contactez-moi.

Davy Abderrahman

Davy Abderrahman

Expert WPF et MVVM. 15+ ans d'expérience en développement d'applications desktop.

En savoir plus