如何将命令从大视图模型中分离出来

How to seperate commands out of big viewmodel

我的视图模型包含很多命令,这让我的视图模型变得非常大。我想将我的命令从视图模型中分离出来。目前,我的解决方案是为每个命令创建一个 class,如下所示,

 public class TestCommand : CommandBase
{
    private MainViewModel vm;

    public TestCommand(MainViewModel vm)
    {
        this.vm = vm;
    }

    public override bool CanExecute(object parameter)
    {
        return true;
    }

    public override void ExecuteCommand(object parameter)
    {
        vm.logger.log(...);
        ...
    }
}

由于我需要在ViewModel中使用一些方法或属性,所以我必须将viewmodel作为参数传递给命令。对于此解决方案,有两个缺点: 1、项目中命令文件较多,如果一个视图中命令的平均数为15条,则10个视图在项目中有150个命令文件; 2.将ViewModel作为参数传递给命令需要一些应该私有的属性或方法必须更改为public;把viewmodel传给command也很奇怪

是否有任何其他解决方案来分离命令?

检查您的视图模型是否可以划分为逻辑块并为每个块创建 sub-viewmodels。额外的优势是,当您想在其他地方以不同的方式显示相同的信息时,通常可以重复使用这些较小的视图模型。

此外,我更喜欢有一个通用的 RelayCommand 定义,并且只在我的视图模型中创建命令而不指定不同的方法,这样我就可以将 Execute 和 CanExecute 放在一起作为 lambda-expressions。

如果无法创建不同的视图模型,您还可以将 class 的代码拆分到多个文件(部分 classes)以提高可维护性。

您问题的答案是 Single Responsibility Principle。您的视图模型做得太多了。将功能与您的 vm 分开并将其放入不同的 classes 并将 classes 作为对您的命令的引用发送。在你的情况下

public class TestCommand : CommandBase
{
    private Logger logger;

    public TestCommand(Logger logger)
    {
        this.logger = logger;
    }

    public override bool CanExecute(object parameter)
    {
        return true;
    }

    public override void ExecuteCommand(object parameter)
    {
        logger.log(...);
    }
}

这里我将 Logger 对象发送到 Command 而不是视图模型。在一个项目中有很多命令文件只是一个好习惯,只要你将它们保存在一个逻辑文件夹中。

注意: 在现实世界中,我们不仅仅记录命令执行。基本上我们做一些功能并记录相同的内容。我在这里使用记录器的唯一原因只是为了让 OP 快速理解。理想情况下,我们应该发送一个 class,它具有必须在命令执行时完成的功能。

使用 ICommand 作为消息模式

此解决方案侧重于 关注点分离单一职责原则

它允许您跳过 MVVM 中的 RelayCommand 模式。

如果您正在使用 XAML,您可以引用具有单个命令 class 的命名空间。像这样:

 xmlns:cmd="clr-namespace:MyProject"

然后可以定义全局或局部样式,如下所示。这使得所有按钮只使用一个命令,将按钮的文本作为参数传递。大多数按钮使用文本作为上下文,但标签也可以使用。

        <Style BasedOn="{StaticResource XDButton}" TargetType="{x:Type Button}">
            <Setter Property="Command" Value="{StaticResource ResourceKey=cmd}"/>
            <Setter Property="CommandParameter" Value="{Binding Content, RelativeSource={RelativeSource Self}}"/>
        </Style>

您可以像这样为整个项目创建一个命令,请注意 'routing' 是基于按钮文本的。 'Favor naming conventions over configuration'

   public class Commands : ICommand
    {
        private bool canExecute = true;

        public bool CanExecute(object parameter)
        {
            return canExecute;
        }

        public event EventHandler CanExecuteChanged;

        public void Execute(object parameter)
        {
            NotifyCanExecute(false);
            var information = parameter.ToString();
            try
            {
                if (information == "Show Passed") Events.ShowAllPassedTests(this, new EventArgs());
                if (information == "Show Failed") Events.ShowAllFailedTests(this, new EventArgs());
                if (information == "Sort By elapsed Time") Events.SortByElapsedTime(this, new EventArgs());
                if (information == "Sort By Run Data") Events.SortByRunData(this, new EventArgs());
                if (information == "Sort By Title") Events.SortByTitle(this, new EventArgs());
                if (information == "Generate HTML Report") Events.GenerateHTMLReport(this, new EventArgs());
            }
            catch (NullReferenceException nre) {
                Trace.WriteLine("Test Runner Commands 320- An attempt to fire an event failed due to no subscribers");
            }
            NotifyCanExecute(true);
        }

        private void NotifyCanExecute(bool p)
        {
            canExecute = p;
            if (CanExecuteChanged != null) CanExecuteChanged(this, new EventArgs());
        }
    }

