如何修复循环依赖项



所以我最近才开始在我的 WPF 项目中使用Microsoft.Extensions.DependencyInjectionnuget 包,因为我想开始了解有关 DI 的更多信息。

问题

每当我尝试从除MainViewModel之外的任何其他ViewModel访问依赖项时,我都会不断得到Circular Dependency Exception


这就是我到目前为止所做的。

我已将这两个 Nuget 包安装到我的项目中

Microsoft.Extensions.Hosting --version 7.0.0

Microsoft.Extensions.DependencyInjection --version 7.0.0

然后我继续在我的App.xaml.cs里创建了我的容器

public partial class App : Application
{
private readonly ServiceProvider _serviceProvider;
public App()
{
IServiceCollection _services = new ServiceCollection();

_services.AddSingleton<MainViewModel>();
_services.AddSingleton<HomeViewModel>();
_services.AddSingleton<SettingsViewModel>();

_services.AddSingleton<DataService>();
_services.AddSingleton<NavService>();

_services.AddSingleton<MainWindow>(o => new MainWindow
{
DataContext = o.GetRequiredService<MainViewModel>()
});
_serviceProvider = _services.BuildServiceProvider();
}
protected override void OnStartup(StartupEventArgs e)
{
var MainWindow = _serviceProvider.GetRequiredService<MainWindow>();
MainWindow.Show();
base.OnStartup(e);
}
}

在我的App.xaml中,我还定义了一些DataTemplates,这些将允许我根据其DataType显示不同的视图

<Application.Resources>
<DataTemplate DataType="{x:Type viewModel:HomeViewModel}">
<view:HomeView/>
</DataTemplate>

<DataTemplate DataType="{x:Type viewModel:SettingsViewModel}">
<view:SettingsView/>
</DataTemplate>
</Application.Resources>

然后我继续创建我的MainWindow.xaml

<Window x:Class="Navs.MainWindow"
...>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition />
</Grid.ColumnDefinitions>
<Border>
<StackPanel>
<Button Height="25" Content="Home" Command="{Binding HomeViewCommand}"/>
<Button Height="25" Content="Settings" Command="{Binding SettingsViewCommand}"/>
</StackPanel>
</Border>
<ContentControl Grid.Column="1" Content="{Binding NavService.CurrentView}">

</ContentControl>
</Grid>
</Window>

以及相应的ViewModel

public class MainViewModel : ObservableObject
{
private NavService _navService;
public NavService NavService
{
get => _navService;
set
{
_navService = value;
OnPropertyChanged();
}
}
/* Commands */
public RelayCommand SettingsViewCommand { get; set; }
public RelayCommand HomeViewCommand { get; set; }
public MainViewModel(NavService navService, HomeViewModel homeViewModel, SettingsViewModel settingsViewModel)
{
NavService = navService;

HomeViewCommand = new RelayCommand(o => true, o => { NavService.CurrentView = homeViewModel; });
SettingsViewCommand = new RelayCommand(o => true, o => { NavService.CurrentView = settingsViewModel; });
}
}

如您所见,在依赖注入的帮助下,我现在可以通过构造函数访问我在容器中注册的对象。

我还创建了两个UserControls

UserControl1

<Grid>
<StackPanel VerticalAlignment="Center">
<Button Height="25" Content="Click me" Command="{Binding OpenWindowCommand}" />
<Button Content="Settings View" Command="{Binding SettingsViewCommand}" Height="25" />
</StackPanel>
</Grid>

这是相应的ViewModel

public class HomeViewModel
{
public RelayCommand SettingsViewCommand { get; set; }
public HomeViewModel()
{

}
}

然后我们有UserControl2

<Grid>
<StackPanel VerticalAlignment="Center">

<TextBox Text="{Binding Message}"
Height="25"/>

<Button Height="25" Content="Home View" Command="{Binding HomeViewCommand}"/>
<Button Height="25" Content="Fetch" Command="{Binding FetchDataCommand}"/>
</StackPanel>
</Grid>

与它对应的ViewModel

public class SettingsViewModel : ObservableObject
{
public string Message { get; set; }
public RelayCommand HomeViewCommand { get; set; }
public RelayCommand FetchDataCommand { get; set; }
public SettingsViewModel()
{

}
}

NavService.cs

