切换到 MVVM






4.93/5 (12投票s)
如何将 MVVM 逐步整合到现有代码库中,以及每个步骤中涉及的风险。
1. 引言
如果我们要编写一个新的应用程序,那么使用良好的实践和经过验证的架构设计来编写应用程序相对容易。但大多数时候,我们都在处理现有应用程序,其中更改并不容易。如果我们有机会重写现有应用程序,那么我们可能不会重复我们或其他人由于时间限制、技术限制、范围蔓延等原因所犯的相同错误。如果我们能够以最小或没有风险的方式重构现有代码库以使其更好,那会更好。
MVVM 是一种经过验证的设计模式,在许多 WPF / Silverlight 应用程序中大量使用。但我们可能已经有很多代码库没有利用它的优势。本文适用于那些已经拥有应用程序而不是从头开始并希望利用 MVVM 的人。在本文中,我们将看到如何逐步迈向 MVVM。本文主要分为两部分。在第一部分中,我们将研究 MVVM 的演进。在第二部分中,我们将看到实现 MVVM 的不同步骤。尽管有些步骤涉及大量重构和潜在的高风险,但也有一些对项目没有或影响最小的事情。
2. MVVM 的演进
让我们从更高的层面审视 MVVM,并采取循序渐进的方法来理解它。我们的讨论基于架构的复杂性,从简单到复杂,而不是历史顺序。
将数据与其表示分离的最简单的设计原则可能是观察者设计模式 [1]。在观察者设计模式中,我们为数据(主题/模型)或其表示(观察者/视图)提供了两个不同的类。主题类包含所有观察者的实例,并在数据发生任何更改时向所有观察者发送通知。以下是观察者设计模式的简单框图
下一步是在数据及其表示之间引入一个中间层。该层的主要目的是在这两个组件之间进行通信。这是模型-视图-控制器 (MVC) [2] 的主要概念。它显示在此框图中
这种方法有一些优点和缺点。主要缺点是我们的视图并不完全独立于我们的模型。模型-视图-表示器 (MVP) [3] 正好解决了同样的问题。在 MVP 模型中,视图和模型之间没有关系。
MVVM 与 MVP 模式非常相似。或者它是 MVP 模式的一种特殊形式。在 MVVM 中,表示器被称为 ViewModel。模型通过通知与 ViewModel 通信,ViewModel 通过数据绑定和命令绑定与视图通信,如本框图所示
现在让我们更详细地了解 MVVM。它最大的优势是什么?它的第一个优势是我们的表示完全不知道我们的模型。我们不在 ViewModel 中编写任何特定于用户界面的代码,所有通信都基于数据绑定和命令绑定。这意味着我们可以轻松地为其编写单元测试。我们可以轻松地更改任何用户界面,甚至轻松地更改数据模型。以下是 MVVM 的详细框图
此图解释了我们如何利用 MVVM。这种模式中最重要的事情是正确设计 ViewModel。WPF 具有非常正交的设计。这意味着我们可以自定义或增强库的不同部分,而不会影响其他部分。WPF 的大部分可重用性基于组合而不是继承。因此,即使在运行时我们也可以最大限度地利用它。因为,我们可以在运行时轻松更改组合行为,但不能更改继承组件。在此框图中,我们看到了我们在大多数 WPF 应用程序中开发、增强或自定义的 WPF 类库的主要组件
3. 转向 MVVM
我们将从一个不是用 WPF 编写的简单应用程序开始,并逐步对其进行更改以引入 MVVM 模式。我们的起点是一个用 VC++ 编写并使用 WPF [4] 的贷款摊销应用程序。选择该应用程序的原因是,根据定义,我们无法在 VC++ 中使用 MVVM,因为 VC++ 中的 XAML 支持非常有限(目前唯一支持的是在运行时加载 XAML 并使用它)。
虽然我们类中可能有一些存储数据的属性,但我们通常使用字段来存储信息。在我们的起始项目中,我们有一个类来存储每个付款的信息。这是我们的类
public class PaymentInfo
{
public int PaymentNo
{ get; set; }
public double Payment
{ get; set; }
public double Principle
{ get; set; }
public double Interest
{ get; set; }
public double Balance
{ get; set; }
}
我们有字段变量来存储来自用户界面的信息并将其显示回用户界面。
private double principle;
private double interestRate;
private int duration;
private double payment;
以下是一段获取用户输入并将其存储在字段变量中的代码
if (txtPrincipleAmount.Text.Length > 0)
{
principle = Convert.ToDouble(txtPrincipleAmount.Text);
}
else
{
MessageBox.Show("Please enter principle amount",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (txtInterestRate.Text.Length > 0)
{
interestRate = Convert.ToDouble(txtInterestRate.Text);
interestRate /= 100;
interestRate /= 12;
}
else
{
MessageBox.Show("Please enter interest",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (txtDuration.Text.Length > 0)
{
duration = Convert.ToInt32(txtDuration.Text);
}
else
{
MessageBox.Show("Please enter duration",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
我们有一些实用方法来执行我们的计算,我们从事件处理程序中调用它们
// Calculate the remaining balance at particular payment
private double CalculateBalance(int month)
{
double interestTerm = Math.Pow((1 + interestRate), month);
double totalInterest = principle * interestTerm;
double totalPaid = payment * (interestTerm - 1) / interestRate;
return totalInterest - totalPaid;
}
// Calculate the Interest part of any particular payment
private double CalculateInterestPart(int month)
{
double interestTerm = Math.Pow((1 + interestRate), (month - 1));
double totalInterest = principle * interestTerm;
double totalPaid = payment * (interestTerm - 1) / interestRate;
return (totalInterest - totalPaid) * interestRate;
}
// Calculate the principle part of any particular payment
private double CalculatePrinciple(int month)
{
return payment - CalculateInterestPart(month);
}
我们有一个方法来计算整个贷款期间的摊销计划,并将这些值添加到 DataGrid
中。
// Calculate the complete amortization schedule and fill the data grid control
private void CalculatePayment()
{
int totalpayments = duration * 12;
Title = "Amortization Schedule for " +
Convert.ToString(totalpayments) + " Payments";
// calculate interest term
double interestTerm = Math.Pow((1 + interestRate), totalpayments);
// calculate payment
payment = (principle * interestRate) / (1 - (1 / interestTerm));
payments.Clear();
for (int iIndex = 1; iIndex <= totalpayments; ++iIndex)
{
PaymentInfo paymentInfo = new PaymentInfo();
paymentInfo.PaymentNo = iIndex;
paymentInfo.Balance = CalculateBalance(iIndex);
paymentInfo.Payment = payment;
paymentInfo.Interest = CalculateInterestPart(iIndex);
paymentInfo.Principle = CalculatePrinciple(iIndex);
payments.Add(paymentInfo);
}
lstAmortization.ItemsSource = payments;
}
请注意,用户界面代码与业务逻辑(在本例中为计算贷款摊销)紧密耦合。如果没有用户界面的参与,就无法简单地编写测试用例来检查此计算。
我们项目的 XAML 非常简单。这是我们程序的完整 XAML 代码
<Window x:Class="MVVM.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="400" Width="600">
<Grid Background="Beige">
<Grid.RowDefinitions>
<RowDefinition Height="2*"/>
<RowDefinition Height="2*"/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Grid.Column="0" Grid.ColumnSpan="2"
Grid.Row="0" FontSize="18" FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center">Loan Amortization</TextBlock>
<TextBlock Grid.Column="0" Grid.Row="1"
VerticalAlignment="Center" Margin="5">Principle Amount</TextBlock>
<TextBlock Grid.Column="0" Grid.Row="2"
VerticalAlignment="Center" Margin="5">Interest Rate</TextBlock>
<TextBlock Grid.Column="0" Grid.Row="3"
VerticalAlignment="Center" Margin="5">Duration</TextBlock>
<TextBox Grid.Column="1" Grid.Row="1"
Margin="5" VerticalAlignment="Center" Name="txtPrincipleAmount"/>
<TextBox Grid.Column="1" Grid.Row="2"
Margin="5" VerticalAlignment="Center" Name="txtInterestRate"/>
<TextBox Grid.Column="1" Grid.Row="3"
Margin="5" VerticalAlignment="Center" Name="txtDuration"/>
</Grid>
<DataGrid Grid.Row="1" Name="lstAmortization" Margin="5">
</DataGrid>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Name="btnCalculate"
Width="75" Height="45"
Click="btnCalculate_Click">Calculate</Button>
<Button Grid.Column="1" Name="btnExit"
Width="75" Height="45"
Click="btnExit_Click">Exit</Button>
</Grid>
</Grid>
</Window>
另请注意,像其他编程风格一样,这里我们为每个控件都有一个名称。
3.1. 步骤 1:使用属性
如果代码使用字段而不是属性,则将它们从字段转换为属性(这对现有源代码应该没有或影响最小,因为我选择的属性名称与字段非常相似(或相同))。在此步骤中,我们只是更改为属性而不是字段。这是我们程序的更新版本
public double Principle
{ get; set; }
public double InterestRate
{ get; set; }
public int Duration
{ get; set; }
public double Payment
{ get; set; }
程序的 XAML 没有更改。如果我们使用与以前使用的字段相同的属性名称,那么我们不必更改代码中的任何内容。在我们的示例中,我使用大写字母表示属性,驼峰命名法表示字段,因此我们相应地更新源代码并在此处使用大写字母。
3.2. 步骤 2:实现 INotifyPropertyChanged 接口 / 依赖属性
我的第一步是更改数据模型,使其与 WPF 兼容。为此,我将实现 INotifyPropertyChange
接口或将它们设置为依赖属性。如果我们已经有属性,那么这对项目的其余部分根本没有影响。其余代码应该相同,并且应该以相同的方式工作,但至少现在您的模型已准备就绪。
在此示例中,我们将把属性更改为依赖属性。在接下来的步骤中,我们还将看到 INotifyPropertyChanged
接口的示例,以查看两种变体。这是我们更新后的代码
public static readonly DependencyProperty PrincipleProperty =
DependencyProperty.Register("Principle", typeof(double), typeof(MainWindow));
public double Principle
{
get { return (double)GetValue(PrincipleProperty); }
set { SetValue(PrincipleProperty, value); }
}
public static readonly DependencyProperty InterestRateProperty =
DependencyProperty.Register("InterestRate", typeof(double), typeof(MainWindow));
public double InterestRate
{
get { return (double)GetValue(InterestRateProperty); }
set { SetValue(InterestRateProperty, value); }
}
public static readonly DependencyProperty DurationProperty =
DependencyProperty.Register("Duration", typeof(int), typeof(MainWindow));
public int Duration
{
get { return (int)GetValue(DurationProperty); }
set { SetValue(DurationProperty, value); }
}
public static readonly DependencyProperty PaymentProperty =
DependencyProperty.Register("Payment", typeof(double), typeof(MainWindow));
public double Payment
{
get { return (double)GetValue(PaymentProperty); }
set { SetValue(PaymentProperty, value); }
}
XAML 文件没有更改。
3.3. 步骤 3:使用数据绑定
下一步是使用数据绑定。这将减少大量的代码。现在它不应该有任何类似的代码
txtName.Text = firstName;
这是代码库中的一项重大更改。但在此步骤中,我只关注数据而不关注行为。我的代码仍然有一个事件处理程序,并且应该正常工作,但现在我不必担心将数据更新到控件中或从控件获取数据到我的变量中。(不再有控件到变量的交互。)现在我们只直接从属性(它们会自动从用户界面获取值)检查无效输入。这是我们现在的验证代码
if (Principle < 0)
{
MessageBox.Show("Please enter principle amount",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (InterestRate < 0)
{
MessageBox.Show("Please enter interest", "Error",
MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (Duration < 0)
{
MessageBox.Show("Please enter duration",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
现在我们必须更新我们的 XAML 并在此处使用数据绑定。这是我们 XAML 的更新版本
<TextBox Grid.Column="1" Grid.Row="1"
Margin="5" VerticalAlignment="Center" Text="{Binding Principle}" />
<TextBox Grid.Column="1" Grid.Row="2"
Margin="5" VerticalAlignment="Center" Text="{Binding InterestRate}"/>
<TextBox Grid.Column="1" Grid.Row="3"
Margin="5" VerticalAlignment="Center" Text="{Binding Duration}"/>
另请注意,现在我们不再需要指定控件的名称;借助数据绑定,我们将自动在正确的属性(在此示例中为依赖属性)中获取值。
3.4. 步骤 4:重构事件处理程序
这是一个中间步骤。我已经以数据绑定的形式处理了所有数据,其中没有控件/变量代码。我将遍历所有事件处理程序,并将其中编写的任何代码移动到方法中,然后从事件处理程序中调用该方法。这是我们事件处理程序的更新版本
private void btnCalculate_Click(object sender, RoutedEventArgs e)
{
CalculateAmortization();
}
而 CalculateAmortization
方法将完成其余的工作。这是 CalculateAmortization
方法的代码
private void CalculateAmortization()
{
ValidateInput();
CalculatePayment();
}
我们将代码重构为两个不同的方法:一个用于执行验证,另一个用于执行实际计算。
3.5. 步骤 5:实现 ICommand 接口
在下一步中,我将引入 ICommand
接口并实现它。现有代码库没有更改。这是一个示例
public class MyCommand : ICommand
{
public Action Function
{ get; set; }
public MyCommand()
{
}
public MyCommand(Action function)
{
Function = function;
}
public bool CanExecute(object parameter)
{
if (Function != null)
{
return true;
}
return false;
}
public void Execute(object parameter)
{
if (Function != null)
{
Function();
}
}
public event EventHandler CanExecuteChanged
{
add { CommandManager.RequerySuggested += value; }
remove { CommandManager.RequerySuggested -= value; }
}
}
我们项目的 XAML 没有更改。
3.6. 步骤 6:添加 ICommand 属性
现在我将在我的类中添加属性(该类以前有事件处理程序)。属性的类型是 ICommand
。这再次不应对代码的其余部分产生任何影响。属性的数量至少应与我在事件处理程序中调用的方法相同,我选择了一个相似的名称。
public ICommand ExitCommand
{ get; set; }
public ICommand CalculateAmortizationCommand
{ get; set; }
我们项目的 XAML 没有更改。
3.7. 步骤 7:将方法分配给 ICommand 类型属性
我已经创建了一个方法来计算摊销(或执行其他工作)。创建 MyCommand
类的对象并在构造函数中将其分配给我们的 ICommand
属性。
ExitCommand = new MyCommand(Close);
CalculateAmortizationCommand = new MyCommand(CalculateAmortization);
我们项目的 XAML 没有更改。
3.8. 步骤 8:使用命令绑定
从代码和 XAML 中删除事件处理程序,并改为使用命令绑定。这又是代码(代码和 XAML)中的一项重大更改。
<Button Grid.Column="0" Name="btnCalculate" Width="75"
Height="45" Command="{Binding CalculateAmortizationCommand}" >Calculate</Button>
<Button Grid.Column="1" Name="btnExit" Width="75"
Height="45" Command="{Binding ExitCommand}" >Exit</Button>
另请注意,现在我们所有的业务逻辑都在方法中,而不是在事件处理程序中。这意味着现在我们可以以某种方式在这些方法上执行单元测试,而无需任何用户界面的参与。
3.9. 步骤 9:定义 ViewModel 类
定义一个 ViewModel 类,并将所有 ICommand
接口属性和分配给它的方法移动到该类中。我们在 ViewModel 类中引入了一个事件类型属性来处理关闭事件。
public event EventHandler RequestClose;
private void CloseWindow()
{
EventHandler handler = this.RequestClose;
if (handler != null)
{
handler(this, EventArgs.Empty);
}
}
我们从我们的窗口(视图)类设置此事件处理程序。有多种方法可以做到这一点,但在这里我们使用匿名委托来完成。
public partial class MainWindow : Window
{
private MyViewModel vm = new MyViewModel();
public MainWindow()
{
InitializeComponent();
vm.RequestClose += delegate
{
Close();
};
DataContext = vm;
}
}
这是我们 ViewModel 类的完整代码
public class MyViewModel : DependencyObject
{
public static DependencyProperty PrincipleProperty =
DependencyProperty.Register("Principle", typeof(double), typeof(MainWindow));
public double Principle
{
get { return (double)GetValue(PrincipleProperty); }
set { SetValue(PrincipleProperty, value); }
}
public static DependencyProperty InterestRateProperty =
DependencyProperty.Register("InterestRate", typeof(double), typeof(MainWindow));
public double InterestRate
{
get { return (double)GetValue(InterestRateProperty); }
set { SetValue(InterestRateProperty, value); }
}
public static DependencyProperty DurationProperty =
DependencyProperty.Register("Duration", typeof(int), typeof(MainWindow));
public int Duration
{
get { return (int)GetValue(DurationProperty); }
set { SetValue(DurationProperty, value); }
}
public static DependencyProperty PaymentProperty =
DependencyProperty.Register("Payment", typeof(double), typeof(MainWindow));
public double Payment
{
get { return (double)GetValue(PaymentProperty); }
set { SetValue(PaymentProperty, value); }
}
public event EventHandler RequestClose;
public ObservableCollection<PaymentInfo> Payments
{ get; set; }
public ICommand CalculateAmortizationCommand
{ get; set; }
public ICommand ExitCommand
{ get; set; }
public MyViewModel()
{
CalculateAmortizationCommand = new MyCommand(CalculateAmortization);
ExitCommand = new MyCommand(CloseWindow);
Payments = new ObservableCollection<PaymentInfo>();
}
private void CloseWindow()
{
EventHandler handler = this.RequestClose;
if (handler != null)
{
handler(this, EventArgs.Empty);
}
}
private void CalculateAmortization()
{
ValidateInput();
CalculatePayment();
}
// Validate Input
private void ValidateInput()
{
if (Principle < 0)
{
MessageBox.Show("Please enter principle amount",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (InterestRate < 0)
{
MessageBox.Show("Please enter interest",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
if (Duration < 0)
{
MessageBox.Show("Please enter duration",
"Error", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
}
// Calculate the complete amortization schedule and fill the list control
private void CalculatePayment()
{
int totalpayments = Duration * 12;
double monthlyInterest = CalculateMonthlyInterest(InterestRate);
// calculate interest term
double interestTerm = Math.Pow((1 + monthlyInterest), totalpayments);
// calculate payment
Payment = (Principle * monthlyInterest) / (1 - (1 / interestTerm));
Payments.Clear();
for (int iIndex = 1; iIndex <= totalpayments; ++iIndex)
{
PaymentInfo paymentInfo = new PaymentInfo();
paymentInfo.PaymentNo = iIndex;
paymentInfo.Balance = CalculateBalance(iIndex);
paymentInfo.Payment = Payment;
paymentInfo.Interest = CalculateInterestPart(iIndex);
paymentInfo.Principle = CalculatePrinciple(iIndex);
Payments.Add(paymentInfo);
}
//lstAmortization.ItemsSource = payments;
}
// Calculate the remaining balance at particular payment
private double CalculateBalance(int month)
{
double monthlyInterest = CalculateMonthlyInterest(InterestRate);
double interestTerm = Math.Pow((1 + monthlyInterest), month);
double totalInterest = Principle * interestTerm;
double totalPaid = Payment * (interestTerm - 1) / monthlyInterest;
return totalInterest - totalPaid;
}
// Calculate the Interest part of any particular payment
private double CalculateInterestPart(int month)
{
double monthlyInterest = CalculateMonthlyInterest(InterestRate);
double interestTerm = Math.Pow((1 + monthlyInterest), (month - 1));
double totalInterest = Principle * interestTerm;
double totalPaid = Payment * (interestTerm - 1) / monthlyInterest;
return (totalInterest - totalPaid) * monthlyInterest;
}
// Calculate the principle part of any particular payment
private double CalculatePrinciple(int month)
{
return Payment - CalculateInterestPart(month);
}
// Calculate the monthly interest rate
private double CalculateMonthlyInterest(double InterestRate)
{
double monthlyInterest = InterestRate;
monthlyInterest /= 100;
monthlyInterest /= 12;
return monthlyInterest;
}
}
请注意,此类没有任何用户界面元素(除了数据验证中的几个消息框,如果使用 XAML 中的 DataValidation,可以轻松删除),因此我们可以轻松地在其上编写测试用例。此外,如果我们想更改其演示文稿,例如 WPF 到 Silverlight 或基于控制台的应用程序,我们可以非常轻松地做到这一点。此类包含所有业务逻辑,现在由该类的用户决定如何呈现这些信息。
3.10. 步骤 10:定义 ViewModelBase 类
让我们再做一步。我们可能希望一遍又一遍地做同样的事情,所以为什么不创建一个基类来拥有最小的 ViewModel 功能,以便在其他 ViewModel 类中重用它。创建 ViewModelBase
类的另一个原因是展示如何实现 INotifyPropertyChanged
接口。这是我们基类的代码
public abstract class ViewModelBase : INotifyPropertyChanged
{
public event EventHandler RequestClose;
public ICommand ExitCommand
{ get; set; }
public void Close()
{
EventHandler handler = this.RequestClose;
if (handler != null)
{
handler(this, EventArgs.Empty);
}
}
public void RaisePropertyChanged(string propertyName)
{
PropertyChangedEventHandler handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
public event PropertyChangedEventHandler PropertyChanged;
}
另请注意,我们将 ViewModelBase
类设为抽象类,以避免创建此类的对象。这是我们的 ViewModel
类的一部分,它继承自 ViewModelBase
。
public class MyViewModel : ViewModelBase
{
public double principle;
public double interestRate;
public int duration;
public double payment;
public double Principle
{
get { return principle; }
set
{
principle = value;
RaisePropertyChanged("Principle");
}
}
public double InterestRate
{
get { return interestRate; }
set
{
interestRate = value;
RaisePropertyChanged("InterestRate");
}
}
public int Duration
{
get { return duration; }
set
{
duration = value;
RaisePropertyChanged("Duration");
}
}
public double Payment
{
get { return payment; }
set
{
payment = value;
RaisePropertyChanged("Payment");
}
}
}
在此类中,每当属性发生更改时,我们都会引发属性更改事件。我们可以做很多其他事情来改进我们的代码库,但这只是朝着正确方向迈出的第一步。我们可以在以下指南中轻松包含更多步骤,但在遵循这些步骤之后,我们将拥有一个合理良好的程序设计,朝着 MVVM 的方向发展。
这是此程序的输出。
4. 参考资料
- 设计模式,可重用面向对象软件的元素,Erich Gamma,Richard Helm,Ralph Johnson,John Vlissides。
- 在 Smalltalk-80 中使用模型-视图-控制器用户界面范例的食谱,Glenn E. Krasner,Stephen T. Pope,面向对象编程杂志,1988 年 8 月/9 月 http://www.ics.uci.edu/~redmiles/ics227-SQ04/papers/KrasnerPope88.pdf。
- GUI 架构,Martin Fowler,https://martinfowler.com.cn/eaaDev/uiArchs.html。
- 使用 C++ 在 WPF 中实现的贷款摊销应用程序,Zeeshan Amjad,LoanAmortizationWPF.aspx,http://www.codeguru.com/cpp/cpp/cpp_managed/general/print.php/c16355/。