65.9K
CodeProject 正在变化。 阅读更多。
Home

MVVM # 第四集

2011年3月27日

CPOL

9分钟阅读

viewsIcon

102579

downloadIcon

2253

将扩展的 MVVM 模式用于实际的 LOB 应用程序:第四部分

本系列其他文章
第一部分
第二部分
第三部分
第四部分  

引言

在这篇文章系列的第一部分中,我介绍了我的 MVVM 模式观点,并讨论了我认为在某些实现以及模式本身中存在的一些不足。

第二部分中,我介绍了我在实现中使用的一些基类和接口,我称之为 MVVM#(为图方便起的名称)。

第三部分中,我提供了足够应用程序代码,让我们能够启动和运行,并在窗体中显示客户选择视图,同时包含运行时和设计时数据。

在本部分系列文章中,我将在该基本应用程序的基础上进行扩展,展示实际功能。

过滤

我们过滤需求的技术规范非常简单。允许用户输入一个州代码,然后过滤列表,只包含在该州的客户。(我是一个住在澳大利亚的英国人——所以我在这里使用澳大利亚的州代码——如果你感兴趣的话,完整的列表是 QLD、NSW、SA、WA、TAS、NT、ACT、VIC。)(实际上,无论你是否感兴趣,这都是完整的列表)

通常,解决这个问题有无数种方法,但我的设计师希望将其实现为“即时搜索”。 

实现这一点的第一步实际上已经在我们的 CustomerSelectionView 的 XAML 中。

Text="{Binding Path=StateFilter, UpdateSourceTrigger=PropertyChanged}"></TextBox>

这行代码将州文本框绑定到 StateFilter 属性,而 UpdateSourceTrigger 属性告诉 WPF 在属性更改时立即更新该属性。

在此处提醒一下可能很重要;此属性虽然是 Observable 属性,但它是*功能*的一部分,而不是正在处理的数据的一部分——因此该属性驻留在 ViewModel 中,而不是 ViewData 对象中。

因此,将会发生的是,用户在 TextBox 中输入内容,该操作会设置 ViewModel 上的属性,然后 ViewModel 会要求 Controller 提供一个新过滤后的数据集。由于该数据已绑定到 View,因此 View 中的 DataGrid 将会更新。

但等等!如果我们这样实现,一旦输入一个字母,列表就会变为空,因为没有州的代码是单个字母。我们可以将过滤设置为“以…开头”的过滤器——但那样输入“N”代表 NT 会将所有 NSW 的客户都找出来。好吧,这其实也不是什么大问题,但可能会出现获取过滤结果所花费时间的问题——所以我们不想在用户按下按键时不断刷新结果。

因此,我想引入一个延迟。在用户输入内容后,大约半秒钟内不会进行任何过滤——然后半秒钟后,列表将使用 TextBox 中的内容进行过滤,除非按下另一个按键,在这种情况下,半秒钟的倒计时会重新开始。

所以,我们需要一个 Timer——我使用的是 DispatcherTimer,因此需要在 ViewModels 项目中添加对 WindowsBase 的引用。

然后,我们可以添加一个新的 private 字段。

DispatcherTimer stateFilterTimer;

我们还需要添加我们的 ObservableProperty StateFilter

private string stateFilter;
public string StateFilter
{
	get
	{
		return stateFilter;
	}
	set
	{
		if (value != stateFilter)
		{
			stateFilterTimer.Stop();
			stateFilter = value;
			RaisePropertyChanged("StateFilter");
			stateFilterTimer.Start();
		}
	}
}

在构造函数中,我们需要实例化计时器,将其时间间隔设置为半秒,并为其提供一个事件处理程序方法,以便在计时器归零时处理事件。

/// <summary>
/// Use the base class to store the controller and set the Data Context of the view (view)
/// Initialise any data that needs initialising
/// </summary>
/// <param name="controller"></param>
/// <param name="view"></param>
public CustomerSelectionViewModel(ICustomerController controller,
IView view, string stateFilter = "")
            : base(controller, view)
{
	controller.Messenger.Register(MessageTypes.MSG_CUSTOMER_SAVED,
	new Action<Message>(RefreshList));
	// Leave it for half a second before filtering on State
	stateFilterTimer = new DispatcherTimer()
	{
		Interval = new TimeSpan(0, 0, 0, 0, 500)
	};
	stateFilterTimer.Tick += StateFilterTimerTick;
	StateFilter = stateFilter;
	RefreshList();
}

当然,然后我们需要编写那个事件处理程序……

/// <summary>
/// Event handler for the timer used for 'filter as you type' on the State filter.
/// When the timer triggers, filter the list with the existing filter.
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
void StateFilterTimerTick(object sender, EventArgs e)
{
	stateFilterTimer.Stop();
	RefreshList();
}