像这样创建单个事件聚合 class:

public  class Events  
{
    public static EventHandler ShowAllPassedTests;
    public static EventHandler ShowAllFailedTests;
    public static EventHandler ClearAllFilters;
    public static EventHandler SortByElapsedTime;
    public static EventHandler SortByRunData;
    public static EventHandler SortByTitle;
    public static EventHandler GenerateHTMLReport;
    public static EventHandler<CheckBox> ColumnViewChanged;
}

您可以像这样创建一个带有按钮的解耦导航器用户控件。单击按钮时,它只调用传入按钮上下文的命令 class。

 <StackPanel Orientation="Vertical">
        <StackPanel.Resources>
            <Style BasedOn="{StaticResource XDButton}" TargetType="{x:Type Button}">
                <Setter Property="Command" Value="{StaticResource ResourceKey=cmd}"/>
                <Setter Property="CommandParameter" Value="{Binding Content, RelativeSource={RelativeSource Self}}"/>
            </Style>
        </StackPanel.Resources>
        <Button x:Name="XBTNShowPassed"  >Show Passed</Button>
        <Button x:Name="XBTNShowFailed"  >Show Failed</Button>
        <Button x:Name="XBTNShowAll"  >Show All</Button>
        <Button x:Name="XBTNSortByElapsedTime"  >Sort by Elapsed Time</Button>
        <Button x:Name="XBTNSortByRunData"  >Sort By Run Data</Button>
        <Button x:Name="XBTNSortByTitle"  >Sort By Title</Button>
        <Button x:Name="XBTNGenerateHTMLReport"  >Generate HTML Report</Button>
    </StackPanel>

最终接收 ViewModel 或其他 class 看起来像这样:

            Events.ColumnViewChanged += OnColumnViewChanged;
            Events.SortByTitle += OnSortByTitle;
            Events.SortByRunData += OnSortByRunData;
            Events.SortByElapsedTime += OnSortByElapsedTime;
            Events.GenerateHTMLReport += OnGenerateHTMLReport;
            Events.ShowAllFailedTests += OnShowAllFailedTests;
            Events.ShowAllPassedTests += OnShowAllPassedTests;

        }

        private void OnShowAllPassedTests(object sender, EventArgs e)
        {
            FilterCVS(tr => tr.DidTestPass);
        }

        private void OnShowAllFailedTests(object sender, EventArgs e)
        {
            FilterCVS(tr => tr.DidTestFail);
        }

别忘了执行 Dispose

当代码连接到 EventHandler 时,它就没有资格进行垃圾收集。要解决此问题,请实施 Dispose 模式并断开事件处理程序...例如

Events.OnColumnViewChanged -= OnColumnViewChanged;

TL;DR:

ViewModel 是主要在命令中表达的表示逻辑,因此命令占用大量 ViewModel 代码的情况并不少见。不要太努力地使用 INotifyPropertyChanged 将 ViewModel 作为普通数据持有者(就像在 ASP.NET MVC 中使用 ViewModel 时常见的那样)。

长版