public class NavService : ObservableObject
{
private object _currentView;
public object CurrentView
{
get => _currentView;
set
{
_currentView = value;
OnPropertyChanged();
}
}
private HomeViewModel HomeViewModel { get; set; }
private SettingsViewModel SettingsViewModel { get; set; }
public NavService(HomeViewModel homeViewModel, SettingsViewModel settingsViewModel)
{
HomeViewModel = homeViewModel;
SettingsViewModel = settingsViewModel;
CurrentView = HomeViewModel;
}
public void NavigateTo(string viewName)
{
switch (viewName)
{
case "Settings":
CurrentView = SettingsViewModel;
break;
case "Home":
CurrentView = HomeViewModel;
break;
}
}
}

这一切都很好用,当我拿HomeViewModel并尝试将NavService作为构造函数传入时,就会出现问题。

public HomeViewModel(NavService navService)
{

}

此时,它会引发异常。

我希望能够从各种Views访问NavService,以便我可以从多个位置更改NavService.CurrentView

这是一个设计问题。主要视图模型紧密耦合,充当传递并违反SRP(单一责任原则)。导航服务和其他视图模型都显式相互依赖,这是循环依赖问题的直接原因。

为简单起见,请注意以下NavService

重构
public abstract class ViewModel : ObservableObject {
}
public interface INavigationService {
object CurrentView { get; }
void NavigateTo<T>() where T : ViewModel;
}
public class NavService : INavigationService, ObservableObject {
private readonly Func<Type, object> factory;
private object _currentView;

public NavService(Func<Type, object> factory) {
this.factory = factory;
}

public object CurrentView {
get => _currentView;
private set {
_currentView = value;
OnPropertyChanged();
}
}

public void NavigateTo<T>() where T: ViewModel {
object viewModel = factory.Invoke(typeof(T)) 
?? throw new InvalidOperationException("Error message here");
CurrentView = viewModel;
}
}

注册时,此服务应配置用于获取视图模型的工厂。

public partial class App : Application {
private readonly IServiceProvider _serviceProvider;
public App() {
IServiceCollection _services = new ServiceCollection();

_services.AddSingleton<MainViewModel>();
_services.AddSingleton<HomeViewModel>();
_services.AddSingleton<SettingsViewModel>();

_services.AddSingleton<DataService>();
_services.AddSingleton<INavigationService, NavService>()(sp => {
return new NavService(type => sp.GetRequiredService(type));
});

_services.AddSingleton<MainWindow>(o => new MainWindow {
DataContext = o.GetRequiredService<MainViewModel>()
});
_serviceProvider = _services.BuildServiceProvider();
}
protected override void OnStartup(StartupEventArgs e) {
var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
mainWindow.Show();
base.OnStartup(e);
}
}

这完全分离了主视图模型和其他视图模型,但允许强类型导航并消除循环依赖关系,因为在此特定方案中,模型只需要了解导航服务

public class MainViewModel : ViewModel {
private INavigationService _navService;

/* Ctor */
public MainViewModel(INavigationService navService) {
NavService = navService;
HomeViewCommand = new RelayCommand(o => true, o => { NavService.NavigateTo<HomeViewModel>(); });
SettingsViewCommand = new RelayCommand(o => true, o => { NavService.NavigateTo<SettingsViewModel(); });
}

public INavigationService NavService {
get => _navService;
set {
_navService = value;
OnPropertyChanged();
}
}
/* Commands */
public RelayCommand SettingsViewCommand { get; set; }
public RelayCommand HomeViewCommand { get; set; }
}

请注意,不需要对视图进行任何更改。导航服务现在也足够灵活,允许将任意数量的视图模型引入系统,而无需对其进行任何更改。

将主窗口解析为 DI 容器很少是一个好主意。

您希望尽快渲染,您将注入到它的构造函数中吗?

可能什么都没有。

你打算把它换成什么?

可能什么都没有。

您需要接口的主要实际原因是,您可以在单元测试中模拟该类。

你构建的所有内容都应该有一个单一的责任。

主窗口的工作是包含您最初向用户显示的内容。也许几乎一切。

单元测试 UI 是一个相当大的挑战,因此通常将其内容减少为可重用的组件,而不是编写测试。

您不应该将视图模型的具体版本传递给主视图模型。如果它需要了解它们,当您有 50 次观看时,您会怎么做?