然后,所有需要做的就是更改 RefreshList 方法,以便我们传递 StateFilter 属性,而不是一个空的 string

/// <summary>
/// Ask for an updated list of customers based on the filter
/// </summary>
private void RefreshList()
{
	ViewData = CustomerController.GetCustomerSelectionViewData(StateFilter);
}

这就差不多了——运行一下。请记住,在我的测试数据中,我只使用了两个州——Qld 和 NSW。

Filtering in action

编辑

好吧,这个应用程序的全部意义在于能够编辑客户详细信息,所以让我们开始创建我们的 CustomerEditViewModel

using System;
using System.Windows.Input;
using Messengers;

namespace ViewModels
{
    /// <summary>
    /// A ViewModel for a view that allows a Customer to be modified
    /// </summary>
    public class CustomerEditViewModel : BaseViewModel
    {
        #region Private Fields
        #endregion
        #region Properties
        /// <summary>
        /// Just to save us casting the base class's IController 
        /// to ICustomerController all the time...
        /// </summary>
        private ICustomerController CustomerController
        {
            get
            {
                return (ICustomerController)Controller;
            }
        }
        #region Observable Properties
        #endregion
        #endregion
        #region Commands
        #region Command Relays
        private RelayCommand<IView> cancelledCommand;
        private RelayCommand<IView> saveCommand;
        public ICommand CancelledCommand
        {
            get
            {
                return cancelledCommand ?? (cancelledCommand =
                new RelayCommand<IView>(param => ObeyCancelledCommand(param),
                param => CanObeyCancelledCommand(param)));
            }
        }

        public ICommand SaveCommand
        {
            get
            {
                return saveCommand ?? (saveCommand = new RelayCommand<IView>
                (param => ObeySaveCommand(param), param => CanObeySaveCommand(param)));

            }
        }

        #endregion // Command Relays

        #region Command Handlers

        /// <summary>
        /// </summary>
        /// <returns></returns>
        private bool CanObeyCancelledCommand(IView view)
        {
            return true;
        }

        private void ObeyCancelledCommand(IView view)
        {
            CloseViewModel(false);
        }
        private bool CanObeySaveCommand(IView view)
        {
            return true;
        }

        private void ObeySaveCommand(IView view)
        {
            CustomerController.UpdateCustomer((CustomerEditViewData)ViewData);
            CloseViewModel(true);
        }
        #endregion // Command Handlers
        #endregion // Commands

        #region Constructor

        public CustomerEditViewModel(ICustomerController controller)
            : this(controller, null)
        {
        }


        public CustomerEditViewModel(ICustomerController controller, IView view)
            : base(controller, view)
        {
            controller.Messenger.Register(MessageTypes.MSG_CUSTOMER_SELECTED_FOR_EDIT,
            new Action<Message>(HandleCustomerSelectedForEditMessage));
        }

        #endregion

        /// <summary>
        /// If somewhere someone selects a customer for editing and this 
        /// Edit ViewModel is already
        /// Editing that customer, then abort the message, and make the View active
        /// </summary>
        /// <param name="message"></param>
        private void HandleCustomerSelectedForEditMessage(Message message)
        {
            CustomerListItemViewData customer = 
		message.Payload as CustomerListItemViewData;
            if (customer != null && customer.CustomerId == 
			((CustomerEditViewData)ViewData).CustomerId)
            {
                message.HandledStatus = MessageHandledStatus.HandledCompleted;
                ActivateViewModel();
            }
        }
    }
}

花一分钟时间浏览一下这段代码,确保我们理解了各个部分。

同样,我定义了一个类型为 ICustomerControllerprivate 属性,用于返回 BaseViewModel 中定义的 IController,这样可以省去每次使用它时进行强制类型转换。

没有 ObservableProperties。我们正在编辑的 Customer 数据的 ObservablePropertiesCustomerViewData 中——ViewModel 中缺少 ObservableProperties 表明此视图没有额外的绑定功能。

我们定义了两个类型为 IViewRelayCommandscancelledCommandsaveCommand),它们在需要的关联属性 Getters 中实例化。

CanObeyCancelledCommand 方法始终返回 true——因此用户随时都可以取消。

CanObeySaveCommand 也始终返回 true。当然,在实际应用中,你可能只想在当前的 CustomerEditViewData 是“脏”的——也就是说,用户进行了更改——时才返回 true——但本系列文章已经足够长了,无需添加额外的变更跟踪复杂性!

ObeyCancelledCommand 方法(当用户取消时调用的方法)使用带有 False 参数的 CloseViewModel 方法。此参数决定,如果视图以对话框形式显示,dialogresulttrue 还是 false