由于缺乏更多的细节,很难给你具体的提示,但这里有一些一般的指导方针。您可以使用有关您正在使用的命令类型的更多详细信息来更新您的问题,我将尝试更新该问题。

  1. 表示逻辑

    ViewModels 的主要关注点是呈现。 ViewModels 中没有业务逻辑的位置。

    必须将业务逻辑提取到您的 business/domain 模型(如果您遵循富领域模型)或服务(在贫血领域模型中)。在丰富的领域模型中,您的服务层通常非常薄,并且主要用于协调多个模型之间的操作。

    因此,如果您的 ViewModel/commands 正在执行与演示无关的任何类型的逻辑(如果单击按钮 A,禁用按钮 B、C 和 D 或隐藏 GroupBoxA 或“如果数据丢失则禁用按钮 A(CanExecute of ICommand)) 它可能做得太多了。

  2. 关注点分离

    您的 ViewModel 可能会尝试做比预期更多的事情。您示例中的记录器就是这样的提示。日志记录不是 ViewModel 关注的问题。

    ViewModel 是关于表示和表示逻辑的,而日志记录是一个应用程序问题(因为它不属于 domain/business 逻辑)。

    通常一个 ViewModel 可以拆分为两个或多个 ViewModel(即管理客户列表并允许编辑所选客户的 ViewModel,通常可以拆分为 2 或 3 个 ViewModel:CustomersViewModel(显示列表),CustomerDetailViewModelCustomerViewModel(客户的详细信息)和 CustomerEditViewModel(编辑相关客户)

    日志记录和缓存等问题应该使用装饰器模式来完成。这需要您的服务 and/or 存储库正确使用和实现接口,然后您可以创建用于缓存或日志记录的装饰器,而不是注入服务的原始实例,而是实现装饰器。

    依赖注入 (DI) 和控制反转 (IoC) 容器确实可以帮助您解决这个问题。必须手动连接它(又名穷人 DI)是屁股上的痛苦。具体的例子超出了这个答案的范围。

  3. 命令中的业务逻辑

    命令不应包含业务逻辑。当您的命令包含太多代码(通常超过 5-20 行代码)时,这是一个很好的线索,您的命令可能做的太多了。

    命令实际上应该只连接对服务的多个调用并将数据分配给属性 and/or 上升 events/messages(与表示层相关。不要与域事件混淆,域事件应该'在命令中被提升)。它们类似于 MVC 中的 "Actions"(例如 ASP.NET MVC 中使用的框架)。

    命令通常应如下所示

    var customer = new Customer { Name = this.CustomerName, Mail = this.CustomerMail };
    try {
        this.customerService.AddCustomer(customer);
        // Add it to Observable<Customer> list so the UI gets updated
        this.Customers.Add(customer);
        // the service should have populated the Id field of Customer when persisting it
        // so we notify all other ViewModels that a new customer has been added
        this.messageBus.Publish(new CustomerCreated() { CustomerId = customer.Id } );
    } catch (SomeSpecificException e) { // Handle the Exception } 
    

    this.Customers = this.customerRepository.GetAll();
    // Or this for async commands
    this.Customers = await this.customerRepository.GetAllAsync();
    
  4. 封装

    许多命令与 ViewModel 本身紧密耦合,需要访问 ViewModel 或模型的内部状态(模型不应直接暴露给视图,这会将模型耦合到视图和任何模型中的更改会破坏您的视图和绑定)。

    在不破坏封装的情况下将这些 ICommands 移出 ViewModel 可能很困难。

当然你也可以一个实现多个命令class

public class MyViewModelCommandHandler
{
    private readonly IMyRepository myRepository;

    public MyViewModelCommandHandler(/* pass dependencies here*/)
    {
        // assign and guard dependencies

        MyCommand = new RelayCommand(MyCommand, CanExecuteMyCommand);
        MyOtherCommand = new RelayCommand(MyOtherCommand, CanExecuteMyOtherCommand);
    }

    public ICommand MyCommand { get; protected set; } 
    public ICommand MyOtherCommand { get; protected set; } 

    private void MyCommand() 
    {
        // do something
    }

    private void CanExecuteMyCommand() 
    {
        // validate
    }

    private void MyOtherCommand() 
    {
        // do something else
    }

    private void CanExecuteMyOtherCommand() 
    {
        // validate
    }
}

并且在您的 ViewModel 中只需分配这些命令

public class MyViewModel : ViewModelBase 
{
    public MyViewModel()
    {
        var commandHandler = new MyCommandHandler(this);
        OneCommand = commandHandler.MyCommand;
        OtherCommand = commandHandler.MyOtherCommand;
    }

    public ICommand OneCommand { get; private set; } 
    public ICommand OtherCommand { get; private set; } 
}

您还可以使用 IoC 容器将您的 MyCommandHandler 注入您的视图,这需要稍微改造您的命令处理程序 class,以按需创建 ICommand。然后你可以像

这样使用它
public class MyViewModel : ViewModelBase 
{
    public MyViewModel(MyCommandHandler commandHandler)
    {
        OneCommand = commandHandler.CreateMyCommand(this);
        OtherCommand = commandHandler.CreateMyOtherCommand(this);
    }

    public ICommand OneCommand { get; private set; } 
    public ICommand OtherCommand { get; private set; } 
}

但这只是转移了你的问题,并不能解决第 1 点到第 5 点。所以我建议首先尝试上面列表中的建议,如果您的命令仍然包含 "too many lines of code",请尝试其他解决方案。

我不太喜欢它,因为它创建了不必要的抽象,但收效甚微。

ViewModel 主要由表示逻辑组成并不少见,因为这是它们的目的,而表示逻辑通常在命令内部。除此之外,您只有属性和构造函数。除了检查值是否更改,然后分配和一个或多个 OnPropertyChanged 调用之外,属性不应该有任何其他内容。

所以 50-80% 的 ViewModel 是来自命令的代码。