public MainViewModel(NavService navService)
{

导航服务应按需解析具体实例。更像。

NavService.CurrentView = 

(IInitiatedViewModel)serviceProvider.GetService(typeofregisteredinterface);

服务提供程序是一个实例类。你需要对它的某种引用,除非你在 app.xaml 中实例化所有内容.cs在启动时解决所有内容。

在实际应用中,您将需要瞬态。您将无法依赖所有事物都是单例。

您将需要导航到FooViewModel的新实例,因为您想要一个新的Foo,它与上一个Foo不同,也不会与未来的Foo相同。 您将需要一个新的客户或存储库实例。

在任何实际应用程序中,您都会拥有比两个视图模型更复杂的东西。

请注意,服务提供商是:

https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.serviceprovider?view=dotnet-plat-ext-7.0

它已经有一个接口定义了IServiceProvider。因此,您可以轻松地注入模拟以进行测试。

需要在 app.xaml 中使用的服务提供商以某种方式引用。

您通常需要视图模型接口:

interface IInitiatedViewModel
{
Task Initiate();
}

因此,您可以在视图模型实例化后获取视图模型的任何数据。

public async Task Initiate()
{
Suppliers = await _repository.GetAddressesByTypeAsync((int)AddressType.Supplier);
if(Suppliers.Count == 1)
{
ChosenSupplier = Suppliers[0];
}
}

我建议你还应该在某处有一个列表,其中包含你的视图模型和视图的类型和描述。

然后,您可以从特定视图模型类型抽象出导航。 他们选择[3]视图是什么,这被称为描述中的任何内容,它的视图模型接口是类型中的任何内容。

如有必要,您可以扩展此原则以在其中使用可选的工厂方法。

逻辑可以位于导航服务或其他注入的类中。

父视图模型(如 homeviewmodel)可能有依赖关系,但它们使用的东西而不是被其他东西使用。为它们定义接口没有真正的优势,因为您永远不会真正用测试的最小起订量替换它们。这意味着无需将接口注册为与它们相关联。

我经常发现有很多这样的。

您可以为这些定义一些自定义属性,并动态撰写导航列表。 这样可以节省编辑单独的内容。 您不希望在向用户显示第一个视图之前撰写此列表,因此通常立即解析 HomeViewModel 和 MainWindowViewModel。如有必要,您可以在入口点附近注册这些 di。

其他的可以使用反射添加。

因此,您可以装饰视图模型:

[ParentViewModel("Foo View Name Here")]
....
public class FooViewModel()
{

您可以找到允许此类反射的代码示例:

如何使用自定义类属性枚举所有类?

随着应用的增长,你可能会有很多视图。可以在控制台应用中迭代所有这些类,生成 xml 或源生成器。您还可以让属性定义菜单位置等方面。由于属性位于类上,因此它们与您正在编写/维护的代码相关联,而不是完全断开连接。这意味着您不太可能犯错误,例如拼写错误枚举或在删除不需要的视图时无法删除过时的枚举条目。

属性驱动生成非常强大。

您可以使用代码生成为重复模式(如视图和视图模型)生成接口和注册。

如果不希望每个容器都有一个单一实例,则在向依赖项注入容器显式注册这些容器时可能找不到任何值。 您可以使用 ActivatorUtilities.CreateInstance 实例化 UnregisterClass 的实例

ActivatorUtilities.CreateInstance<UnregisteredClass>(serviceProvider);

这将为您的未注册类提供任何依赖项。

https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.activatorutilities.createinstance?view=dotnet-plat-ext-7.0&viewFallbackFrom=aspnetcore-2.1#Microsoft_Extensions_DependencyInjection_ActivatorUtilities_CreateInstance__1_System_IServiceProvider_System_Object___

有一个可选参数可用于提供变量参数。比如说你想要发票nnn。

不要在构造函数中配置 IoC 容器!将相关代码移动到OnStartup重写或Application.Startup事件处理程序。构造函数仅用于初始化/配置实例。构造函数必须始终快速返回。

您正在错误地实施依赖注入。正如您目前实施的那样,它违背了它的目的。第一步始终遵循依赖反转原则(SOLID 中的D):不要依赖具体类型。
这意味着你必须引入抽象类和接口。然后只将这些抽象注入到具体类型中。

循环依赖通常是通过设计错误的职责(糟糕的类设计)引入的。IoC 揭示了此设计缺陷,因为依赖项现在通常通过构造函数公开

。循环依赖项存在于构造函数级别和类级别。 构造函数级别和类级别循环依赖关系彼此独立存在,必须单独修复。修复一个并不能修复另一个。它是抛出InvalidOperationExceptionStackOverflowException的构造函数级循环依赖关系。

类级设计的角度来看,
当类型A依赖于B并且B依赖于A(AB)
那么你有一个循环依赖和以下选项来修复它:
a)AB应该合并成一个类(A)
b)BA或其他类有太多的责任或太多的知识。将相关责任移回A。现在B将不得不使用A来履行其职责

