Открытие окна из vm окна через serviceProvider или свой велосипед

Есть пока две мысли по этому поводу:

  1. Имеется такая конфигурация дефолтного DI:
services.AddTransient<MainWindow>();
services.AddTransient<MainViewModel>();

получаю окно и открываю:

var view = _serviceProvider.GetService<MainWindow>();
view.Show();

В каждую зависимость прокидывается автоматически ServiceProvider если я попрошу его в конструкторе. И через него я точно также могу просить окно и открывать. Можно ли так через serviceprovider открывать окно из vm окна если требуется еще одно.

Или же 2. Придумать велосипед в виде отдельного оконного сервиса в котором запрашивается serviceprovider и, который будет сопоставлять в словаре vm и окно.

Пока писал появилась третья мысль сделать статический класс и метод, которые будут отвечать за получение окна и vm для него, просто в vm классах запрашивать у этого метода, нужное окно и открывать. Может ли появиться проблема с внедрением зависимостей туда?

Может быть есть уже готовое решение по этому поводу?

Спасибо за внимание)


Ответы (1 шт):

Автор решения: EvgeniyZ

В MVVM подходе ваш код должен быть максимально разделен на слои, которые должны быть мало связаны друг с другом. Самое важное в MVVM - это отделить UI от логики, то есть View от ViewModel и Model. Исходя из этого, вы не можете в VM слое напрямую вызвать что-то, что относиться к View слою. Создать окно, диалог, да даже MessageBox.Show() (MessageBox - это UI (View слой)) - это будет нарушением в MVVM. Все это, вы должны максимально абстрагировать и отделить от основной логики, отдав задачу на открытие окон некому, сервису.

Покажу простой пример

  • Создадим интерфейс, который опишет все нужные методы сервиса. В моем примере я покажу открытие окна и открытие диалогового окна (модальное).

    public interface IWindowService
    {
        void Show<TViewModel>();
        bool? ShowDialog<TViewModel>();
    }
    
  • Теперь реализуем сам сервис. Его реализовать можно по разному, основные подходы это:

    • Следование некому соглашению имен, где скажем MainWindow будет иметь VM с именем MainViewModel, а TestWindow - TestViewModel, и т.д. То есть, Window меняем на ViewModel.
    • Сделать свой "контейнер", который будет хранить в себе все сопоставления VM с их View, а при запуске приложения регистрировать туда все типы. (этот пример я показывал в комментариях).

    Я чуть заморочусь и пойду первым путем. Правда для примера он немного сложноват, но вы главное поймите принцип, а реализацию напишите какую хотите.

    public class WindowService(IServiceProvider provider) : IWindowService
    {
        public void Show<TViewModel>()
        {
            var viewModel = provider.GetRequiredService<TViewModel>();
            var window = CreateWindowFor<TViewModel>();
            window.DataContext = viewModel;
            window.Show();
        }
    
        public bool? ShowDialog<TViewModel>()
        {
            var viewModel = provider.GetRequiredService<TViewModel>();
            var window = CreateWindowFor<TViewModel>();
            window.DataContext = viewModel;
            return window.ShowDialog();
        }
    
        private Window CreateWindowFor<TViewModel>()
        {
            var vmName = typeof(TViewModel).Name;
            var windowTypeName = vmName.EndsWith("ViewModel")
                ? vmName.Replace("ViewModel", "Window")
                : vmName + "Window";
    
            var windowType = AppDomain.CurrentDomain
                .GetAssemblies()
                .SelectMany(a => a.GetTypes())
                .FirstOrDefault(t => t.Name == windowTypeName && typeof(Window).IsAssignableFrom(t));
    
            return provider.GetRequiredService(windowType) as Window;
        }
    }
    

    Как видите, тут не так все сложно. Создаем класс, наследуем его от интерфейса и реализуем все методы. Каждый метод принимает тип VM, который контейнер нам создает. В методе CreateWindowFor берется имя типа (MainViewModel), и меняется ViewModel на Window (получаем MainWindow), далее при помощи рефлексии находит тип окна по полученному имени, ну и отдаем его контейнеру, чтобы тот создал. P.S. Отлов ошибок специально не делал, ибо хотел максимально просто показать суть. Так что, сами допишите то, что надо.

  • Остается дело за малым, создаем окна, делаем для них свои VM, ну и регистрируем в контейнер. У меня будет MainWindow как стартовое окно, а также TestWindow в качестве подопытного. Весь класс App с регистрациями и открытием главного окна будет примерно следующим:

    public partial class App : Application
    {
        private readonly ServiceProvider _serviceProvider;
    
        public App()
        {
            var serviceCollection = new ServiceCollection();
            ConfigureServices(serviceCollection);
            _serviceProvider = serviceCollection.BuildServiceProvider();
        }
    
        private void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<IWindowService, WindowService>();
            services.AddTransient<MainWindow>();
            services.AddTransient<TestWindow>();
            services.AddTransient<MainViewModel>();
            services.AddTransient<TestViewModel>();
        }
    
        protected override void OnStartup(StartupEventArgs e)
        {
            base.OnStartup(e);
            var windowService = _serviceProvider.GetRequiredService<IWindowService>();
            windowService.Show<MainViewModel>();
        }
    
        protected override void OnExit(ExitEventArgs e)
        {
            base.OnExit(e);
            _serviceProvider?.Dispose();
        }
    }
    
  • На этом все. Теперь в любом месте вашего приложения вы можете запросить IWindowService и вызвать нужный метод, указав конкретную VM нужного окна. По аналогии можно подобным способом "обернуть" любой вызов и добиться любой реализации, хоть запрашивайте данные и возвращайте их обратно, ну или двигайте/сворачивайте/изменяйте окна. Тут уже ваша фантазия.

На последок приведу пример того, что это работает:

Класс MainViewModel:

internal partial class MainViewModel(IWindowService windowService)
{
    public string Text { get; } = "Привет из MainViewModel!";

    [RelayCommand]
    void Open()
    {
        windowService.Show<TestViewModel>();
    }

    [RelayCommand]
    void OpenModal()
    {
        windowService.ShowDialog<TestViewModel>();
    }
}

В TestViewModel аналогично, но со своим текстом и без команд. Команды если что реализованы при помощи CommunityToolkit.MVVM.

XAML MainWindow:

<Grid>
    <Grid HorizontalAlignment="Center" VerticalAlignment="Center">
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <Grid.RowDefinitions>
            <RowDefinition />
            <RowDefinition />
        </Grid.RowDefinitions>
        <TextBlock
            Grid.ColumnSpan="2"
            Margin="5"
            VerticalAlignment="Center"
            FontSize="20"
            FontWeight="Medium"
            Text="{Binding Text}" />
        <Button
            Grid.Row="1"
            Grid.Column="0"
            Margin="2,0"
            HorizontalAlignment="Stretch"
            Command="{Binding OpenCommand}"
            Content="Открыть" />
        <Button
            Grid.Row="1"
            Grid.Column="1"
            Margin="2,0"
            HorizontalAlignment="Stretch"
            Command="{Binding OpenModalCommand}"
            Content="Открыть модально" />
    </Grid>
</Grid>

Результат (не модальные окна):

Не модальные окна

Результат (модальное, основное окно, как и положено, заблокировано)

Модальное окно

→ Ссылка