ObeySaveCommand 要求 CustomerController 保存我们 ViewData 中的数据(该数据绑定到用户正在使用的控件,并反映了这些更改)。然后它使用 CloseViewModel 方法,传递“True”,这样,如果视图以对话框形式显示,DialogResult 将为 true

在构造函数中,你会看到 CustomerEditViewModel 注册接收类型为‘MSG_CUSTOMER_SELECTED_FOR_EDIT’的消息。这样做的原因是,如果我们愿意,可以打开多个 CustomerEditViewModels,每个都编辑自己的 Customer——但我们真的不希望同一个客户在两个 View 中被编辑——所以每次选择一个 Customer 进行编辑时,CustomerEditViewModel 都会检查消息,如果选择的 customer 与它当前正在编辑的 customer 匹配,它会将消息状态设置为 HandledCompleted 并“激活”自身,这实际上意味着它会激活 View——所以对用户的影响将是包含 View 的窗口将成为活动窗口。)

这一切都在 HandleCustomerSelectedForEditMessage 方法中实现。Message 对象有一个 Payload 属性,对于这种消息类型,它是一个 CustomerListItemViewData。(顺便说一句,在此版本中,我没有实现任何东西来强制确保此消息类型的 payload 对象是正确的类型,也没有尝试自动化此类型转换。这样做并不难,如果你想做的话——但你在这方面走得越远,你就越接近一个复杂的 Framework——而这正是我想要避免的。)

好了——这就是 ViewModel——让我们为此创建一个 View——然后我们可以把它交给设计师来让它看起来漂亮。

CustomerEditView.xaml

<views:BaseView x:Class="Views.CustomerEditView"
                xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
                xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                xmlns:views="clr-namespace:Views"
                mc:Ignorable="d"
                d:DesignHeight="243"
                d:DesignWidth="346"
                d:DataContext="{d:DesignInstance 
			Type=views:DesignTimeCustomerEditViewModel,
                IsDesignTimeCreatable=true}">
    <StackPanel Margin="10">
        <Grid >
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100*" />
                <ColumnDefinition Width="200*" />
            </Grid.ColumnDefinitions>
            <TextBlock Text="Name"
                       Grid.Column="0"
                       Margin="8" />
            <TextBox Text="{Binding ViewData.Name}"
                     Grid.Column="1"
                     Margin="2" />
        </Grid>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100*" />
                <ColumnDefinition Width="200*" />
            </Grid.ColumnDefinitions>
            <TextBlock Text="Address"
                       Grid.Column="0"
                       Grid.Row="0"
                       Margin="8" />
            <TextBox Text="{Binding ViewData.Address}"
                     Grid.Column="1"
                     Grid.Row="0"
                     Margin="2" />
        </Grid>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100*" />
                <ColumnDefinition Width="200*" />
            </Grid.ColumnDefinitions>
            <TextBlock Text="Suburb"
                       Grid.Column="0"
                       Grid.Row="0"
                       Margin="8" />
            <TextBox Text="{Binding ViewData.Suburb}"
                     Grid.Column="1"
                     Grid.Row="0"
                     Margin="2" />
        </Grid>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100*" />
                <ColumnDefinition Width="200*" />
            </Grid.ColumnDefinitions>
            <TextBlock Text="State"
                       Grid.Column="0"
                       Grid.Row="0"
                       Margin="8" />
            <TextBox Text="{Binding ViewData.State}"
                     Grid.Column="1"
                     Grid.Row="0"
                     Margin="2" />
        </Grid>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100*" />
                <ColumnDefinition Width="200*" />
            </Grid.ColumnDefinitions>
            <TextBlock Text="PostCode"
                       Grid.Column="0"
                       Grid.Row="0"
                       Margin="8" />
            <TextBox Text="{Binding ViewData.PostCode}"
                     Grid.Column="1"
                     Grid.Row="0"
                     Margin="2" />
        </Grid>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100*" />
                <ColumnDefinition Width="200*" />
            </Grid.ColumnDefinitions>
            <TextBlock Text="Phone"
                       Grid.Column="0"
                       Grid.Row="0"
                       Margin="8" />
            <TextBox Text="{Binding ViewData.Phone}"
                     Grid.Column="1"
                     Grid.Row="0"
                     Margin="2" />
        </Grid>
        <Grid>
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="100*" />
                <ColumnDefinition Width="200*" />
            </Grid.ColumnDefinitions>
            <TextBlock Text="eMail"
                       Grid.Column="0"
                       Grid.Row="0"
                       Margin="8" />
            <TextBox Text="{Binding ViewData.Email}"
                     Grid.Column="1"
                     Grid.Row="0"
                     Margin="2" />
        </Grid>
        <StackPanel Orientation="Horizontal"
                    FlowDirection="RightToLeft"
                    Height="35">
            <Button Content="Save"
                    Command="{Binding Path=SaveCommand, Mode=OneTime}"
                    Height="23"
                    Width="75"
                    Margin="5,5,25,2" />
            <Button Content="Cancel"
                    Command="{Binding Path=CancelledCommand, Mode=OneTime}"
                    Height="23"
                    Width="75"
                    Margin="5,5,25,2" />
        </StackPanel>
    </StackPanel>