A ⟷ B ➽ A ⟶ B

c)必须将共享逻辑移动/提取到第三类C

A ⟷ B ➽ A ⟶ C ⟵ B  

d)引入接口反转依赖(依赖反转原理):

IA    
⬀ ⬉        
A ⟷ B ➽ A   B  
⬊ ⬃  
IB 

这意味着:
a)A知道如何导航。这可能导致A承担太多责任。证明:每个需要导航的类型还必须实现完整的逻辑(重复代码)
b)每个类型都知道它可以/被允许导航到哪里。虽然导航逻辑由专用类型(NavigationService)封装,但实际有效的目的地只有客户端知道。这增加了代码的健壮性。在您的情况下,这意味着A必须为B提供论据,以允许B履行其职责。B现在不知道A(AB)的存在.
c),因为没有引入依赖关系以使特定的类成员(API)可用,c)不能应用于您的情况。在您的情况下,您的B仅依赖于A类型(实例而不是实例成员).
d) 因为循环依赖关系在构造函数中表现出来,因此单独引入接口无法解决 IoC 容器(或一般创建者)引发的循环依赖异常。

三种解决方案

要解决原始问题,您有三种选择:

  1. 隐藏工厂后面的(构造函数)依赖项(不推荐)
  2. 修复您的设计。NavigationService知道的太多了。按照您的模式,NavigationService必须明确知道每个视图模型类(或每个导航目标)。
  3. 使用属性注入而不是构造函数注入(不推荐)

以下示例将使用Func<TProduct>而不是抽象工厂来简化示例.
这些示例还使用enum作为目标标识符来消除魔术字符串的使用
并假定您为每个依赖项引入了一个接口。

重要的是要了解基本上有两个循环依赖关系:类级别和构造函数。两者都可以单独解决.
类级循环依赖关系通常通过引入接口(应用依赖反转原则)来解决。构造函数循环依赖关系使用以下三个建议之一进行修复.
为了完整起见,所有三个建议还修复了类级循环依赖关系(尽管它不负责 IoC 容器引发的循环依赖异常)。

NavigationId.cs
enum所有示例都使用它来替换魔术字符串参数以标识导航目标。

public enum NavigationId
{
None = 0,
HomeScreen,
SettingsScreen
}

解决方案 1):隐藏依赖项(不推荐)

通过实现抽象工厂模式,让您的类依赖于(抽象)工厂,而不是依赖于显式类型。

请注意,仍然会有一个隐式循环依赖关系。它只是隐藏在工厂后面。依赖关系刚刚从构造函数中删除(构造函数依赖关系 - 构造NavigationService不再需要构造HomeViewModel,反之亦然).
如前所述,您必须引入接口(例如IHomeViewModel)才能完全删除循环依赖关系。

您还将看到,为了添加更多目的地,您还必须修改NavigationService。这是一个很好的指标,表明你实现了糟糕的设计。事实上,你违反了开-闭原则(固体中的O)。

导航服务.cs

class NavigationService : INavigationService, INotifyPropertyChanged
{
// Constructor.
// Because of the factory the circular dependency of the constructor
// is broken. On class level the dependency still exists,
// but could be removed by introducing a 'IHomeViewModel'  interface.
public NavigationService(Func<IHomeViewModel> homeViewModelFactory)
{
// This reveals that the class knows too much.
// To introduce more destinations, 
// you will always have to modify this code.
// Same applies to your switch-statement.
// A switch-statement is another good indicator 
// for breaking the Open-Closed principle
this.NavigationDestinationFactoryTable = new Dictionary<NavigationId, Func<object>>()
{
{ NavigationId.HomeScreen, homeViewModelFactory.Invoke()}
};
}
public void Navigate(NavigationId navigationId)
=> this.CurrentSource = this.NavigationDestinationTable.TryGetValue(navigationId, out Func<object> factory) ? factory.Invoke() : default;
public object CurrentSource { get; private set; }
private Dictionary<NavigationId, Func<object>> NavigationDestinationFactoryTable { get; }
}

