Уявіть: ваш MainViewModel створює SettingsViewModel через new, SettingsViewModel створює SettingsService через new, SettingsService створює FileRepository через new. Все працює... поки не з'являється 20 залежностей. Поки не потрібно використати той самий UserService у 5 різних ViewModels. Поки не потрібно замінити реальний DatabaseService на mock для тестування.
Ручне створення залежностей (Poor Man's DI) швидко перетворюється на кошмар підтримки. Composition Root розмивається по всьому коду, тестування стає неможливим, зміна однієї залежності вимагає правок у десятках місць.
Рішення — Dependency Injection Container. У .NET екосистемі це Microsoft.Extensions.DependencyInjection — той самий DI, що використовується в ASP.NET Core. Ви реєструєте всі залежності в одному місці (Composition Root), а контейнер автоматично створює об'єкти з усіма їх залежностями. Змінити реалізацію? Один рядок коду. Замінити на mock? Один рядок коду. Додати нову залежність? Просто додайте параметр у конструктор.
У цій статті ми детально розберемо інтеграцію DI у WPF та Avalonia: від налаштування IServiceProvider до реєстрації ViewModels, від lifecycles (Transient/Singleton/Scoped) до тестування з mock-об'єктами.
Розглянемо типовий MVVM-додаток без DI:
public class MainViewModel
{
private readonly SettingsViewModel _settingsViewModel;
private readonly UserService _userService;
public MainViewModel()
{
// Ручне створення залежностей
var fileRepository = new FileRepository();
var settingsService = new SettingsService(fileRepository);
_settingsViewModel = new SettingsViewModel(settingsService);
var apiClient = new ApiClient();
_userService = new UserService(apiClient);
}
}
Проблеми:
// UserService залежить від ApiClient та Logger
public class UserService
{
public UserService(IApiClient apiClient, ILogger logger) { }
}
// MainViewModel створює UserService
public class MainViewModel
{
public MainViewModel()
{
var logger = new ConsoleLogger();
var apiClient = new ApiClient(logger); // ApiClient теж потребує Logger!
var userService = new UserService(apiClient, logger);
// Що якщо ApiClient потребує ще HttpClient?
// Що якщо Logger потребує FileWriter?
// Граф залежностей росте експоненційно...
}
}
Рішення: Dependency Injection Container автоматично резолвить весь граф залежностей.
Якщо ви не знайомі з DI або хочете освіжити знання, ось короткий огляд.
Без DI (залежність створюється всередині):
public class OrderService
{
private readonly EmailSender _emailSender;
public OrderService()
{
_emailSender = new EmailSender(); // Жорстка прив'язка
}
}
З DI (залежність передається ззовні):
public class OrderService
{
private readonly IEmailSender _emailSender;
public OrderService(IEmailSender emailSender) // Ін'єкція через конструктор
{
_emailSender = emailSender;
}
}
Переваги:
IEmailSender (EmailSender, MockEmailSender, SmsEmailSender)Замість ручного створення:
var emailSender = new EmailSender();
var orderService = new OrderService(emailSender);
Використовуємо контейнер:
// Реєстрація (один раз при старті)
services.AddSingleton<IEmailSender, EmailSender>();
services.AddTransient<OrderService>();
// Резолвінг (коли потрібно)
var orderService = serviceProvider.GetRequiredService<OrderService>();
// Контейнер автоматично створить EmailSender і передасть у OrderService
| Lifecycle | Опис | Коли використовувати |
|---|---|---|
| Transient | Новий екземпляр кожен раз | Легковагові об'єкти без стану (ViewModels, Commands) |
| Singleton | Один екземпляр на весь додаток | Сервіси зі станом, що має бути спільним (UserService, SettingsService) |
| Scoped | Один екземпляр на scope | У десктопних додатках рідко використовується (більше для веб) |
// Transient — новий кожен раз
services.AddTransient<MainViewModel>();
// Singleton — один на весь додаток
services.AddSingleton<IUserService, UserService>();
// Scoped — один на scope (рідко в десктопі)
services.AddScoped<IDatabaseContext, DatabaseContext>();
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting
Microsoft.Extensions.Hosting надає зручні методи для налаштування DI та конфігурації.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Windows;
public partial class App : Application
{
private IHost? _host;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// Створення Host з DI
_host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Реєстрація сервісів
ConfigureServices(services);
})
.Build();
// Отримання MainWindow через DI
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
private void ConfigureServices(IServiceCollection services)
{
// ViewModels (Transient — новий кожен раз)
services.AddTransient<MainViewModel>();
services.AddTransient<SettingsViewModel>();
services.AddTransient<UserListViewModel>();
// Services (Singleton — один на додаток)
services.AddSingleton<IUserService, UserService>();
services.AddSingleton<ISettingsService, SettingsService>();
services.AddSingleton<IApiClient, ApiClient>();
// Repositories
services.AddSingleton<IUserRepository, UserRepository>();
// Windows (Transient)
services.AddTransient<MainWindow>();
}
protected override void OnExit(ExitEventArgs e)
{
_host?.Dispose();
base.OnExit(e);
}
}
<!-- Видаліть StartupUri, бо ми створюємо MainWindow через DI -->
<Application x:Class="MyApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<!-- Resources -->
</Application.Resources>
</Application>
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
<!-- MainWindow.xaml -->
<Window x:Class="MyApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
Title="My App" Width="800" Height="600">
<Grid>
<TextBlock Text="{Binding Title}" FontSize="24"/>
<!-- UI прив'язується до ViewModel через Binding -->
</Grid>
</Window>
public class MainViewModel : ViewModelBase
{
private readonly IUserService _userService;
private readonly ISettingsService _settingsService;
public MainViewModel(IUserService userService, ISettingsService settingsService)
{
_userService = userService;
_settingsService = settingsService;
LoadDataCommand = new RelayCommand(LoadData);
}
public string Title => "Main Window";
public ICommand LoadDataCommand { get; }
private async void LoadData()
{
var users = await _userService.GetUsersAsync();
// ...
}
}
Що відбувається:
App.OnStartup() створює IHost з DIConfigureServices()MainWindow через GetRequiredService<MainWindow>()MainWindow потребує MainViewModelMainViewModel потребує IUserService та ISettingsServiceІноді потрібен доступ до DI з місць, де ін'єкція через конструктор неможлива (наприклад, у Converter або Markup Extension).
public partial class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) => ConfigureServices(services))
.Build();
Services = _host.Services; // Зберігаємо для глобального доступу
var mainWindow = Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
}
Використання:
// З будь-якого місця в коді
var userService = App.Services.GetRequiredService<IUserService>();
public static class ServiceLocator
{
private static IServiceProvider? _serviceProvider;
public static void Initialize(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public static T GetService<T>() where T : notnull
{
if (_serviceProvider == null)
throw new InvalidOperationException("ServiceLocator not initialized");
return _serviceProvider.GetRequiredService<T>();
}
}
// В App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
// ...
ServiceLocator.Initialize(_host.Services);
// ...
}
Avalonia має трохи інший підхід до життєвого циклу додатку.
dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting
using Avalonia;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public class Program
{
public static IHost? AppHost { get; private set; }
[STAThread]
public static void Main(string[] args)
{
// Створення Host з DI
AppHost = Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) =>
{
ConfigureServices(services);
})
.Build();
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace();
private static void ConfigureServices(IServiceCollection services)
{
// ViewModels
services.AddTransient<MainViewModel>();
services.AddTransient<SettingsViewModel>();
// Services
services.AddSingleton<IUserService, UserService>();
services.AddSingleton<ISettingsService, SettingsService>();
// Views
services.AddTransient<MainWindow>();
}
}
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Microsoft.Extensions.DependencyInjection;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Отримання MainWindow через DI
desktop.MainWindow = Program.AppHost!.Services.GetRequiredService<MainWindow>();
}
base.OnFrameworkInitializationCompleted();
}
}
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
}
Різниця з WPF:
App.OnStartup()Program.Main(), а MainWindow створюється в App.OnFrameworkInitializationCompleted()services.AddTransient<MainViewModel>();
// Кожен виклик створює новий екземпляр
var vm1 = serviceProvider.GetRequiredService<MainViewModel>();
var vm2 = serviceProvider.GetRequiredService<MainViewModel>();
// vm1 != vm2
Коли використовувати:
services.AddSingleton<IUserService, UserService>();
// Завжди повертається той самий екземпляр
var service1 = serviceProvider.GetRequiredService<IUserService>();
var service2 = serviceProvider.GetRequiredService<IUserService>();
// service1 == service2
Коли використовувати:
services.AddScoped<IDatabaseContext, DatabaseContext>();
using (var scope = serviceProvider.CreateScope())
{
var db1 = scope.ServiceProvider.GetRequiredService<IDatabaseContext>();
var db2 = scope.ServiceProvider.GetRequiredService<IDatabaseContext>();
// db1 == db2 (в межах одного scope)
}
using (var scope = serviceProvider.CreateScope())
{
var db3 = scope.ServiceProvider.GetRequiredService<IDatabaseContext>();
// db3 != db1 (новий scope — новий екземпляр)
}
Коли використовувати в десктопі:
Приклад: Scoped для діалогових вікон
private async void OpenDialog()
{
using var scope = App.Services.CreateScope();
var dialogViewModel = scope.ServiceProvider.GetRequiredService<DialogViewModel>();
var dialogWindow = new DialogWindow { DataContext = dialogViewModel };
dialogWindow.ShowDialog();
// Після закриття діалогу scope dispose, і всі Scoped сервіси очищаються
}
Замість ручного встановлення DataContext у code-behind, можна створити ViewModelLocator для автоматичного прив'язування.
public static class ViewModelLocator
{
public static readonly DependencyProperty AutoWireViewModelProperty =
DependencyProperty.RegisterAttached(
"AutoWireViewModel",
typeof(bool),
typeof(ViewModelLocator),
new PropertyMetadata(false, AutoWireViewModelChanged));
public static bool GetAutoWireViewModel(DependencyObject obj)
=> (bool)obj.GetValue(AutoWireViewModelProperty);
public static void SetAutoWireViewModel(DependencyObject obj, bool value)
=> obj.SetValue(AutoWireViewModelProperty, value);
private static void AutoWireViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if ((bool)e.NewValue && d is FrameworkElement element)
{
// Отримуємо тип ViewModel на основі імені View
var viewType = element.GetType();
var viewModelTypeName = viewType.FullName!.Replace("View", "ViewModel");
var viewModelType = viewType.Assembly.GetType(viewModelTypeName);
if (viewModelType != null)
{
var viewModel = App.Services.GetRequiredService(viewModelType);
element.DataContext = viewModel;
}
}
}
}
Використання:
<Window x:Class="MyApp.Views.MainWindow"
xmlns:local="clr-namespace:MyApp"
local:ViewModelLocator.AutoWireViewModel="True">
<!-- ViewModel автоматично прив'язується -->
</Window>
public class ViewModelExtension : MarkupExtension
{
public Type? ViewModelType { get; set; }
public override object ProvideValue(IServiceProvider serviceProvider)
{
if (ViewModelType == null)
{
// Визначаємо тип ViewModel на основі View
var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget))!;
var targetType = target.TargetObject.GetType();
var viewModelTypeName = targetType.FullName!.Replace("View", "ViewModel");
ViewModelType = targetType.Assembly.GetType(viewModelTypeName);
}
return App.Services.GetRequiredService(ViewModelType!);
}
}
Використання:
<Window x:Class="MyApp.Views.MainWindow"
DataContext="{local:ViewModel}">
<!-- ViewModel автоматично резолвиться через DI -->
</Window>
Одна з головних переваг DI — легке тестування через підміну реалізацій.
// Інтерфейс сервісу
public interface IUserService
{
Task<List<User>> GetUsersAsync();
}
// Реальна реалізація
public class UserService : IUserService
{
private readonly IApiClient _apiClient;
public UserService(IApiClient apiClient)
{
_apiClient = apiClient;
}
public async Task<List<User>> GetUsersAsync()
{
return await _apiClient.GetAsync<List<User>>("/users");
}
}
// ViewModel
public class UserListViewModel : ViewModelBase
{
private readonly IUserService _userService;
private ObservableCollection<User> _users = new();
public UserListViewModel(IUserService userService)
{
_userService = userService;
LoadUsersCommand = new AsyncRelayCommand(LoadUsers);
}
public ObservableCollection<User> Users
{
get => _users;
set => SetProperty(ref _users, value);
}
public IAsyncRelayCommand LoadUsersCommand { get; }
private async Task LoadUsers()
{
var users = await _userService.GetUsersAsync();
Users = new ObservableCollection<User>(users);
}
}
using Moq;
using Xunit;
public class UserListViewModelTests
{
[Fact]
public async Task LoadUsers_ShouldPopulateUsersCollection()
{
// Arrange
var mockUserService = new Mock<IUserService>();
mockUserService
.Setup(s => s.GetUsersAsync())
.ReturnsAsync(new List<User>
{
new User { Id = 1, Name = "Alice" },
new User { Id = 2, Name = "Bob" }
});
var viewModel = new UserListViewModel(mockUserService.Object);
// Act
await viewModel.LoadUsersCommand.ExecuteAsync(null);
// Assert
Assert.Equal(2, viewModel.Users.Count);
Assert.Equal("Alice", viewModel.Users[0].Name);
Assert.Equal("Bob", viewModel.Users[1].Name);
}
[Fact]
public async Task LoadUsers_ShouldCallServiceOnce()
{
// Arrange
var mockUserService = new Mock<IUserService>();
mockUserService
.Setup(s => s.GetUsersAsync())
.ReturnsAsync(new List<User>());
var viewModel = new UserListViewModel(mockUserService.Object);
// Act
await viewModel.LoadUsersCommand.ExecuteAsync(null);
// Assert
mockUserService.Verify(s => s.GetUsersAsync(), Times.Once);
}
}
Без DI це було б неможливо — ViewModel створював би UserService через new, і не було б способу підмінити його на mock.
Для великих проєктів ручна реєстрація кожного сервісу стає громіздкою. Можна автоматизувати через рефлексію.
private void ConfigureServices(IServiceCollection services)
{
// Автоматична реєстрація всіх ViewModels
var assembly = Assembly.GetExecutingAssembly();
var viewModelTypes = assembly.GetTypes()
.Where(t => t.Name.EndsWith("ViewModel") && !t.IsAbstract && !t.IsInterface);
foreach (var type in viewModelTypes)
{
services.AddTransient(type);
}
// Автоматична реєстрація всіх сервісів
var serviceTypes = assembly.GetTypes()
.Where(t => t.Name.EndsWith("Service") && !t.IsAbstract && !t.IsInterface);
foreach (var type in serviceTypes)
{
var interfaceType = type.GetInterfaces()
.FirstOrDefault(i => i.Name == $"I{type.Name}");
if (interfaceType != null)
{
services.AddSingleton(interfaceType, type);
}
else
{
services.AddSingleton(type);
}
}
}
// Атрибут для позначення lifecycle
[AttributeUsage(AttributeTargets.Class)]
public class ServiceAttribute : Attribute
{
public ServiceLifetime Lifetime { get; }
public ServiceAttribute(ServiceLifetime lifetime = ServiceLifetime.Singleton)
{
Lifetime = lifetime;
}
}
// Використання
[Service(ServiceLifetime.Singleton)]
public class UserService : IUserService
{
// ...
}
[Service(ServiceLifetime.Transient)]
public class MainViewModel : ViewModelBase
{
// ...
}
// Автоматична реєстрація
private void ConfigureServices(IServiceCollection services)
{
var assembly = Assembly.GetExecutingAssembly();
var typesWithAttribute = assembly.GetTypes()
.Where(t => t.GetCustomAttribute<ServiceAttribute>() != null);
foreach (var type in typesWithAttribute)
{
var attribute = type.GetCustomAttribute<ServiceAttribute>()!;
var interfaceType = type.GetInterfaces().FirstOrDefault();
if (interfaceType != null)
{
services.Add(new ServiceDescriptor(interfaceType, type, attribute.Lifetime));
}
else
{
services.Add(new ServiceDescriptor(type, type, attribute.Lifetime));
}
}
}
Мета: Навчитися налаштовувати DI у WPF/Avalonia проєкті.
Завдання:
Створіть простий додаток з DI:
MainWindow з MainViewModelIUserService інтерфейс та UserService реалізаціяUser модельIUserService.GetUsersAsync()Microsoft.Extensions.DependencyInjectionApp.xaml.cs (WPF) або Program.cs (Avalonia)Критерії успіху:
new для створення залежностейПідказка (App.xaml.cs для WPF):
public partial class App : Application
{
private IHost? _host;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_host = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
services.AddTransient<MainViewModel>();
services.AddSingleton<IUserService, UserService>();
services.AddTransient<MainWindow>();
})
.Build();
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
}
Мета: Навчитися використовувати Scoped lifecycle для діалогових вікон.
Завдання:
Створіть додаток з діалоговим вікном:
Критерії успіху:
Підказка:
private async void OpenDialog_Click(object sender, RoutedEventArgs e)
{
using var scope = App.Services.CreateScope();
var dialogViewModel = scope.ServiceProvider.GetRequiredService<DialogViewModel>();
var dialogWindow = new DialogWindow { DataContext = dialogViewModel };
var result = dialogWindow.ShowDialog();
if (result == true)
{
// Дані збережено
RefreshData();
}
// Scope dispose — IDatabaseService.Dispose() викликається автоматично
}
Мета: Навчитися тестувати ViewModels з mock-об'єктами.
Завдання:
Створіть unit-тести для ViewModel:
Критерії успіху:
Підказка:
[Fact]
public async Task LoadUsers_WhenServiceThrows_ShouldSetErrorMessage()
{
// Arrange
var mockUserService = new Mock<IUserService>();
mockUserService
.Setup(s => s.GetUsersAsync())
.ThrowsAsync(new Exception("Network error"));
var viewModel = new UserListViewModel(mockUserService.Object);
// Act
await viewModel.LoadUsersCommand.ExecuteAsync(null);
// Assert
Assert.NotNull(viewModel.ErrorMessage);
Assert.Contains("Network error", viewModel.ErrorMessage);
}
Dependency Injection трансформує архітектуру десктопних додатків, роблячи код тестованим, підтримуваним та гнучким. Microsoft.Extensions.DependencyInjection інтегрується в WPF та Avalonia природно, використовуючи ті ж принципи, що й ASP.NET Core.
Ключові висновки:
🎯 Composition Root
⚡ Lifecycles
🧪 Тестування
🔌 Гнучкість
Що далі?
Наступна стаття — SQLite та EF Core покаже, як зберігати дані локально через Entity Framework Core.
2D Графіка та Мультимедіа у WPF
Векторна графіка через Shapes та Path. Градієнти, геометрії, трансформації. MediaElement для відео та аудіо. Створення складних візуальних ефектів.
SQLite та EF Core у десктопних додатках
Локальне зберігання даних через SQLite. DbContext, міграції, seeding. Інтеграція з DI. IDbContextFactory для багатопоточності.