</views:BaseView>

编辑视图中没什么特别的。成对的 Text Blocks 和 TextBoxes。TextBoxes 绑定到 CustomerEditViewData 的属性。

有两个按钮——一个用于取消,一个用于保存,每个都绑定到相应的 Command。就这些了!

所以,如果我们回到 CustomerController_ViewManagement.csGetCustomerEditView 可以被取消注释,因为我们现在有一个 CustomerEditView,它会编译。同样在 CustomerController.cs 源代码中,editCustomer 方法的内容也可以被取消注释。

在继续之前,请记住我们的目标之一是 Blendability——能够将我们的 View 发送给设计师进行美化?在 CustomerEditView XAML 中,我们有

d:DataContext="{d:DesignInstance Type=views:DesignTimeCustomerEditViewModel, 
	IsDesignTimeCreatable=true}">

这告诉设计师实例化一个 DesignTimeCustomerEditViewModel 实例,以便我们的设计师可以看到一些数据。所以,我们最好创建一个。

DesignTimeCustomerEidtViewModel.cs

using ViewModels;

namespace Views
{
    class DesignTimeCustomerEditViewModel : CustomerEditViewModel
    {
        public DesignTimeCustomerEditViewModel()
        {
            ViewData = new CustomerEditViewData()
            {
                Address = "23 Netherington on Wallop Street",
                CustomerId = 123,
                Email = "Oldhag@GeeMail.Com",
                Name = "Betty Boop",
                Phone = "0414 4142424",
                PostCode = "4540",
                State = "QLD",
                Suburb = "Indooroopilly"
            };
        }
    }
}

Blendability - 题外话

看看下面的三个截图。它们是 VS2010、Expression Blend 4 和运行时中客户窗体(添加了简单的按钮下拉阴影效果)的对比(此效果不包含在此处和下载的版本中)。

View editing in Blend

View editing in VS2010

View at Runtime

有趣的是,Blend 将按钮和阴影对齐到左侧,而 VS2010(以及运行时)则将它们都对齐到右侧!这类事情往往会惹恼设计师,但至少他们可以通过看到数据而不是空白输入字段来安抚。

继续表演

现在,运行程序。你应该能够选择一个 customer,点击按钮进行编辑,这将打开一个窗口,你可以在其中更改 customer。该窗口不是模态的,所以你可以返回并选择另一个 customer,这将打开另一个窗口。

关闭选择窗口,所有编辑窗口也会随之关闭。

更改选择列表中的一个字段,保存 customer,然后选择列表刷新以显示修改后的详细信息。

更改同一个字段并取消,选择列表中不会显示任何更改。

两次选择同一个 customer,第一次打开的窗口会获得焦点。

我认为这很好地符合了规范(如果你还记得第一部分的话)。

变更

当然,我们都知道生活并不那么简单!一旦我们的项目赞助商看到这个应用程序,他就想要改变。他不希望能够打开多个编辑窗口——这太混乱了。

好的,让我们为他做出更改。

进入 CustomerController 源代码,找到 EditCustomer 方法。这是我们进行编辑客户所需操作的地方——所以我们在这里需要将 view.ShowInWindow 的第一个参数从 false 改为 true

工作完成。

结论

我们已经通过四个章节走了很长的路,但我希望这对某些人有所帮助。

正如我一直强调的,这不是一个 Framework,而只是我如何将可工作的组件组合在一起,以构建一个 MVVM WPF 应用程序的示例,该应用程序对我来说,在解决其他解决方案的一些不足之处方面,有所改进。

这可能不适合你。你可能想使用现有的框架之一——Cinch、MVVM Light 或其他数以百万计的框架——或者你想自己开发。或者,像我一样,你可能想用自己的方式去做,在你自己的环境中实现你认为最有效的东西。

无论你做什么,我都非常感谢你的反馈。我相信我在过程中犯过错误,并且一直乐于向他人学习来改进我自己的东西。

再次感谢我所站立的巨人的肩膀——尤其是 Pete O'Hanlon。

© . All rights reserved.