首页视图模型.cs

class HomeViewModel : IHomeViewModel, INotifyPropertyChanged
{
private INavigationService NavigationService { get; }
// Constructor
public HomeViewModel(Func<INavigationService> navigationServiceFactory)
=> this.NavigationService = navigationServiceFactory.Invoke();
}

App.xaml.cs
配置 IoC 容器以注入工厂。在此示例中,工厂是简单的Func<T>委托。对于更复杂的方案,您可能希望改为实现抽象工厂。

protected override void OnStartup(StartupEventArgs e)
{  
IServiceCollection _services = new ServiceCollection();

// Because ServiceCollection registration members return the current ServiceCollection instance
// you can chain registrations       
_services.AddSingleton<IHomeViewModel, HomeViewModel>()
.AddSingleton<INavigationService, NavigationService>()
/* Register the factory delegates */
.AddSingleton<Func<IHomeViewModel>>(serviceProvider => serviceProvider.GetRequiredService<HomeViewModel>)
.AddSingleton<Func<INavigationService>>(serviceProvider => serviceProvider.GetRequiredService<NavigationService>);
}

解决方案 2):修复类设计/职责(推荐)

每个类都应该知道允许导航到的导航目标。任何类都不应知道它可以导航到的其他类,或者它是否可以导航。

与解决方案1)相反,循环依赖完全解除。

导航服务.cs

public class NavigationService : INavigationService, INotifyPropertyChanged
{
// The critical knowledge of particular types is now removed
public NavigationService()
{}
// Every class that wants to navigate to a destination 
// must know/provide his destination explicitly
public void Navigate(object navigationDestination) 
=> this.CurrentSource = navigationDestination;
public object CurrentSource { get; private set; }
}

首页视图模型.cs

class HomeViewModel : IHomeViewModel, INotifyPropertyChanged
{
private INavigationService NavigationService { get; }
// Constructor
public HomeViewModel(INavigationService navigationService)
=> this.NavigationService = navigationService;
}

解决方案3):属性注入

.NET 依赖项注入框架不支持属性注入。但是,通常不建议注入属性。除了隐藏依赖项之外,它还带来了意外使糟糕的类设计工作的危险,而不是修复真正需要修复的内容(就像这个例子一样)。


虽然2) 是推荐的解决方案,但您可以将解决方案1)2)结合起来,并确定特定导航源需要了解多少目的地信息。

public class NavigationService : INavigationService, INotifyPropertyChanged
{
public Navigator(Func<IHomeViewModel> homeViewModelFactory)
{
this.HomeViewModelFactory = homeViewModelFactory;
// This reveals that the class knows too much.
// To introduce more destinations, 
// you will always have to modify this code.
// Same applies to your switch-statement.
// A switch-statement is another good indicator 
// for breaking the Open-Closed principle
this.NavigationDestinationFactoryTable = new Dictionary<NavigationId, Func<object>>()
{
{ NavigationId.HomeScreen, homeViewModelFactory }
};
}
public void Navigate(NavigationId navigationId)
=> this.CurrentSource = this.NavigationDestinationTable.TryGetValue(navigationId, out Func<object> factory) ? factory.Invoke() : default;
public void Navigate(object navigationDestination)
=> this.CurrentSource = navigationDestination;
public object CurrentSource { get; private set; }
public Func<IHomeViewModel> HomeViewModelFactory { get; }
private Dictionary<NavigationId, Func<object>> NavigationDestinationFactoryTable { get; }
}

然后改进MainViewModel初始化并清理其依赖项.
它应该使用NavigationService而不是显式赋值.
这就是为什么它注入了NavigationService

主视图模型.cs

public class MainViewModel : INotifyPropertyChanged
{
// Must be read-only
public INavigationService NavigationService { get; }

/* Commands. Must be read-only too */
public RelayCommand SettingsViewCommand { get; }
public RelayCommand HomeViewCommand { get; }
public MainViewModel(INavigationService navigationService)
{
this.NavigationService = navigationService;

this.HomeViewCommand = new RelayCommand(
o => true, 
o => this.NavigationService.Navigate(NavigationId.HomeScreen));
this.SettingsViewCommand = new RelayCommand(
o => true, 
o => this.NavigationService.Navigate(NavigationId.SettingsScreen));
}
}

最新更新